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 0fcd473fdd3 (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 <ps@pks.im> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Patrick Steinhardt committed Apr 30, 2025 at 14:44 UTC 8583c9dcbc7d362250c0310e4cee771ec5003327
2 files changed +79 -6
builtin/mv.c
+59 -2
@@ -37,6 +37,13 @@ enum update_mode {
37 INDEX = (1 << 2),
38 SPARSE = (1 << 3),
39 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),
47 };
48
49 #define DUP_BASENAME 1
@@ -181,6 +188,21 @@ static void remove_empty_src_dirs(const char **src_dir, size_t src_dir_nr)
188 strbuf_release(&a_src_dir);
189 }
190
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 +
206 int cmd_mv(int argc,
207 const char **argv,
208 const char *prefix,
@@ -211,6 +233,8 @@ int cmd_mv(int argc,
233 struct cache_entry *ce;
234 struct string_list only_match_skip_worktree = STRING_LIST_INIT_DUP;
235 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;
238 int ret;
239
240 git_config(git_default_config, NULL);
@@ -329,6 +353,7 @@ int cmd_mv(int argc,
353
354 dir_check:
355 if (S_ISDIR(st.st_mode)) {
356 + struct pathmap_entry *entry;
357 char *dst_with_slash;
358 size_t dst_with_slash_len;
359 int j, n;
@@ -346,6 +371,11 @@ dir_check:
371 goto act_on_entry;
372 }
373
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 +
379 /* last - first >= 1 */
380 modes[i] |= WORKING_DIRECTORY;
381
@@ -366,8 +396,7 @@ dir_check:
396 strvec_push(&sources, path);
397 strvec_push(&destinations, prefixed_path);
398
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);
400 submodule_gitfiles[argc + j] = NULL;
401
402 free(prefixed_path);
@@ -463,6 +492,32 @@ remove_entry:
492 }
493 }
494
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 +
521 if (only_match_skip_worktree.nr) {
522 advise_on_updating_sparse_paths(&only_match_skip_worktree);
523 if (!ignore_errors) {
@@ -587,6 +642,8 @@ out:
642 strvec_clear(&dest_paths);
643 strvec_clear(&destinations);
644 strvec_clear(&submodule_gitfiles_to_free);
645 + hashmap_clear_and_free(&moved_dirs, struct pathmap_entry, ent);
646 + strbuf_release(&pathbuf);
647 free(submodule_gitfiles);
648 free(modes);
649 return ret;
t/t7001-mv.sh
+20 -4
@@ -550,16 +550,32 @@ test_expect_success 'moving nested submodules' '
550 git status
551 '
552
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' '
554 test_when_finished git reset --hard HEAD &&
555 git reset --hard HEAD &&
556 mkdir -p a &&
557 mkdir -p b &&
558 >a/a.txt &&
559 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
579 '
580
581 test_done