add: introduce '--resolved' option

During a conflicted merge, rebase, or cherry-pick, 'git add -u' is a handy way to add modified paths to the index. However, '-u' indiscriminately adds all modified tracked paths, including unmerged paths that may still contain unresolved conflict markers. It also adds tracked files modified in the worktree that are not involved in the ongoing merge. The latter is not a huge problem for "git rebase", which refuses to start with any local changes, but is a problem for "git merge", which is often run with local changes in maintainer workflows. Introduce 'git add --resolved' to add only unmerged paths, limited by an optional pathspec, where no conflict markers remain in the working tree. Before modifying the index, scan unmerged regular files for leftover conflict markers using a new helper, has_conflict_markers(), defined in merge-ll.c in terms of the is_conflict_marker_line() helper we introduced earlier. If any unmerged path still contains conflict markers, show an error listing the conflicted paths and abort without updating the index. Otherwise, add these unmerged paths that do not have conflict markers to the index. Note that unmerged paths without conflict markers (such as binary files and deletions) are added as resolved using add_file_to_index() and remove_file_from_index_with_flags(). Tracked files that were not in a conflicted state are ignored by '--resolved'. Signed-off-by: Junio C Hamano <gitster@pobox.com>

Junio C Hamano committed Jul 31, 2026 at 05:56 UTC 94cf62176ec5e416b7748d60b59bf8f6aa6dd7b3
6 files changed +230 -7
Documentation/git-add.adoc
+9 -1
@@ -11,7 +11,7 @@ SYNOPSIS
11 git add [--verbose | -v] [--dry-run | -n] [--force | -f] [--interactive | -i] [--patch | -p]
12 [--edit | -e] [--[no-]all | -A | --[no-]ignore-removal | [--update | -u]] [--sparse]
13 [--intent-to-add | -N] [--refresh] [--ignore-errors] [--ignore-missing] [--renormalize]
14 - [--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]
14 + [--resolved] [--chmod=(+|-)x] [--pathspec-from-file=<file> [--pathspec-file-nul]]
15 [--] [<pathspec>...]
16
17 DESCRIPTION
@@ -195,6 +195,14 @@ for `git add --no-all <pathspec>...`, i.e. ignored removed files.
195 while a _CRLF_ cleans to _LF_, a _CRCRLF_ sequence is only partially
196 cleaned to _CRLF_.
197
198 +`--resolved`::
199 + Update the index for unmerged paths matching _<pathspec>_ where
200 + no conflict markers remain in the working tree. Unmerged paths
201 + without conflict markers (including binary files and file
202 + deletions) are staged as resolved, while any path with leftover
203 + conflict markers causes the command to refuse to stage any files.
204 + Cannot be combined with `-u` or `-A`.
205 +
206 `--chmod=(+|-)x`::
207 Override the executable bit of the added files. The executable
208 bit is only changed in the index, the files on disk are left
builtin/add.c
+86 -6
@@ -26,6 +26,7 @@
26 #include "strvec.h"
27 #include "submodule.h"
28 #include "add-interactive.h"
29 +#include "merge-ll.h"
30
31 static const char * const builtin_add_usage[] = {
32 N_("git add [<options>] [--] <pathspec>..."),
@@ -35,6 +36,7 @@ static int patch_interactive, add_interactive, edit_interactive;
36 static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
37 static int take_worktree_changes;
38 static int add_renormalize;
39 +static int add_resolved;
40 static int pathspec_file_nul;
41 static int include_sparse;
42 static const char *pathspec_from_file;
@@ -265,6 +267,7 @@ static struct option builtin_add_options[] = {
267 OPT__FORCE(&ignored_too, N_("allow adding otherwise ignored files"), 0),
268 OPT_BOOL('u', "update", &take_worktree_changes, N_("update tracked files")),
269 OPT_BOOL(0, "renormalize", &add_renormalize, N_("renormalize EOL of tracked files (implies -u)")),
270 + OPT_BOOL(0, "resolved", &add_resolved, N_("add conflict-resolved tracked files")),
271 OPT_BOOL('N', "intent-to-add", &intent_to_add, N_("record only the fact that the path will be added later")),
272 OPT_BOOL('A', "all", &addremove_explicit, N_("add changes from all tracked and untracked files")),
273 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)
382 return exit_status;
383 }
384
385 +static int failed_to_add(int flags, const char *path)
386 +{
387 + if (!(flags & ADD_CACHE_IGNORE_ERRORS))
388 + die(_("updating file '%s' failed"), path);
389 + return 1;
390 +}
391 +
392 +static int add_resolved_files(struct repository *repo,
393 + const struct pathspec *pathspec,
394 + int flags)
395 +{
396 + struct index_state *istate = repo->index;
397 + struct string_list unmerged_paths = STRING_LIST_INIT_DUP;
398 + struct string_list unresolved_paths = STRING_LIST_INIT_DUP;
399 + int exit_status = 0;
400 + size_t i;
401 +
402 + for (i = 0; i < istate->cache_nr; i++) {
403 + struct cache_entry *ce = istate->cache[i];
404 + if (!ce_stage(ce))
405 + continue;
406 + if (pathspec->nr && !ce_path_match(istate, ce, pathspec, NULL))
407 + continue;
408 + if (!unmerged_paths.nr ||
409 + strcmp(unmerged_paths.items[unmerged_paths.nr - 1].string, ce->name))
410 + string_list_append(&unmerged_paths, ce->name);
411 + }
412 +
413 + if (!unmerged_paths.nr) {
414 + string_list_clear(&unmerged_paths, 0);
415 + return 0;
416 + }
417 +
418 + for (i = 0; i < unmerged_paths.nr; i++) {
419 + const char *path = unmerged_paths.items[i].string;
420 + struct stat st;
421 +
422 + if (!lstat(path, &st) && S_ISREG(st.st_mode)) {
423 + if (has_conflict_markers(istate, path))
424 + string_list_append(&unresolved_paths, path);
425 + }
426 + }
427 +
428 + if (unresolved_paths.nr) {
429 + struct strbuf sb = STRBUF_INIT;
430 + for (i = 0; i < unresolved_paths.nr; i++)
431 + strbuf_addf(&sb, "\t%s\n", unresolved_paths.items[i].string);
432 + die(_("the following paths still have conflict markers:\n%s"), sb.buf);
433 + }
434 +
435 + for (i = 0; i < unmerged_paths.nr; i++) {
436 + const char *path = unmerged_paths.items[i].string;
437 + struct stat st;
438 +
439 + if (lstat(path, &st)) {
440 + if (errno != ENOENT)
441 + die_errno(_("cannot lstat: '%s'"), path);
442 + if (remove_file_from_index_with_flags(istate, path, flags))
443 + exit_status = failed_to_add(flags, path);
444 + } else {
445 + if (add_file_to_index(istate, path, flags))
446 + exit_status = failed_to_add(flags, path);
447 + }
448 + }
449 +
450 + string_list_clear(&unmerged_paths, 0);
451 + string_list_clear(&unresolved_paths, 0);
452 + return exit_status;
453 +}
454 +
455 int cmd_add(int argc,
456 const char **argv,
457 const char *prefix,
@@ -438,8 +511,9 @@ int cmd_add(int argc,
511 else if (take_worktree_changes && ADDREMOVE_DEFAULT)
512 addremove = 0; /* "-u" was given but not "-A" */
513
441 - if (addremove && take_worktree_changes)
442 - die(_("options '%s' and '%s' cannot be used together"), "-A", "-u");
514 + die_for_incompatible_opt3(take_worktree_changes, "-u/--update",
515 + 0 < addremove_explicit, "-A/--all",
516 + add_resolved, "--resolved");
517
518 if (!show_only && ignore_missing)
519 die(_("the option '%s' requires '%s'"), "--ignore-missing", "--dry-run");
@@ -448,8 +522,11 @@ int cmd_add(int argc,
522 chmod_arg[1] != 'x' || chmod_arg[2]))
523 die(_("--chmod param '%s' must be either -x or +x"), chmod_arg);
524
451 - add_new_files = !take_worktree_changes && !refresh_only && !add_renormalize;
452 - require_pathspec = !(take_worktree_changes || (0 < addremove_explicit));
525 + add_new_files = !take_worktree_changes && !refresh_only &&
526 + !add_renormalize && !add_resolved;
527 + require_pathspec = !(take_worktree_changes ||
528 + (0 < addremove_explicit) ||
529 + add_resolved);
530
531 repo_hold_locked_index(repo, &lock_file, LOCK_DIE_ON_ERROR);
532
@@ -481,7 +558,8 @@ int cmd_add(int argc,
558 return 0;
559 }
560
484 - if (!take_worktree_changes && addremove_explicit < 0 && pathspec.nr)
561 + if (!take_worktree_changes && !add_resolved &&
562 + addremove_explicit < 0 && pathspec.nr)
563 /* Turn "git add pathspec..." to "git add -A pathspec..." */
564 addremove = 1;
565
@@ -584,7 +662,9 @@ int cmd_add(int argc,
662 odb_transaction_begin_or_die(repo->objects, &transaction, 0);
663
664 ps_matched = xcalloc(pathspec.nr, 1);
587 - if (add_renormalize)
665 + if (add_resolved)
666 + exit_status |= add_resolved_files(repo, &pathspec, flags);
667 + else if (add_renormalize)
668 exit_status |= renormalize_tracked_files(repo, &pathspec, flags);
669 else
670 exit_status |= add_files_to_cache(repo, prefix,
merge-ll.c
+25
@@ -499,3 +499,28 @@ int is_conflict_marker_line(const char *line, unsigned long len, int marker_size
499
500 return firstchar;
501 }
502 +
503 +int has_conflict_markers(struct index_state *istate, const char *path)
504 +{
505 + FILE *f;
506 + struct strbuf sb = STRBUF_INIT;
507 + int marker_size = ll_merge_marker_size(istate, path);
508 + int has_markers = 0;
509 +
510 + f = fopen(path, "r");
511 + if (!f)
512 + return 0;
513 +
514 + while (strbuf_getwholeline(&sb, f, '\n') != EOF) {
515 + if (is_conflict_marker_line(sb.buf, sb.len, marker_size)) {
516 + has_markers = 1;
517 + break;
518 + }
519 + if (buffer_is_binary(sb.buf,
520 + ULONG_MAX <= sb.len ? ULONG_MAX : sb.len))
521 + break;
522 + }
523 + fclose(f);
524 + strbuf_release(&sb);
525 + return has_markers;
526 +}
merge-ll.h
+1
@@ -110,6 +110,7 @@ enum ll_merge_result ll_merge(mmbuffer_t *result_buf,
110
111 int ll_merge_marker_size(struct index_state *istate, const char *path);
112 int is_conflict_marker_line(const char *line, unsigned long len, int marker_size);
113 +int has_conflict_markers(struct index_state *istate, const char *path);
114 void reset_merge_attributes(void);
115
116 #endif
t/meson.build
+1
@@ -304,6 +304,7 @@ integration_tests = [
304 't2204-add-ignored.sh',
305 't2205-add-worktree-config.sh',
306 't2206-add-submodule-ignored.sh',
307 + 't2207-add-resolved.sh',
308 't2300-cd-to-toplevel.sh',
309 't2400-worktree-add.sh',
310 't2401-worktree-prune.sh',
t/t2207-add-resolved.sh new
+108
@@ -0,0 +1,108 @@
1 +#!/bin/sh
2 +
3 +test_description='git add --resolved
4 +
5 +Test that "git add --resolved" stages conflict-resolved paths and
6 +refuses to stage when conflict markers remain.'
7 +
8 +. ./test-lib.sh
9 +
10 +test_expect_success 'setup repo' '
11 + echo base >file1.txt &&
12 + echo base >file2.txt &&
13 + echo base >file3.txt &&
14 + echo base >file4.txt &&
15 + git add file1.txt file2.txt file3.txt file4.txt &&
16 + git commit -m initial &&
17 +
18 + git branch topic &&
19 + echo "ours 1" >file1.txt &&
20 + echo "ours 2" >file2.txt &&
21 + echo "ours 3" >file3.txt &&
22 + git commit -a -m ours &&
23 +
24 + git checkout topic &&
25 + echo "theirs 1" >file1.txt &&
26 + echo "theirs 2" >file2.txt &&
27 + echo "theirs 3" >file3.txt &&
28 + git commit -a -m theirs &&
29 +
30 + git checkout @{-1}
31 +'
32 +
33 +test_expect_success 'git add --resolved refuses files with conflict markers' '
34 + test_when_finished "git reset --hard HEAD" &&
35 + test_must_fail git merge topic &&
36 + echo "resolved 1" >file1.txt &&
37 + test_must_fail git add --resolved 2>err &&
38 + test_grep "the following paths still have conflict markers:" err &&
39 + test_grep "file2.txt" err &&
40 + test_grep "file3.txt" err &&
41 + # Index should remain unmerged for all files
42 + git ls-files -u file1.txt >unmerged &&
43 + test_line_count = 3 unmerged
44 +'
45 +
46 +test_expect_success 'git add --resolved succeeds when all conflict markers are removed' '
47 + test_when_finished "git reset --hard HEAD" &&
48 + test_must_fail git merge topic &&
49 + echo "resolved 1" >file1.txt &&
50 + echo "resolved 2" >file2.txt &&
51 + echo "resolved 3" >file3.txt &&
52 + git add --resolved &&
53 + git ls-files -u >unmerged &&
54 + test_must_be_empty unmerged &&
55 + git ls-files -s file1.txt file2.txt file3.txt >staged &&
56 + test_line_count = 3 staged
57 +'
58 +
59 +test_expect_success 'git add --resolved ignores unconflicted modified files' '
60 + test_when_finished "git reset --hard HEAD" &&
61 + echo "unconflicted local change" >>file4.txt &&
62 + test_must_fail git merge topic &&
63 + echo "resolved 1" >file1.txt &&
64 + echo "resolved 2" >file2.txt &&
65 + echo "resolved 3" >file3.txt &&
66 + git add --resolved &&
67 + # file1, file2, file3 should be staged as resolved
68 + git ls-files -u >unmerged &&
69 + test_must_be_empty unmerged &&
70 + # file4 should remain unstaged in working tree
71 + git diff file4.txt >diff_out &&
72 + test_grep "unconflicted local change" diff_out &&
73 + git diff --cached file4.txt >cached_out &&
74 + test_must_be_empty cached_out
75 +'
76 +
77 +test_expect_success 'git add --resolved handles file removals' '
78 + test_when_finished "git reset --hard HEAD" &&
79 + test_must_fail git merge topic &&
80 + echo "resolved 1" >file1.txt &&
81 + rm file2.txt &&
82 + echo "resolved 3" >file3.txt &&
83 + git add --resolved &&
84 + git ls-files -s file2.txt >out &&
85 + test_must_be_empty out
86 +'
87 +
88 +test_expect_success 'git add --resolved honors pathspec' '
89 + test_when_finished "git reset --hard HEAD" &&
90 + test_must_fail git merge topic &&
91 + echo "resolved 1" >file1.txt &&
92 + # file2.txt and file3.txt still have conflict markers,
93 + # but pathspec targets only file1.txt
94 + git add --resolved file1.txt &&
95 + git ls-files -u file1.txt >unmerged1 &&
96 + test_must_be_empty unmerged1 &&
97 + git ls-files -u file2.txt >unmerged2 &&
98 + test_line_count = 3 unmerged2
99 +'
100 +
101 +test_expect_success 'git add --resolved incompatibility with -u and -A' '
102 + test_must_fail git add --resolved -u 2>err1 &&
103 + test_grep "cannot be used together" err1 &&
104 + test_must_fail git add --resolved -A 2>err2 &&
105 + test_grep "cannot be used together" err2
106 +'
107 +
108 +test_done