git/list[1] front-page[2] threads[3] people[4] search[5] about
 

Re: [PATCH v4 2/2] mv: reject a destination whose leading path is missing or a symlink

From
Junio C Hamano <gitster@pobox.com>
Date
Jul 27, 2026, 22:24 UTC
Message-ID
<xmqqbjbsgjfu.fsf@gitster.g>
In-Reply-To
<6b72efb4130d96947c7f90026042fa09a440d091.1785097071.git.gitgitgadget@gmail.com>

"Lucas Zamboni Orioli via GitGitGadget" <gitgitgadget@gmail.com> writes:

Show 5 quoted lines
> From: Lucas Zamboni Orioli <lucaszam0@gmail.com>
>
> Moving a file into a destination whose leading directories are not all
> present, real directories is only diagnosed later at rename(2), and for
> a symlinked component is not diagnosed at all.
I cannot quite parse this.  Do you mean to say something like this?
    When moving a file, if any leading directory in the destination 
    path is missing or is not a real directory, the problem is detected 
    only later when rename() is called.  Furthermore, if a leading 
    directory component is a symbolic link, the issue is not detected 
    at all.
Show 5 quoted lines
> Three cases reach rename(2) unchecked today:
>
>   - A leading directory is missing: rename(2) fails with ENOENT,
>     reported against the source (misleading), and "git mv -n" does not
>     detect it since the dry run never reaches the syscall.

OK. With [PATCH 1/2] in place, this is an easy case for the user to deal with. Either the directory name was misspelled, or the user forgot to create intermediate levels of the destination directory.

