diff --git a/Documentation/git-add.adoc b/Documentation/git-add.adoc index 941135dc63..16b06e38e1 100644 --- a/Documentation/git-add.adoc +++ b/Documentation/git-add.adoc @@ -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= [--pathspec-file-nul]] + [--resolved] [--chmod=(+|-)x] [--pathspec-from-file= [--pathspec-file-nul]] [--] [...] DESCRIPTION @@ -195,6 +195,14 @@ for `git add --no-all ...`, 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 __ 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 diff --git a/builtin/add.c b/builtin/add.c index 60ffbede2b..eab8f03cad 100644 --- a/builtin/add.c +++ b/builtin/add.c @@ -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 [] [--] ..."), @@ -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, diff --git a/merge-ll.c b/merge-ll.c index 41c97fb90a..ef5287dee8 100644 --- a/merge-ll.c +++ b/merge-ll.c @@ -499,3 +499,28 @@ int is_conflict_marker_line(const char *line, unsigned long len, int marker_size 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; +} diff --git a/merge-ll.h b/merge-ll.h index b348aee15d..f26aef238d 100644 --- a/merge-ll.h +++ b/merge-ll.h @@ -110,6 +110,7 @@ enum ll_merge_result ll_merge(mmbuffer_t *result_buf, 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 diff --git a/t/meson.build b/t/meson.build index 8ae6ab6c5f..e0f4b85ad5 100644 --- a/t/meson.build +++ b/t/meson.build @@ -304,6 +304,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', diff --git a/t/t2207-add-resolved.sh b/t/t2207-add-resolved.sh new file mode 100755 index 0000000000..1b88efcb99 --- /dev/null +++ b/t/t2207-add-resolved.sh @@ -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