Merge branch 'jc/unresolve-removal'

"checkout --merge -- path" and "update-index --unresolve path" did
not resurrect conflicted state that was resolved to remove path,
but now they do.

* jc/unresolve-removal:
  checkout: allow "checkout -m path" to unmerge removed paths
  checkout/restore: add basic tests for --merge
  checkout/restore: refuse unmerging paths unless checking out of the index
  update-index: remove stale fallback code for "--unresolve"
  update-index: use unmerge_index_entry() to support removal
  resolve-undo: allow resurrecting conflicted state that resolved to deletion
  update-index: do not read HEAD and MERGE_HEAD unconditionally
This commit is contained in:
Junio C Hamano
2023-10-02 11:20:00 -07:00
10 changed files with 228 additions and 165 deletions

View File

@ -12,8 +12,10 @@ SYNOPSIS
'git checkout' [-q] [-f] [-m] --detach [<branch>] 'git checkout' [-q] [-f] [-m] --detach [<branch>]
'git checkout' [-q] [-f] [-m] [--detach] <commit> 'git checkout' [-q] [-f] [-m] [--detach] <commit>
'git checkout' [-q] [-f] [-m] [[-b|-B|--orphan] <new-branch>] [<start-point>] 'git checkout' [-q] [-f] [-m] [[-b|-B|--orphan] <new-branch>] [<start-point>]
'git checkout' [-f|--ours|--theirs|-m|--conflict=<style>] [<tree-ish>] [--] <pathspec>... 'git checkout' [-f] <tree-ish> [--] <pathspec>...
'git checkout' [-f|--ours|--theirs|-m|--conflict=<style>] [<tree-ish>] --pathspec-from-file=<file> [--pathspec-file-nul] 'git checkout' [-f] <tree-ish> --pathspec-from-file=<file> [--pathspec-file-nul]
'git checkout' [-f|--ours|--theirs|-m|--conflict=<style>] [--] <pathspec>...
'git checkout' [-f|--ours|--theirs|-m|--conflict=<style>] --pathspec-from-file=<file> [--pathspec-file-nul]
'git checkout' (-p|--patch) [<tree-ish>] [--] [<pathspec>...] 'git checkout' (-p|--patch) [<tree-ish>] [--] [<pathspec>...]
DESCRIPTION DESCRIPTION
@ -260,7 +262,8 @@ and mark the resolved paths with `git add` (or `git rm` if the merge
should result in deletion of the path). should result in deletion of the path).
+ +
When checking out paths from the index, this option lets you recreate When checking out paths from the index, this option lets you recreate
the conflicted merge in the specified paths. the conflicted merge in the specified paths. This option cannot be
used when checking out paths from a tree-ish.
+ +
When switching branches with `--merge`, staged changes may be lost. When switching branches with `--merge`, staged changes may be lost.

View File

@ -78,6 +78,8 @@ all modified paths.
--theirs:: --theirs::
When restoring files in the working tree from the index, use When restoring files in the working tree from the index, use
stage #2 ('ours') or #3 ('theirs') for unmerged paths. stage #2 ('ours') or #3 ('theirs') for unmerged paths.
This option cannot be used when checking out paths from a
tree-ish (i.e. with the `--source` option).
+ +
Note that during `git rebase` and `git pull --rebase`, 'ours' and Note that during `git rebase` and `git pull --rebase`, 'ours' and
'theirs' may appear swapped. See the explanation of the same options 'theirs' may appear swapped. See the explanation of the same options
@ -87,6 +89,8 @@ in linkgit:git-checkout[1] for details.
--merge:: --merge::
When restoring files on the working tree from the index, When restoring files on the working tree from the index,
recreate the conflicted merge in the unmerged paths. recreate the conflicted merge in the unmerged paths.
This option cannot be used when checking out paths from a
tree-ish (i.e. with the `--source` option).
--conflict=<style>:: --conflict=<style>::
The same as `--merge` option above, but changes the way the The same as `--merge` option above, but changes the way the

