Merge branch 'jc/add-resolved' into jch

'git add' has been taught a new '--resolved' option to stage
conflict-resolved paths, while leaving unrelated local changes
unstaged.  It scans the unmerged paths for leftover conflict
markers and aborts if any are found.

* jc/add-resolved:
  add: introduce '--resolved' option
  read-cache: add remove_file_from_index_with_flags()
  merge-ll: consolidate conflict marker scanning logic
  read-cache: reindent
This commit is contained in:
Junio C Hamano
2026-08-07 14:48:09 -07:00
10 changed files with 321 additions and 99 deletions

View File

@@ -11,7 +11,7 @@ SYNOPSIS
git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]
[--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]
[--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]
[--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]
[--resolved] [--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]
[--] [<pathspec>...]
DESCRIPTION
@@ -195,6 +195,14 @@ for `git add --no-all <pathspec>...`, i.e. ignored removed files.
while a _CRLF_ cleans to _LF_, a _CRCRLF_ sequence is only partially
cleaned to _CRLF_.
`--resolved`::
Update the index for unmerged paths matching _<pathspec>_ where
no conflict markers remain in the working tree. Unmerged paths
without conflict markers (including binary files and file
deletions) are staged as resolved, while any path with leftover
conflict markers causes the command to refuse to stage any files.
Cannot be combined with `-u` or `-A`.
`--chmod=(+|-)x`::
Override the executable bit of the added files. The executable
bit is only changed in the index, the files on disk are left

View File

