Merge branch 'en/keep-cwd'

Many git commands that deal with working tree files try to remove a
directory that becomes empty (i.e. "git switch" from a branch that
has the directory to another branch that does not would attempt
remove all files in the directory and the directory itself).  This
drops users into an unfamiliar situation if the command was run in
a subdirectory that becomes subject to removal due to the command.
The commands have been taught to keep an empty directory if it is
the directory they were started in to avoid surprising users.

* en/keep-cwd:
  t2501: simplify the tests since we can now assume desired behavior
  dir: new flag to remove_dir_recurse() to spare the original_cwd
  dir: avoid incidentally removing the original_cwd in remove_path()
  stash: do not attempt to remove startup_info->original_cwd
  rebase: do not attempt to remove startup_info->original_cwd
  clean: do not attempt to remove startup_info->original_cwd
  symlinks: do not include startup_info->original_cwd in dir removal
  unpack-trees: add special cwd handling
  unpack-trees: refuse to remove startup_info->original_cwd
  setup: introduce startup_info->original_cwd
  t2501: add various tests for removing the current working directory
This commit is contained in:
Junio C Hamano 2022-01-05 14:01:28 -08:00
commit da81d473fc
13 changed files with 442 additions and 22 deletions

View File

@ -36,6 +36,8 @@ static const char *msg_skip_git_dir = N_("Skipping repository %s\n");
static const char *msg_would_skip_git_dir = N_("Would skip repository %s\n");
static const char *msg_warn_remove_failed = N_("failed to remove %s");
static const char *msg_warn_lstat_failed = N_("could not lstat %s\n");
static const char *msg_skip_cwd = N_("Refusing to remove current working directory\n");
static const char *msg_would_skip_cwd = N_("Would refuse to remove current working directory\n");
enum color_clean {
CLEAN_COLOR_RESET = 0,
@ -153,6 +155,8 @@ static int remove_dirs(struct strbuf *path, const char *prefix, int force_flag,
{
DIR *dir;
struct strbuf quoted = STRBUF_INIT;
struct strbuf realpath = STRBUF_INIT;
struct strbuf real_ocwd = STRBUF_INIT;
struct dirent *e;
int res = 0, ret = 0, gone = 1, original_len = path->len, len;
struct string_list dels = STRING_LIST_INIT_DUP;
@ -231,16 +235,36 @@ static int remove_dirs(struct strbuf *path, const char *prefix, int force_flag,
strbuf_setlen(path, original_len);
if (*dir_gone) {
res = dry_run ? 0 : rmdir(path->buf);
if (!res)
*dir_gone = 1;
else {
int saved_errno = errno;
quote_path(path->buf, prefix, &quoted, 0);
errno = saved_errno;
warning_errno(_(msg_warn_remove_failed), quoted.buf);
/*
* Normalize path components in path->buf, e.g. change '\' to
* '/' on Windows.
*/
strbuf_realpath(&realpath, path->buf, 1);
/*
* path and realpath are absolute; for comparison, we would
* like to transform startup_info->original_cwd to an absolute
* path too.
*/
if (startup_info->original_cwd)
strbuf_realpath(&real_ocwd,
startup_info->original_cwd, 1);
if (!strbuf_cmp(&realpath, &real_ocwd)) {
printf("%s", dry_run ? _(msg_would_skip_cwd) : _(msg_skip_cwd));
*dir_gone = 0;
ret = 1;
} else {
res = dry_run ? 0 : rmdir(path->buf);
if (!res)
*dir_gone = 1;
else {
int saved_errno = errno;
quote_path(path->buf, prefix, &quoted, 0);
errno = saved_errno;
warning_errno(_(msg_warn_remove_failed), quoted.buf);
*dir_gone = 0;
ret = 1;
}
}
}
@ -250,6 +274,8 @@ static int remove_dirs(struct strbuf *path, const char *prefix, int force_flag,
printf(dry_run ? _(msg_would_remove) : _(msg_remove), dels.items[i].string);
}
out:
strbuf_release(&realpath);
strbuf_release(&real_ocwd);
strbuf_release(&quoted);
string_list_clear(&dels, 0);
return ret;

View File

@ -399,12 +399,13 @@ int cmd_rm(int argc, const char **argv, const char *prefix)
if (!index_only) {
int removed = 0, gitmodules_modified = 0;
struct strbuf buf = STRBUF_INIT;
int flag = force ? REMOVE_DIR_PURGE_ORIGINAL_CWD : 0;
for (i = 0; i < list.nr; i++) {
const char *path = list.entry[i].name;
if (list.entry[i].is_submodule) {
strbuf_reset(&buf);
strbuf_addstr(&buf, path);
if (remove_dir_recursively(&buf, 0))
if (remove_dir_recursively(&buf, flag))
die(_("could not remove '%s'"), path);
removed = 1;

View File

@ -1538,8 +1538,10 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q
struct child_process cp = CHILD_PROCESS_INIT;
cp.git_cmd = 1;
if (startup_info->original_cwd)
cp.dir = startup_info->original_cwd;
strvec_pushl(&cp.args, "clean", "--force",
"--quiet", "-d", NULL);
"--quiet", "-d", ":/", NULL);
if (include_untracked == INCLUDE_ALL_FILES)
strvec_push(&cp.args, "-x");
if (run_command(&cp)) {

View File

@ -1846,8 +1846,10 @@ void overlay_tree_on_index(struct index_state *istate,
struct startup_info {
int have_repository;
const char *prefix;
const char *original_cwd;
};
extern struct startup_info *startup_info;
extern const char *tmp_original_cwd;
/* merge.c */
struct commit_list;

View File

@ -26,6 +26,7 @@ static void restore_sigpipe_to_default(void)
int main(int argc, const char **argv)
{
int result;
struct strbuf tmp = STRBUF_INIT;
trace2_initialize_clock();
@ -49,6 +50,9 @@ int main(int argc, const char **argv)
trace2_cmd_start(argv);
trace2_collect_process_info(TRACE2_PROCESS_INFO_STARTUP);
if (!strbuf_getcwd(&tmp))
tmp_original_cwd = strbuf_detach(&tmp, NULL);
result = cmd_main(argc, argv);
/*

15
dir.c
View File

@ -3160,6 +3160,7 @@ static int remove_dir_recurse(struct strbuf *path, int flag, int *kept_up)
int ret = 0, original_len = path->len, len, kept_down = 0;
int only_empty = (flag & REMOVE_DIR_EMPTY_ONLY);
int keep_toplevel = (flag & REMOVE_DIR_KEEP_TOPLEVEL);
int purge_original_cwd = (flag & REMOVE_DIR_PURGE_ORIGINAL_CWD);
struct object_id submodule_head;
if ((flag & REMOVE_DIR_KEEP_NESTED_GIT) &&
@ -3215,9 +3216,14 @@ static int remove_dir_recurse(struct strbuf *path, int flag, int *kept_up)
closedir(dir);
strbuf_setlen(path, original_len);
if (!ret && !keep_toplevel && !kept_down)
ret = (!rmdir(path->buf) || errno == ENOENT) ? 0 : -1;
else if (kept_up)
if (!ret && !keep_toplevel && !kept_down) {
if (!purge_original_cwd &&
startup_info->original_cwd &&
!strcmp(startup_info->original_cwd, path->buf))
ret = -1; /* Do not remove current working directory */
else
ret = (!rmdir(path->buf) || errno == ENOENT) ? 0 : -1;
} else if (kept_up)
/*
* report the uplevel that it is not an error that we
* did not rmdir() our directory.
@ -3283,6 +3289,9 @@ int remove_path(const char *name)
slash = dirs + (slash - name);
do {
*slash = '\0';
if (startup_info->original_cwd &&
!strcmp(startup_info->original_cwd, dirs))
break;
} while (rmdir(dirs) == 0 && (slash = strrchr(dirs, '/')));
free(dirs);
}

9
dir.h
View File

@ -495,6 +495,9 @@ int get_sparse_checkout_patterns(struct pattern_list *pl);
/* Remove the contents of path, but leave path itself. */
#define REMOVE_DIR_KEEP_TOPLEVEL 04
/* Remove the_original_cwd too */
#define REMOVE_DIR_PURGE_ORIGINAL_CWD 0x08
/*
* Remove path and its contents, recursively. flags is a combination
* of the above REMOVE_DIR_* constants. Return 0 on success.
@ -504,7 +507,11 @@ int get_sparse_checkout_patterns(struct pattern_list *pl);
*/
int remove_dir_recursively(struct strbuf *path, int flag);
/* tries to remove the path with empty directories along it, ignores ENOENT */
/*
* Tries to remove the path, along with leading empty directories so long as
* those empty directories are not startup_info->original_cwd. Ignores
* ENOENT.
*/
int remove_path(const char *path);
int fspathcmp(const char *a, const char *b);

View File

@ -4223,6 +4223,8 @@ static int run_git_checkout(struct repository *r, struct replay_opts *opts,
cmd.git_cmd = 1;
if (startup_info->original_cwd)
cmd.dir = startup_info->original_cwd;
strvec_push(&cmd.args, "checkout");
strvec_push(&cmd.args, commit);
strvec_pushf(&cmd.env_array, GIT_REFLOG_ACTION "=%s", action);

65
setup.c
View File

@ -12,6 +12,7 @@ static int work_tree_config_is_bogus;
static struct startup_info the_startup_info;
struct startup_info *startup_info = &the_startup_info;
const char *tmp_original_cwd;
/*
* The input parameter must contain an absolute path, and it must already be
@ -432,6 +433,69 @@ void setup_work_tree(void)
initialized = 1;
}
static void setup_original_cwd(void)
{
struct strbuf tmp = STRBUF_INIT;
const char *worktree = NULL;
int offset = -1;
if (!tmp_original_cwd)
return;
/*
* startup_info->original_cwd points to the current working
* directory we inherited from our parent process, which is a
* directory we want to avoid removing.
*
* For convience, we would like to have the path relative to the
* worktree instead of an absolute path.
*
* Yes, startup_info->original_cwd is usually the same as 'prefix',
* but differs in two ways:
* - prefix has a trailing '/'
* - if the user passes '-C' to git, that modifies the prefix but
* not startup_info->original_cwd.
*/
/* Normalize the directory */
strbuf_realpath(&tmp, tmp_original_cwd, 1);
free((char*)tmp_original_cwd);
tmp_original_cwd = NULL;
startup_info->original_cwd = strbuf_detach(&tmp, NULL);
/*
* Get our worktree; we only protect the current working directory
* if it's in the worktree.
*/
worktree = get_git_work_tree();
if (!worktree)
goto no_prevention_needed;
offset = dir_inside_of(startup_info->original_cwd, worktree);
if (offset >= 0) {
/*
* If startup_info->original_cwd == worktree, that is already
* protected and we don't need original_cwd as a secondary
* protection measure.
*/
if (!*(startup_info->original_cwd + offset))
goto no_prevention_needed;
/*
* original_cwd was inside worktree; precompose it just as
* we do prefix so that built up paths will match
*/
startup_info->original_cwd = \
precompose_string_if_needed(startup_info->original_cwd
+ offset);
return;
}
no_prevention_needed:
free((char*)startup_info->original_cwd);
startup_info->original_cwd = NULL;
}
static int read_worktree_config(const char *var, const char *value, void *vdata)
{
struct repository_format *data = vdata;
@ -1330,6 +1394,7 @@ const char *setup_git_directory_gently(int *nongit_ok)
setenv(GIT_PREFIX_ENVIRONMENT, "", 1);
}
setup_original_cwd();
strbuf_release(&dir);
strbuf_release(&gitdir);

View File

@ -279,7 +279,9 @@ static void do_remove_scheduled_dirs(int new_len)
{
while (removal.len > new_len) {
removal.buf[removal.len] = '\0';
if (rmdir(removal.buf))
if ((startup_info->original_cwd &&
!strcmp(removal.buf, startup_info->original_cwd)) ||
rmdir(removal.buf))
break;
do {
removal.len--;
@ -293,6 +295,10 @@ void schedule_dir_for_removal(const char *name, int len)
{
int match_len, last_slash, i, previous_slash;
if (startup_info->original_cwd &&
!strcmp(name, startup_info->original_cwd))
return; /* Do not remove the current working directory */
match_len = last_slash = i =
longest_path_match(name, len, removal.buf, removal.len,
&previous_slash);

277
t/t2501-cwd-empty.sh Executable file
View File

@ -0,0 +1,277 @@
#!/bin/sh
test_description='Test handling of the current working directory becoming empty'
. ./test-lib.sh
test_expect_success setup '
test_commit init &&
git branch fd_conflict &&
mkdir -p foo/bar &&
test_commit foo/bar/baz &&
git revert HEAD &&
git tag reverted &&
git checkout fd_conflict &&
mkdir dirORfile &&
test_commit dirORfile/foo &&
git rm -r dirORfile &&
echo not-a-directory >dirORfile &&
git add dirORfile &&
git commit -m dirORfile &&
git switch -c df_conflict HEAD~1 &&
test_commit random_file &&
git switch -c undo_fd_conflict fd_conflict &&
git revert HEAD
'
test_incidental_dir_removal () {
test_when_finished "git reset --hard" &&
git checkout foo/bar/baz^{commit} &&
test_path_is_dir foo/bar &&
(
cd foo &&
"$@" &&
# Make sure foo still exists, and commands needing it work
test-tool getcwd &&
git status --porcelain
) &&
test_path_is_missing foo/bar/baz &&
test_path_is_missing foo/bar &&
test_path_is_dir foo
}
test_required_dir_removal () {
git checkout df_conflict^{commit} &&
test_when_finished "git clean -fdx" &&
(
cd dirORfile &&
# Ensure command refuses to run
test_must_fail "$@" 2>../error &&
grep "Refusing to remove.*current working directory" ../error &&
# ...and that the index and working tree are left clean
git diff --exit-code HEAD &&
# Ensure that getcwd and git status do not error out (which
# they might if the current working directory had been removed)
test-tool getcwd &&
git status --porcelain
) &&
test_path_is_dir dirORfile
}
test_expect_success 'checkout does not clean cwd incidentally' '
test_incidental_dir_removal git checkout init
'
test_expect_success 'checkout fails if cwd needs to be removed' '
test_required_dir_removal git checkout fd_conflict
'
test_expect_success 'reset --hard does not clean cwd incidentally' '
test_incidental_dir_removal git reset --hard init
'
test_expect_success 'reset --hard fails if cwd needs to be removed' '
test_required_dir_removal git reset --hard fd_conflict
'
test_expect_success 'merge does not clean cwd incidentally' '
test_incidental_dir_removal git merge reverted
'
# This file uses some simple merges where
# Base: 'dirORfile/' exists
# Side1: random other file changed
# Side2: 'dirORfile/' removed, 'dirORfile' added
# this should resolve cleanly, but merge-recursive throws merge conflicts
# because it's dumb. Add a special test for checking merge-recursive (and
# merge-ort), then after this just hard require ort for all remaining tests.
#
test_expect_success 'merge fails if cwd needs to be removed; recursive friendly' '
git checkout foo/bar/baz &&
test_when_finished "git clean -fdx" &&
mkdir dirORfile &&
(
cd dirORfile &&
test_must_fail git merge fd_conflict 2>../error
) &&
test_path_is_dir dirORfile &&
grep "Refusing to remove the current working directory" error
'
GIT_TEST_MERGE_ALGORITHM=ort
test_expect_success 'merge fails if cwd needs to be removed' '
test_required_dir_removal git merge fd_conflict
'
test_expect_success 'cherry-pick does not clean cwd incidentally' '
test_incidental_dir_removal git cherry-pick reverted
'
test_expect_success 'cherry-pick fails if cwd needs to be removed' '
test_required_dir_removal git cherry-pick fd_conflict
'
test_expect_success 'rebase does not clean cwd incidentally' '
test_incidental_dir_removal git rebase reverted
'
test_expect_success 'rebase fails if cwd needs to be removed' '
test_required_dir_removal git rebase fd_conflict
'
test_expect_success 'revert does not clean cwd incidentally' '
test_incidental_dir_removal git revert HEAD
'
test_expect_success 'revert fails if cwd needs to be removed' '
test_required_dir_removal git revert undo_fd_conflict
'
test_expect_success 'rm does not clean cwd incidentally' '
test_incidental_dir_removal git rm bar/baz.t
'
test_expect_success 'apply does not remove cwd incidentally' '
git diff HEAD HEAD~1 >patch &&
test_incidental_dir_removal git apply ../patch
'
test_incidental_untracked_dir_removal () {
test_when_finished "git reset --hard" &&
git checkout foo/bar/baz^{commit} &&
mkdir -p untracked &&
mkdir empty
>untracked/random &&
(
cd untracked &&
"$@" &&
# Make sure untracked still exists, and commands needing it work
test-tool getcwd &&
git status --porcelain
) &&
test_path_is_missing empty &&
test_path_is_missing untracked/random &&
test_path_is_dir untracked
}
test_expect_success 'clean does not remove cwd incidentally' '
test_incidental_untracked_dir_removal \
git -C .. clean -fd -e warnings . >warnings &&
grep "Refusing to remove current working directory" warnings
'
test_expect_success 'stash does not remove cwd incidentally' '
test_incidental_untracked_dir_removal \
git stash --include-untracked
'
test_expect_success '`rm -rf dir` only removes a subset of dir' '
test_when_finished "rm -rf a/" &&
mkdir -p a/b/c &&
>a/b/c/untracked &&
>a/b/c/tracked &&
git add a/b/c/tracked &&
(
cd a/b &&
git rm -rf ../b
) &&
test_path_is_dir a/b &&
test_path_is_missing a/b/c/tracked &&
test_path_is_file a/b/c/untracked
'
test_expect_success '`rm -rf dir` even with only tracked files will remove something else' '
test_when_finished "rm -rf a/" &&
mkdir -p a/b/c &&
>a/b/c/tracked &&
git add a/b/c/tracked &&
(
cd a/b &&
git rm -rf ../b
) &&
test_path_is_missing a/b/c/tracked &&
test_path_is_missing a/b/c &&
test_path_is_dir a/b
'
test_expect_success 'git version continues working from a deleted dir' '
mkdir tmp &&
(
cd tmp &&
rm -rf ../tmp &&
git version
)
'
test_submodule_removal () {
path_status=$1 &&
shift &&
test_status=
test "$path_status" = dir && test_status=test_must_fail
test_when_finished "git reset --hard HEAD~1" &&
test_when_finished "rm -rf .git/modules/my_submodule" &&
git checkout foo/bar/baz &&
git init my_submodule &&
touch my_submodule/file &&
git -C my_submodule add file &&
git -C my_submodule commit -m "initial commit" &&
git submodule add ./my_submodule &&
git commit -m "Add the submodule" &&
(
cd my_submodule &&
$test_status "$@"
) &&
test_path_is_${path_status} my_submodule
}
test_expect_success 'rm -r with -C leaves submodule if cwd inside' '
test_submodule_removal dir git -C .. rm -r my_submodule/
'
test_expect_success 'rm -r leaves submodule if cwd inside' '
test_submodule_removal dir \
git --git-dir=../.git --work-tree=.. rm -r ../my_submodule/
'
test_expect_success 'rm -rf removes submodule even if cwd inside' '
test_submodule_removal missing \
git --git-dir=../.git --work-tree=.. rm -rf ../my_submodule/
'
test_done

View File

@ -36,6 +36,9 @@ static const char *unpack_plumbing_errors[NB_UNPACK_TREES_WARNING_TYPES] = {
/* ERROR_NOT_UPTODATE_DIR */
"Updating '%s' would lose untracked files in it",
/* ERROR_CWD_IN_THE_WAY */
"Refusing to remove '%s' since it is the current working directory.",
/* ERROR_WOULD_LOSE_UNTRACKED_OVERWRITTEN */
"Untracked working tree file '%s' would be overwritten by merge.",
@ -131,6 +134,9 @@ void setup_unpack_trees_porcelain(struct unpack_trees_options *opts,
msgs[ERROR_NOT_UPTODATE_DIR] =
_("Updating the following directories would lose untracked files in them:\n%s");
msgs[ERROR_CWD_IN_THE_WAY] =
_("Refusing to remove the current working directory:\n%s");
if (!strcmp(cmd, "checkout"))
msg = advice_enabled(ADVICE_COMMIT_BEFORE_MERGE)
? _("The following untracked working tree files would be removed by checkout:\n%%s"
@ -2159,10 +2165,7 @@ static int verify_clean_subdirectory(const struct cache_entry *ce,
cnt++;
}
/*
* Then we need to make sure that we do not lose a locally
* present file that is not ignored.
*/
/* Do not lose a locally present file that is not ignored. */
pathbuf = xstrfmt("%.*s/", namelen, ce->name);
memset(&d, 0, sizeof(d));
@ -2173,6 +2176,12 @@ static int verify_clean_subdirectory(const struct cache_entry *ce,
free(pathbuf);
if (i)
return add_rejected_path(o, ERROR_NOT_UPTODATE_DIR, ce->name);
/* Do not lose startup_info->original_cwd */
if (startup_info->original_cwd &&
!strcmp(startup_info->original_cwd, ce->name))
return add_rejected_path(o, ERROR_CWD_IN_THE_WAY, ce->name);
return cnt;
}
@ -2265,10 +2274,19 @@ static int verify_absent_1(const struct cache_entry *ce,
int len;
struct stat st;
if (o->index_only || !o->update ||
o->reset == UNPACK_RESET_OVERWRITE_UNTRACKED)
if (o->index_only || !o->update)
return 0;
if (o->reset == UNPACK_RESET_OVERWRITE_UNTRACKED) {
/* Avoid nuking startup_info->original_cwd... */
if (startup_info->original_cwd &&
!strcmp(startup_info->original_cwd, ce->name))
return add_rejected_path(o, ERROR_CWD_IN_THE_WAY,
ce->name);
/* ...but nuke anything else. */
return 0;
}
len = check_leading_path(ce->name, ce_namelen(ce), 0);
if (!len)
return 0;

View File

@ -19,6 +19,7 @@ enum unpack_trees_error_types {
ERROR_WOULD_OVERWRITE = 0,
ERROR_NOT_UPTODATE_FILE,
ERROR_NOT_UPTODATE_DIR,
ERROR_CWD_IN_THE_WAY,
ERROR_WOULD_LOSE_UNTRACKED_OVERWRITTEN,
ERROR_WOULD_LOSE_UNTRACKED_REMOVED,
ERROR_BIND_OVERLAP,