View File

@ -523,6 +523,15 @@ static int checkout_paths(const struct checkout_opts *opts,
"--merge", "--conflict", "--staged"); "--merge", "--conflict", "--staged");
} }
/*
* recreating unmerged index entries and writing out data from
* unmerged index entries would make no sense when checking out
* of a tree-ish.
*/
if ((opts->merge || opts->writeout_stage) && opts->source_tree)
die(_("'%s', '%s', or '%s' cannot be used when checking out of a tree"),
"--merge", "--ours", "--theirs");
if (opts->patch_mode) { if (opts->patch_mode) {
enum add_p_mode patch_mode; enum add_p_mode patch_mode;
const char *rev = new_branch_info->name; const char *rev = new_branch_info->name;
@ -560,6 +569,8 @@ static int checkout_paths(const struct checkout_opts *opts,
if (opts->source_tree) if (opts->source_tree)
read_tree_some(opts->source_tree, &opts->pathspec); read_tree_some(opts->source_tree, &opts->pathspec);
if (opts->merge)
unmerge_index(&the_index, &opts->pathspec, CE_MATCHED);
ps_matched = xcalloc(opts->pathspec.nr, 1); ps_matched = xcalloc(opts->pathspec.nr, 1);
@ -583,10 +594,6 @@ static int checkout_paths(const struct checkout_opts *opts,
} }
free(ps_matched); free(ps_matched);
/* "checkout -m path" to recreate conflicted state */
if (opts->merge)
unmerge_marked_index(&the_index);
/* Any unmerged paths? */ /* Any unmerged paths? */
for (pos = 0; pos < the_index.cache_nr; pos++) { for (pos = 0; pos < the_index.cache_nr; pos++) {
const struct cache_entry *ce = the_index.cache[pos]; const struct cache_entry *ce = the_index.cache[pos];

View File

@ -609,9 +609,6 @@ static const char * const update_index_usage[] = {
NULL NULL
}; };
static struct object_id head_oid;
static struct object_id merge_head_oid;
static struct cache_entry *read_one_ent(const char *which, static struct cache_entry *read_one_ent(const char *which,
struct object_id *ent, const char *path, struct object_id *ent, const char *path,
int namelen, int stage) int namelen, int stage)
@ -642,84 +639,17 @@ static struct cache_entry *read_one_ent(const char *which,
static int unresolve_one(const char *path) static int unresolve_one(const char *path)
{ {
int namelen = strlen(path); struct string_list_item *item;
int pos; int res = 0;
int ret = 0;
struct cache_entry *ce_2 = NULL, *ce_3 = NULL;
/* See if there is such entry in the index. */ if (!the_index.resolve_undo)
pos = index_name_pos(&the_index, path, namelen); return res;
if (0 <= pos) { item = string_list_lookup(the_index.resolve_undo, path);
/* already merged */ if (!item)
pos = unmerge_index_entry_at(&the_index, pos); return res; /* no resolve-undo record for the path */
if (pos < the_index.cache_nr) { res = unmerge_index_entry(&the_index, path, item->util, 0);
const struct cache_entry *ce = the_index.cache[pos]; FREE_AND_NULL(item->util);
if (ce_stage(ce) && return res;
ce_namelen(ce) == namelen &&
!memcmp(ce->name, path, namelen))
return 0;
}
/* no resolve-undo information; fall back */
} else {
/* If there isn't, either it is unmerged, or
* resolved as "removed" by mistake. We do not
* want to do anything in the former case.
*/
pos = -pos-1;
if (pos < the_index.cache_nr) {
const struct cache_entry *ce = the_index.cache[pos];
if (ce_namelen(ce) == namelen &&
!memcmp(ce->name, path, namelen)) {
fprintf(stderr,
"%s: skipping still unmerged path.\n",
path);
goto free_return;
}
}
}
/* Grab blobs from given path from HEAD and MERGE_HEAD,
* stuff HEAD version in stage #2,
* stuff MERGE_HEAD version in stage #3.
*/
ce_2 = read_one_ent("our", &head_oid, path, namelen, 2);
ce_3 = read_one_ent("their", &merge_head_oid, path, namelen, 3);
if (!ce_2 || !ce_3) {
ret = -1;
goto free_return;
}
if (oideq(&ce_2->oid, &ce_3->oid) &&
ce_2->ce_mode == ce_3->ce_mode) {
fprintf(stderr, "%s: identical in both, skipping.\n",
path);
goto free_return;
}
remove_file_from_index(&the_index, path);
if (add_index_entry(&the_index, ce_2, ADD_CACHE_OK_TO_ADD)) {
error("%s: cannot add our version to the index.", path);
ret = -1;
goto free_return;
}
if (!add_index_entry(&the_index, ce_3, ADD_CACHE_OK_TO_ADD))
return 0;
error("%s: cannot add their version to the index.", path);
ret = -1;
free_return:
discard_cache_entry(ce_2);
discard_cache_entry(ce_3);
return ret;
}
static void read_head_pointers(void)
{
if (read_ref("HEAD", &head_oid))
die("No HEAD -- no initial commit yet?");
if (read_ref("MERGE_HEAD", &merge_head_oid)) {
fprintf(stderr, "Not in the middle of a merge.\n");
exit(0);
}
} }
static int do_unresolve(int ac, const char **av, static int do_unresolve(int ac, const char **av,
@ -728,11 +658,6 @@ static int do_unresolve(int ac, const char **av,
int i; int i;
int err = 0; int err = 0;
/* Read HEAD and MERGE_HEAD; if MERGE_HEAD does not exist, we
* are not doing a merge, so exit with success status.
*/
read_head_pointers();
for (i = 1; i < ac; i++) { for (i = 1; i < ac; i++) {
const char *arg = av[i]; const char *arg = av[i];
char *p = prefix_path(prefix, prefix_length, arg); char *p = prefix_path(prefix, prefix_length, arg);
@ -751,6 +676,7 @@ static int do_reupdate(const char **paths,
int pos; int pos;
int has_head = 1; int has_head = 1;
struct pathspec pathspec; struct pathspec pathspec;
struct object_id head_oid;
parse_pathspec(&pathspec, 0, parse_pathspec(&pathspec, 0,
PATHSPEC_PREFER_CWD, PATHSPEC_PREFER_CWD,

View File

@ -1112,7 +1112,7 @@ int rerere_forget(struct repository *r, struct pathspec *pathspec)
* recover the original conflicted state and then * recover the original conflicted state and then
* find the conflicted paths. * find the conflicted paths.
*/ */
unmerge_index(r->index, pathspec); unmerge_index(r->index, pathspec, 0);
find_conflict(r, &conflict); find_conflict(r, &conflict);
for (i = 0; i < conflict.nr; i++) { for (i = 0; i < conflict.nr; i++) {
struct string_list_item *it = &conflict.items[i]; struct string_list_item *it = &conflict.items[i];

View File

@ -117,86 +117,59 @@ void resolve_undo_clear_index(struct index_state *istate)
istate->cache_changed |= RESOLVE_UNDO_CHANGED; istate->cache_changed |= RESOLVE_UNDO_CHANGED;
} }
int unmerge_index_entry_at(struct index_state *istate, int pos) int unmerge_index_entry(struct index_state *istate, const char *path,
struct resolve_undo_info *ru, unsigned ce_flags)
{ {
const struct cache_entry *ce; int i = index_name_pos(istate, path, strlen(path));
struct string_list_item *item;
struct resolve_undo_info *ru;
int i, err = 0, matched;
char *name;
if (!istate->resolve_undo) if (i < 0) {
return pos; /* unmerged? */
i = -i - 1;
ce = istate->cache[pos]; if (i < istate->cache_nr &&
if (ce_stage(ce)) { !strcmp(istate->cache[i]->name, path))
/* already unmerged */ /* yes, it is already unmerged */
while ((pos < istate->cache_nr) && return 0;
! strcmp(istate->cache[pos]->name, ce->name)) /* fallthru: resolved to removal */
pos++; } else {
return pos - 1; /* return the last entry processed */ /* merged - remove it to replace it with unmerged entries */
remove_index_entry_at(istate, i);
} }
item = string_list_lookup(istate->resolve_undo, ce->name);
if (!item)
return pos;
ru = item->util;
if (!ru)
return pos;
matched = ce->ce_flags & CE_MATCHED;
name = xstrdup(ce->name);
remove_index_entry_at(istate, pos);
for (i = 0; i < 3; i++) { for (i = 0; i < 3; i++) {
struct cache_entry *nce; struct cache_entry *ce;
if (!ru->mode[i]) if (!ru->mode[i])
continue; continue;
nce = make_cache_entry(istate, ce = make_cache_entry(istate, ru->mode[i], &ru->oid[i],
ru->mode[i], path, i + 1, 0);
&ru->oid[i], ce->ce_flags |= ce_flags;
name, i + 1, 0); if (add_index_entry(istate, ce, ADD_CACHE_OK_TO_ADD))
if (matched) return error("cannot unmerge '%s'", path);
nce->ce_flags |= CE_MATCHED;
if (add_index_entry(istate, nce, ADD_CACHE_OK_TO_ADD)) {
err = 1;
error("cannot unmerge '%s'", name);
}
} }
free(name); return 0;
if (err)
return pos;
free(ru);
item->util = NULL;
return unmerge_index_entry_at(istate, pos);
} }
void unmerge_marked_index(struct index_state *istate) void unmerge_index(struct index_state *istate, const struct pathspec *pathspec,
unsigned ce_flags)
{ {
int i; struct string_list_item *item;
if (!istate->resolve_undo) if (!istate->resolve_undo)
return; return;
/* TODO: audit for interaction with sparse-index. */ /* TODO: audit for interaction with sparse-index. */
ensure_full_index(istate); ensure_full_index(istate);
for (i = 0; i < istate->cache_nr; i++) {
const struct cache_entry *ce = istate->cache[i];
if (ce->ce_flags & CE_MATCHED)
i = unmerge_index_entry_at(istate, i);
}
}
void unmerge_index(struct index_state *istate, const struct pathspec *pathspec) for_each_string_list_item(item, istate->resolve_undo) {
{ const char *path = item->string;
int i; struct resolve_undo_info *ru = item->util;
if (!item->util)
if (!istate->resolve_undo)
return;
/* TODO: audit for interaction with sparse-index. */
ensure_full_index(istate);
for (i = 0; i < istate->cache_nr; i++) {
const struct cache_entry *ce = istate->cache[i];
if (!ce_path_match(istate, ce, pathspec, NULL))
continue; continue;
i = unmerge_index_entry_at(istate, i); if (!match_pathspec(istate, pathspec,
item->string, strlen(item->string),
0, NULL, 0))
continue;
unmerge_index_entry(istate, path, ru, ce_flags);
free(ru);
item->util = NULL;
} }
} }

View File

@ -17,8 +17,7 @@ void record_resolve_undo(struct index_state *, struct cache_entry *);
void resolve_undo_write(struct strbuf *, struct string_list *); void resolve_undo_write(struct strbuf *, struct string_list *);
struct string_list *resolve_undo_read(const char *, unsigned long); struct string_list *resolve_undo_read(const char *, unsigned long);
void resolve_undo_clear_index(struct index_state *); void resolve_undo_clear_index(struct index_state *);
int unmerge_index_entry_at(struct index_state *, int); int unmerge_index_entry(struct index_state *, const char *, struct resolve_undo_info *, unsigned);
void unmerge_index(struct index_state *, const struct pathspec *); void unmerge_index(struct index_state *, const struct pathspec *, unsigned);
void unmerge_marked_index(struct index_state *);
#endif #endif

View File

@ -37,11 +37,17 @@ prime_resolve_undo () {
git checkout second^0 && git checkout second^0 &&
test_tick && test_tick &&
test_must_fail git merge third^0 && test_must_fail git merge third^0 &&
echo merge does not leave anything &&
check_resolve_undo empty && check_resolve_undo empty &&
echo different >fi/le &&
git add fi/le && # how should the conflict be resolved?
echo resolving records && case "$1" in
remove)
rm -f file/le && git rm fi/le
;;
*) # modify
echo different >fi/le && git add fi/le
;;
esac
check_resolve_undo recorded fi/le initial:fi/le second:fi/le third:fi/le check_resolve_undo recorded fi/le initial:fi/le second:fi/le third:fi/le
} }
@ -122,6 +128,37 @@ test_expect_success 'add records checkout -m undoes' '
test_expect_success 'unmerge with plumbing' ' test_expect_success 'unmerge with plumbing' '
prime_resolve_undo && prime_resolve_undo &&
git update-index --unresolve fi/le && git update-index --unresolve fi/le &&
git ls-files --resolve-undo fi/le >actual &&
test_must_be_empty actual &&
git ls-files -u >actual &&
test_line_count = 3 actual
'
test_expect_success 'unmerge can be done even after committing' '
prime_resolve_undo &&
git commit -m "record to nuke MERGE_HEAD" &&
git update-index --unresolve fi/le &&
git ls-files --resolve-undo fi/le >actual &&
test_must_be_empty actual &&
git ls-files -u >actual &&
test_line_count = 3 actual
'
test_expect_success 'unmerge removal' '
prime_resolve_undo remove &&
git update-index --unresolve fi/le &&
git ls-files --resolve-undo fi/le >actual &&
test_must_be_empty actual &&
git ls-files -u >actual &&
test_line_count = 3 actual
'
test_expect_success 'unmerge removal after committing' '
prime_resolve_undo remove &&
git commit -m "record to nuke MERGE_HEAD" &&
git update-index --unresolve fi/le &&
git ls-files --resolve-undo fi/le >actual &&
test_must_be_empty actual &&
git ls-files -u >actual && git ls-files -u >actual &&
test_line_count = 3 actual test_line_count = 3 actual
' '

View File

@ -137,11 +137,78 @@ test_expect_success 'restore --staged invalidates cache tree for deletions' '
test_must_fail git rev-parse HEAD:new1 test_must_fail git rev-parse HEAD:new1
' '
test_expect_success 'restore with merge options rejects --staged' ' test_expect_success 'restore --merge to unresolve' '
O=$(echo original | git hash-object -w --stdin) &&
A=$(echo ourside | git hash-object -w --stdin) &&
B=$(echo theirside | git hash-object -w --stdin) &&
{
echo "100644 $O 1 file" &&
echo "100644 $A 2 file" &&
echo "100644 $B 3 file"
} | git update-index --index-info &&
echo nothing >file &&
git restore --worktree --merge file &&
cat >expect <<-\EOF &&
<<<<<<< ours
ourside
=======
theirside
>>>>>>> theirs
EOF
test_cmp expect file
'
test_expect_success 'restore --merge to unresolve after (mistaken) resolution' '
O=$(echo original | git hash-object -w --stdin) &&
A=$(echo ourside | git hash-object -w --stdin) &&
B=$(echo theirside | git hash-object -w --stdin) &&
{
echo "100644 $O 1 file" &&
echo "100644 $A 2 file" &&
echo "100644 $B 3 file"
} | git update-index --index-info &&
echo nothing >file &&
git add file &&
git restore --worktree --merge file &&
cat >expect <<-\EOF &&
<<<<<<< ours
ourside
=======
theirside
>>>>>>> theirs
EOF
test_cmp expect file
'
test_expect_success 'restore --merge to unresolve after (mistaken) resolution' '
O=$(echo original | git hash-object -w --stdin) &&
A=$(echo ourside | git hash-object -w --stdin) &&
B=$(echo theirside | git hash-object -w --stdin) &&
{
echo "100644 $O 1 file" &&
echo "100644 $A 2 file" &&
echo "100644 $B 3 file"
} | git update-index --index-info &&
git rm -f file &&
git restore --worktree --merge file &&
cat >expect <<-\EOF &&
<<<<<<< ours
ourside
=======
theirside
>>>>>>> theirs
EOF
test_cmp expect file
'
test_expect_success 'restore with merge options are incompatible with certain options' '
for opts in \ for opts in \
"--staged --ours" \ "--staged --ours" \
"--staged --theirs" \ "--staged --theirs" \
"--staged --merge" \ "--staged --merge" \
"--source=HEAD --ours" \
"--source=HEAD --theirs" \
"--source=HEAD --merge" \
"--staged --conflict=diff3" \ "--staged --conflict=diff3" \
"--staged --worktree --ours" \ "--staged --worktree --ours" \
"--staged --worktree --theirs" \ "--staged --worktree --theirs" \
@ -149,7 +216,7 @@ test_expect_success 'restore with merge options rejects --staged' '
"--staged --worktree --conflict=zdiff3" "--staged --worktree --conflict=zdiff3"
do do
test_must_fail git restore $opts . 2>err && test_must_fail git restore $opts . 2>err &&
grep "cannot be used with --staged" err || return grep "cannot be used" err || return
done done
' '

View File

@ -497,6 +497,11 @@ test_expect_success 'checkout unmerged stage' '
test ztheirside = "z$(cat file)" test ztheirside = "z$(cat file)"
' '
test_expect_success 'checkout path with --merge from tree-ish is a no-no' '
setup_conflicting_index &&
test_must_fail git checkout -m HEAD -- file
'
test_expect_success 'checkout with --merge' ' test_expect_success 'checkout with --merge' '
setup_conflicting_index && setup_conflicting_index &&
echo "none of the above" >sample && echo "none of the above" >sample &&
@ -517,6 +522,48 @@ test_expect_success 'checkout with --merge' '
test_cmp merged file test_cmp merged file
' '
test_expect_success 'checkout -m works after (mistaken) resolution' '
setup_conflicting_index &&
echo "none of the above" >sample &&
cat sample >fild &&
cat sample >file &&
cat sample >filf &&
# resolve to something
git add file &&
git checkout --merge -- fild file filf &&
{
echo "<<<<<<< ours" &&
echo ourside &&
echo "=======" &&
echo theirside &&
echo ">>>>>>> theirs"
} >merged &&
test_cmp expect fild &&
test_cmp expect filf &&
test_cmp merged file
'
test_expect_success 'checkout -m works after (mistaken) resolution to remove' '
setup_conflicting_index &&
echo "none of the above" >sample &&
cat sample >fild &&
cat sample >file &&
cat sample >filf &&
# resolve to remove
git rm file &&
git checkout --merge -- fild file filf &&
{
echo "<<<<<<< ours" &&
echo ourside &&
echo "=======" &&
echo theirside &&
echo ">>>>>>> theirs"
} >merged &&
test_cmp expect fild &&
test_cmp expect filf &&
test_cmp merged file
'
test_expect_success 'checkout with --merge, in diff3 -m style' ' test_expect_success 'checkout with --merge, in diff3 -m style' '
git config merge.conflictstyle diff3 && git config merge.conflictstyle diff3 &&
setup_conflicting_index && setup_conflicting_index &&