sparse-checkout: match some 'clean' behavior

The 'git sparse-checkout clean' subcommand is somewhat similar to 'git clean' in that it will delete files that should not be in the worktree. The big difference is that it focuses on the directories that should not be in the worktree due to cone-mode sparse-checkout. It also does not discriminate in the kinds of files and focuses on deleting entire directories. However, there are some restrictions that would be good to bring over from 'git clean', specifically how it refuses to do anything without the '-f'/'--force' or '-n'/'--dry-run' arguments. The 'clean.requireForce' config can be set to 'false' to imply '--force'. Add this behavior to avoid accidental deletion of files that cannot be recovered from Git. Signed-off-by: Derrick Stolee <stolee@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Derrick Stolee committed Sep 12, 2025 at 10:30 UTC a8077c19131a86fa123c5be2c8b735acdb5dacf4
3 files changed +76 -2
Documentation/git-sparse-checkout.adoc
+9
@@ -127,6 +127,15 @@ clean` to resolve these cases.
127 This command can be used to be sure the sparse index works efficiently,
128 though it does not require enabling the sparse index feature via the
129 `index.sparse=true` configuration.
130 ++
131 +To prevent accidental deletion of worktree files, the `clean` subcommand
132 +will not delete any files without the `-f` or `--force` option, unless
133 +the `clean.requireForce` config option is set to `false`.
134 ++
135 +The `--dry-run` option will list the directories that would be removed
136 +without deleting them. Running in this mode can be helpful to predict the
137 +behavior of the clean comand or to determine which kinds of files are left
138 +in the sparse directories.
139
140 'disable'::
141 Disable the `core.sparseCheckout` config setting, and restore the
builtin/sparse-checkout.c
+14 -1
@@ -931,6 +931,7 @@ static char const * const builtin_sparse_checkout_clean_usage[] = {
931 };
932
933 static const char *msg_remove = N_("Removing %s\n");
934 +static const char *msg_would_remove = N_("Would remove %s\n");
935
936 static int sparse_checkout_clean(int argc, const char **argv,
937 const char *prefix,
@@ -939,8 +940,12 @@ static int sparse_checkout_clean(int argc, const char **argv,
940 struct strbuf full_path = STRBUF_INIT;
941 const char *msg = msg_remove;
942 size_t worktree_len;
943 + int force = 0, dry_run = 0;
944 + int require_force = 1;
945
946 struct option builtin_sparse_checkout_clean_options[] = {
947 + OPT__DRY_RUN(&dry_run, N_("dry run")),
948 + OPT__FORCE(&force, N_("force"), PARSE_OPT_NOCOMPLETE),
949 OPT_END(),
950 };
951
@@ -954,6 +959,13 @@ static int sparse_checkout_clean(int argc, const char **argv,
959 builtin_sparse_checkout_clean_options,
960 builtin_sparse_checkout_clean_usage, 0);
961
962 + repo_config_get_bool(repo, "clean.requireforce", &require_force);
963 + if (require_force && !force && !dry_run)
964 + die(_("for safety, refusing to clean without one of --force or --dry-run"));
965 +
966 + if (dry_run)
967 + msg = msg_would_remove;
968 +
969 if (repo_read_index(repo) < 0)
970 die(_("failed to read index"));
971
@@ -977,7 +989,8 @@ static int sparse_checkout_clean(int argc, const char **argv,
989
990 printf(msg, ce->name);
991
980 - if (remove_dir_recursively(&full_path, 0))
992 + if (dry_run <= 0 &&
993 + remove_dir_recursively(&full_path, 0))
994 warning_errno(_("failed to remove '%s'"), ce->name);
995 }
996
t/t1091-sparse-checkout-builtin.sh
+53 -1
@@ -1059,12 +1059,29 @@ test_expect_success 'clean' '
1059 touch repo/deep/deeper2/file &&
1060 touch repo/folder1/file &&
1061
1062 + test_must_fail git -C repo sparse-checkout clean 2>err &&
1063 + grep "refusing to clean" err &&
1064 +
1065 + git -C repo config clean.requireForce true &&
1066 + test_must_fail git -C repo sparse-checkout clean 2>err &&
1067 + grep "refusing to clean" err &&
1068 +
1069 + cat >expect <<-\EOF &&
1070 + Would remove deep/deeper2/
1071 + Would remove folder1/
1072 + EOF
1073 +
1074 + git -C repo sparse-checkout clean --dry-run >out &&
1075 + test_cmp expect out &&
1076 + test_path_exists repo/deep/deeper2 &&
1077 + test_path_exists repo/folder1 &&
1078 +
1079 cat >expect <<-\EOF &&
1080 Removing deep/deeper2/
1081 Removing folder1/
1082 EOF
1083
1067 - git -C repo sparse-checkout clean >out &&
1084 + git -C repo sparse-checkout clean -f >out &&
1085 test_cmp expect out &&
1086
1087 test_path_is_missing repo/deep/deeper2 &&
@@ -1076,6 +1093,10 @@ test_expect_success 'clean with sparse file states' '
1093 git -C repo sparse-checkout set --cone deep/deeper1 &&
1094 mkdir repo/folder2 &&
1095
1096 + # The previous test case checked the -f option, so
1097 + # test the config option in this one.
1098 + git -C repo config clean.requireForce false &&
1099 +
1100 # create an untracked file and a modified file
1101 touch repo/folder2/file &&
1102 echo dirty >repo/folder2/a &&
@@ -1154,4 +1175,35 @@ test_expect_success 'clean with sparse file states' '
1175 test_must_be_empty out
1176 '
1177
1178 +test_expect_success 'clean with merge conflict status' '
1179 + git clone repo clean-merge &&
1180 +
1181 + echo dirty >clean-merge/deep/deeper2/a &&
1182 + touch clean-merge/folder2/extra &&
1183 +
1184 + cat >input <<-EOF &&
1185 + 0 $ZERO_OID folder1/a
1186 + 100644 $(git -C clean-merge rev-parse HEAD:folder1/a) 1 folder1/a
1187 + EOF
1188 + git -C clean-merge update-index --index-info <input &&
1189 +
1190 + git -C clean-merge sparse-checkout set deep/deeper1 &&
1191 +
1192 + test_must_fail git -C clean-merge sparse-checkout clean -f 2>err &&
1193 + grep "failed to convert index to a sparse index" err &&
1194 +
1195 + mkdir -p clean-merge/folder1/ &&
1196 + echo merged >clean-merge/folder1/a &&
1197 + git -C clean-merge add --sparse folder1/a &&
1198 +
1199 + # deletes folder2/ but leaves staged change in folder1
1200 + # and dirty change in deep/deeper2/
1201 + cat >expect <<-\EOF &&
1202 + Removing folder2/
1203 + EOF
1204 +
1205 + git -C clean-merge sparse-checkout clean -f >out &&
1206 + test_cmp expect out
1207 +'
1208 +
1209 test_done