@@ -26,6 +26,7 @@
#include "strvec.h"
#include "submodule.h"
#include "add-interactive.h"
#include "merge-ll.h"
static const char * const builtin_add_usage[] = {
N_("git add [<options>] [--] <pathspec>..."),
@@ -35,6 +36,7 @@ static int patch_interactive, add_interactive, edit_interactive;
static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
static int take_worktree_changes;
static int add_renormalize;
static int add_resolved;
static int pathspec_file_nul;
static int include_sparse;
static const char *pathspec_from_file;
@@ -265,6 +267,7 @@ static struct option builtin_add_options[] = {
OPT__FORCE(&ignored_too, N_("allow adding otherwise ignored files"), 0),
OPT_BOOL('u', "update", &take_worktree_changes, N_("update tracked files")),
OPT_BOOL(0, "renormalize", &add_renormalize, N_("renormalize EOL of tracked files (implies -u)")),
OPT_BOOL(0, "resolved", &add_resolved, N_("add conflict-resolved tracked files")),
OPT_BOOL('N', "intent-to-add", &intent_to_add, N_("record only the fact that the path will be added later")),
OPT_BOOL('A', "all", &addremove_explicit, N_("add changes from all tracked and untracked files")),
OPT_CALLBACK_F(0, "ignore-removal", &addremove_explicit,
@@ -379,6 +382,76 @@ static int add_files(struct repository *repo, struct dir_struct *dir, int flags)
return exit_status;
}
static int failed_to_add(int flags, const char *path)
{
if (!(flags & ADD_CACHE_IGNORE_ERRORS))
die(_("updating file '%s' failed"), path);
return 1;
}
static int add_resolved_files(struct repository *repo,
const struct pathspec *pathspec,
int flags)
{
struct index_state *istate = repo->index;
struct string_list unmerged_paths = STRING_LIST_INIT_DUP;
struct string_list unresolved_paths = STRING_LIST_INIT_DUP;
int exit_status = 0;
size_t i;
for (i = 0; i < istate->cache_nr; i++) {
struct cache_entry *ce = istate->cache[i];
if (!ce_stage(ce))
continue;
if (pathspec->nr && !ce_path_match(istate, ce, pathspec, NULL))
continue;
if (!unmerged_paths.nr ||
strcmp(unmerged_paths.items[unmerged_paths.nr - 1].string, ce->name))
string_list_append(&unmerged_paths, ce->name);
}
if (!unmerged_paths.nr) {
string_list_clear(&unmerged_paths, 0);
return 0;
}
for (i = 0; i < unmerged_paths.nr; i++) {
const char *path = unmerged_paths.items[i].string;
struct stat st;
if (!lstat(path, &st) && S_ISREG(st.st_mode)) {
if (has_conflict_markers(istate, path))
string_list_append(&unresolved_paths, path);
}
}
if (unresolved_paths.nr) {
struct strbuf sb = STRBUF_INIT;
for (i = 0; i < unresolved_paths.nr; i++)
strbuf_addf(&sb, "\t%s\n", unresolved_paths.items[i].string);
die(_("the following paths still have conflict markers:\n%s"), sb.buf);
}
for (i = 0; i < unmerged_paths.nr; i++) {
const char *path = unmerged_paths.items[i].string;
struct stat st;
if (lstat(path, &st)) {
if (errno != ENOENT)
die_errno(_("cannot lstat: '%s'"), path);
if (remove_file_from_index_with_flags(istate, path, flags))
exit_status = failed_to_add(flags, path);
} else {
if (add_file_to_index(istate, path, flags))
exit_status = failed_to_add(flags, path);
}
}
string_list_clear(&unmerged_paths, 0);
string_list_clear(&unresolved_paths, 0);
return exit_status;
}
int cmd_add(int argc,
const char **argv,
const char *prefix,
@@ -438,8 +511,9 @@ int cmd_add(int argc,
else if (take_worktree_changes && ADDREMOVE_DEFAULT)
addremove = 0; /* "-u" was given but not "-A" */
if (addremove && take_worktree_changes)
die(_("options '%s' and '%s' cannot be used together"), "-A", "-u");
die_for_incompatible_opt3(take_worktree_changes, "-u/--update",
0 < addremove_explicit, "-A/--all",
add_resolved, "--resolved");
if (!show_only && ignore_missing)
die(_("the option '%s' requires '%s'"), "--ignore-missing", "--dry-run");
@@ -448,8 +522,11 @@ int cmd_add(int argc,
chmod_arg[1] != 'x' || chmod_arg[2]))
die(_("--chmod param '%s' must be either -x or +x"), chmod_arg);
add_new_files = !take_worktree_changes && !refresh_only && !add_renormalize;
require_pathspec = !(take_worktree_changes || (0 < addremove_explicit));
add_new_files = !take_worktree_changes && !refresh_only &&
!add_renormalize && !add_resolved;
require_pathspec = !(take_worktree_changes ||
(0 < addremove_explicit) ||
add_resolved);
repo_hold_locked_index(repo, &lock_file, LOCK_DIE_ON_ERROR);
@@ -481,7 +558,8 @@ int cmd_add(int argc,
return 0;
}
if (!take_worktree_changes && addremove_explicit < 0 && pathspec.nr)
if (!take_worktree_changes && !add_resolved &&
addremove_explicit < 0 && pathspec.nr)
/* Turn "git add pathspec..." to "git add -A pathspec..." */
addremove = 1;
@@ -584,7 +662,9 @@ int cmd_add(int argc,
odb_transaction_begin_or_die(repo->objects, &transaction, 0);
ps_matched = xcalloc(pathspec.nr, 1);
if (add_renormalize)
if (add_resolved)
exit_status |= add_resolved_files(repo, &pathspec, flags);
else if (add_renormalize)
exit_status |= renormalize_tracked_files(repo, &pathspec, flags);
else
exit_status |= add_files_to_cache(repo, prefix,

25
diff.c
View File

@@ -3519,29 +3519,6 @@ struct checkdiff_t {
int last_line_kind;
};
static int is_conflict_marker(const char *line, int marker_size, unsigned long len)
{
char firstchar;
int cnt;
if (len < marker_size + 1)
return 0;
firstchar = line[0];
switch (firstchar) {
case '=': case '>': case '<': case '|':
break;
default:
return 0;
}
for (cnt = 1; cnt < marker_size; cnt++)
if (line[cnt] != firstchar)
return 0;
/* line[1] through line[marker_size-1] are same as firstchar */
if (len < marker_size + 1 || !isspace(line[marker_size]))
return 0;
return 1;
}
static void checkdiff_consume_hunk(void *priv,
long ob UNUSED, long on UNUSED,
long nb, long nn UNUSED,
@@ -3571,7 +3548,7 @@ static int checkdiff_consume(void *priv, char *line, unsigned long len)
if (line[0] == '+') {
unsigned bad;
data->lineno++;
if (is_conflict_marker(line + 1, marker_size, len - 1)) {
if (is_conflict_marker_line(line + 1, len - 1, marker_size)) {
data->status |= 1;
fprintf(data->o->file,
"%s%s:%d: leftover conflict marker\n",

View File

@@ -468,3 +468,59 @@ int ll_merge_marker_size(struct index_state *istate, const char *path)
}
return marker_size;
}
int is_conflict_marker_line(const char *line, unsigned long len, int marker_size)
{
char firstchar;
int cnt;
if (len < marker_size + 1)
return 0;
firstchar = line[0];
switch (firstchar) {
case '=': case '>': case '<': case '|':
break;
default:
return 0;
}
for (cnt = 1; cnt < marker_size; cnt++) {
if (line[cnt] != firstchar)
return 0;
}
if (((firstchar == '<') || (firstchar == '>')) &&
line[marker_size] != ' ')
return 0;
if (!isspace((unsigned char)line[marker_size]))
return 0;
return firstchar;
}
int has_conflict_markers(struct index_state *istate, const char *path)
{
FILE *f;
struct strbuf sb = STRBUF_INIT;
int marker_size = ll_merge_marker_size(istate, path);
int has_markers = 0;
f = fopen(path, "r");
if (!f)
return 0;
while (strbuf_getwholeline(&sb, f, '\n') != EOF) {
if (is_conflict_marker_line(sb.buf, sb.len, marker_size)) {
has_markers = 1;
break;
}
if (buffer_is_binary(sb.buf,
ULONG_MAX <= sb.len ? ULONG_MAX : sb.len))
break;
}
fclose(f);
strbuf_release(&sb);
return has_markers;
}

View File

@@ -109,6 +109,8 @@ enum ll_merge_result ll_merge(mmbuffer_t *result_buf,
const struct ll_merge_options *opts);
int ll_merge_marker_size(struct index_state *istate, const char *path);
int is_conflict_marker_line(const char *line, unsigned long len, int marker_size);
int has_conflict_markers(struct index_state *istate, const char *path);
void reset_merge_attributes(void);
#endif

View File

@@ -391,11 +391,14 @@ int remove_index_entry_at(struct index_state *, int pos);
void remove_marked_cache_entries(struct index_state *istate, int invalidate);
int remove_file_from_index(struct index_state *, const char *path);
int remove_file_from_index_with_flags(struct index_state *, const char *, int);
#define ADD_CACHE_VERBOSE 1
#define ADD_CACHE_PRETEND 2
#define ADD_CACHE_IGNORE_ERRORS 4
#define ADD_CACHE_IGNORE_REMOVAL 8
#define ADD_CACHE_INTENT 16
/*
* These two are used to add the contents of the file at path
* to the index, marking the working tree up-to-date by storing

View File

@@ -636,6 +636,20 @@ int remove_file_from_index(struct index_state *istate, const char *path)
return 0;
}
int remove_file_from_index_with_flags(struct index_state *istate,
const char *path,
int flags)
{
int verbose = flags & (ADD_CACHE_VERBOSE | ADD_CACHE_PRETEND);
int pretend = flags & ADD_CACHE_PRETEND;
if (verbose)
printf(_("remove '%s'\n"), path);
if (pretend)
return 0;
return remove_file_from_index(istate, path);
}
static int compare_name(struct cache_entry *ce, const char *path, int namelen)
{
return namelen != ce_namelen(ce) || memcmp(path, ce->name, namelen);
@@ -3909,32 +3923,33 @@ static int fix_unmerged_status(struct diff_filepair *p,
}
static int skip_submodule(const char *path,
struct repository *repo,
struct pathspec *pathspec,
int ignored_too)
struct repository *repo,
struct pathspec *pathspec,
int ignored_too)
{
struct stat st;
const struct submodule *sub;
int pathspec_matches = 0;
int ps_i;
char *norm_pathspec = NULL;
struct stat st;
const struct submodule *sub;
int pathspec_matches = 0;
int ps_i;
char *norm_pathspec = NULL;
/* Only consider if path is a directory */
if (lstat(path, &st) || !S_ISDIR(st.st_mode))
/* Only consider if path is a directory */
if (lstat(path, &st) || !S_ISDIR(st.st_mode))
return 0;
/* Check if it's a submodule with ignore=all */
sub = submodule_from_path(repo, null_oid(the_hash_algo), path);
if (!sub || !sub->name || !sub->ignore || strcmp(sub->ignore, "all"))
/* Check if it's a submodule with ignore=all */
sub = submodule_from_path(repo, null_oid(the_hash_algo), path);
if (!sub || !sub->name || !sub->ignore || strcmp(sub->ignore, "all"))
return 0;
trace_printf("ignore=all: %s\n", path);
trace_printf("pathspec %s\n", (pathspec && pathspec->nr)
? "has pathspec"
: "no pathspec");
trace_printf("ignore=all: %s\n", path);
trace_printf("pathspec %s\n",
((pathspec && pathspec->nr)
? "has pathspec"
: "no pathspec"));
/* Check if submodule path is explicitly mentioned in pathspec */
if (pathspec) {
/* Check if submodule path is explicitly mentioned in pathspec */
if (pathspec) {
for (ps_i = 0; ps_i < pathspec->nr; ps_i++) {
const char *m = pathspec->items[ps_i].match;
if (!m)
@@ -3948,28 +3963,29 @@ static int skip_submodule(const char *path,
}
FREE_AND_NULL(norm_pathspec);
}
}
}
/* If explicitly matched and forced, allow adding */
if (pathspec_matches) {
/* If explicitly matched and forced, allow adding */
if (pathspec_matches) {
if (ignored_too && ignored_too > 0) {
trace_printf("Add submodule due to --force: %s\n", path);
return 0;
} else {
advise_if_enabled(ADVICE_ADD_IGNORED_FILE,
_("Skipping submodule due to ignore=all: %s\n"
"Use --force if you really want to add the submodule."), path);
_("Skipping submodule due to ignore=all: %s\n"
"Use --force if you really want to "
"add the submodule."), path);
return 1;
}
}
}
/* No explicit pathspec match -> skip silently */
trace_printf("Pathspec to submodule does not match explicitly: %s\n", path);
return 1;
/* No explicit pathspec match -> skip silently */
trace_printf("Pathspec to submodule does not match explicitly: %s\n", path);
return 1;
}
static void update_callback(struct diff_queue_struct *q,
struct diff_options *opt UNUSED, void *cbdata)
struct diff_options *opt UNUSED, void *cbdata)
{
int i;
struct update_callback_data *data = cbdata;
@@ -3979,7 +3995,7 @@ static void update_callback(struct diff_queue_struct *q,
const char *path = p->one->path;
if (!data->include_sparse &&
!path_in_sparse_checkout(path, data->index))
!path_in_sparse_checkout(path, data->index))
continue;
switch (fix_unmerged_status(p, data)) {
@@ -3988,8 +4004,8 @@ static void update_callback(struct diff_queue_struct *q,
case DIFF_STATUS_MODIFIED:
case DIFF_STATUS_TYPE_CHANGED:
if (skip_submodule(path, data->repo,
data->pathspec,
data->ignored_too))
data->pathspec,
data->ignored_too))
continue;
if (add_file_to_index(data->index, path, data->flags)) {
@@ -4001,10 +4017,7 @@ static void update_callback(struct diff_queue_struct *q,
case DIFF_STATUS_DELETED:
if (data->flags & ADD_CACHE_IGNORE_REMOVAL)
break;
if (!(data->flags & ADD_CACHE_PRETEND))
remove_file_from_index(data->index, path);
if (data->flags & (ADD_CACHE_PRETEND|ADD_CACHE_VERBOSE))
printf(_("remove '%s'\n"), path);
remove_file_from_index_with_flags(data->index, path, data->flags);
break;
}
}

View File

@@ -331,33 +331,6 @@ static int rerere_file_getline(struct strbuf *sb, struct rerere_io *io_)
return strbuf_getwholeline(sb, io->input, '\n');
}
/*
* Require the exact number of conflict marker letters, no more, no
* less, followed by SP or any whitespace
* (including LF).
*/
static int is_cmarker(char *buf, int marker_char, int marker_size)
{
int want_sp;
/*
* The beginning of our version and the end of their version
* always are labeled like "<<<<< ours" or ">>>>> theirs",
* hence we set want_sp for them. Note that the version from
* the common ancestor in diff3-style output is not always
* labelled (e.g. "||||| common" is often seen but "|||||"
* alone is also valid), so we do not set want_sp.
*/
want_sp = (marker_char == '<') || (marker_char == '>');
while (marker_size--)
if (*buf++ != marker_char)
return 0;
if (want_sp && *buf != ' ')
return 0;
return isspace(*buf);
}
static void rerere_strbuf_putconflict(struct strbuf *buf, int ch, size_t size)
{
strbuf_addchars(buf, ch, size);
@@ -375,7 +348,8 @@ static int handle_conflict(struct strbuf *out, struct rerere_io *io,
int has_conflicts = -1;
while (!io->getline(&buf, io)) {
if (is_cmarker(buf.buf, '<', marker_size)) {
int marker = is_conflict_marker_line(buf.buf, buf.len, marker_size);
if (marker == '<') {
if (handle_conflict(&conflict, io, marker_size, NULL) < 0)
break;
if (hunk == RR_SIDE_1)
@@ -383,15 +357,15 @@ static int handle_conflict(struct strbuf *out, struct rerere_io *io,
else
strbuf_addbuf(&two, &conflict);
strbuf_release(&conflict);
} else if (is_cmarker(buf.buf, '|', marker_size)) {
} else if (marker == '|') {
if (hunk != RR_SIDE_1)
break;
hunk = RR_ORIGINAL;
} else if (is_cmarker(buf.buf, '=', marker_size)) {
} else if (marker == '=') {
if (hunk != RR_SIDE_1 && hunk != RR_ORIGINAL)
break;
hunk = RR_SIDE_2;
} else if (is_cmarker(buf.buf, '>', marker_size)) {
} else if (marker == '>') {
if (hunk != RR_SIDE_2)
break;
if (strbuf_cmp(&one, &two) > 0)
@@ -442,7 +416,7 @@ static int handle_path(unsigned char *hash, struct rerere_io *io, int marker_siz
git_hash_init(&ctx, the_hash_algo);
while (!io->getline(&buf, io)) {
if (is_cmarker(buf.buf, '<', marker_size)) {
if (is_conflict_marker_line(buf.buf, buf.len, marker_size) == '<') {
has_conflicts = handle_conflict(&out, io, marker_size,
hash ? &ctx : NULL);
if (has_conflicts < 0)

View File

@@ -305,6 +305,7 @@ integration_tests = [
't2204-add-ignored.sh',
't2205-add-worktree-config.sh',
't2206-add-submodule-ignored.sh',
't2207-add-resolved.sh',
't2300-cd-to-toplevel.sh',
't2400-worktree-add.sh',
't2401-worktree-prune.sh',

108
t/t2207-add-resolved.sh Executable file
View File

@@ -0,0 +1,108 @@
#!/bin/sh
test_description='git add --resolved
Test that "git add --resolved" stages conflict-resolved paths and
refuses to stage when conflict markers remain.'
. ./test-lib.sh
test_expect_success 'setup repo' '
echo base >file1.txt &&
echo base >file2.txt &&
echo base >file3.txt &&
echo base >file4.txt &&
git add file1.txt file2.txt file3.txt file4.txt &&
git commit -m initial &&
git branch topic &&
echo "ours 1" >file1.txt &&
echo "ours 2" >file2.txt &&
echo "ours 3" >file3.txt &&
git commit -a -m ours &&
git checkout topic &&
echo "theirs 1" >file1.txt &&
echo "theirs 2" >file2.txt &&
echo "theirs 3" >file3.txt &&
git commit -a -m theirs &&
git checkout @{-1}
'
test_expect_success 'git add --resolved refuses files with conflict markers' '
test_when_finished "git reset --hard HEAD" &&
test_must_fail git merge topic &&
echo "resolved 1" >file1.txt &&
test_must_fail git add --resolved 2>err &&
test_grep "the following paths still have conflict markers:" err &&
test_grep "file2.txt" err &&
test_grep "file3.txt" err &&
# Index should remain unmerged for all files
git ls-files -u file1.txt >unmerged &&
test_line_count = 3 unmerged
'
test_expect_success 'git add --resolved succeeds when all conflict markers are removed' '
test_when_finished "git reset --hard HEAD" &&
test_must_fail git merge topic &&
echo "resolved 1" >file1.txt &&
echo "resolved 2" >file2.txt &&
echo "resolved 3" >file3.txt &&
git add --resolved &&
git ls-files -u >unmerged &&
test_must_be_empty unmerged &&
git ls-files -s file1.txt file2.txt file3.txt >staged &&
test_line_count = 3 staged
'
test_expect_success 'git add --resolved ignores unconflicted modified files' '
test_when_finished "git reset --hard HEAD" &&
echo "unconflicted local change" >>file4.txt &&
test_must_fail git merge topic &&
echo "resolved 1" >file1.txt &&
echo "resolved 2" >file2.txt &&
echo "resolved 3" >file3.txt &&
git add --resolved &&
# file1, file2, file3 should be staged as resolved
git ls-files -u >unmerged &&
test_must_be_empty unmerged &&
# file4 should remain unstaged in working tree
git diff file4.txt >diff_out &&
test_grep "unconflicted local change" diff_out &&
git diff --cached file4.txt >cached_out &&
test_must_be_empty cached_out
'
test_expect_success 'git add --resolved handles file removals' '
test_when_finished "git reset --hard HEAD" &&
test_must_fail git merge topic &&
echo "resolved 1" >file1.txt &&
rm file2.txt &&
echo "resolved 3" >file3.txt &&
git add --resolved &&
git ls-files -s file2.txt >out &&
test_must_be_empty out
'
test_expect_success 'git add --resolved honors pathspec' '
test_when_finished "git reset --hard HEAD" &&
test_must_fail git merge topic &&
echo "resolved 1" >file1.txt &&
# file2.txt and file3.txt still have conflict markers,
# but pathspec targets only file1.txt
git add --resolved file1.txt &&
git ls-files -u file1.txt >unmerged1 &&
test_must_be_empty unmerged1 &&
git ls-files -u file2.txt >unmerged2 &&
test_line_count = 3 unmerged2
'
test_expect_success 'git add --resolved incompatibility with -u and -A' '
test_must_fail git add --resolved -u 2>err1 &&
test_grep "cannot be used together" err1 &&
test_must_fail git add --resolved -A 2>err2 &&
test_grep "cannot be used together" err2
'
test_done