{"thread":{"id":"66005","subject":"[PATCH] mv: report missing destination leading directory","startedAt":"2026-07-15T14:32:47Z","lastAt":"2026-07-30T20:13:43Z","messageCount":28,"participants":["Lucas Zamboni Orioli via GitGitGadget","Ben Knoble","Lucas Zamboni Orioli","Junio C Hamano","Pablo Sabater"],"isPatch":true,"patchVersion":1,"patchTotal":null},"messages":[{"id":"548279","messageId":"pull.2356.git.git.1784125963694.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":null,"subject":"[PATCH] mv: report missing destination leading directory","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-15T14:32:43Z","receivedAt":"2026-07-15T14:32:47Z","isPatch":true,"body":"From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n\nWhen moving a file to a destination whose leading directory does not\nexist, \"git mv\" fails at the rename(2) syscall with ENOENT. Because\nthe error is reported via die_errno() using only the source path:\n\n    fatal: renaming 'src' failed: No such file or directory\n\nthe message misleadingly blames the source, even though it is the\ndestination's parent directory that is missing. A user who runs\n\n    git mv a/file b/does-not-exist/file\n\nis told the problem is with 'a/file', which exists, giving no hint\nthat 'b/does-not-exist/' needs to be created first.\n\nThe checking phase already rejects a missing destination directory\nwhen the destination ends in a slash, but a destination that names a\nfile inside a non-existent directory is not caught and only fails\nlater at rename(2). As a result \"git mv -n\" also fails to detect the\nproblem, since the dry run never reaches the syscall and reports a\nmove that would not actually succeed.\n\nDetect this during the checking phase instead: for entries that will\nbe renamed on disk, stat the destination's leading directory and, if\nit is missing, fail with the existing \"destination directory does not\nexist\" message. Guard the check with the same condition under which\nrename(2) is invoked so that directory moves, whose child entries are\nexpanded to paths under a not-yet-created directory, and sparse or\nout-of-cone destinations, which are not written to the worktree, are\nnot flagged incorrectly.\n\nThis gives a clear message and lets \"git mv -n\" report the failure.\n\nSigned-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n---\n    mv: report missing destination leading directory\n\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2356%2FZamboniL%2Fmv-detect-non-existing-target-folder-v1\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2356/ZamboniL/mv-detect-non-existing-target-folder-v1\nPull-Request: https://github.com/git/git/pull/2356\n\n builtin/mv.c  | 21 +++++++++++++++++++++\n t/t7001-mv.sh | 14 ++++++++++++++\n 2 files changed, 35 insertions(+)\n\ndiff --git a/builtin/mv.c b/builtin/mv.c\nindex e03823370c..a95531f0b2 100644\n--- a/builtin/mv.c\n+++ b/builtin/mv.c\n@@ -444,6 +444,27 @@ dir_check:\n \t\t\tgoto act_on_entry;\n \t\t}\n \n+\t\t/*\n+\t\t* If we are going to move SRC to DST on disk, DST's leading\n+\t\t* directories must already exist.\n+\t\t*/\n+\t\tif (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n+\t\t\t\t!(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n+\t\t\t\tchar *dst_dir = xstrdup(dst);\n+\t\t\t\tchar *slash = strrchr(dst_dir, '/');\n+\n+\t\t\t\tif (slash) {\n+\t\t\t\t\t\tstruct stat dir_st;\n+\t\t\t\t\t\t*slash = '\\0';\n+\t\t\t\t\t\tif (lstat(dst_dir, &dir_st) < 0 && errno == ENOENT) {\n+\t\t\t\t\t\t\t\tfree(dst_dir);\n+\t\t\t\t\t\t\t\tbad = _(\"destination directory does not exist\");\n+\t\t\t\t\t\t\t\tgoto act_on_entry;\n+\t\t\t\t\t\t}\n+\t\t\t\t}\n+\t\t\t\tfree(dst_dir);\n+\t\t}\n+\n \t\tif (ignore_sparse &&\n \t\t    (dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&\n \t\t    index_entry_exists(the_repository->index, dst, strlen(dst))) {\ndiff --git a/t/t7001-mv.sh b/t/t7001-mv.sh\nindex 920479e925..8a45997b33 100755\n--- a/t/t7001-mv.sh\n+++ b/t/t7001-mv.sh\n@@ -114,6 +114,20 @@ test_expect_success 'clean up' '\n \tgit reset --hard\n '\n \n+test_expect_success 'moving to non-existent destination parent directory' '\n+\tgit reset --hard &&\n+\tmkdir -p from &&\n+\techo content >from/file &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv from/file no-such-dir/file 2>actual &&\n+\ttest_grep \"destination directory does not exist\" actual\n+'\n+\n+test_expect_success 'mv --dry-run detects non-existent destination parent directory' '\n+\ttest_must_fail git mv -n from/file no-such-dir/file 2>actual &&\n+\ttest_grep \"destination directory does not exist\" actual\n+'\n+\n test_expect_success 'moving to existing untracked target with trailing slash' '\n \tmkdir path1 &&\n \tgit mv path0/ path1/ &&\n\nbase-commit: 55526a18268bbc1ddaf8a6b7850c33d984eac9e9\n-- \ngitgitgadget\n"},{"id":"548305","messageId":"C6C7AB29-7027-467B-8DCC-3443CC356628@gmail.com","threadId":"66005","inReplyTo":"pull.2356.git.git.1784125963694.gitgitgadget@gmail.com","subject":"Re: [PATCH] mv: report missing destination leading directory","fromName":"Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-07-15T16:46:44Z","receivedAt":"2026-07-15T16:50:09Z","isPatch":true,"body":"\n> Le 15 juil. 2026 à 10:51, Lucas Zamboni Orioli via GitGitGadget <gitgitgadget@gmail.com> a écrit :\n> \n> ﻿From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n> \n> When moving a file to a destination whose leading directory does not\n> exist, \"git mv\" fails at the rename(2) syscall with ENOENT. Because\n> the error is reported via die_errno() using only the source path:\n> \n>    fatal: renaming 'src' failed: No such file or directory\n> \n> the message misleadingly blames the source, even though it is the\n> destination's parent directory that is missing. A user who runs\n> \n>    git mv a/file b/does-not-exist/file\n> \n> is told the problem is with 'a/file', which exists, giving no hint\n> that 'b/does-not-exist/' needs to be created first.\n> \n> The checking phase already rejects a missing destination directory\n> when the destination ends in a slash, but a destination that names a\n> file inside a non-existent directory is not caught and only fails\n> later at rename(2). As a result \"git mv -n\" also fails to detect the\n> problem, since the dry run never reaches the syscall and reports a\n> move that would not actually succeed.\n> \n> Detect this during the checking phase instead: for entries that will\n> be renamed on disk, stat the destination's leading directory and, if\n> it is missing, fail with the existing \"destination directory does not\n> exist\" message. Guard the check with the same condition under which\n> rename(2) is invoked so that directory moves, whose child entries are\n> expanded to paths under a not-yet-created directory, and sparse or\n> out-of-cone destinations, which are not written to the worktree, are\n> not flagged incorrectly.\n\nI suppose this still allows a TOCTOU issue where the check succeeds and (with lucky timing) the destination then disappears?\n\nIn that case, I think a worthwhile additional change would also be for the error message to diagnose which file is missing (or at least include both source and destination).\n\nNow, without checking I somehow doubt whether rename(2) tells us which entry is missing. Worse, if we check afterwards, we could have a « TOUTOC » :p where the entry reappears to confuse the error diagnosis.\n\nSo perhaps\n\n    fatal: renaming A -> B failed: no such file or directory\n\ntaking some inspiration from the -i modes of cp, mv?\n\n> This gives a clear message and lets \"git mv -n\" report the failure.\n> \n> Signed-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n> ---\n>    mv: report missing destination leading directory\n> \n> Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2356%2FZamboniL%2Fmv-detect-non-existing-target-folder-v1\n> Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2356/ZamboniL/mv-detect-non-existing-target-folder-v1\n> Pull-Request: https://github.com/git/git/pull/2356\n> \n> builtin/mv.c  | 21 +++++++++++++++++++++\n> t/t7001-mv.sh | 14 ++++++++++++++\n> 2 files changed, 35 insertions(+)\n> \n> diff --git a/builtin/mv.c b/builtin/mv.c\n> index e03823370c..a95531f0b2 100644\n> --- a/builtin/mv.c\n> +++ b/builtin/mv.c\n> @@ -444,6 +444,27 @@ dir_check:\n>            goto act_on_entry;\n>        }\n> \n> +        /*\n> +        * If we are going to move SRC to DST on disk, DST's leading\n> +        * directories must already exist.\n> +        */\n> +        if (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n> +                !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n> +                char *dst_dir = xstrdup(dst);\n> +                char *slash = strrchr(dst_dir, '/');\n> +\n> +                if (slash) {\n> +                        struct stat dir_st;\n> +                        *slash = '\\0';\n> +                        if (lstat(dst_dir, &dir_st) < 0 && errno == ENOENT) {\n> +                                free(dst_dir);\n> +                                bad = _(\"destination directory does not exist\");\n> +                                goto act_on_entry;\n> +                        }\n> +                }\n> +                free(dst_dir);\n> +        }\n> +\n>        if (ignore_sparse &&\n>            (dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&\n>            index_entry_exists(the_repository->index, dst, strlen(dst))) {\n> diff --git a/t/t7001-mv.sh b/t/t7001-mv.sh\n> index 920479e925..8a45997b33 100755\n> --- a/t/t7001-mv.sh\n> +++ b/t/t7001-mv.sh\n> @@ -114,6 +114,20 @@ test_expect_success 'clean up' '\n>    git reset --hard\n> '\n> \n> +test_expect_success 'moving to non-existent destination parent directory' '\n> +    git reset --hard &&\n> +    mkdir -p from &&\n> +    echo content >from/file &&\n> +    git add from/file &&\n> +    test_must_fail git mv from/file no-such-dir/file 2>actual &&\n> +    test_grep \"destination directory does not exist\" actual\n> +'\n> +\n> +test_expect_success 'mv --dry-run detects non-existent destination parent directory' '\n> +    test_must_fail git mv -n from/file no-such-dir/file 2>actual &&\n> +    test_grep \"destination directory does not exist\" actual\n> +'\n> +\n> test_expect_success 'moving to existing untracked target with trailing slash' '\n>    mkdir path1 &&\n>    git mv path0/ path1/ &&\n> \n> base-commit: 55526a18268bbc1ddaf8a6b7850c33d984eac9e9\n> --\n> gitgitgadget\n> \n"},{"id":"548798","messageId":"CAH01Q-9vWb0j3=W=vZ4yrAvaofabeZH2hYR8m_haviqZnp6DHg@mail.gmail.com","threadId":"66005","inReplyTo":"C6C7AB29-7027-467B-8DCC-3443CC356628@gmail.com","subject":"Re: [PATCH] mv: report missing destination leading directory","fromName":"Lucas Zamboni Orioli","fromEmail":"lucaszam0@gmail.com","sentAt":"2026-07-22T21:32:12Z","receivedAt":"2026-07-22T21:32:27Z","isPatch":true,"body":"Em qua., 15 de jul. de 2026 às 13:50, Ben Knoble\n<ben.knoble@gmail.com> escreveu:\n> I suppose this still allows a TOCTOU issue where the check succeeds and (with lucky timing) the destination then disappears?\n\nThank you for the feedback, also great catch, this does end up with a\nTOCTOU issue.\n\n>\n> In that case, I think a worthwhile additional change would also be for the error message to diagnose which file is missing (or at least include both source and destination).\n>\n> Now, without checking I somehow doubt whether rename(2) tells us which entry is missing. Worse, if we check afterwards, we could have a « TOUTOC » :p where the entry reappears to confuse the error diagnosis.\n\nI think your suggestion of including both source and destination in the\nerror message is a good solution, I verified rename(2) just in case and\nit does not provide the information about which file is missing.\n\nSo what I'm thinking of doing is change the error message to\n\n        fatal: renaming 'source/file' to 'destination/file' failed: No\n        such file or directory\n\n'%s' to '%s' seems to be more in the pattern of other git messages\ninstead of the cp arrow style.\n\nSo for v2 I'll split this into two commits:\n\n        1. mv: name both source and destination when rename fails\n                (the die_errno change is race-free and always applicable)\n        2. mv: check for missing destination directory before renaming\n                (the checking-phase/dry-run detection)\n\nThe first stands on its own even if the second is dropped, so I'll\norder it first.\n\nSince this introduces a new message I'll leave the po/ files to the\nl10n team, the new message adds one string and the early check reuses\nthe existing\n'destination directory does not exist' one.\n"},{"id":"548809","messageId":"pull.2356.v2.git.git.1784812390.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":"pull.2356.git.git.1784125963694.gitgitgadget@gmail.com","subject":"[PATCH v2 0/2] mv: report missing destination leading directory","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-23T13:13:08Z","receivedAt":"2026-07-23T13:13:15Z","isPatch":true,"body":"Changes since v1:\n\n * altered the error message to include both source and destination as\n   suggested by Ben Knoble\n\nLucas Zamboni Orioli (2):\n  mv: name both source and destination when rename fails\n  mv: check for missing destination directory before renaming\n\n builtin/mv.c  | 23 ++++++++++++++++++++++-\n t/t7001-mv.sh | 14 ++++++++++++++\n 2 files changed, 36 insertions(+), 1 deletion(-)\n\n\nbase-commit: 9a0c4701dcd5725c4184599322b52933ff5005ca\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2356%2FZamboniL%2Fmv-detect-non-existing-target-folder-v2\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2356/ZamboniL/mv-detect-non-existing-target-folder-v2\nPull-Request: https://github.com/git/git/pull/2356\n\nRange-diff vs v1:\n\n -:  ---------- > 1:  0d67da588b mv: name both source and destination when rename fails\n 1:  692f44456f ! 2:  1a790e0016 mv: report missing destination leading directory\n     @@ Metadata\n      Author: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n      \n       ## Commit message ##\n     -    mv: report missing destination leading directory\n     +    mv: check for missing destination directory before renaming\n      \n     -    When moving a file to a destination whose leading directory does not\n     -    exist, \"git mv\" fails at the rename(2) syscall with ENOENT. Because\n     -    the error is reported via die_errno() using only the source path:\n     +    Moving a file into a directory that does not exist fails at rename(2)\n     +    with ENOENT. The checking phase already rejects a missing destination\n     +    directory when the destination ends in a slash, but a destination that\n     +    names a file inside a non-existent directory is not caught and only\n     +    fails later at the syscall. As a consequence \"git mv -n\" does not\n     +    detect the problem either: the dry run never reaches rename(2) and\n     +    reports a move that would not actually succeed.\n      \n     -        fatal: renaming 'src' failed: No such file or directory\n     +    Detect this during the checking phase. For entries that will be renamed\n     +    on disk, stat the destination's leading directory and, if it is\n     +    missing, fail with the existing \"destination directory does not exist\"\n     +    message. Guard the check with the same condition under which rename(2)\n     +    is invoked, so that directory moves, whose child entries are expanded\n     +    to paths under a not-yet-created directory, and sparse or out-of-cone\n     +    destinations, which are not written to the worktree, are not flagged\n     +    incorrectly.\n      \n     -    the message misleadingly blames the source, even though it is the\n     -    destination's parent directory that is missing. A user who runs\n     +    This is a best-effort diagnostic rather than a guarantee: the\n     +    destination directory can still disappear between the check and the\n     +    rename(2). It fixes the common case and, unlike the syscall path,\n     +    lets \"git mv -n\" report the failure.\n      \n     -        git mv a/file b/does-not-exist/file\n     -\n     -    is told the problem is with 'a/file', which exists, giving no hint\n     -    that 'b/does-not-exist/' needs to be created first.\n     -\n     -    The checking phase already rejects a missing destination directory\n     -    when the destination ends in a slash, but a destination that names a\n     -    file inside a non-existent directory is not caught and only fails\n     -    later at rename(2). As a result \"git mv -n\" also fails to detect the\n     -    problem, since the dry run never reaches the syscall and reports a\n     -    move that would not actually succeed.\n     -\n     -    Detect this during the checking phase instead: for entries that will\n     -    be renamed on disk, stat the destination's leading directory and, if\n     -    it is missing, fail with the existing \"destination directory does not\n     -    exist\" message. Guard the check with the same condition under which\n     -    rename(2) is invoked so that directory moves, whose child entries are\n     -    expanded to paths under a not-yet-created directory, and sparse or\n     -    out-of-cone destinations, which are not written to the worktree, are\n     -    not flagged incorrectly.\n     -\n     -    This gives a clear message and lets \"git mv -n\" report the failure.\n     +    Add tests covering both the error path and the dry-run detection.\n      \n          Signed-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n      \n\n-- \ngitgitgadget\n"},{"id":"548810","messageId":"0d67da588bc86c5257ce366903ae58e171159b8b.1784812390.git.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":"pull.2356.v2.git.git.1784812390.gitgitgadget@gmail.com","subject":"[PATCH v2 1/2] mv: name both source and destination when rename fails","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-23T13:13:09Z","receivedAt":"2026-07-23T13:13:16Z","isPatch":true,"body":"From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n\nWhen \"git mv\" fails at the rename(2) syscall, the error is reported\nwith die_errno() using only the source path:\n\n    fatal: renaming 'src' failed: No such file or directory\n\nrename(2) returns ENOENT both when the source does not exist and when\na directory component of the destination does not exist, and errno\ndoes not distinguish the two. Reporting only the source therefore\nmisleads the user in the latter case: for\n\n    git mv a/file b/no-such-dir/file\n\nthe message blames 'a/file', which exists, and gives no hint that\n'b/no-such-dir/' is the missing part.\n\nInspecting the paths again after the failure to determine which one is\nat fault would be racy, since either could appear or disappear between\nthe rename(2) and the follow-up check. Instead, simply name both the\nsource and the destination in the message and let the reader see which\none is wrong:\n\n    fatal: renaming 'a/file' to 'b/no-such-dir/file' failed:\n    No such file or directory\n\nSigned-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n---\n builtin/mv.c | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/builtin/mv.c b/builtin/mv.c\nindex a82fc97a19..35e504484a 100644\n--- a/builtin/mv.c\n+++ b/builtin/mv.c\n@@ -549,7 +549,7 @@ remove_entry:\n \t\t    rename(src, dst) < 0) {\n \t\t\tif (ignore_errors)\n \t\t\t\tcontinue;\n-\t\t\tdie_errno(_(\"renaming '%s' failed\"), src);\n+\t\t\tdie_errno(_(\"renaming '%s' to '%s' failed\"), src, dst);\n \t\t}\n \t\tif (submodule_gitfiles[i]) {\n \t\t\tif (!update_path_in_gitmodules(src, dst))\n-- \ngitgitgadget\n\n"},{"id":"548811","messageId":"1a790e001610d3324ec45d86ac67ca5720678cb8.1784812390.git.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":"pull.2356.v2.git.git.1784812390.gitgitgadget@gmail.com","subject":"[PATCH v2 2/2] mv: check for missing destination directory before renaming","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-23T13:13:10Z","receivedAt":"2026-07-23T13:13:18Z","isPatch":true,"body":"From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n\nMoving a file into a directory that does not exist fails at rename(2)\nwith ENOENT. The checking phase already rejects a missing destination\ndirectory when the destination ends in a slash, but a destination that\nnames a file inside a non-existent directory is not caught and only\nfails later at the syscall. As a consequence \"git mv -n\" does not\ndetect the problem either: the dry run never reaches rename(2) and\nreports a move that would not actually succeed.\n\nDetect this during the checking phase. For entries that will be renamed\non disk, stat the destination's leading directory and, if it is\nmissing, fail with the existing \"destination directory does not exist\"\nmessage. Guard the check with the same condition under which rename(2)\nis invoked, so that directory moves, whose child entries are expanded\nto paths under a not-yet-created directory, and sparse or out-of-cone\ndestinations, which are not written to the worktree, are not flagged\nincorrectly.\n\nThis is a best-effort diagnostic rather than a guarantee: the\ndestination directory can still disappear between the check and the\nrename(2). It fixes the common case and, unlike the syscall path,\nlets \"git mv -n\" report the failure.\n\nAdd tests covering both the error path and the dry-run detection.\n\nSigned-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n---\n builtin/mv.c  | 21 +++++++++++++++++++++\n t/t7001-mv.sh | 14 ++++++++++++++\n 2 files changed, 35 insertions(+)\n\ndiff --git a/builtin/mv.c b/builtin/mv.c\nindex 35e504484a..eb59fe0f31 100644\n--- a/builtin/mv.c\n+++ b/builtin/mv.c\n@@ -444,6 +444,27 @@ dir_check:\n \t\t\tgoto act_on_entry;\n \t\t}\n \n+\t\t/*\n+\t\t* If we are going to move SRC to DST on disk, DST's leading\n+\t\t* directories must already exist.\n+\t\t*/\n+\t\tif (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n+\t\t\t\t!(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n+\t\t\t\tchar *dst_dir = xstrdup(dst);\n+\t\t\t\tchar *slash = strrchr(dst_dir, '/');\n+\n+\t\t\t\tif (slash) {\n+\t\t\t\t\t\tstruct stat dir_st;\n+\t\t\t\t\t\t*slash = '\\0';\n+\t\t\t\t\t\tif (lstat(dst_dir, &dir_st) < 0 && errno == ENOENT) {\n+\t\t\t\t\t\t\t\tfree(dst_dir);\n+\t\t\t\t\t\t\t\tbad = _(\"destination directory does not exist\");\n+\t\t\t\t\t\t\t\tgoto act_on_entry;\n+\t\t\t\t\t\t}\n+\t\t\t\t}\n+\t\t\t\tfree(dst_dir);\n+\t\t}\n+\n \t\tif (ignore_sparse &&\n \t\t    (dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&\n \t\t    index_entry_exists(the_repository->index, dst, strlen(dst))) {\ndiff --git a/t/t7001-mv.sh b/t/t7001-mv.sh\nindex 7cf4aa5ba1..2d8a98d8b0 100755\n--- a/t/t7001-mv.sh\n+++ b/t/t7001-mv.sh\n@@ -114,6 +114,20 @@ test_expect_success 'clean up' '\n \tgit reset --hard\n '\n \n+test_expect_success 'moving to non-existent destination parent directory' '\n+\tgit reset --hard &&\n+\tmkdir -p from &&\n+\techo content >from/file &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv from/file no-such-dir/file 2>actual &&\n+\ttest_grep \"destination directory does not exist\" actual\n+'\n+\n+test_expect_success 'mv --dry-run detects non-existent destination parent directory' '\n+\ttest_must_fail git mv -n from/file no-such-dir/file 2>actual &&\n+\ttest_grep \"destination directory does not exist\" actual\n+'\n+\n test_expect_success 'moving to existing untracked target with trailing slash' '\n \tmkdir path1 &&\n \tgit mv path0/ path1/ &&\n-- \ngitgitgadget\n"},{"id":"548818","messageId":"xmqqh5lpk3r3.fsf@gitster.g","threadId":"66005","inReplyTo":"0d67da588bc86c5257ce366903ae58e171159b8b.1784812390.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v2 1/2] mv: name both source and destination when rename fails","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-23T17:36:00Z","receivedAt":"2026-07-23T17:36:03Z","isPatch":true,"body":"\"Lucas Zamboni Orioli via GitGitGadget\" <gitgitgadget@gmail.com>\nwrites:\n\n> From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n>\n> When \"git mv\" fails at the rename(2) syscall, the error is reported\n> with die_errno() using only the source path:\n>\n>     fatal: renaming 'src' failed: No such file or directory\n>\n> rename(2) returns ENOENT both when the source does not exist and when\n> a directory component of the destination does not exist, and errno\n> does not distinguish the two. Reporting only the source therefore\n> misleads the user in the latter case: for\n>\n>     git mv a/file b/no-such-dir/file\n>\n> the message blames 'a/file', which exists, and gives no hint that\n> 'b/no-such-dir/' is the missing part.\n>\n> Inspecting the paths again after the failure to determine which one is\n> at fault would be racy, since either could appear or disappear between\n> the rename(2) and the follow-up check. Instead, simply name both the\n> source and the destination in the message and let the reader see which\n> one is wrong:\n>\n>     fatal: renaming 'a/file' to 'b/no-such-dir/file' failed:\n>     No such file or directory\n>\n> Signed-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n> ---\n>  builtin/mv.c | 2 +-\n>  1 file changed, 1 insertion(+), 1 deletion(-)\n>\n> diff --git a/builtin/mv.c b/builtin/mv.c\n> index a82fc97a19..35e504484a 100644\n> --- a/builtin/mv.c\n> +++ b/builtin/mv.c\n> @@ -549,7 +549,7 @@ remove_entry:\n>  \t\t    rename(src, dst) < 0) {\n>  \t\t\tif (ignore_errors)\n>  \t\t\t\tcontinue;\n> -\t\t\tdie_errno(_(\"renaming '%s' failed\"), src);\n> +\t\t\tdie_errno(_(\"renaming '%s' to '%s' failed\"), src, dst);\n>  \t\t}\n>  \t\tif (submodule_gitfiles[i]) {\n>  \t\t\tif (!update_path_in_gitmodules(src, dst))\n\nMakes sense.\n"},{"id":"548819","messageId":"xmqq8q71k3fy.fsf@gitster.g","threadId":"66005","inReplyTo":"1a790e001610d3324ec45d86ac67ca5720678cb8.1784812390.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v2 2/2] mv: check for missing destination directory before renaming","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-23T17:42:41Z","receivedAt":"2026-07-23T17:42:44Z","isPatch":true,"body":"\"Lucas Zamboni Orioli via GitGitGadget\" <gitgitgadget@gmail.com>\nwrites:\n\n> This is a best-effort diagnostic rather than a guarantee: the\n> destination directory can still disappear between the check and the\n> rename(2). It fixes the common case and, unlike the syscall path,\n> lets \"git mv -n\" report the failure.\n\nIf \"can still disappear\" is because we are not taking into account a\nmove that we are scheduled to make, then that is not very nice, but\nas long as it is *not* our making (in other words, somebody else may\nactively interferring with the mv we are trying to perform), I think\nthis is OK.  It is the best we can do.\n\n> Add tests covering both the error path and the dry-run detection.\n>\n> Signed-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n> ---\n>  builtin/mv.c  | 21 +++++++++++++++++++++\n>  t/t7001-mv.sh | 14 ++++++++++++++\n>  2 files changed, 35 insertions(+)\n>\n> diff --git a/builtin/mv.c b/builtin/mv.c\n> index 35e504484a..eb59fe0f31 100644\n> --- a/builtin/mv.c\n> +++ b/builtin/mv.c\n> @@ -444,6 +444,27 @@ dir_check:\n>  \t\t\tgoto act_on_entry;\n>  \t\t}\n>  \n> +\t\t/*\n> +\t\t* If we are going to move SRC to DST on disk, DST's leading\n> +\t\t* directories must already exist.\n> +\t\t*/\n> +\t\tif (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n> +\t\t\t\t!(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n> +\t\t\t\tchar *dst_dir = xstrdup(dst);\n> +\t\t\t\tchar *slash = strrchr(dst_dir, '/');\n> +\n> +\t\t\t\tif (slash) {\n> +\t\t\t\t\t\tstruct stat dir_st;\n> +\t\t\t\t\t\t*slash = '\\0';\n> +\t\t\t\t\t\tif (lstat(dst_dir, &dir_st) < 0 && errno == ENOENT) {\n> +\t\t\t\t\t\t\t\tfree(dst_dir);\n> +\t\t\t\t\t\t\t\tbad = _(\"destination directory does not exist\");\n> +\t\t\t\t\t\t\t\tgoto act_on_entry;\n> +\t\t\t\t\t\t}\n> +\t\t\t\t}\n> +\t\t\t\tfree(dst_dir);\n> +\t\t}\n\nHorrible.  Please fix this overly deep indentation.\n\n> diff --git a/t/t7001-mv.sh b/t/t7001-mv.sh\n> index 7cf4aa5ba1..2d8a98d8b0 100755\n> --- a/t/t7001-mv.sh\n> +++ b/t/t7001-mv.sh\n> @@ -114,6 +114,20 @@ test_expect_success 'clean up' '\n>  \tgit reset --hard\n>  '\n>  \n> +test_expect_success 'moving to non-existent destination parent directory' '\n> +\tgit reset --hard &&\n> +\tmkdir -p from &&\n> +\techo content >from/file &&\n> +\tgit add from/file &&\n> +\ttest_must_fail git mv from/file no-such-dir/file 2>actual &&\n> +\ttest_grep \"destination directory does not exist\" actual\n> +'\n> +\n> +test_expect_success 'mv --dry-run detects non-existent destination parent directory' '\n> +\ttest_must_fail git mv -n from/file no-such-dir/file 2>actual &&\n> +\ttest_grep \"destination directory does not exist\" actual\n> +'\n> +\n>  test_expect_success 'moving to existing untracked target with trailing slash' '\n>  \tmkdir path1 &&\n>  \tgit mv path0/ path1/ &&\n"},{"id":"548820","messageId":"xmqqo6fximn2.fsf@gitster.g","threadId":"66005","inReplyTo":"1a790e001610d3324ec45d86ac67ca5720678cb8.1784812390.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v2 2/2] mv: check for missing destination directory before renaming","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-23T18:30:57Z","receivedAt":"2026-07-23T18:31:02Z","isPatch":true,"body":"\"Lucas Zamboni Orioli via GitGitGadget\" <gitgitgadget@gmail.com>\nwrites:\n\n> +\t\t/*\n> +\t\t* If we are going to move SRC to DST on disk, DST's leading\n> +\t\t* directories must already exist.\n> +\t\t*/\n\n\t/*\n\t * Our multi-line comment is formatted like this.  The\n\t * asterisks align vertically.\n\t */\n\n> +\t\tif (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n> +\t\t    !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n> +\t\t\tchar *dst_dir = xstrdup(dst);\n> +\t\t\tchar *slash = strrchr(dst_dir, '/');\n> +\n> +\t\t\tif (slash) {\n> +\t\t\t\tstruct stat dir_st;\n> +\t\t\t\t*slash = '\\0';\n> +\t\t\t\tif (lstat(dst_dir, &dir_st) < 0 && errno == ENOENT) {\n> +\t\t\t\t\tfree(dst_dir);\n> +\t\t\t\t\tbad = _(\"destination directory does not exist\");\n> +\t\t\t\t\tgoto act_on_entry;\n> +\t\t\t\t}\n> +\t\t\t}\n> +\t\t\tfree(dst_dir);\n> +\t\t}\n\nlstat() can succeed and 'dir_st' may indicate something other than a\ndirectory (for example, a symbolic link or a regular file).\nAlternatively, it can fail with ENOTDIR when, for example, 'dst_dir'\nis 'a/b/c' and 'a/b' is a file rather than a directory.\n\nBoth cases will cause 'git mv' into a path assumed to be a directory\nto fail.  Shouldn't we handle these conditions as well?\n"},{"id":"548834","messageId":"CAH01Q-_2APONq2fXmjF=Wo08rTzScMEjyXL-G=_GH6TbjJmTBw@mail.gmail.com","threadId":"66005","inReplyTo":"xmqqo6fximn2.fsf@gitster.g","subject":"Re: [PATCH v2 2/2] mv: check for missing destination directory before renaming","fromName":"Lucas Zamboni Orioli","fromEmail":"lucaszam0@gmail.com","sentAt":"2026-07-23T21:38:18Z","receivedAt":"2026-07-23T21:38:32Z","isPatch":true,"body":"> lstat() can succeed and 'dir_st' may indicate something other than a\n> directory (for example, a symbolic link or a regular file).\n> Alternatively, it can fail with ENOTDIR when, for example, 'dst_dir'\n> is 'a/b/c' and 'a/b' is a file rather than a directory.\n>\n> Both cases will cause 'git mv' into a path assumed to be a directory\n> to fail.  Shouldn't we handle these conditions as well?\n\nYes, agreed, both should be handled. For v3 I switched from lstat()\nto stat() so that the check follows symlinks the same way rename()\ndoes, and I handle the non-directory cases:\n\nstat() failing with ENOENT or ENOTDIR (missing directory, or a\nleading path component that is a file) reports \"destination\ndirectory does not exist\".\n\nstat() succeeding on something that is not a directory reports\n\"destination is not a directory\".\n\nOther stat() errors fall through to rename(), which reports them as before.\n\nFor the messages I used the existing \"destination directory does not\nexist\" string for the missing case and added one new string,\n\"destination is not a directory\", for the non-directory case. I'm\nhappy to collapse these into a single message instead if you'd prefer\nto avoid the extra translatable string, let me know.\n"},{"id":"548835","messageId":"0d67da588bc86c5257ce366903ae58e171159b8b.1784842831.git.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":"pull.2356.v3.git.git.1784842831.gitgitgadget@gmail.com","subject":"[PATCH v3 1/2] mv: name both source and destination when rename fails","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-23T21:40:30Z","receivedAt":"2026-07-23T21:40:40Z","isPatch":true,"body":"From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n\nWhen \"git mv\" fails at the rename(2) syscall, the error is reported\nwith die_errno() using only the source path:\n\n    fatal: renaming 'src' failed: No such file or directory\n\nrename(2) returns ENOENT both when the source does not exist and when\na directory component of the destination does not exist, and errno\ndoes not distinguish the two. Reporting only the source therefore\nmisleads the user in the latter case: for\n\n    git mv a/file b/no-such-dir/file\n\nthe message blames 'a/file', which exists, and gives no hint that\n'b/no-such-dir/' is the missing part.\n\nInspecting the paths again after the failure to determine which one is\nat fault would be racy, since either could appear or disappear between\nthe rename(2) and the follow-up check. Instead, simply name both the\nsource and the destination in the message and let the reader see which\none is wrong:\n\n    fatal: renaming 'a/file' to 'b/no-such-dir/file' failed:\n    No such file or directory\n\nSigned-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n---\n builtin/mv.c | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/builtin/mv.c b/builtin/mv.c\nindex a82fc97a19..35e504484a 100644\n--- a/builtin/mv.c\n+++ b/builtin/mv.c\n@@ -549,7 +549,7 @@ remove_entry:\n \t\t    rename(src, dst) < 0) {\n \t\t\tif (ignore_errors)\n \t\t\t\tcontinue;\n-\t\t\tdie_errno(_(\"renaming '%s' failed\"), src);\n+\t\t\tdie_errno(_(\"renaming '%s' to '%s' failed\"), src, dst);\n \t\t}\n \t\tif (submodule_gitfiles[i]) {\n \t\t\tif (!update_path_in_gitmodules(src, dst))\n-- \ngitgitgadget\n\n"},{"id":"548836","messageId":"5ac15873623a3f519b01aa7419c579a310be164b.1784842831.git.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":"pull.2356.v3.git.git.1784842831.gitgitgadget@gmail.com","subject":"[PATCH v3 2/2] mv: check for missing destination directory before renaming","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-23T21:40:31Z","receivedAt":"2026-07-23T21:40:42Z","isPatch":true,"body":"From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n\nMoving a file into a directory that does not exist fails at rename(2)\nwith ENOENT. The checking phase already rejects a missing destination\ndirectory when the destination ends in a slash, but a destination that\nnames a file inside a non-existent directory is not caught and only\nfails later at the syscall. The same is true when a leading path\ncomponent exists but is not a directory: rename(2) fails with ENOTDIR,\nagain only at the syscall. As a consequence \"git mv -n\" does not detect\neither problem: the dry run never reaches rename(2) and reports a move\nthat would not actually succeed.\n\nDetect this during the checking phase. For entries that will be renamed\non disk, stat the destination's leading directory and fail with a\nsuitable message if it is missing or is not a directory. stat() is used\nrather than lstat() so that the check follows symlinks the same way\nrename(2) does: a symlink to a directory is accepted, while a symlink to\na file is rejected. A missing directory or a non-directory path\ncomponent (ENOENT or ENOTDIR) reuses the existing \"destination directory\ndoes not exist\" message; a leading component that resolves to a\nnon-directory reports \"destination is not a directory\". Other stat()\nerrors fall through to rename(2), which reports them as before.\n\nAdd tests covering the missing directory, a path component that is a\nfile, a symlink to a file, a symlink to a directory (which must still\nsucceed), and dry-run detection.\n\nSigned-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n---\n builtin/mv.c  | 24 ++++++++++++++++++++++++\n t/t7001-mv.sh | 49 +++++++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 73 insertions(+)\n\ndiff --git a/builtin/mv.c b/builtin/mv.c\nindex 35e504484a..08e27484f2 100644\n--- a/builtin/mv.c\n+++ b/builtin/mv.c\n@@ -444,6 +444,30 @@ dir_check:\n \t\t\tgoto act_on_entry;\n \t\t}\n \n+\t\t/*\n+\t\t * If we are going to move SRC to DST on disk, DST's leading\n+\t\t * directories must already exist.\n+\t\t */\n+\t\tif (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n+\t\t\t\t!(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n+\t\t\tchar *dst_dir = xstrdup(dst);\n+\t\t\tchar *slash = strrchr(dst_dir, '/');\n+\n+\t\t\tif (slash) {\n+\t\t\t\tstruct stat dir_st;\n+\t\t\t\t*slash = '\\0';\n+\t\t\t\tif (stat(dst_dir, &dir_st) < 0) {\n+\t\t\t\t\t/* other errors fall through to rename(), which reports them */\n+\t\t\t\t\tif (errno == ENOENT || errno == ENOTDIR)\n+\t\t\t\t\t\tbad = _(\"destination directory does not exist\");\n+\t\t\t\t} else if (!S_ISDIR(dir_st.st_mode))\n+\t\t\t\t\tbad = _(\"destination is not a directory\");\n+\t\t\t}\n+\t\t\tfree(dst_dir);\n+\t\t\tif (bad)\n+\t\t\t\tgoto act_on_entry;\n+\t\t}\n+\n \t\tif (ignore_sparse &&\n \t\t    (dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&\n \t\t    index_entry_exists(the_repository->index, dst, strlen(dst))) {\ndiff --git a/t/t7001-mv.sh b/t/t7001-mv.sh\nindex 7cf4aa5ba1..c878fb92a8 100755\n--- a/t/t7001-mv.sh\n+++ b/t/t7001-mv.sh\n@@ -114,6 +114,55 @@ test_expect_success 'clean up' '\n \tgit reset --hard\n '\n \n+test_expect_success 'moving to a non-existent path component in the destination' '\n+\tgit reset --hard &&\n+\tmkdir -p from &&\n+\techo content >from/file &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv from/file no-such-dir/file 2>actual &&\n+\ttest_grep \"destination directory does not exist\" actual\n+'\n+\n+test_expect_success 'moving to a destination with a file as a path component' '\n+\tgit reset --hard &&\n+\tmkdir -p from &&\n+\techo contents >from/file &&\n+\techo blocker >not-dir &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv from/file not-dir/file 2>actual &&\n+\ttest_grep \"destination is not a directory\" actual\n+'\n+\n+test_expect_success SYMLINKS 'moving to a destination with a symlink to a file as a path component' '\n+\tgit reset --hard &&\n+\tmkdir -p from &&\n+\techo contents >from/file &&\n+\techo target >regular &&\n+\tln -s regular link-to-file &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv from/file link-to-file/file 2>actual &&\n+\ttest_grep \"not a directory\" actual\n+'\n+\n+test_expect_success SYMLINKS 'moving to a destination with a symlink to a directory' '\n+\tgit reset --hard &&\n+\tmkdir -p from realdir &&\n+\techo contents >from/file &&\n+\tln -s realdir link-to-dir &&\n+\tgit add from/file &&\n+\tgit mv from/file link-to-dir/file &&\n+\ttest_path_is_file realdir/file\n+'\n+\n+test_expect_success 'mv --dry-run detects non-existent destination parent directory' '\n+\tgit reset --hard &&\n+\tmkdir -p from &&\n+\techo content >from/file &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv -n from/file no-such-dir/file 2>actual &&\n+\ttest_grep \"destination directory does not exist\" actual\n+'\n+\n test_expect_success 'moving to existing untracked target with trailing slash' '\n \tmkdir path1 &&\n \tgit mv path0/ path1/ &&\n-- \ngitgitgadget\n"},{"id":"548837","messageId":"pull.2356.v3.git.git.1784842831.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":"pull.2356.v2.git.git.1784812390.gitgitgadget@gmail.com","subject":"[PATCH v3 0/2] mv: report missing destination leading directory","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-23T21:40:29Z","receivedAt":"2026-07-23T21:40:42Z","isPatch":true,"body":"Changes in v3:\n\n * changed check from lstat to stat so it follows symlinks as suggested by\n   Junio C Hamano\n * added ENOTDIR verification as suggested by Junio C Hamano\n * added S_ISDIR check to catch files as path components as suggested by\n   Junio C Hamano\n * fixed indentation\n\nChanges in v2:\n\n * altered the error message to include both source and destination as\n   suggested by Ben Knoble\n\nLucas Zamboni Orioli (2):\n  mv: name both source and destination when rename fails\n  mv: check for missing destination directory before renaming\n\n builtin/mv.c  | 26 +++++++++++++++++++++++++-\n t/t7001-mv.sh | 49 +++++++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 74 insertions(+), 1 deletion(-)\n\n\nbase-commit: 9a0c4701dcd5725c4184599322b52933ff5005ca\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2356%2FZamboniL%2Fmv-detect-non-existing-target-folder-v3\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2356/ZamboniL/mv-detect-non-existing-target-folder-v3\nPull-Request: https://github.com/git/git/pull/2356\n\nRange-diff vs v2:\n\n 1:  0d67da588b = 1:  0d67da588b mv: name both source and destination when rename fails\n 2:  1a790e0016 ! 2:  5ac1587362 mv: check for missing destination directory before renaming\n     @@ Commit message\n          with ENOENT. The checking phase already rejects a missing destination\n          directory when the destination ends in a slash, but a destination that\n          names a file inside a non-existent directory is not caught and only\n     -    fails later at the syscall. As a consequence \"git mv -n\" does not\n     -    detect the problem either: the dry run never reaches rename(2) and\n     -    reports a move that would not actually succeed.\n     +    fails later at the syscall. The same is true when a leading path\n     +    component exists but is not a directory: rename(2) fails with ENOTDIR,\n     +    again only at the syscall. As a consequence \"git mv -n\" does not detect\n     +    either problem: the dry run never reaches rename(2) and reports a move\n     +    that would not actually succeed.\n      \n          Detect this during the checking phase. For entries that will be renamed\n     -    on disk, stat the destination's leading directory and, if it is\n     -    missing, fail with the existing \"destination directory does not exist\"\n     -    message. Guard the check with the same condition under which rename(2)\n     -    is invoked, so that directory moves, whose child entries are expanded\n     -    to paths under a not-yet-created directory, and sparse or out-of-cone\n     -    destinations, which are not written to the worktree, are not flagged\n     -    incorrectly.\n     +    on disk, stat the destination's leading directory and fail with a\n     +    suitable message if it is missing or is not a directory. stat() is used\n     +    rather than lstat() so that the check follows symlinks the same way\n     +    rename(2) does: a symlink to a directory is accepted, while a symlink to\n     +    a file is rejected. A missing directory or a non-directory path\n     +    component (ENOENT or ENOTDIR) reuses the existing \"destination directory\n     +    does not exist\" message; a leading component that resolves to a\n     +    non-directory reports \"destination is not a directory\". Other stat()\n     +    errors fall through to rename(2), which reports them as before.\n      \n     -    This is a best-effort diagnostic rather than a guarantee: the\n     -    destination directory can still disappear between the check and the\n     -    rename(2). It fixes the common case and, unlike the syscall path,\n     -    lets \"git mv -n\" report the failure.\n     -\n     -    Add tests covering both the error path and the dry-run detection.\n     +    Add tests covering the missing directory, a path component that is a\n     +    file, a symlink to a file, a symlink to a directory (which must still\n     +    succeed), and dry-run detection.\n      \n          Signed-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n      \n     @@ builtin/mv.c: dir_check:\n       \t\t}\n       \n      +\t\t/*\n     -+\t\t* If we are going to move SRC to DST on disk, DST's leading\n     -+\t\t* directories must already exist.\n     -+\t\t*/\n     ++\t\t * If we are going to move SRC to DST on disk, DST's leading\n     ++\t\t * directories must already exist.\n     ++\t\t */\n      +\t\tif (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n      +\t\t\t\t!(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n     -+\t\t\t\tchar *dst_dir = xstrdup(dst);\n     -+\t\t\t\tchar *slash = strrchr(dst_dir, '/');\n     ++\t\t\tchar *dst_dir = xstrdup(dst);\n     ++\t\t\tchar *slash = strrchr(dst_dir, '/');\n      +\n     -+\t\t\t\tif (slash) {\n     -+\t\t\t\t\t\tstruct stat dir_st;\n     -+\t\t\t\t\t\t*slash = '\\0';\n     -+\t\t\t\t\t\tif (lstat(dst_dir, &dir_st) < 0 && errno == ENOENT) {\n     -+\t\t\t\t\t\t\t\tfree(dst_dir);\n     -+\t\t\t\t\t\t\t\tbad = _(\"destination directory does not exist\");\n     -+\t\t\t\t\t\t\t\tgoto act_on_entry;\n     -+\t\t\t\t\t\t}\n     -+\t\t\t\t}\n     -+\t\t\t\tfree(dst_dir);\n     ++\t\t\tif (slash) {\n     ++\t\t\t\tstruct stat dir_st;\n     ++\t\t\t\t*slash = '\\0';\n     ++\t\t\t\tif (stat(dst_dir, &dir_st) < 0) {\n     ++\t\t\t\t\t/* other errors fall through to rename(), which reports them */\n     ++\t\t\t\t\tif (errno == ENOENT || errno == ENOTDIR)\n     ++\t\t\t\t\t\tbad = _(\"destination directory does not exist\");\n     ++\t\t\t\t} else if (!S_ISDIR(dir_st.st_mode))\n     ++\t\t\t\t\tbad = _(\"destination is not a directory\");\n     ++\t\t\t}\n     ++\t\t\tfree(dst_dir);\n     ++\t\t\tif (bad)\n     ++\t\t\t\tgoto act_on_entry;\n      +\t\t}\n      +\n       \t\tif (ignore_sparse &&\n     @@ t/t7001-mv.sh: test_expect_success 'clean up' '\n       \tgit reset --hard\n       '\n       \n     -+test_expect_success 'moving to non-existent destination parent directory' '\n     ++test_expect_success 'moving to a non-existent path component in the destination' '\n      +\tgit reset --hard &&\n      +\tmkdir -p from &&\n      +\techo content >from/file &&\n     @@ t/t7001-mv.sh: test_expect_success 'clean up' '\n      +\ttest_grep \"destination directory does not exist\" actual\n      +'\n      +\n     ++test_expect_success 'moving to a destination with a file as a path component' '\n     ++\tgit reset --hard &&\n     ++\tmkdir -p from &&\n     ++\techo contents >from/file &&\n     ++\techo blocker >not-dir &&\n     ++\tgit add from/file &&\n     ++\ttest_must_fail git mv from/file not-dir/file 2>actual &&\n     ++\ttest_grep \"destination is not a directory\" actual\n     ++'\n     ++\n     ++test_expect_success SYMLINKS 'moving to a destination with a symlink to a file as a path component' '\n     ++\tgit reset --hard &&\n     ++\tmkdir -p from &&\n     ++\techo contents >from/file &&\n     ++\techo target >regular &&\n     ++\tln -s regular link-to-file &&\n     ++\tgit add from/file &&\n     ++\ttest_must_fail git mv from/file link-to-file/file 2>actual &&\n     ++\ttest_grep \"not a directory\" actual\n     ++'\n     ++\n     ++test_expect_success SYMLINKS 'moving to a destination with a symlink to a directory' '\n     ++\tgit reset --hard &&\n     ++\tmkdir -p from realdir &&\n     ++\techo contents >from/file &&\n     ++\tln -s realdir link-to-dir &&\n     ++\tgit add from/file &&\n     ++\tgit mv from/file link-to-dir/file &&\n     ++\ttest_path_is_file realdir/file\n     ++'\n     ++\n      +test_expect_success 'mv --dry-run detects non-existent destination parent directory' '\n     ++\tgit reset --hard &&\n     ++\tmkdir -p from &&\n     ++\techo content >from/file &&\n     ++\tgit add from/file &&\n      +\ttest_must_fail git mv -n from/file no-such-dir/file 2>actual &&\n      +\ttest_grep \"destination directory does not exist\" actual\n      +'\n\n-- \ngitgitgadget\n"},{"id":"548839","messageId":"xmqqldb1cosx.fsf@gitster.g","threadId":"66005","inReplyTo":"CAH01Q-_2APONq2fXmjF=Wo08rTzScMEjyXL-G=_GH6TbjJmTBw@mail.gmail.com","subject":"Re: [PATCH v2 2/2] mv: check for missing destination directory before renaming","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-23T22:40:46Z","receivedAt":"2026-07-23T22:40:49Z","isPatch":true,"body":"Lucas Zamboni Orioli <lucaszam0@gmail.com> writes:\n\n>> lstat() can succeed and 'dir_st' may indicate something other than a\n>> directory (for example, a symbolic link or a regular file).\n>> Alternatively, it can fail with ENOTDIR when, for example, 'dst_dir'\n>> is 'a/b/c' and 'a/b' is a file rather than a directory.\n>>\n>> Both cases will cause 'git mv' into a path assumed to be a directory\n>> to fail.  Shouldn't we handle these conditions as well?\n>\n> Yes, agreed, both should be handled. For v3 I switched from lstat()\n> to stat() so that the check follows symlinks the same way rename()\n> does, and I handle the non-directory cases:\n\nGenerally, a symbolic link in a Git-managed working tree should\nnot be followed.  Following a symbolic link would mean that\n'git mv x y' could move 'x' outside the working tree if 'y' is\na tracked symbolic link pointing to a directory outside the\nworking tree.  \"git apply\" for example avoids being fooled by a\nsymbolic link for the same reason, for example.\n\nI doubt that using stat() instead of lstat() is the right\napproach.  Doing so essentially amounts to ignoring the\npresence of symbolic links.\n"},{"id":"548840","messageId":"xmqqcxwdcmln.fsf@gitster.g","threadId":"66005","inReplyTo":"CAH01Q-_2APONq2fXmjF=Wo08rTzScMEjyXL-G=_GH6TbjJmTBw@mail.gmail.com","subject":"Re: [PATCH v2 2/2] mv: check for missing destination directory before renaming","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-23T23:28:20Z","receivedAt":"2026-07-23T23:28:23Z","isPatch":true,"body":"Lucas Zamboni Orioli <lucaszam0@gmail.com> writes:\n\n>> lstat() can succeed and 'dir_st' may indicate something other than a\n>> directory (for example, a symbolic link or a regular file).\n>> Alternatively, it can fail with ENOTDIR when, for example, 'dst_dir'\n>> is 'a/b/c' and 'a/b' is a file rather than a directory.\n>>\n>> Both cases will cause 'git mv' into a path assumed to be a directory\n>> to fail.  Shouldn't we handle these conditions as well?\n>\n> Yes, agreed, both should be handled. For v3 I switched from lstat()\n> to stat() so that the check follows symlinks the same way rename()\n> does, and I handle the non-directory cases:\n\nGenerally, a symbolic link in a Git-managed working tree should not\nbe followed.  Following a symbolic link would mean that 'git mv x y'\ncould move 'x' outside the working tree if 'y' is a tracked symbolic\nlink pointing to a directory outside the working tree.  'git apply',\nfor example, avoids being fooled by a symbolic link for the same\nreason.\n\nI doubt that using stat() instead of lstat() is the right approach.\nDoing so essentially amounts to ignoring the presence of symbolic\nlinks.\n"},{"id":"549014","messageId":"xmqqik61yeyn.fsf@gitster.g","threadId":"66005","inReplyTo":"xmqqcxwdcmln.fsf@gitster.g","subject":"Re: [PATCH v2 2/2] mv: check for missing destination directory before renaming","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-26T14:59:12Z","receivedAt":"2026-07-26T14:59:15Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n> Lucas Zamboni Orioli <lucaszam0@gmail.com> writes:\n>\n>>> lstat() can succeed and 'dir_st' may indicate something other than a\n>>> directory (for example, a symbolic link or a regular file).\n>>> Alternatively, it can fail with ENOTDIR when, for example, 'dst_dir'\n>>> is 'a/b/c' and 'a/b' is a file rather than a directory.\n>>>\n>>> Both cases will cause 'git mv' into a path assumed to be a directory\n>>> to fail.  Shouldn't we handle these conditions as well?\n>>\n>> Yes, agreed, both should be handled. For v3 I switched from lstat()\n>> to stat() so that the check follows symlinks the same way rename()\n>> does, and I handle the non-directory cases:\n>\n> Generally, a symbolic link in a Git-managed working tree should not\n> be followed.  Following a symbolic link would mean that 'git mv x y'\n> could move 'x' outside the working tree if 'y' is a tracked symbolic\n> link pointing to a directory outside the working tree.  'git apply',\n> for example, avoids being fooled by a symbolic link for the same\n> reason.\n>\n> I doubt that using stat() instead of lstat() is the right approach.\n> Doing so essentially amounts to ignoring the presence of symbolic\n> links.\n\nI actually think \"outside the working tree\" is an irrelevant red\nherring.  What is relevant is the fact that Git tracks symbolic\nlinks.\n\nIf you have x (file) and y (another file), you would want to\ncomplain when the user says:\n\n    $ git mv x y  \n\nbecause the location y is \"taken\" and the command line tells us only\nabout what it wants to do to x, without saying anything about what\nyou want to do to that existing y.  If y were a symbolic link\ninstead, you should behave exactly the same way.\n\nIt actually takes even more care, and I do not know if the\nimplementation of git-mv is done carefully enough, but think about\nwhat should happen to:\n\n    $ git mv x a/b/c  \n\nwhen 'a' is a tracked symbolic link, and it points at, say, '.'.\nShould it behave exactly the same as:\n\n    $ git mv x b/c  \n\nor should it simply error out?  I think the latter, \"I see a symlink\nin the middle, so I refuse to follow,\" is the right behavior.\n\nThink carefully about cases where 'a' is a directory and 'a/b' is a\nsymlink, or where 'a' and 'a/b' are directories and 'a/b/c' is a\nsymlink, and so on.  We do not want to craft an arbitrary rule that\nsays we allow or refuse to operate depending on the link target.\n"},{"id":"549015","messageId":"DK8LXXC1AXDS.MFS49865S0NF@gmail.com","threadId":"66005","inReplyTo":"5ac15873623a3f519b01aa7419c579a310be164b.1784842831.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v3 2/2] mv: check for missing destination directory before renaming","fromName":"Pablo Sabater","fromEmail":"pabloosabaterr@gmail.com","sentAt":"2026-07-26T15:28:20Z","receivedAt":"2026-07-26T15:28:24Z","isPatch":true,"body":"On Thu Jul 23, 2026 at 11:40 PM CEST, Lucas Zamboni Orioli via GitGitGadget wrote:\n> From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n>\n> Moving a file into a directory that does not exist fails at rename(2)\n> with ENOENT. The checking phase already rejects a missing destination\n> directory when the destination ends in a slash, but a destination that\n> names a file inside a non-existent directory is not caught and only\n> fails later at the syscall. The same is true when a leading path\n> component exists but is not a directory: rename(2) fails with ENOTDIR,\n> again only at the syscall. As a consequence \"git mv -n\" does not detect\n> either problem: the dry run never reaches rename(2) and reports a move\n> that would not actually succeed.\n>\n> Detect this during the checking phase. For entries that will be renamed\n> on disk, stat the destination's leading directory and fail with a\n> suitable message if it is missing or is not a directory. stat() is used\n> rather than lstat() so that the check follows symlinks the same way\n> rename(2) does: a symlink to a directory is accepted, while a symlink to\n> a file is rejected. A missing directory or a non-directory path\n> component (ENOENT or ENOTDIR) reuses the existing \"destination directory\n> does not exist\" message; a leading component that resolves to a\n> non-directory reports \"destination is not a directory\". Other stat()\n> errors fall through to rename(2), which reports them as before.\n>\n> Add tests covering the missing directory, a path component that is a\n> file, a symlink to a file, a symlink to a directory (which must still\n> succeed), and dry-run detection.\n>\n> Signed-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n> ---\n>  builtin/mv.c  | 24 ++++++++++++++++++++++++\n>  t/t7001-mv.sh | 49 +++++++++++++++++++++++++++++++++++++++++++++++++\n>  2 files changed, 73 insertions(+)\n>\n> diff --git a/builtin/mv.c b/builtin/mv.c\n> index 35e504484a..08e27484f2 100644\n> --- a/builtin/mv.c\n> +++ b/builtin/mv.c\n> @@ -444,6 +444,30 @@ dir_check:\n>  \t\t\tgoto act_on_entry;\n>  \t\t}\n>\n> +\t\t/*\n> +\t\t * If we are going to move SRC to DST on disk, DST's leading\n> +\t\t * directories must already exist.\n> +\t\t */\n> +\t\tif (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n> +\t\t\t\t!(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n\nnit: indentation.\n\n> +\t\t\tchar *dst_dir = xstrdup(dst);\n> +\t\t\tchar *slash = strrchr(dst_dir, '/');\n> +\n> +\t\t\tif (slash) {\n> +\t\t\t\tstruct stat dir_st;\n> +\t\t\t\t*slash = '\\0';\n> +\t\t\t\tif (stat(dst_dir, &dir_st) < 0) {\n> +\t\t\t\t\t/* other errors fall through to rename(), which reports them */\n> +\t\t\t\t\tif (errno == ENOENT || errno == ENOTDIR)\n> +\t\t\t\t\t\tbad = _(\"destination directory does not exist\");\n> +\t\t\t\t} else if (!S_ISDIR(dir_st.st_mode))\n\nnit: the if above has braces, this else if should too.\n\n> +\t\t\t\t\tbad = _(\"destination is not a directory\");\n> +\t\t\t}\n> +\t\t\tfree(dst_dir);\n> +\t\t\tif (bad)\n> +\t\t\t\tgoto act_on_entry;\n> +\t\t}\n> +\n>  \t\tif (ignore_sparse &&\n>  \t\t    (dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&\n>  \t\t    index_entry_exists(the_repository->index, dst, strlen(dst))) {\n> diff --git a/t/t7001-mv.sh b/t/t7001-mv.sh\n> index 7cf4aa5ba1..c878fb92a8 100755\n> --- a/t/t7001-mv.sh\n> +++ b/t/t7001-mv.sh\n> @@ -114,6 +114,55 @@ test_expect_success 'clean up' '\n>  \tgit reset --hard\n>  '\n>\n> +test_expect_success 'moving to a non-existent path component in the destination' '\n> +\tgit reset --hard &&\n> +\tmkdir -p from &&\n> +\techo content >from/file &&\n> +\tgit add from/file &&\n> +\ttest_must_fail git mv from/file no-such-dir/file 2>actual &&\n> +\ttest_grep \"destination directory does not exist\" actual\n> +'\n> +\n> +test_expect_success 'moving to a destination with a file as a path component' '\n> +\tgit reset --hard &&\n> +\tmkdir -p from &&\n> +\techo contents >from/file &&\n> +\techo blocker >not-dir &&\n> +\tgit add from/file &&\n> +\ttest_must_fail git mv from/file not-dir/file 2>actual &&\n> +\ttest_grep \"destination is not a directory\" actual\n> +'\n> +\n> +test_expect_success SYMLINKS 'moving to a destination with a symlink to a file as a path component' '\n> +\tgit reset --hard &&\n> +\tmkdir -p from &&\n> +\techo contents >from/file &&\n> +\techo target >regular &&\n> +\tln -s regular link-to-file &&\n> +\tgit add from/file &&\n> +\ttest_must_fail git mv from/file link-to-file/file 2>actual &&\n> +\ttest_grep \"not a directory\" actual\n> +'\n> +\n> +test_expect_success SYMLINKS 'moving to a destination with a symlink to a directory' '\n> +\tgit reset --hard &&\n> +\tmkdir -p from realdir &&\n> +\techo contents >from/file &&\n> +\tln -s realdir link-to-dir &&\n> +\tgit add from/file &&\n> +\tgit mv from/file link-to-dir/file &&\n> +\ttest_path_is_file realdir/file\n> +'\n> +\n> +test_expect_success 'mv --dry-run detects non-existent destination parent directory' '\n> +\tgit reset --hard &&\n> +\tmkdir -p from &&\n> +\techo content >from/file &&\n> +\tgit add from/file &&\n> +\ttest_must_fail git mv -n from/file no-such-dir/file 2>actual &&\n> +\ttest_grep \"destination directory does not exist\" actual\n> +'\n> +\n>  test_expect_success 'moving to existing untracked target with trailing slash' '\n>  \tmkdir path1 &&\n>  \tgit mv path0/ path1/ &&\n\n\nThe rest looks good.\n\nRegards,\nPablo\n\n"},{"id":"549028","messageId":"CAH01Q-_k1QEcTLTMygBceUWjGNFLNewixf80bYOD2_s20jajrQ@mail.gmail.com","threadId":"66005","inReplyTo":"xmqqik61yeyn.fsf@gitster.g","subject":"Re: [PATCH v2 2/2] mv: check for missing destination directory before renaming","fromName":"Lucas Zamboni Orioli","fromEmail":"lucaszam0@gmail.com","sentAt":"2026-07-26T17:59:42Z","receivedAt":"2026-07-26T17:59:56Z","isPatch":true,"body":"Em dom., 26 de jul. de 2026 às 11:59, Junio C Hamano\n<gitster@pobox.com> escreveu:\n> Think carefully about cases where 'a' is a directory and 'a/b' is a\n> symlink, or where 'a' and 'a/b' are directories and 'a/b/c' is a\n> symlink, and so on.  We do not want to craft an arbitrary rule that\n> says we allow or refuse to operate depending on the link target.\n\nThanks for pushing on this, chasing the symlink case down turned up\nmore than a bad message. With a tracked symlink in the leading path,\n\"git mv\" leaves the index inconsistent with the worktree:\n\n    mkdir repo && cd repo\n    git init\n    echo content >a\n    mkdir real-dir\n    echo content >real-dir/b\n    ln -s . c\n    git add .\n    git commit -m \"initial\"\n    git mv a c/real-dir/a\n    git status\n\n'c' is a tracked symlink to '.'. The move follows it, so on disk the\nfile lands at the resolved path 'real-dir/a', but the index records\nthe literal 'c/real-dir/a'. \"git status\" then reports a staged rename\nto 'c/real-dir/a', an unstaged deletion of that same path (nothing is\nthere on disk), and the real file untracked at 'real-dir/a', with the\nsymlink 'c' also shown untracked. A later \"git add\" did reconcile it\nby finding the file at its real location, but \"git mv\" on its own has\nalready produced an index that describes a worktree that doesn't\nexist, it got there precisely by traversing a tracked symlink.\n\nSo this is the \"not careful enough\" case you suspected, and the fix is\nthe behavior you described: refuse to operate when any component of the\ndestination's leading path is a symlink, independent of where it\npoints. I'm thinking of using has_symlink_leading_path() (symlinks.c) for\nthat check, which is what \"git apply\" already uses to avoid following in-tree\nsymlinks, so the behavior stays consistent with the rest of the tree.\n\nFor v3 I'll fold this into the series: the leading-directory check will\nreject a missing directory or a non-directory/symlink component up\nfront, which covers both the original misleading-error case and this\nsymlink traversal. Tests will cover a symlink as the final component\nand as an intermediate one ('a/b/c' with 'a' a symlink), plus the\nexisting missing-directory and dry-run cases.\n"},{"id":"549042","messageId":"pull.2356.v4.git.git.1785097071.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":"pull.2356.v3.git.git.1784842831.gitgitgadget@gmail.com","subject":"[PATCH v4 0/2] mv: report missing destination leading directory","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-26T20:17:49Z","receivedAt":"2026-07-26T20:17:53Z","isPatch":true,"body":"Changes in v4:\n\n * reverted to lstat and added has_symlink_leading_path() to refuse a\n   destination that goes through a symbolic link, independent of the link\n   target, per Junio C Hamano's point that Git tracks symlinks and must not\n   follow them here\n * added new \"destination is beyond a symbolic link\" message\n * added tests: symlink as immediate parent and as intermediate component,\n   symlink at the destination, -f does not bypass the symlink refusal, and a\n   regression test that a move through a symlink no longer corrupts the\n   index (see the reproduction reported on the list)\n\nChanges in v3:\n\n * added ENOTDIR handling and an S_ISDIR check so a non-directory leading\n   path component is caught, as suggested by Junio C Hamano\n * (v3 used stat() to resolve symlinks; this was reverted in v4 after Junio\n   pointed out symlinks must not be followed)\n * fixed indentation\n\nChanges in v2:\n\n * altered the error message to include both source and destination as\n   suggested by Ben Knoble\n\nLucas Zamboni Orioli (2):\n  mv: name both source and destination when rename fails\n  mv: reject a destination whose leading path is missing or a symlink\n\n builtin/mv.c  | 37 ++++++++++++++++++++++-\n t/t7001-mv.sh | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 119 insertions(+), 1 deletion(-)\n\n\nbase-commit: 9a0c4701dcd5725c4184599322b52933ff5005ca\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2356%2FZamboniL%2Fmv-detect-non-existing-target-folder-v4\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2356/ZamboniL/mv-detect-non-existing-target-folder-v4\nPull-Request: https://github.com/git/git/pull/2356\n\nRange-diff vs v3:\n\n 1:  0d67da588b = 1:  0d67da588b mv: name both source and destination when rename fails\n 2:  5ac1587362 ! 2:  6b72efb413 mv: check for missing destination directory before renaming\n     @@ Metadata\n      Author: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n      \n       ## Commit message ##\n     -    mv: check for missing destination directory before renaming\n     +    mv: reject a destination whose leading path is missing or a symlink\n      \n     -    Moving a file into a directory that does not exist fails at rename(2)\n     -    with ENOENT. The checking phase already rejects a missing destination\n     -    directory when the destination ends in a slash, but a destination that\n     -    names a file inside a non-existent directory is not caught and only\n     -    fails later at the syscall. The same is true when a leading path\n     -    component exists but is not a directory: rename(2) fails with ENOTDIR,\n     -    again only at the syscall. As a consequence \"git mv -n\" does not detect\n     -    either problem: the dry run never reaches rename(2) and reports a move\n     -    that would not actually succeed.\n     +    Moving a file into a destination whose leading directories are not all\n     +    present, real directories is only diagnosed later at rename(2), and for\n     +    a symlinked component is not diagnosed at all.\n      \n     -    Detect this during the checking phase. For entries that will be renamed\n     -    on disk, stat the destination's leading directory and fail with a\n     -    suitable message if it is missing or is not a directory. stat() is used\n     -    rather than lstat() so that the check follows symlinks the same way\n     -    rename(2) does: a symlink to a directory is accepted, while a symlink to\n     -    a file is rejected. A missing directory or a non-directory path\n     -    component (ENOENT or ENOTDIR) reuses the existing \"destination directory\n     -    does not exist\" message; a leading component that resolves to a\n     -    non-directory reports \"destination is not a directory\". Other stat()\n     -    errors fall through to rename(2), which reports them as before.\n     +    Three cases reach rename(2) unchecked today:\n      \n     -    Add tests covering the missing directory, a path component that is a\n     -    file, a symlink to a file, a symlink to a directory (which must still\n     -    succeed), and dry-run detection.\n     +      - A leading directory is missing: rename(2) fails with ENOENT,\n     +        reported against the source (misleading), and \"git mv -n\" does not\n     +        detect it since the dry run never reaches the syscall.\n     +\n     +      - A leading component is a non-directory (\"git mv x a/b\" with 'a' a\n     +        file): rename(2) fails with ENOTDIR, again only at the syscall.\n     +\n     +      - A leading component is a symbolic link: \"git mv\" follows it. Since\n     +        Git tracks symlinks, the destination is really occupied by a\n     +        tracked object, and following it is wrong regardless of the link\n     +        target. The move is done on disk at the resolved location while the\n     +        index records the literal path, leaving the index describing a\n     +        worktree that does not exist. A later \"git add\" can reconcile it,\n     +        but \"git mv\" alone has already corrupted the state.\n     +\n     +    Detect all three in the checking phase. Reject a destination that goes\n     +    through a symlink with has_symlink_leading_path(), which uses lstat()\n     +    and never follows the link, so the refusal is independent of the\n     +    target. Then lstat() the leading directory: report \"destination\n     +    directory does not exist\" for ENOENT/ENOTDIR and \"destination is not a\n     +    directory\" for a non-directory. Other errors fall through to rename().\n     +    Guard the directory check with the same condition under which rename(2)\n     +    runs, so directory moves and sparse/out-of-cone destinations are not\n     +    flagged incorrectly.\n     +\n     +    This changes behavior: a move through a tracked symlink that previously\n     +    \"succeeded\" while corrupting the index is now refused. The other two\n     +    cases only change when the failure is diagnosed.\n      \n          Signed-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n      \n       ## builtin/mv.c ##\n     +@@\n     + #include \"string-list.h\"\n     + #include \"parse-options.h\"\n     + #include \"read-cache-ll.h\"\n     ++#include \"symlinks.h\"\n     + \n     + #include \"setup.h\"\n     + #include \"strvec.h\"\n      @@ builtin/mv.c: dir_check:\n     + \t\t\tbad = _(\"destination directory does not exist\");\n       \t\t\tgoto act_on_entry;\n       \t\t}\n     - \n     ++\t\tif (has_symlink_leading_path(dst, strlen(dst))) {\n     ++\t\t\tbad = _(\"destination is beyond a symbolic link\");\n     ++\t\t\tgoto act_on_entry;\n     ++\t\t}\n     ++\n      +\t\t/*\n      +\t\t * If we are going to move SRC to DST on disk, DST's leading\n      +\t\t * directories must already exist.\n      +\t\t */\n      +\t\tif (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n     -+\t\t\t\t!(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n     ++\t\t    !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n      +\t\t\tchar *dst_dir = xstrdup(dst);\n      +\t\t\tchar *slash = strrchr(dst_dir, '/');\n      +\n      +\t\t\tif (slash) {\n      +\t\t\t\tstruct stat dir_st;\n     ++\n      +\t\t\t\t*slash = '\\0';\n     -+\t\t\t\tif (stat(dst_dir, &dir_st) < 0) {\n     -+\t\t\t\t\t/* other errors fall through to rename(), which reports them */\n     ++\t\t\t\tif (lstat(dst_dir, &dir_st) < 0) {\n     ++\t\t\t\t\t/*\n     ++\t\t\t\t\t * other errors fall through to rename(),\n     ++\t\t\t\t\t * which reports them\n     ++\t\t\t\t\t */\n      +\t\t\t\t\tif (errno == ENOENT || errno == ENOTDIR)\n      +\t\t\t\t\t\tbad = _(\"destination directory does not exist\");\n     -+\t\t\t\t} else if (!S_ISDIR(dir_st.st_mode))\n     ++\t\t\t\t} else if (!S_ISDIR(dir_st.st_mode)) {\n      +\t\t\t\t\tbad = _(\"destination is not a directory\");\n     ++\t\t\t\t}\n      +\t\t\t}\n      +\t\t\tfree(dst_dir);\n     ++\n      +\t\t\tif (bad)\n      +\t\t\t\tgoto act_on_entry;\n      +\t\t}\n     -+\n     + \n       \t\tif (ignore_sparse &&\n       \t\t    (dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&\n     - \t\t    index_entry_exists(the_repository->index, dst, strlen(dst))) {\n      \n       ## t/t7001-mv.sh ##\n      @@ t/t7001-mv.sh: test_expect_success 'clean up' '\n       \tgit reset --hard\n       '\n       \n     -+test_expect_success 'moving to a non-existent path component in the destination' '\n     ++test_expect_success 'moving to a non-existent directory' '\n      +\tgit reset --hard &&\n     -+\tmkdir -p from &&\n     ++\trm -rf from && mkdir from &&\n      +\techo content >from/file &&\n      +\tgit add from/file &&\n      +\ttest_must_fail git mv from/file no-such-dir/file 2>actual &&\n      +\ttest_grep \"destination directory does not exist\" actual\n      +'\n      +\n     -+test_expect_success 'moving to a destination with a file as a path component' '\n     ++test_expect_success 'moving to a destination with a file as a leading path component' '\n      +\tgit reset --hard &&\n     -+\tmkdir -p from &&\n     ++\trm -rf from && mkdir from &&\n      +\techo contents >from/file &&\n      +\techo blocker >not-dir &&\n      +\tgit add from/file &&\n     @@ t/t7001-mv.sh: test_expect_success 'clean up' '\n      +\ttest_grep \"destination is not a directory\" actual\n      +'\n      +\n     -+test_expect_success SYMLINKS 'moving to a destination with a symlink to a file as a path component' '\n     ++test_expect_success SYMLINKS 'moving to a destination beyond a symlink' '\n      +\tgit reset --hard &&\n     -+\tmkdir -p from &&\n     ++\trm -rf from regular-dir link-to-dir &&\n     ++\tmkdir from regular-dir &&\n      +\techo contents >from/file &&\n     -+\techo target >regular &&\n     -+\tln -s regular link-to-file &&\n     ++\tln -s regular-dir link-to-dir &&\n      +\tgit add from/file &&\n     -+\ttest_must_fail git mv from/file link-to-file/file 2>actual &&\n     -+\ttest_grep \"not a directory\" actual\n     ++\ttest_must_fail git mv from/file link-to-dir/file 2>actual &&\n     ++\ttest_grep \"destination is beyond a symbolic link\" actual\n      +'\n      +\n     -+test_expect_success SYMLINKS 'moving to a destination with a symlink to a directory' '\n     ++test_expect_success SYMLINKS 'moving to a destination with a symlink as an intermediate component' '\n      +\tgit reset --hard &&\n     -+\tmkdir -p from realdir &&\n     ++\trm -rf from && mkdir -p from/real/inner &&\n      +\techo contents >from/file &&\n     -+\tln -s realdir link-to-dir &&\n     -+\tgit add from/file &&\n     -+\tgit mv from/file link-to-dir/file &&\n     -+\ttest_path_is_file realdir/file\n     ++\tln -s real from/link &&\n     ++\tgit add from/file from/link &&\n     ++\ttest_must_fail git mv from/file from/link/inner/dst 2>actual &&\n     ++\ttest_grep \"destination is beyond a symbolic link\" actual\n     ++'\n     ++\n     ++test_expect_success SYMLINKS 'refuses to overwrite a symlink at the destination' '\n     ++\tgit reset --hard &&\n     ++\trm -rf from && mkdir from &&\n     ++\techo contents >from/file &&\n     ++\tln -s target from/link &&\n     ++\tgit add from/file from/link &&\n     ++\ttest_must_fail git mv from/file from/link 2>actual &&\n     ++\ttest_grep \"destination exists\" actual\n     ++'\n     ++\n     ++test_expect_success SYMLINKS 'mv through a symlinked leading path does not touch the index' '\n     ++\tgit reset --hard &&\n     ++\trm -rf from && mkdir from &&\n     ++\techo contents >from/src &&\n     ++\tln -s . from/link &&\n     ++\tgit add from/src from/link &&\n     ++\tgit commit -m \"setup symlink case\" &&\n     ++\tgit ls-files --stage >expect.index &&\n     ++\ttest_must_fail git mv from/src from/link/real/dst 2>actual &&\n     ++\ttest_grep \"destination is beyond a symbolic link\" actual &&\n     ++\tgit ls-files --stage >actual.index &&\n     ++\ttest_cmp expect.index actual.index\n     ++'\n     ++\n     ++test_expect_success SYMLINKS 'mv -f does not follow a symlinked leading path' '\n     ++\tgit reset --hard &&\n     ++\trm -rf from && mkdir from &&\n     ++\techo contents >from/src &&\n     ++\tln -s file from/link &&\n     ++\tgit add from/src from/link &&\n     ++\ttest_must_fail git mv -f from/src from/link/dst 2>actual &&\n     ++\ttest_grep \"destination is beyond a symbolic link\" actual\n      +'\n      +\n      +test_expect_success 'mv --dry-run detects non-existent destination parent directory' '\n      +\tgit reset --hard &&\n     -+\tmkdir -p from &&\n     -+\techo content >from/file &&\n     ++\trm -rf from && mkdir from &&\n     ++\techo contents >from/file &&\n      +\tgit add from/file &&\n      +\ttest_must_fail git mv -n from/file no-such-dir/file 2>actual &&\n      +\ttest_grep \"destination directory does not exist\" actual\n\n-- \ngitgitgadget\n"},{"id":"549043","messageId":"0d67da588bc86c5257ce366903ae58e171159b8b.1785097071.git.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":"pull.2356.v4.git.git.1785097071.gitgitgadget@gmail.com","subject":"[PATCH v4 1/2] mv: name both source and destination when rename fails","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-26T20:17:50Z","receivedAt":"2026-07-26T20:17:55Z","isPatch":true,"body":"From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n\nWhen \"git mv\" fails at the rename(2) syscall, the error is reported\nwith die_errno() using only the source path:\n\n    fatal: renaming 'src' failed: No such file or directory\n\nrename(2) returns ENOENT both when the source does not exist and when\na directory component of the destination does not exist, and errno\ndoes not distinguish the two. Reporting only the source therefore\nmisleads the user in the latter case: for\n\n    git mv a/file b/no-such-dir/file\n\nthe message blames 'a/file', which exists, and gives no hint that\n'b/no-such-dir/' is the missing part.\n\nInspecting the paths again after the failure to determine which one is\nat fault would be racy, since either could appear or disappear between\nthe rename(2) and the follow-up check. Instead, simply name both the\nsource and the destination in the message and let the reader see which\none is wrong:\n\n    fatal: renaming 'a/file' to 'b/no-such-dir/file' failed:\n    No such file or directory\n\nSigned-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n---\n builtin/mv.c | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/builtin/mv.c b/builtin/mv.c\nindex a82fc97a19..35e504484a 100644\n--- a/builtin/mv.c\n+++ b/builtin/mv.c\n@@ -549,7 +549,7 @@ remove_entry:\n \t\t    rename(src, dst) < 0) {\n \t\t\tif (ignore_errors)\n \t\t\t\tcontinue;\n-\t\t\tdie_errno(_(\"renaming '%s' failed\"), src);\n+\t\t\tdie_errno(_(\"renaming '%s' to '%s' failed\"), src, dst);\n \t\t}\n \t\tif (submodule_gitfiles[i]) {\n \t\t\tif (!update_path_in_gitmodules(src, dst))\n-- \ngitgitgadget\n\n"},{"id":"549044","messageId":"6b72efb4130d96947c7f90026042fa09a440d091.1785097071.git.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":"pull.2356.v4.git.git.1785097071.gitgitgadget@gmail.com","subject":"[PATCH v4 2/2] mv: reject a destination whose leading path is missing or a symlink","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-26T20:17:51Z","receivedAt":"2026-07-26T20:17:57Z","isPatch":true,"body":"From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n\nMoving a file into a destination whose leading directories are not all\npresent, real directories is only diagnosed later at rename(2), and for\na symlinked component is not diagnosed at all.\n\nThree cases reach rename(2) unchecked today:\n\n  - A leading directory is missing: rename(2) fails with ENOENT,\n    reported against the source (misleading), and \"git mv -n\" does not\n    detect it since the dry run never reaches the syscall.\n\n  - A leading component is a non-directory (\"git mv x a/b\" with 'a' a\n    file): rename(2) fails with ENOTDIR, again only at the syscall.\n\n  - A leading component is a symbolic link: \"git mv\" follows it. Since\n    Git tracks symlinks, the destination is really occupied by a\n    tracked object, and following it is wrong regardless of the link\n    target. The move is done on disk at the resolved location while the\n    index records the literal path, leaving the index describing a\n    worktree that does not exist. A later \"git add\" can reconcile it,\n    but \"git mv\" alone has already corrupted the state.\n\nDetect all three in the checking phase. Reject a destination that goes\nthrough a symlink with has_symlink_leading_path(), which uses lstat()\nand never follows the link, so the refusal is independent of the\ntarget. Then lstat() the leading directory: report \"destination\ndirectory does not exist\" for ENOENT/ENOTDIR and \"destination is not a\ndirectory\" for a non-directory. Other errors fall through to rename().\nGuard the directory check with the same condition under which rename(2)\nruns, so directory moves and sparse/out-of-cone destinations are not\nflagged incorrectly.\n\nThis changes behavior: a move through a tracked symlink that previously\n\"succeeded\" while corrupting the index is now refused. The other two\ncases only change when the failure is diagnosed.\n\nSigned-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n---\n builtin/mv.c  | 35 ++++++++++++++++++++++\n t/t7001-mv.sh | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 118 insertions(+)\n\ndiff --git a/builtin/mv.c b/builtin/mv.c\nindex 35e504484a..535599e6be 100644\n--- a/builtin/mv.c\n+++ b/builtin/mv.c\n@@ -22,6 +22,7 @@\n #include \"string-list.h\"\n #include \"parse-options.h\"\n #include \"read-cache-ll.h\"\n+#include \"symlinks.h\"\n \n #include \"setup.h\"\n #include \"strvec.h\"\n@@ -443,6 +444,40 @@ dir_check:\n \t\t\tbad = _(\"destination directory does not exist\");\n \t\t\tgoto act_on_entry;\n \t\t}\n+\t\tif (has_symlink_leading_path(dst, strlen(dst))) {\n+\t\t\tbad = _(\"destination is beyond a symbolic link\");\n+\t\t\tgoto act_on_entry;\n+\t\t}\n+\n+\t\t/*\n+\t\t * If we are going to move SRC to DST on disk, DST's leading\n+\t\t * directories must already exist.\n+\t\t */\n+\t\tif (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n+\t\t    !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n+\t\t\tchar *dst_dir = xstrdup(dst);\n+\t\t\tchar *slash = strrchr(dst_dir, '/');\n+\n+\t\t\tif (slash) {\n+\t\t\t\tstruct stat dir_st;\n+\n+\t\t\t\t*slash = '\\0';\n+\t\t\t\tif (lstat(dst_dir, &dir_st) < 0) {\n+\t\t\t\t\t/*\n+\t\t\t\t\t * other errors fall through to rename(),\n+\t\t\t\t\t * which reports them\n+\t\t\t\t\t */\n+\t\t\t\t\tif (errno == ENOENT || errno == ENOTDIR)\n+\t\t\t\t\t\tbad = _(\"destination directory does not exist\");\n+\t\t\t\t} else if (!S_ISDIR(dir_st.st_mode)) {\n+\t\t\t\t\tbad = _(\"destination is not a directory\");\n+\t\t\t\t}\n+\t\t\t}\n+\t\t\tfree(dst_dir);\n+\n+\t\t\tif (bad)\n+\t\t\t\tgoto act_on_entry;\n+\t\t}\n \n \t\tif (ignore_sparse &&\n \t\t    (dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&\ndiff --git a/t/t7001-mv.sh b/t/t7001-mv.sh\nindex 7cf4aa5ba1..7905d629d8 100755\n--- a/t/t7001-mv.sh\n+++ b/t/t7001-mv.sh\n@@ -114,6 +114,89 @@ test_expect_success 'clean up' '\n \tgit reset --hard\n '\n \n+test_expect_success 'moving to a non-existent directory' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir from &&\n+\techo content >from/file &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv from/file no-such-dir/file 2>actual &&\n+\ttest_grep \"destination directory does not exist\" actual\n+'\n+\n+test_expect_success 'moving to a destination with a file as a leading path component' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir from &&\n+\techo contents >from/file &&\n+\techo blocker >not-dir &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv from/file not-dir/file 2>actual &&\n+\ttest_grep \"destination is not a directory\" actual\n+'\n+\n+test_expect_success SYMLINKS 'moving to a destination beyond a symlink' '\n+\tgit reset --hard &&\n+\trm -rf from regular-dir link-to-dir &&\n+\tmkdir from regular-dir &&\n+\techo contents >from/file &&\n+\tln -s regular-dir link-to-dir &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv from/file link-to-dir/file 2>actual &&\n+\ttest_grep \"destination is beyond a symbolic link\" actual\n+'\n+\n+test_expect_success SYMLINKS 'moving to a destination with a symlink as an intermediate component' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir -p from/real/inner &&\n+\techo contents >from/file &&\n+\tln -s real from/link &&\n+\tgit add from/file from/link &&\n+\ttest_must_fail git mv from/file from/link/inner/dst 2>actual &&\n+\ttest_grep \"destination is beyond a symbolic link\" actual\n+'\n+\n+test_expect_success SYMLINKS 'refuses to overwrite a symlink at the destination' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir from &&\n+\techo contents >from/file &&\n+\tln -s target from/link &&\n+\tgit add from/file from/link &&\n+\ttest_must_fail git mv from/file from/link 2>actual &&\n+\ttest_grep \"destination exists\" actual\n+'\n+\n+test_expect_success SYMLINKS 'mv through a symlinked leading path does not touch the index' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir from &&\n+\techo contents >from/src &&\n+\tln -s . from/link &&\n+\tgit add from/src from/link &&\n+\tgit commit -m \"setup symlink case\" &&\n+\tgit ls-files --stage >expect.index &&\n+\ttest_must_fail git mv from/src from/link/real/dst 2>actual &&\n+\ttest_grep \"destination is beyond a symbolic link\" actual &&\n+\tgit ls-files --stage >actual.index &&\n+\ttest_cmp expect.index actual.index\n+'\n+\n+test_expect_success SYMLINKS 'mv -f does not follow a symlinked leading path' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir from &&\n+\techo contents >from/src &&\n+\tln -s file from/link &&\n+\tgit add from/src from/link &&\n+\ttest_must_fail git mv -f from/src from/link/dst 2>actual &&\n+\ttest_grep \"destination is beyond a symbolic link\" actual\n+'\n+\n+test_expect_success 'mv --dry-run detects non-existent destination parent directory' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir from &&\n+\techo contents >from/file &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv -n from/file no-such-dir/file 2>actual &&\n+\ttest_grep \"destination directory does not exist\" actual\n+'\n+\n test_expect_success 'moving to existing untracked target with trailing slash' '\n \tmkdir path1 &&\n \tgit mv path0/ path1/ &&\n-- \ngitgitgadget\n"},{"id":"549051","messageId":"xmqqcxw9tjnb.fsf@gitster.g","threadId":"66005","inReplyTo":"pull.2356.v4.git.git.1785097071.gitgitgadget@gmail.com","subject":"Re: [PATCH v4 0/2] mv: report missing destination leading directory","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-26T23:29:12Z","receivedAt":"2026-07-26T23:29:15Z","isPatch":true,"body":"\"Lucas Zamboni Orioli via GitGitGadget\" <gitgitgadget@gmail.com>\nwrites:\n\n> Changes in v4:\n>\n>  * reverted to lstat and added has_symlink_leading_path() to refuse a\n>    destination that goes through a symbolic link, independent of the link\n>    target, per Junio C Hamano's point that Git tracks symlinks and must not\n>    follow them here\n\nI'll review the series tomorrow, but the use of the helper function\nhas_symlink_leading_path() in other contexts like 'git apply' rings\na bell.  I agree the problem you face is exactly the issue the helper\nfunction aims to address.\n\n\n>  * added new \"destination is beyond a symbolic link\" message\n>  * added tests: symlink as immediate parent and as intermediate component,\n>    symlink at the destination, -f does not bypass the symlink refusal, and a\n>    regression test that a move through a symlink no longer corrupts the\n>    index (see the reproduction reported on the list)\n\nSounds good.\n\nWhen 'y' is a symbolic link to some directory (say, 'a/b'), we do\nnot want 'git mv x y' to create 'y/x' at the same time we have 'y'\nas a symbolic link.  It may be OK if the result has 'a/b/x' (a file\nin a directory) and 'y' (a symbolic link), but I think that would\nbe more confusing than it is worth.\n\nThanks.\n"},{"id":"549103","messageId":"xmqqbjbsgjfu.fsf@gitster.g","threadId":"66005","inReplyTo":"6b72efb4130d96947c7f90026042fa09a440d091.1785097071.git.gitgitgadget@gmail.com","subject":"Re: [PATCH v4 2/2] mv: reject a destination whose leading path is missing or a symlink","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-27T22:24:21Z","receivedAt":"2026-07-27T22:24:23Z","isPatch":true,"body":"\"Lucas Zamboni Orioli via GitGitGadget\" <gitgitgadget@gmail.com>\nwrites:\n\n> From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n>\n> Moving a file into a destination whose leading directories are not all\n> present, real directories is only diagnosed later at rename(2), and for\n> a symlinked component is not diagnosed at all.\n\nI cannot quite parse this.  Do you mean to say something like this?\n\n    When moving a file, if any leading directory in the destination \n    path is missing or is not a real directory, the problem is detected \n    only later when rename() is called.  Furthermore, if a leading \n    directory component is a symbolic link, the issue is not detected \n    at all.\n\n> Three cases reach rename(2) unchecked today:\n>\n>   - A leading directory is missing: rename(2) fails with ENOENT,\n>     reported against the source (misleading), and \"git mv -n\" does not\n>     detect it since the dry run never reaches the syscall.\n\nOK.  With [PATCH 1/2] in place, this is an easy case for the user to\ndeal with.  Either the directory name was misspelled, or the user\nforgot to create intermediate levels of the destination directory.\n\n>   - A leading component is a non-directory (\"git mv x a/b\" with 'a' a\n>     file): rename(2) fails with ENOTDIR, again only at the syscall.\n\nTrue.  'x' cannot become 'a/b' as long as 'a' is a file sitting there.\n\n>   - A leading component is a symbolic link: \"git mv\" follows it. Since\n>     Git tracks symlinks, the destination is really occupied by a\n>     tracked object, and following it is wrong regardless of the link\n>     target. The move is done on disk at the resolved location while the\n>     index records the literal path, leaving the index describing a\n>     worktree that does not exist. A later \"git add\" can reconcile it,\n>     but \"git mv\" alone has already corrupted the state.\n\nYeah, that is horrible.\n\n> Detect all three in the checking phase. Reject a destination that goes\n> through a symlink with has_symlink_leading_path(), which uses lstat()\n> and never follows the link, so the refusal is independent of the\n> target. Then lstat() the leading directory: report \"destination\n> directory does not exist\" for ENOENT/ENOTDIR and \"destination is not a\n> directory\" for a non-directory. Other errors fall through to rename().\n\n> Guard the directory check with the same condition under which rename(2)\n> runs, so directory moves and sparse/out-of-cone destinations are not\n> flagged incorrectly.\n\nNice touch.\n\n> This changes behavior: a move through a tracked symlink that previously\n> \"succeeded\" while corrupting the index is now refused. The other two\n> cases only change when the failure is diagnosed.\n\nNice bugfix.\n\n> diff --git a/builtin/mv.c b/builtin/mv.c\n> index 35e504484a..535599e6be 100644\n> --- a/builtin/mv.c\n> +++ b/builtin/mv.c\n> @@ -22,6 +22,7 @@\n>  #include \"string-list.h\"\n>  #include \"parse-options.h\"\n>  #include \"read-cache-ll.h\"\n> +#include \"symlinks.h\"\n>  \n>  #include \"setup.h\"\n>  #include \"strvec.h\"\n> @@ -443,6 +444,40 @@ dir_check:\n>  \t\t\tbad = _(\"destination directory does not exist\");\n>  \t\t\tgoto act_on_entry;\n>  \t\t}\n> +\t\tif (has_symlink_leading_path(dst, strlen(dst))) {\n> +\t\t\tbad = _(\"destination is beyond a symbolic link\");\n> +\t\t\tgoto act_on_entry;\n> +\t\t}\n\nWith a proper helper, this part of the fix is surprisingly simple.\n\n> +\t\t/*\n> +\t\t * If we are going to move SRC to DST on disk, DST's leading\n> +\t\t * directories must already exist.\n> +\t\t */\n> +\t\tif (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n> +\t\t    !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n\nThis small piece of logic is a duplicate of the next block that\nactually performs the move.  I wonder if we can have a small helper\nfunction that takes mode and dst_mode as parameters and returns this\nvalue?  Then this part would become:\n\n\t\tif (that_function(modes[i], dst_mode)) {\n\nand the \"real thing\" would become\n\n-\t\tif (!(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n-\t\t    !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&\n+\t\tif (that_function(mode, dst_mode) &&\n\t\t    rename(src, dst) < 0) {\n\t\t\tif (ignore_errors)\n\t\t\t\tcontinue;\n\t\t\tdie_errno(_(\"renaming '%s' failed\"), src);\n\t\t}\n\nand we will never risk them drifting apart.  Naming is the tough\npart, though.  I will leave it up to you and the list to come up\nwith a good name that fits the semantics of what that function\ncomputes.\n\n> +\t\t\tchar *dst_dir = xstrdup(dst);\n> +\t\t\tchar *slash = strrchr(dst_dir, '/');\n\nAre the elements of the destinations.v[] array normalized so that\nthey are all full final pathnames?  I mean, 'mv A B' when B is an\nexisting directory would succeed, remove A, and leave 'B/A' in the\nresulting working tree.  If we can depend on the preprocessing code\nand the element in destinations.v[] corresponding to the move is\n'B/A' (and presumably the corresponding element in the sources.v[]\narray would be 'A') in such a case, then stripping the final name\ncomponent and checking whether the remainder (that is, the dirname)\nis a directory, as the code below does, sounds like the right\napproach.\n\n> +\t\t\tif (slash) {\n> +\t\t\t\tstruct stat dir_st;\n> +\n> +\t\t\t\t*slash = '\\0';\n> +\t\t\t\tif (lstat(dst_dir, &dir_st) < 0) {\n> +\t\t\t\t\t/*\n> +\t\t\t\t\t * other errors fall through to rename(),\n> +\t\t\t\t\t * which reports them\n> +\t\t\t\t\t */\n> +\t\t\t\t\tif (errno == ENOENT || errno == ENOTDIR)\n> +\t\t\t\t\t\tbad = _(\"destination directory does not exist\");\n> +\t\t\t\t} else if (!S_ISDIR(dir_st.st_mode)) {\n> +\t\t\t\t\tbad = _(\"destination is not a directory\");\n> +\t\t\t\t}\n> +\t\t\t}\n> +\t\t\tfree(dst_dir);\n\nIf you did this instead\n\n\t\t\tconst char *slash_ = strrchr(dst, '/');\n\t\t\tif (stash_) {\n\t\t\t\tchar *dst_dir = xstrdup(dst);\n\t\t\t\tchar *slash = &dst_dir[slash_ - dst];\n\nthen you need to allocate only if you need a copy.  I do not know if\nit matters, though.  What do we do to elements in destinations.v[]\nthat lacks a slash?\n\n> +\t\t\tif (bad)\n> +\t\t\t\tgoto act_on_entry;\n> +\t\t}\n\nThanks.\n"},{"id":"549289","messageId":"CAH01Q--Jeip3VvrYCOfM69ktvcR1gdeA6gVsQynd_xQ+cjsN8w@mail.gmail.com","threadId":"66005","inReplyTo":"xmqqbjbsgjfu.fsf@gitster.g","subject":"Re: [PATCH v4 2/2] mv: reject a destination whose leading path is missing or a symlink","fromName":"Lucas Zamboni Orioli","fromEmail":"lucaszam0@gmail.com","sentAt":"2026-07-30T11:23:10Z","receivedAt":"2026-07-30T11:23:24Z","isPatch":true,"body":"Em seg., 27 de jul. de 2026 às 19:24, Junio C Hamano\n<gitster@pobox.com> escreveu:\n\n> I cannot quite parse this.  Do you mean to say something like this?\n>\n>     When moving a file, if any leading directory in the destination\n>     path is missing or is not a real directory, the problem is detected\n>     only later when rename() is called.  Furthermore, if a leading\n>     directory component is a symbolic link, the issue is not detected\n>     at all.\n>\n\nYes, that's what I mean. Your wording is clearer, so I'll use it for\nthe opening of the commit message.\n\n> This small piece of logic is a duplicate of the next block that\n> actually performs the move.  I wonder if we can have a small helper\n> function that takes mode and dst_mode as parameters and returns this\n> value?\n\nDone. I added a helper:\n\n+ static int needs_worktree_rename(enum update_mode mode,\n+                                  enum update_mode dst_mode)\n+ {\n+         return !(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n+                !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE));\n+ }\n\nI'm not attached to the name; happy to take a better one if the list\nhas a preference.\n\n> Are the elements of the destinations.v[] array normalized so that\n> they are all full final pathnames?\n\nYes. When the destination is an existing directory, the setup phase\nbuilds the destinations with DUP_BASENAME against dst_w_slash, which\nappends the source's basename, so \"git mv file dir\"  yields \"dir/file\"\nin destinations.v[] by the time this check runs. I added a test for\nthat case which succeeds.\n\n> What do we do to elements in destinations.v[] that lacks a slash?\n\nA slash-less destination is a bare filename in the current directory\n(\"git mv file_a file_b\"), which has no leading directory to check, it lands in\nthe cwd, which always exists, so skipping the check when there is no\nslash is correct. I added a test for that too, moving into a bare\nfilename in the cwd, which succeeds.\n\n> then you need to allocate only if you need a copy.  I do not know if\n> it matters, though.\n\nApplied, the xstrdup() now happens inside the \"if (slash_)\" arm, so a\nslash-less destination does not allocate.\n\nThanks for the review.\n"},{"id":"549290","messageId":"pull.2356.v5.git.git.1785410884.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":"pull.2356.v4.git.git.1785097071.gitgitgadget@gmail.com","subject":"[PATCH v5 0/2] mv: report missing destination leading directory","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-30T11:28:02Z","receivedAt":"2026-07-30T11:28:07Z","isPatch":true,"body":"Changes in v5:\n\n * extracted the shared \"will this move rename on disk?\" condition into a\n   needs_worktree_rename() helper used by both the new leading-directory\n   check and the actual rename(), so the two cannot drift, per Junio C\n   Hamano\n * allocate the dirname copy only when the destination has a slash\n * reworded the opening of the commit message for clarity, per Junio C\n   Hamano\n * added tests: moving into an existing directory (destination is normalized\n   to a full path), and moving to a bare filename in the cwd (no leading\n   directory to check)\n\nChanges in v4:\n\n * reverted to lstat and added has_symlink_leading_path() to refuse a\n   destination that goes through a symbolic link, independent of the link\n   target, per Junio C Hamano's point that Git tracks symlinks and must not\n   follow them here\n * added new \"destination is beyond a symbolic link\" message\n * added tests: symlink as immediate parent and as intermediate component,\n   symlink at the destination, -f does not bypass the symlink refusal, and a\n   regression test that a move through a symlink no longer corrupts the\n   index (see the reproduction reported on the list)\n\nChanges in v3:\n\n * added ENOTDIR handling and an S_ISDIR check so a non-directory leading\n   path component is caught, as suggested by Junio C Hamano\n * (v3 used stat() to resolve symlinks; this was reverted in v4 after Junio\n   pointed out symlinks must not be followed)\n * fixed indentation\n\nChanges in v2:\n\n * altered the error message to include both source and destination as\n   suggested by Ben Knoble\n\nLucas Zamboni Orioli (2):\n  mv: name both source and destination when rename fails\n  mv: reject a destination whose leading path is missing or a symlink\n\n builtin/mv.c  |  47 +++++++++++++++++++++--\n t/t7001-mv.sh | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 146 insertions(+), 3 deletions(-)\n\n\nbase-commit: 9a0c4701dcd5725c4184599322b52933ff5005ca\nPublished-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2356%2FZamboniL%2Fmv-detect-non-existing-target-folder-v5\nFetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2356/ZamboniL/mv-detect-non-existing-target-folder-v5\nPull-Request: https://github.com/git/git/pull/2356\n\nRange-diff vs v4:\n\n 1:  0d67da588b = 1:  0d67da588b mv: name both source and destination when rename fails\n 2:  6b72efb413 ! 2:  6c2909e609 mv: reject a destination whose leading path is missing or a symlink\n     @@ Metadata\n       ## Commit message ##\n          mv: reject a destination whose leading path is missing or a symlink\n      \n     -    Moving a file into a destination whose leading directories are not all\n     -    present, real directories is only diagnosed later at rename(2), and for\n     -    a symlinked component is not diagnosed at all.\n     +    When moving a file, if any leading directory in the destination path\n     +    is missing or is not a real directory, the problem is detected only\n     +    later when rename() is called. Furthermore, if a leading directory\n     +    component is a symbolic link, the issue is not detected at all.\n      \n          Three cases reach rename(2) unchecked today:\n      \n     @@ builtin/mv.c\n       \n       #include \"setup.h\"\n       #include \"strvec.h\"\n     +@@ builtin/mv.c: enum update_mode {\n     + \tMOVE_VIA_PARENT_DIR = (1 << 5),\n     + };\n     + \n     ++static int needs_worktree_rename(enum update_mode mode, enum update_mode dst_mode)\n     ++{\n     ++\treturn !(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n     ++\t       !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE));\n     ++}\n     ++\n     + #define DUP_BASENAME 1\n     + #define KEEP_TRAILING_SLASH 2\n     + \n      @@ builtin/mv.c: dir_check:\n       \t\t\tbad = _(\"destination directory does not exist\");\n       \t\t\tgoto act_on_entry;\n     @@ builtin/mv.c: dir_check:\n      +\t\t * If we are going to move SRC to DST on disk, DST's leading\n      +\t\t * directories must already exist.\n      +\t\t */\n     -+\t\tif (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n     -+\t\t    !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {\n     -+\t\t\tchar *dst_dir = xstrdup(dst);\n     -+\t\t\tchar *slash = strrchr(dst_dir, '/');\n     ++\t\tif (needs_worktree_rename(modes[i], dst_mode)) {\n     ++\t\t\tconst char *slash_ = strrchr(dst, '/');\n      +\n     -+\t\t\tif (slash) {\n     ++\t\t\tif (slash_) {\n      +\t\t\t\tstruct stat dir_st;\n     ++\t\t\t\tchar *dst_dir = xstrdup(dst);\n     ++\t\t\t\tchar *slash = &dst_dir[slash_ - dst];\n      +\n      +\t\t\t\t*slash = '\\0';\n      +\t\t\t\tif (lstat(dst_dir, &dir_st) < 0) {\n     @@ builtin/mv.c: dir_check:\n      +\t\t\t\t} else if (!S_ISDIR(dir_st.st_mode)) {\n      +\t\t\t\t\tbad = _(\"destination is not a directory\");\n      +\t\t\t\t}\n     ++\n     ++\t\t\t\tfree(dst_dir);\n      +\t\t\t}\n     -+\t\t\tfree(dst_dir);\n      +\n      +\t\t\tif (bad)\n      +\t\t\t\tgoto act_on_entry;\n     @@ builtin/mv.c: dir_check:\n       \n       \t\tif (ignore_sparse &&\n       \t\t    (dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&\n     +@@ builtin/mv.c: remove_entry:\n     + \t\t\tprintf(_(\"Renaming %s to %s\\n\"), src, dst);\n     + \t\tif (show_only)\n     + \t\t\tcontinue;\n     +-\t\tif (!(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n     +-\t\t    !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&\n     ++\t\tif (needs_worktree_rename(mode, dst_mode) &&\n     + \t\t    rename(src, dst) < 0) {\n     + \t\t\tif (ignore_errors)\n     + \t\t\t\tcontinue;\n      \n       ## t/t7001-mv.sh ##\n      @@ t/t7001-mv.sh: test_expect_success 'clean up' '\n       \tgit reset --hard\n       '\n       \n     ++test_expect_success 'moving file to directory without trailing slash' '\n     ++\tgit reset --hard HEAD &&\n     ++\trm -rf file.txt target && mkdir target &&\n     ++\techo content > file.txt &&\n     ++\tgit add file.txt &&\n     ++\tgit mv file.txt target &&\n     ++\ttest_path_is_file target/file.txt\n     ++'\n     ++\n     ++test_expect_success 'moving file to a bare filename in the cwd' '\n     ++\tgit reset --hard &&\n     ++\trm -rf from dest.txt &&\n     ++\tmkdir from &&\n     ++\techo content >from/file &&\n     ++\tgit add from/file &&\n     ++\tgit mv from/file dest.txt &&\n     ++\ttest_path_is_file dest.txt\n     ++'\n     ++\n      +test_expect_success 'moving to a non-existent directory' '\n      +\tgit reset --hard &&\n      +\trm -rf from && mkdir from &&\n\n-- \ngitgitgadget\n"},{"id":"549291","messageId":"0d67da588bc86c5257ce366903ae58e171159b8b.1785410884.git.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":"pull.2356.v5.git.git.1785410884.gitgitgadget@gmail.com","subject":"[PATCH v5 1/2] mv: name both source and destination when rename fails","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-30T11:28:03Z","receivedAt":"2026-07-30T11:28:09Z","isPatch":true,"body":"From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n\nWhen \"git mv\" fails at the rename(2) syscall, the error is reported\nwith die_errno() using only the source path:\n\n    fatal: renaming 'src' failed: No such file or directory\n\nrename(2) returns ENOENT both when the source does not exist and when\na directory component of the destination does not exist, and errno\ndoes not distinguish the two. Reporting only the source therefore\nmisleads the user in the latter case: for\n\n    git mv a/file b/no-such-dir/file\n\nthe message blames 'a/file', which exists, and gives no hint that\n'b/no-such-dir/' is the missing part.\n\nInspecting the paths again after the failure to determine which one is\nat fault would be racy, since either could appear or disappear between\nthe rename(2) and the follow-up check. Instead, simply name both the\nsource and the destination in the message and let the reader see which\none is wrong:\n\n    fatal: renaming 'a/file' to 'b/no-such-dir/file' failed:\n    No such file or directory\n\nSigned-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n---\n builtin/mv.c | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/builtin/mv.c b/builtin/mv.c\nindex a82fc97a19..35e504484a 100644\n--- a/builtin/mv.c\n+++ b/builtin/mv.c\n@@ -549,7 +549,7 @@ remove_entry:\n \t\t    rename(src, dst) < 0) {\n \t\t\tif (ignore_errors)\n \t\t\t\tcontinue;\n-\t\t\tdie_errno(_(\"renaming '%s' failed\"), src);\n+\t\t\tdie_errno(_(\"renaming '%s' to '%s' failed\"), src, dst);\n \t\t}\n \t\tif (submodule_gitfiles[i]) {\n \t\t\tif (!update_path_in_gitmodules(src, dst))\n-- \ngitgitgadget\n\n"},{"id":"549292","messageId":"6c2909e609a6212b359b092d845c0a97ef0b4879.1785410884.git.gitgitgadget@gmail.com","threadId":"66005","inReplyTo":"pull.2356.v5.git.git.1785410884.gitgitgadget@gmail.com","subject":"[PATCH v5 2/2] mv: reject a destination whose leading path is missing or a symlink","fromName":"Lucas Zamboni Orioli via GitGitGadget","fromEmail":"gitgitgadget@gmail.com","sentAt":"2026-07-30T11:28:04Z","receivedAt":"2026-07-30T11:28:11Z","isPatch":true,"body":"From: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n\nWhen moving a file, if any leading directory in the destination path\nis missing or is not a real directory, the problem is detected only\nlater when rename() is called. Furthermore, if a leading directory\ncomponent is a symbolic link, the issue is not detected at all.\n\nThree cases reach rename(2) unchecked today:\n\n  - A leading directory is missing: rename(2) fails with ENOENT,\n    reported against the source (misleading), and \"git mv -n\" does not\n    detect it since the dry run never reaches the syscall.\n\n  - A leading component is a non-directory (\"git mv x a/b\" with 'a' a\n    file): rename(2) fails with ENOTDIR, again only at the syscall.\n\n  - A leading component is a symbolic link: \"git mv\" follows it. Since\n    Git tracks symlinks, the destination is really occupied by a\n    tracked object, and following it is wrong regardless of the link\n    target. The move is done on disk at the resolved location while the\n    index records the literal path, leaving the index describing a\n    worktree that does not exist. A later \"git add\" can reconcile it,\n    but \"git mv\" alone has already corrupted the state.\n\nDetect all three in the checking phase. Reject a destination that goes\nthrough a symlink with has_symlink_leading_path(), which uses lstat()\nand never follows the link, so the refusal is independent of the\ntarget. Then lstat() the leading directory: report \"destination\ndirectory does not exist\" for ENOENT/ENOTDIR and \"destination is not a\ndirectory\" for a non-directory. Other errors fall through to rename().\nGuard the directory check with the same condition under which rename(2)\nruns, so directory moves and sparse/out-of-cone destinations are not\nflagged incorrectly.\n\nThis changes behavior: a move through a tracked symlink that previously\n\"succeeded\" while corrupting the index is now refused. The other two\ncases only change when the failure is diagnosed.\n\nSigned-off-by: Lucas Zamboni Orioli <lucaszam0@gmail.com>\n---\n builtin/mv.c  |  45 +++++++++++++++++++++-\n t/t7001-mv.sh | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++\n 2 files changed, 145 insertions(+), 2 deletions(-)\n\ndiff --git a/builtin/mv.c b/builtin/mv.c\nindex 35e504484a..373d4aeba3 100644\n--- a/builtin/mv.c\n+++ b/builtin/mv.c\n@@ -22,6 +22,7 @@\n #include \"string-list.h\"\n #include \"parse-options.h\"\n #include \"read-cache-ll.h\"\n+#include \"symlinks.h\"\n \n #include \"setup.h\"\n #include \"strvec.h\"\n@@ -48,6 +49,12 @@ enum update_mode {\n \tMOVE_VIA_PARENT_DIR = (1 << 5),\n };\n \n+static int needs_worktree_rename(enum update_mode mode, enum update_mode dst_mode)\n+{\n+\treturn !(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n+\t       !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE));\n+}\n+\n #define DUP_BASENAME 1\n #define KEEP_TRAILING_SLASH 2\n \n@@ -443,6 +450,41 @@ dir_check:\n \t\t\tbad = _(\"destination directory does not exist\");\n \t\t\tgoto act_on_entry;\n \t\t}\n+\t\tif (has_symlink_leading_path(dst, strlen(dst))) {\n+\t\t\tbad = _(\"destination is beyond a symbolic link\");\n+\t\t\tgoto act_on_entry;\n+\t\t}\n+\n+\t\t/*\n+\t\t * If we are going to move SRC to DST on disk, DST's leading\n+\t\t * directories must already exist.\n+\t\t */\n+\t\tif (needs_worktree_rename(modes[i], dst_mode)) {\n+\t\t\tconst char *slash_ = strrchr(dst, '/');\n+\n+\t\t\tif (slash_) {\n+\t\t\t\tstruct stat dir_st;\n+\t\t\t\tchar *dst_dir = xstrdup(dst);\n+\t\t\t\tchar *slash = &dst_dir[slash_ - dst];\n+\n+\t\t\t\t*slash = '\\0';\n+\t\t\t\tif (lstat(dst_dir, &dir_st) < 0) {\n+\t\t\t\t\t/*\n+\t\t\t\t\t * other errors fall through to rename(),\n+\t\t\t\t\t * which reports them\n+\t\t\t\t\t */\n+\t\t\t\t\tif (errno == ENOENT || errno == ENOTDIR)\n+\t\t\t\t\t\tbad = _(\"destination directory does not exist\");\n+\t\t\t\t} else if (!S_ISDIR(dir_st.st_mode)) {\n+\t\t\t\t\tbad = _(\"destination is not a directory\");\n+\t\t\t\t}\n+\n+\t\t\t\tfree(dst_dir);\n+\t\t\t}\n+\n+\t\t\tif (bad)\n+\t\t\t\tgoto act_on_entry;\n+\t\t}\n \n \t\tif (ignore_sparse &&\n \t\t    (dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&\n@@ -544,8 +586,7 @@ remove_entry:\n \t\t\tprintf(_(\"Renaming %s to %s\\n\"), src, dst);\n \t\tif (show_only)\n \t\t\tcontinue;\n-\t\tif (!(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&\n-\t\t    !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&\n+\t\tif (needs_worktree_rename(mode, dst_mode) &&\n \t\t    rename(src, dst) < 0) {\n \t\t\tif (ignore_errors)\n \t\t\t\tcontinue;\ndiff --git a/t/t7001-mv.sh b/t/t7001-mv.sh\nindex 7cf4aa5ba1..719562a118 100755\n--- a/t/t7001-mv.sh\n+++ b/t/t7001-mv.sh\n@@ -114,6 +114,108 @@ test_expect_success 'clean up' '\n \tgit reset --hard\n '\n \n+test_expect_success 'moving file to directory without trailing slash' '\n+\tgit reset --hard HEAD &&\n+\trm -rf file.txt target && mkdir target &&\n+\techo content > file.txt &&\n+\tgit add file.txt &&\n+\tgit mv file.txt target &&\n+\ttest_path_is_file target/file.txt\n+'\n+\n+test_expect_success 'moving file to a bare filename in the cwd' '\n+\tgit reset --hard &&\n+\trm -rf from dest.txt &&\n+\tmkdir from &&\n+\techo content >from/file &&\n+\tgit add from/file &&\n+\tgit mv from/file dest.txt &&\n+\ttest_path_is_file dest.txt\n+'\n+\n+test_expect_success 'moving to a non-existent directory' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir from &&\n+\techo content >from/file &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv from/file no-such-dir/file 2>actual &&\n+\ttest_grep \"destination directory does not exist\" actual\n+'\n+\n+test_expect_success 'moving to a destination with a file as a leading path component' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir from &&\n+\techo contents >from/file &&\n+\techo blocker >not-dir &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv from/file not-dir/file 2>actual &&\n+\ttest_grep \"destination is not a directory\" actual\n+'\n+\n+test_expect_success SYMLINKS 'moving to a destination beyond a symlink' '\n+\tgit reset --hard &&\n+\trm -rf from regular-dir link-to-dir &&\n+\tmkdir from regular-dir &&\n+\techo contents >from/file &&\n+\tln -s regular-dir link-to-dir &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv from/file link-to-dir/file 2>actual &&\n+\ttest_grep \"destination is beyond a symbolic link\" actual\n+'\n+\n+test_expect_success SYMLINKS 'moving to a destination with a symlink as an intermediate component' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir -p from/real/inner &&\n+\techo contents >from/file &&\n+\tln -s real from/link &&\n+\tgit add from/file from/link &&\n+\ttest_must_fail git mv from/file from/link/inner/dst 2>actual &&\n+\ttest_grep \"destination is beyond a symbolic link\" actual\n+'\n+\n+test_expect_success SYMLINKS 'refuses to overwrite a symlink at the destination' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir from &&\n+\techo contents >from/file &&\n+\tln -s target from/link &&\n+\tgit add from/file from/link &&\n+\ttest_must_fail git mv from/file from/link 2>actual &&\n+\ttest_grep \"destination exists\" actual\n+'\n+\n+test_expect_success SYMLINKS 'mv through a symlinked leading path does not touch the index' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir from &&\n+\techo contents >from/src &&\n+\tln -s . from/link &&\n+\tgit add from/src from/link &&\n+\tgit commit -m \"setup symlink case\" &&\n+\tgit ls-files --stage >expect.index &&\n+\ttest_must_fail git mv from/src from/link/real/dst 2>actual &&\n+\ttest_grep \"destination is beyond a symbolic link\" actual &&\n+\tgit ls-files --stage >actual.index &&\n+\ttest_cmp expect.index actual.index\n+'\n+\n+test_expect_success SYMLINKS 'mv -f does not follow a symlinked leading path' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir from &&\n+\techo contents >from/src &&\n+\tln -s file from/link &&\n+\tgit add from/src from/link &&\n+\ttest_must_fail git mv -f from/src from/link/dst 2>actual &&\n+\ttest_grep \"destination is beyond a symbolic link\" actual\n+'\n+\n+test_expect_success 'mv --dry-run detects non-existent destination parent directory' '\n+\tgit reset --hard &&\n+\trm -rf from && mkdir from &&\n+\techo contents >from/file &&\n+\tgit add from/file &&\n+\ttest_must_fail git mv -n from/file no-such-dir/file 2>actual &&\n+\ttest_grep \"destination directory does not exist\" actual\n+'\n+\n test_expect_success 'moving to existing untracked target with trailing slash' '\n \tmkdir path1 &&\n \tgit mv path0/ path1/ &&\n-- \ngitgitgadget\n"},{"id":"549323","messageId":"xmqqtspgjkwb.fsf@gitster.g","threadId":"66005","inReplyTo":"pull.2356.v5.git.git.1785410884.gitgitgadget@gmail.com","subject":"Re: [PATCH v5 0/2] mv: report missing destination leading directory","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-07-30T20:13:40Z","receivedAt":"2026-07-30T20:13:43Z","isPatch":true,"body":"\"Lucas Zamboni Orioli via GitGitGadget\" <gitgitgadget@gmail.com>\nwrites:\n\n> Changes in v5:\n>\n>  * extracted the shared \"will this move rename on disk?\" condition into a\n>    needs_worktree_rename() helper used by both the new leading-directory\n>    check and the actual rename(), so the two cannot drift, per Junio C\n>    Hamano\n>  * allocate the dirname copy only when the destination has a slash\n>  * reworded the opening of the commit message for clarity, per Junio C\n>    Hamano\n>  * added tests: moving into an existing directory (destination is normalized\n>    to a full path), and moving to a bare filename in the cwd (no leading\n>    directory to check)\n\nThe changes relative to v4 look as expected.  Looking good.\n\nThanks.\n\n\n"}]}