>   - A leading component is a non-directory ("git mv x a/b" with 'a' a
>     file): rename(2) fails with ENOTDIR, again only at the syscall.
True.  'x' cannot become 'a/b' as long as 'a' is a file sitting there.
Show 7 quoted lines
>   - A leading component is a symbolic link: "git mv" follows it. Since
>     Git tracks symlinks, the destination is really occupied by a
>     tracked object, and following it is wrong regardless of the link
>     target. The move is done on disk at the resolved location while the
>     index records the literal path, leaving the index describing a
>     worktree that does not exist. A later "git add" can reconcile it,
>     but "git mv" alone has already corrupted the state.
Yeah, that is horrible.
Show 6 quoted lines
> Detect all three in the checking phase. Reject a destination that goes
> through a symlink with has_symlink_leading_path(), which uses lstat()
> and never follows the link, so the refusal is independent of the
> target. Then lstat() the leading directory: report "destination
> directory does not exist" for ENOENT/ENOTDIR and "destination is not a
> directory" for a non-directory. Other errors fall through to rename().
> Guard the directory check with the same condition under which rename(2)
> runs, so directory moves and sparse/out-of-cone destinations are not
> flagged incorrectly.
Nice touch.
> This changes behavior: a move through a tracked symlink that previously
> "succeeded" while corrupting the index is now refused. The other two
> cases only change when the failure is diagnosed.
Nice bugfix.
Show 20 quoted lines
> diff --git a/builtin/mv.c b/builtin/mv.c
> index 35e504484a..535599e6be 100644
> --- a/builtin/mv.c
> +++ b/builtin/mv.c
> @@ -22,6 +22,7 @@
>  #include "string-list.h"
>  #include "parse-options.h"
>  #include "read-cache-ll.h"
> +#include "symlinks.h"
>  
>  #include "setup.h"
>  #include "strvec.h"
> @@ -443,6 +444,40 @@ dir_check:
>  			bad = _("destination directory does not exist");
>  			goto act_on_entry;
>  		}
> +		if (has_symlink_leading_path(dst, strlen(dst))) {
> +			bad = _("destination is beyond a symbolic link");
> +			goto act_on_entry;
> +		}
With a proper helper, this part of the fix is surprisingly simple.
Show 6 quoted lines
> +		/*
> +		 * If we are going to move SRC to DST on disk, DST's leading
> +		 * directories must already exist.
> +		 */
> +		if (!(modes[i] & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&
> +		    !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE))) {

This small piece of logic is a duplicate of the next block that actually performs the move. I wonder if we can have a small helper function that takes mode and dst_mode as parameters and returns this value? Then this part would become:

		if (that_function(modes[i], dst_mode)) {
and the "real thing" would become
-		if (!(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&
-		    !(dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&
+		if (that_function(mode, dst_mode) &&
		    rename(src, dst) < 0) {
			if (ignore_errors)
				continue;
			die_errno(_("renaming '%s' failed"), src);
		}

and we will never risk them drifting apart. Naming is the tough part, though. I will leave it up to you and the list to come up with a good name that fits the semantics of what that function computes.

> +			char *dst_dir = xstrdup(dst);
> +			char *slash = strrchr(dst_dir, '/');

Are the elements of the destinations.v[] array normalized so that they are all full final pathnames? I mean, 'mv A B' when B is an existing directory would succeed, remove A, and leave 'B/A' in the resulting working tree. If we can depend on the preprocessing code and the element in destinations.v[] corresponding to the move is 'B/A' (and presumably the corresponding element in the sources.v[] array would be 'A') in such a case, then stripping the final name component and checking whether the remainder (that is, the dirname) is a directory, as the code below does, sounds like the right approach.

Show 16 quoted lines
> +			if (slash) {
> +				struct stat dir_st;
> +
> +				*slash = '\0';
> +				if (lstat(dst_dir, &dir_st) < 0) {
> +					/*
> +					 * other errors fall through to rename(),
> +					 * which reports them
> +					 */
> +					if (errno == ENOENT || errno == ENOTDIR)
> +						bad = _("destination directory does not exist");
> +				} else if (!S_ISDIR(dir_st.st_mode)) {
> +					bad = _("destination is not a directory");
> +				}
> +			}
> +			free(dst_dir);
If you did this instead
			const char *slash_ = strrchr(dst, '/');
			if (stash_) {
				char *dst_dir = xstrdup(dst);
				char *slash = &dst_dir[slash_ - dst];

then you need to allocate only if you need a copy. I do not know if it matters, though. What do we do to elements in destinations.v[] that lacks a slash?

> +			if (bad)
> +				goto act_on_entry;
> +		}
Thanks.
Previous: Lucas Zamboni Orioli via GitGitGadgetNext: Lucas Zamboni Orioli
Message 22 of 28 in “mv: report missing destination leading directory”
  1. mv: report missing destination leading directoryLucas Zamboni Orioli via GitGitGadget, Jul 15, 2026
  2. Ben KnobleJul 15, 2026
  3. Lucas Zamboni OrioliJul 22, 2026
  4. 0/2 mv: report missing destination leading directoryLucas Zamboni Orioli via GitGitGadget, Jul 23, 2026
  5. 1/2 mv: name both source and destination when rename failsLucas Zamboni Orioli via GitGitGadget, Jul 23, 2026
  6. Junio C HamanoJul 23, 2026
  7. 2/2 mv: check for missing destination directory before renamingLucas Zamboni Orioli via GitGitGadget, Jul 23, 2026
  8. Junio C HamanoJul 23, 2026
  9. Junio C HamanoJul 23, 2026
  10. Lucas Zamboni OrioliJul 23, 2026
  11. Junio C HamanoJul 23, 2026
  12. Junio C HamanoJul 23, 2026
  13. Junio C HamanoJul 26, 2026
  14. Lucas Zamboni OrioliJul 26, 2026
  15. 0/2 mv: report missing destination leading directoryLucas Zamboni Orioli via GitGitGadget, Jul 23, 2026
  16. 1/2 mv: name both source and destination when rename failsLucas Zamboni Orioli via GitGitGadget, Jul 23, 2026
  17. 2/2 mv: check for missing destination directory before renamingLucas Zamboni Orioli via GitGitGadget, Jul 23, 2026
  18. Pablo SabaterJul 26, 2026
  19. 0/2 mv: report missing destination leading directoryLucas Zamboni Orioli via GitGitGadget, Jul 26, 2026
  20. 1/2 mv: name both source and destination when rename failsLucas Zamboni Orioli via GitGitGadget, Jul 26, 2026
  21. 2/2 mv: reject a destination whose leading path is missing or a symlinkLucas Zamboni Orioli via GitGitGadget, Jul 26, 2026
  22. Junio C HamanoJul 27, 2026
  23. Lucas Zamboni OrioliJul 30, 2026
  24. Junio C HamanoJul 26, 2026
  25. 0/2 mv: report missing destination leading directoryLucas Zamboni Orioli via GitGitGadget, Jul 30, 2026
  26. 1/2 mv: name both source and destination when rename failsLucas Zamboni Orioli via GitGitGadget, Jul 30, 2026
  27. 2/2 mv: reject a destination whose leading path is missing or a symlinkLucas Zamboni Orioli via GitGitGadget, Jul 30, 2026
  28. Junio C HamanoJul 30, 2026

Read the whole thread, see it on lore, or plain text.

$ cat FOOTERMessages come from the public archive at lore.kernel.org/git, fetched every hour. The front page is chosen and written each morning by an AI editor and can be wrong; the threads themselves are the record. About and API. For agents: an MCP server at https://gitlist.dev/mcp, and any thread, story or person page as Markdown by adding .md to its URL (or sending Accept: text/markdown). Details in /llms.txt.