Skip to content

Commit 8583c9d

Browse files
pks-tgitster
authored andcommitted
builtin/mv: bail out when trying to move child and its parent
We have a known issue in git-mv(1) where moving both a child and any of its parents causes an assert to trigger because the child cannot be found anymore in the index. We have added a test for this in commit 0fcd473 (t7001: add failure test which triggers assertion, 2024-10-22) without addressing the issue, which is why the test itself is marked as `test_expect_failure`. The behaviour of that test relies on a call to assert(3p) though, which may or may not be compiled into the resulting binary depending on whether or not we pass `-DNDEBUG`. When these asserts are compiled into Git this may cause our CI to hang on Windows though, because asserts may cause a modal window to be shown. While we could work around the issue by converting this into a call to `BUG()`, let's rather address the root cause of the issue by bailing out in case we see that both a child and any of its parents are being moved in the same command. Signed-off-by: Patrick Steinhardt <[email protected]> Signed-off-by: Junio C Hamano <[email protected]>
1 parent f93ff17 commit 8583c9d

File tree

2 files changed

+79
-6
lines changed

2 files changed

+79
-6
lines changed

builtin/mv.c

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ enum update_mode {
3737
INDEX = (1 << 2),
3838
SPARSE = (1 << 3),
3939
SKIP_WORKTREE_DIR = (1 << 4),
40+
/*
41+
* A file gets moved implicitly via a move of one of its parent
42+
* directories. This flag causes us to skip the check that we don't try
43+
* to move a file and any of its parent directories at the same point
44+
* in time.
45+
*/
46+
MOVE_VIA_PARENT_DIR = (1 << 5),
4047
};
4148

4249
#define DUP_BASENAME 1
@@ -181,6 +188,21 @@ static void remove_empty_src_dirs(const char **src_dir, size_t src_dir_nr)
181188
strbuf_release(&a_src_dir);
182189
}
183190

