Merge branch 'lo/mv-missing-dest-dir-check' into jch

'git mv' has been updated to check for a missing destination
leading directory during the checking phase, allowing 'git mv -n'
to report the failure.  The error message when the rename(2)
syscall fails has also been improved to name both the source and
the destination.

* lo/mv-missing-dest-dir-check:
  mv: reject a destination whose leading path is missing or a symlink
  mv: name both source and destination when rename fails
This commit is contained in:
Junio C Hamano
2026-08-07 14:48:07 -07:00
2 changed files with 146 additions and 3 deletions

View File

@@ -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"
@@ -48,6 +49,12 @@ enum update_mode {
MOVE_VIA_PARENT_DIR = (1 << 5),
};
static int needs_worktree_rename(enum update_mode mode, enum update_mode dst_mode)
{
return !(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&
!(dst_mode & (SKIP_WORKTREE_DIR | SPARSE));
}
#define DUP_BASENAME 1
#define KEEP_TRAILING_SLASH 2
@@ -443,6 +450,41 @@ 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;
}
/*
* If we are going to move SRC to DST on disk, DST's leading
* directories must already exist.
*/
if (needs_worktree_rename(modes[i], dst_mode)) {
const char *slash_ = strrchr(dst, '/');
if (slash_) {
struct stat dir_st;
char *dst_dir = xstrdup(dst);
char *slash = &dst_dir[slash_ - dst];
*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 (bad)
goto act_on_entry;
}
if (ignore_sparse &&
(dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&
@@ -544,12 +586,11 @@ remove_entry:
printf(_("Renaming %s to %s\n"), src, dst);
if (show_only)
continue;
if (!(mode & (INDEX | SPARSE | SKIP_WORKTREE_DIR)) &&
!(dst_mode & (SKIP_WORKTREE_DIR | SPARSE)) &&
if (needs_worktree_rename(mode, dst_mode) &&
rename(src, dst) < 0) {
if (ignore_errors)
continue;
die_errno(_("renaming '%s' failed"), src);
die_errno(_("renaming '%s' to '%s' failed"), src, dst);
}
if (submodule_gitfiles[i]) {
if (!update_path_in_gitmodules(src, dst))

View File

@@ -114,6 +114,108 @@ test_expect_success 'clean up' '
git reset --hard
'
test_expect_success 'moving file to directory without trailing slash' '
git reset --hard HEAD &&
rm -rf file.txt target && mkdir target &&
echo content > file.txt &&
git add file.txt &&
git mv file.txt target &&
test_path_is_file target/file.txt
'
test_expect_success 'moving file to a bare filename in the cwd' '
git reset --hard &&
rm -rf from dest.txt &&
mkdir from &&
echo content >from/file &&
git add from/file &&
git mv from/file dest.txt &&
test_path_is_file dest.txt
'
test_expect_success 'moving to a non-existent directory' '
git reset --hard &&
rm -rf from && mkdir from &&
echo content >from/file &&
git add from/file &&
test_must_fail git mv from/file no-such-dir/file 2>actual &&
test_grep "destination directory does not exist" actual
'
test_expect_success 'moving to a destination with a file as a leading path component' '
git reset --hard &&
rm -rf from && mkdir from &&
echo contents >from/file &&
echo blocker >not-dir &&
git add from/file &&
test_must_fail git mv from/file not-dir/file 2>actual &&
test_grep "destination is not a directory" actual
'
test_expect_success SYMLINKS 'moving to a destination beyond a symlink' '
git reset --hard &&
rm -rf from regular-dir link-to-dir &&
mkdir from regular-dir &&
echo contents >from/file &&
ln -s regular-dir link-to-dir &&
git add from/file &&
test_must_fail git mv from/file link-to-dir/file 2>actual &&
test_grep "destination is beyond a symbolic link" actual
'
test_expect_success SYMLINKS 'moving to a destination with a symlink as an intermediate component' '
git reset --hard &&
rm -rf from && mkdir -p from/real/inner &&
echo contents >from/file &&
ln -s real from/link &&
git add from/file from/link &&
test_must_fail git mv from/file from/link/inner/dst 2>actual &&
test_grep "destination is beyond a symbolic link" actual
'
test_expect_success SYMLINKS 'refuses to overwrite a symlink at the destination' '
git reset --hard &&
rm -rf from && mkdir from &&
echo contents >from/file &&
ln -s target from/link &&
git add from/file from/link &&
test_must_fail git mv from/file from/link 2>actual &&
test_grep "destination exists" actual
'
test_expect_success SYMLINKS 'mv through a symlinked leading path does not touch the index' '
git reset --hard &&
rm -rf from && mkdir from &&
echo contents >from/src &&
ln -s . from/link &&
git add from/src from/link &&
git commit -m "setup symlink case" &&
git ls-files --stage >expect.index &&
test_must_fail git mv from/src from/link/real/dst 2>actual &&
test_grep "destination is beyond a symbolic link" actual &&
git ls-files --stage >actual.index &&
test_cmp expect.index actual.index
'
test_expect_success SYMLINKS 'mv -f does not follow a symlinked leading path' '
git reset --hard &&
rm -rf from && mkdir from &&
echo contents >from/src &&
ln -s file from/link &&
git add from/src from/link &&
test_must_fail git mv -f from/src from/link/dst 2>actual &&
test_grep "destination is beyond a symbolic link" actual
'
test_expect_success 'mv --dry-run detects non-existent destination parent directory' '
git reset --hard &&
rm -rf from && mkdir from &&
echo contents >from/file &&
git add from/file &&
test_must_fail git mv -n from/file no-such-dir/file 2>actual &&
test_grep "destination directory does not exist" actual
'
test_expect_success 'moving to existing untracked target with trailing slash' '
mkdir path1 &&
git mv path0/ path1/ &&