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/diff.c b/diff.c index 589c1969e4..cfe515af4e 100644 --- a/diff.c +++ b/diff.c @@ -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", diff --git a/merge-ll.c b/merge-ll.c index fafe2c9197..ef5287dee8 100644 --- a/merge-ll.c +++ b/merge-ll.c @@ -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; +} diff --git a/merge-ll.h b/merge-ll.h index d038ee0c1e..f26aef238d 100644 --- a/merge-ll.h +++ b/merge-ll.h @@ -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 diff --git a/read-cache-ll.h b/read-cache-ll.h index 71b87615eb..8eb266cfd1 100644 --- a/read-cache-ll.h +++ b/read-cache-ll.h @@ -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 diff --git a/read-cache.c b/read-cache.c index 6c449f393d..c076984858 100644 --- a/read-cache.c +++ b/read-cache.c @@ -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; } } diff --git a/rerere.c b/rerere.c index 0015e25050..3d3bd0db16 100644 --- a/rerere.c +++ b/rerere.c @@ -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) diff --git a/t/meson.build b/t/meson.build index a25f37d2f5..2133c840da 100644 --- a/t/meson.build +++ b/t/meson.build @@ -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', 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