191+
struct pathmap_entry {
192+
struct hashmap_entry ent;
193+
const char *path;
194+
};
195+
196+
static int pathmap_cmp(const void *cmp_data UNUSED,
197+
const struct hashmap_entry *a,
198+
const struct hashmap_entry *b,
199+
const void *key UNUSED)
200+
{
201+
const struct pathmap_entry *e1 = container_of(a, struct pathmap_entry, ent);
202+
const struct pathmap_entry *e2 = container_of(b, struct pathmap_entry, ent);
203+
return fspathcmp(e1->path, e2->path);
204+
}
205+
184206
int cmd_mv(int argc,
185207
const char **argv,
186208
const char *prefix,
@@ -211,6 +233,8 @@ int cmd_mv(int argc,
211233
struct cache_entry *ce;
212234
struct string_list only_match_skip_worktree = STRING_LIST_INIT_DUP;
213235
struct string_list dirty_paths = STRING_LIST_INIT_DUP;
236+
struct hashmap moved_dirs = HASHMAP_INIT(pathmap_cmp, NULL);
237+
struct strbuf pathbuf = STRBUF_INIT;
214238
int ret;
215239

216240
git_config(git_default_config, NULL);
@@ -329,6 +353,7 @@ int cmd_mv(int argc,
329353

330354
dir_check:
331355
if (S_ISDIR(st.st_mode)) {
356+
struct pathmap_entry *entry;
332357
char *dst_with_slash;
333358
size_t dst_with_slash_len;
334359
int j, n;
@@ -346,6 +371,11 @@ int cmd_mv(int argc,
346371
goto act_on_entry;
347372
}
348373

374+
entry = xmalloc(sizeof(*entry));
375+
entry->path = src;
376+
hashmap_entry_init(&entry->ent, fspathhash(src));
377+
hashmap_add(&moved_dirs, &entry->ent);
378+
349379
/* last - first >= 1 */
350380
modes[i] |= WORKING_DIRECTORY;
351381

@@ -366,8 +396,7 @@ int cmd_mv(int argc,
366396
strvec_push(&sources, path);
367397
strvec_push(&destinations, prefixed_path);
368398

369-
memset(modes + argc + j, 0, sizeof(enum update_mode));
370-
modes[argc + j] |= ce_skip_worktree(ce) ? SPARSE : INDEX;
399+
modes[argc + j] = MOVE_VIA_PARENT_DIR | (ce_skip_worktree(ce) ? SPARSE : INDEX);
371400
submodule_gitfiles[argc + j] = NULL;
372401

373402
free(prefixed_path);
@@ -463,6 +492,32 @@ int cmd_mv(int argc,
463492
}
464493
}
465494

495+
for (i = 0; i < argc; i++) {
496+
const char *slash_pos;
497+
498+
if (modes[i] & MOVE_VIA_PARENT_DIR)
499+
continue;
500+
501+
strbuf_reset(&pathbuf);
502+
strbuf_addstr(&pathbuf, sources.v[i]);
503+
504+
slash_pos = strrchr(pathbuf.buf, '/');
505+
while (slash_pos > pathbuf.buf) {
506+
struct pathmap_entry needle;
507+
508+
strbuf_setlen(&pathbuf, slash_pos - pathbuf.buf);
509+
510+
needle.path = pathbuf.buf;
511+
hashmap_entry_init(&needle.ent, fspathhash(pathbuf.buf));
512+
513+
if (hashmap_get_entry(&moved_dirs, &needle, ent, NULL))
514+
die(_("cannot move both '%s' and its parent directory '%s'"),
515+
sources.v[i], pathbuf.buf);
516+
517+
slash_pos = strrchr(pathbuf.buf, '/');
518+
}
519+
}
520+
466521
if (only_match_skip_worktree.nr) {
467522
advise_on_updating_sparse_paths(&only_match_skip_worktree);
468523
if (!ignore_errors) {
@@ -587,6 +642,8 @@ int cmd_mv(int argc,
587642
strvec_clear(&dest_paths);
588643
strvec_clear(&destinations);
589644
strvec_clear(&submodule_gitfiles_to_free);
645+
hashmap_clear_and_free(&moved_dirs, struct pathmap_entry, ent);
646+
strbuf_release(&pathbuf);
590647
free(submodule_gitfiles);
591648
free(modes);
592649
return ret;

t/t7001-mv.sh

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -550,16 +550,32 @@ test_expect_success 'moving nested submodules' '
550550
git status
551551
'
552552

553-
test_expect_failure 'nonsense mv triggers assertion failure and partially updated index' '
553+
test_expect_success 'moving file and its parent directory at the same time fails' '
554554
test_when_finished git reset --hard HEAD &&
555555
git reset --hard HEAD &&
556556
mkdir -p a &&
557557
mkdir -p b &&
558558
>a/a.txt &&
559559
git add a/a.txt &&
560-
test_must_fail git mv a/a.txt a b &&
561-
git status --porcelain >actual &&
562-
grep "^A[ ]*a/a.txt$" actual
560+
cat >expect <<-EOF &&
561+
fatal: cannot move both ${SQ}a/a.txt${SQ} and its parent directory ${SQ}a${SQ}
562+
EOF
563+
test_must_fail git mv a/a.txt a b 2>err &&
564+
test_cmp expect err
565+
'
566+
567+
test_expect_success 'moving nested directory and its parent directory at the same time fails' '
568+
test_when_finished git reset --hard HEAD &&
569+
git reset --hard HEAD &&
570+
mkdir -p a/b/c &&
571+
>a/b/c/file.txt &&
572+
git add a &&
573+
mkdir target &&
574+
cat >expect <<-EOF &&
575+
fatal: cannot move both ${SQ}a/b/c${SQ} and its parent directory ${SQ}a${SQ}
576+
EOF
577+
test_must_fail git mv a/b/c a target 2>err &&
578+
test_cmp expect err
563579
'
564580

565581
test_done

0 commit comments

Comments
 (0)