builtin/history: implement "drop" subcommand

A common operation when editing the commit history is to drop a specific commit from the history entirely, but this operation is not currently covered by git-history(1). A couple of noteworthy bits: - This is the first git-history(1) command that will ultimately result in changes to both the index and the working tree. We thus have to add logic to merge resulting changes into those. - It is still not possible to replay merge commits, so this limitation is inherited for the new "drop" command. - For now we refuse to drop root commits. While we _can_ indeed drop root commits in the general case, there are edge cases where the resulting history would become completely empty. This is thus left to a subsequent patch series. Other than that, most of the logic is rather straight-forward as we can continue to build on the preexisting logic in git-history(1) for most of the part. Signed-off-by: Patrick Steinhardt <ps@pks.im> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Patrick Steinhardt committed Jul 1, 2026 at 13:35 UTC d11b348f7840c7ae4aba5d273ff56c813475a88e
4 files changed +783 -1
Documentation/git-history.adoc
+37 -1
@@ -8,6 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history
8 SYNOPSIS
9 --------
10 [synopsis]
11 +git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)]
12 git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
13 git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
14 git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
@@ -51,13 +52,28 @@ be stateful operations. The limitation can be lifted once (if) Git learns about
52 first-class conflicts.
53
54 When using `fixup` with `--empty=drop`, dropping the root commit is not yet
54 -supported.
55 +supported. Likewise, `drop` cannot remove the root commit or a merge commit.
56
57 COMMANDS
58 --------
59
60 The following commands are available to rewrite history in different ways:
61
62 +`drop <commit>`::
63 + Remove the specified commit from the history. All descendants of the
64 + commit are replayed directly onto its parent.
65 ++
66 +The root commit cannot be dropped as that may lead to edge cases where refs
67 +end up with no commits anymore. Merge commits cannot be dropped either; see
68 +LIMITATIONS.
69 ++
70 +If `HEAD` points at a commit that is to be rewritten, the index and working
71 +tree are updated to match the new `HEAD`. The command aborts before any
72 +references are updated in case local modifications would be overwritten.
73 ++
74 +If replaying any descendant would result in a conflict, the command aborts
75 +with an error.
76 +
77 `fixup <commit>`::
78 Apply the currently staged changes to the specified commit. This is
79 similar in nature to `git commit --fixup=<commit>` followed by `git
@@ -170,6 +186,26 @@ The staged addition of `unrelated.txt` has been incorporated into the `first`
186 commit. All descendant commits have been replayed on top of the rewritten
187 history.
188
189 +Drop a commit
190 +~~~~~~~~~~~~~
191 +
192 +----------
193 +$ git log --oneline
194 +abc1234 (HEAD -> main) third
195 +def5678 second
196 +ghi9012 first
197 +
198 +$ git history drop 'main^{/second}'
199 +
200 +$ git log --oneline
201 +jkl3456 (HEAD -> main) third
202 +ghi9012 first
203 +----------
204 +
205 +The `second` commit has been removed from the history, and `third` has been
206 +replayed directly on top of `first`. All branches that pointed at the dropped
207 +commit have been moved to its parent.
208 +
209 Split a commit
210 ~~~~~~~~~~~~~~
211
builtin/history.c
+184
@@ -17,13 +17,17 @@
17 #include "read-cache.h"
18 #include "refs.h"
19 #include "replay.h"
20 +#include "reset.h"
21 #include "revision.h"
22 #include "sequencer.h"
23 #include "strvec.h"
24 #include "tree.h"
25 +#include "tree-walk.h"
26 #include "unpack-trees.h"
27 #include "wt-status.h"
28
29 +#define GIT_HISTORY_DROP_USAGE \
30 + N_("git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)]")
31 #define GIT_HISTORY_FIXUP_USAGE \
32 N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]")
33 #define GIT_HISTORY_REWORD_USAGE \
@@ -999,12 +1003,191 @@ out:
1003 return ret;
1004 }
1005
1006 +static int update_worktree(struct repository *repo,
1007 + const struct commit *old_head,
1008 + const struct commit *new_head,
1009 + bool dry_run)
1010 +{
1011 + struct reset_working_tree_options opts = {
1012 + .oid_from = &old_head->object.oid,
1013 + .oid = &new_head->object.oid,
1014 + };
1015 + if (dry_run)
1016 + opts.flags |= RESET_WORKING_TREE_DRY_RUN;
1017 + return reset_working_tree(repo, &opts);
1018 +}
1019 +
1020 +static int find_head_tree_change(struct repository *repo,
1021 + const struct replay_result *result,
1022 + struct commit **old_head,
1023 + struct commit **new_head,
1024 + bool *changed)
1025 +{
1026 + const struct replay_ref_update *head_update = NULL;
1027 + struct commit *old_head_commit, *new_head_commit;
1028 + struct tree *old_head_tree, *new_head_tree;
1029 + const char *head_target;
1030 + int head_flags;
1031 +
1032 + *changed = false;
1033 +
1034 + head_target = refs_resolve_ref_unsafe(get_main_ref_store(repo), "HEAD",
1035 + RESOLVE_REF_NO_RECURSE | RESOLVE_REF_READING,
1036 + NULL, &head_flags);
1037 + if (!head_target)
1038 + return error(_("cannot look up HEAD"));
1039 +
1040 + for (size_t i = 0; i < result->updates_nr; i++) {
1041 + if (!strcmp(result->updates[i].refname, head_target)) {
1042 + head_update = &result->updates[i];
1043 + break;
1044 + }
1045 + }
1046 +
1047 + if (!head_update)
1048 + return 0;
1049 +
1050 + old_head_commit = lookup_commit_reference(repo, &head_update->old_oid);
1051 + new_head_commit = lookup_commit_reference(repo, &head_update->new_oid);
1052 + if (!old_head_commit || !new_head_commit)
1053 + return error(_("cannot resolve HEAD commit"));
1054 +
1055 + old_head_tree = repo_get_commit_tree(repo, old_head_commit);
1056 + new_head_tree = repo_get_commit_tree(repo, new_head_commit);
1057 + if (!old_head_tree || !new_head_tree)
1058 + return error(_("cannot resolve tree for HEAD"));
1059 +
1060 + if (oideq(&old_head_tree->object.oid, &new_head_tree->object.oid))
1061 + return 0;
1062 +
1063 + *old_head = old_head_commit;
1064 + *new_head = new_head_commit;
1065 + *changed = true;
1066 +
1067 + return 0;
1068 +}
1069 +
1070 +static int cmd_history_drop(int argc,
1071 + const char **argv,
1072 + const char *prefix,
1073 + struct repository *repo)
1074 +{
1075 + const char * const usage[] = {
1076 + GIT_HISTORY_DROP_USAGE,
1077 + NULL,
1078 + };
1079 + enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP;
1080 + enum ref_action action = REF_ACTION_DEFAULT;
1081 + int dry_run = 0;
1082 + struct option options[] = {
1083 + OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
1084 + N_("control which refs should be updated"),
1085 + PARSE_OPT_NONEG, parse_ref_action),
1086 + OPT_BOOL('n', "dry-run", &dry_run,
1087 + N_("perform a dry-run without updating any refs")),
1088 + OPT_CALLBACK_F(0, "empty", &empty, "(drop|keep|abort)",
1089 + N_("how to handle descendants that become empty"),
1090 + PARSE_OPT_NONEG, parse_opt_empty),
1091 + OPT_END(),
1092 + };
1093 + struct strbuf reflog_msg = STRBUF_INIT;
1094 + struct commit *original, *rewritten;
1095 + struct rev_info revs = { 0 };
1096 + struct replay_result result = { 0 };
1097 + struct commit *old_head, *new_head;
1098 + bool head_moves = false;
1099 + int ret;
1100 +
1101 + argc = parse_options(argc, argv, prefix, options, usage, 0);
1102 + if (argc != 1) {
1103 + ret = error(_("command expects a single revision"));
1104 + goto out;
1105 + }
1106 + repo_config(repo, git_default_config, NULL);
1107 +
1108 + if (action == REF_ACTION_DEFAULT)
1109 + action = REF_ACTION_BRANCHES;
1110 +
1111 + original = lookup_commit_reference_by_name(argv[0]);
1112 + if (!original) {
1113 + ret = error(_("commit cannot be found: %s"), argv[0]);
1114 + goto out;
1115 + }
1116 +
1117 + if (!original->parents) {
1118 + ret = error(_("cannot drop root commit %s: "
1119 + "it has no parent to replay onto"),
1120 + argv[0]);
1121 + goto out;
1122 + } else if (original->parents->next) {
1123 + ret = error(_("cannot drop merge commit: %s"), argv[0]);
1124 + goto out;
1125 + }
1126 +
1127 + ret = setup_revwalk(repo, action, original, &revs);
1128 + if (ret)
1129 + goto out;
1130 +
1131 + rewritten = original->parents->item;
1132 +
1133 + ret = compute_pending_ref_updates(&revs, action, original, rewritten,
1134 + empty, &result);
1135 + if (ret) {
1136 + ret = error(_("failed replaying descendants"));
1137 + goto out;
1138 + }
1139 +
1140 + /*
1141 + * If HEAD will move as a result of the rewrite then we'll have to
1142 + * merge in the changes into the worktree and index. This merge can of
1143 + * course conflict, which will cause the whole operation to abort.
1144 + *
1145 + * If we had already updated the refs at that point then we'd have an
1146 + * inconsistent repository state. So we first perform a dry-run merge
1147 + * here before updating refs.
1148 + */
1149 + if (!is_bare_repository()) {
1150 + ret = find_head_tree_change(repo, &result, &old_head,
1151 + &new_head, &head_moves);
1152 + if (ret < 0)
1153 + goto out;
1154 +
1155 + if (head_moves && update_worktree(repo, old_head, new_head, true) < 0) {
1156 + ret = error(_("dropping this commit would "
1157 + "overwrite local changes; aborting"));
1158 + goto out;
1159 + }
1160 + }
1161 +
1162 + strbuf_addf(&reflog_msg, "drop: dropping %s", argv[0]);
1163 + ret = apply_pending_ref_updates(repo, &result, reflog_msg.buf, dry_run);
1164 + if (ret < 0) {
1165 + ret = error(_("failed to update references"));
1166 + goto out;
1167 + }
1168 +
1169 + if (!dry_run && head_moves && update_worktree(repo, old_head, new_head, false) < 0) {
1170 + ret = error(_("could not update working tree to new commit %s"),
1171 + oid_to_hex(&new_head->object.oid));
1172 + goto out;
1173 + }
1174 +
1175 + ret = 0;
1176 +
1177 +out:
1178 + replay_result_release(&result);
1179 + strbuf_release(&reflog_msg);
1180 + release_revisions(&revs);
1181 + return ret;
1182 +}
1183 +
1184 int cmd_history(int argc,
1185 const char **argv,
1186 const char *prefix,
1187 struct repository *repo)
1188 {
1189 const char * const usage[] = {
1190 + GIT_HISTORY_DROP_USAGE,
1191 GIT_HISTORY_FIXUP_USAGE,
1192 GIT_HISTORY_REWORD_USAGE,
1193 GIT_HISTORY_SPLIT_USAGE,
@@ -1012,6 +1195,7 @@ int cmd_history(int argc,
1195 };
1196 parse_opt_subcommand_fn *fn = NULL;
1197 struct option options[] = {
1198 + OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
1199 OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
1200 OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
1201 OPT_SUBCOMMAND("split", &fn, cmd_history_split),
t/meson.build
+1
@@ -399,6 +399,7 @@ integration_tests = [
399 't3451-history-reword.sh',
400 't3452-history-split.sh',
401 't3453-history-fixup.sh',
402 + 't3454-history-drop.sh',
403 't3500-cherry.sh',
404 't3501-revert-cherry-pick.sh',
405 't3502-cherry-pick-merge.sh',
t/t3454-history-drop.sh new
+561
@@ -0,0 +1,561 @@
1 +#!/bin/sh
2 +
3 +test_description='tests for git-history drop subcommand'
4 +
5 +. ./test-lib.sh
6 +. "$TEST_DIRECTORY/lib-log-graph.sh"
7 +
8 +expect_graph () {
9 + cat >expect &&
10 + lib_test_cmp_graph --format=%s "$@"
11 +}
12 +
13 +expect_log () {
14 + git log --format="%s" "$@" >actual &&
15 + cat >expect &&
16 + test_cmp expect actual
17 +}
18 +
19 +test_expect_success 'errors on missing commit argument' '
20 + test_when_finished "rm -rf repo" &&
21 + git init repo &&
22 + (
23 + cd repo &&
24 + test_commit initial &&
25 + test_must_fail git history drop 2>err &&
26 + test_grep "command expects a single revision" err
27 + )
28 +'
29 +
30 +test_expect_success 'errors on too many arguments' '
31 + test_when_finished "rm -rf repo" &&
32 + git init repo &&
33 + (
34 + cd repo &&
35 + test_commit initial &&
36 + test_must_fail git history drop HEAD HEAD 2>err &&
37 + test_grep "command expects a single revision" err
38 + )
39 +'
40 +
41 +test_expect_success 'errors on unknown revision' '
42 + test_when_finished "rm -rf repo" &&
43 + git init repo &&
44 + (
45 + cd repo &&
46 + test_commit initial &&
47 + test_must_fail git history drop does-not-exist 2>err &&
48 + test_grep "commit cannot be found: does-not-exist" err
49 + )
50 +'
51 +
52 +test_expect_success 'errors with invalid --empty= value' '
53 + test_when_finished "rm -rf repo" &&
54 + git init repo &&
55 + (
56 + cd repo &&
57 + test_commit initial &&
58 + test_commit second &&
59 + test_must_fail git history drop --empty=bogus HEAD 2>err &&
60 + test_grep "unrecognized.*--empty.*bogus" err
61 + )
62 +'
63 +
64 +test_expect_success 'drops a commit in the middle and replays descendants' '
65 + test_when_finished "rm -rf repo" &&
66 + git init repo &&
67 + (
68 + cd repo &&
69 + test_commit first &&
70 + test_commit second &&
71 + test_commit third &&
72 +
73 + git symbolic-ref HEAD >expect &&
74 + git history drop HEAD~ &&
75 + git symbolic-ref HEAD >actual &&
76 + test_cmp expect actual &&
77 +
78 + expect_log <<-\EOF &&
79 + third
80 + first
81 + EOF
82 +
83 + test_must_fail git show HEAD:second.t &&
84 + test_path_is_missing second.t &&
85 +
86 + git reflog >reflog &&
87 + test_grep "drop: dropping HEAD~" reflog
88 + )
89 +'
90 +
91 +test_expect_success 'drops the HEAD commit' '
92 + test_when_finished "rm -rf repo" &&
93 + git init repo &&
94 + (
95 + cd repo &&
96 + test_commit first &&
97 + test_commit second &&
98 +
99 + git history drop HEAD &&
100 +
101 + expect_log <<-\EOF
102 + first
103 + EOF
104 + )
105 +'
106 +
107 +test_expect_success 'drops a commit on detached HEAD' '
108 + test_when_finished "rm -rf repo" &&
109 + git init repo &&
110 + (
111 + cd repo &&
112 + test_commit first &&
113 + test_commit second &&
114 + test_commit third &&
115 + git checkout --detach HEAD &&
116 +
117 + git history drop HEAD~ &&
118 +
119 + expect_log <<-\EOF
120 + third
121 + first
122 + EOF
123 + )
124 +'
125 +
126 +# Note: in this case it would actually be fine to drop the root commit, as we
127 +# do have a descendant commit, and no reference points to the root commit
128 +# directly. So this is something that we may relax eventually.
129 +test_expect_success 'refuses to drop the root commit' '
130 + test_when_finished "rm -rf repo" &&
131 + git init repo &&
132 + (
133 + cd repo &&
134 + test_commit first &&
135 + test_commit second &&
136 +
137 + test_must_fail git history drop HEAD~ 2>err &&
138 + test_grep "cannot drop root commit" err
139 + )
140 +'
141 +
142 +# In contrast to the above case, we actually don't want to drop the root commit
143 +# here as that would cause us to end up with an empty commit graph.
144 +test_expect_success 'refuses to drop the root commit when branch becomes empty' '
145 + test_when_finished "rm -rf repo" &&
146 + git init repo &&
147 + (
148 + cd repo &&
149 + test_commit first &&
150 +
151 + test_must_fail git history drop HEAD 2>err &&
152 + test_grep "cannot drop root commit" err
153 + )
154 +'
155 +
156 +test_expect_success 'refuses to drop a merge commit' '
157 + test_when_finished "rm -rf repo" &&
158 + git init repo &&
159 + (
160 + cd repo &&
161 + test_commit base &&
162 + git branch branch &&
163 + test_commit ours &&
164 + git switch branch &&
165 + test_commit theirs &&
166 + git switch - &&
167 + git merge theirs &&
168 +
169 + test_must_fail git history drop HEAD 2>err &&
170 + test_grep "cannot drop merge commit" err
171 + )
172 +'
173 +
174 +test_expect_success 'refuses when descendants contain a merge commit' '
175 + test_when_finished "rm -rf repo" &&
176 + git init repo &&
177 + (
178 + cd repo &&
179 + test_commit base &&
180 + test_commit middle &&
181 + git branch branch &&
182 + test_commit ours &&
183 + git switch branch &&
184 + test_commit theirs &&
185 + git switch - &&
186 + git merge theirs &&
187 +
188 + test_must_fail git history drop middle 2>err &&
189 + test_grep "replaying merge commits is not supported yet" err
190 + )
191 +'
192 +
193 +test_expect_success 'works in a bare repository' '
194 + test_when_finished "rm -rf repo repo.git" &&
195 +
196 + git init repo &&
197 + test_commit -C repo first &&
198 + test_commit -C repo second &&
199 + test_commit -C repo third &&
200 +
201 + git clone --bare repo repo.git &&
202 + (
203 + cd repo.git &&
204 +
205 + git history drop HEAD~ &&
206 + expect_log <<-\EOF
207 + third
208 + first
209 + EOF
210 + )
211 +'
212 +
213 +test_expect_success 'updates branches on other lines of descent' '
214 + test_when_finished "rm -rf repo" &&
215 + git init repo &&
216 + (
217 + cd repo &&
218 + test_commit base &&
219 + test_commit target &&
220 + git branch theirs &&
221 + test_commit ours &&
222 + git switch theirs &&
223 + test_commit theirs &&
224 +
225 + expect_graph --branches <<-\EOF &&
226 + * theirs
227 + | * ours
228 + |/
229 + * target
230 + * base
231 + EOF
232 +
233 + git history drop target &&
234 +
235 + expect_graph --branches <<-\EOF
236 + * ours
237 + | * theirs
238 + |/
239 + * base
240 + EOF
241 + )
242 +'
243 +
244 +test_expect_success 'moves branch pointing at dropped commit to its parent' '
245 + test_when_finished "rm -rf repo" &&
246 + git init repo --initial-branch=main &&
247 + (
248 + cd repo &&
249 + test_commit first &&
250 + test_commit second &&
251 + git branch points-at-second &&
252 + test_commit third &&
253 +
254 + git rev-parse first >expect &&
255 + git history drop second &&
256 + git rev-parse points-at-second >actual &&
257 + test_cmp expect actual &&
258 +
259 + expect_log --format="%s %D" --branches <<-\EOF
260 + third HEAD -> main
261 + first tag: first, points-at-second
262 + EOF
263 + )
264 +'
265 +
266 +test_expect_success '--dry-run prints ref updates without modifying repo' '
267 + test_when_finished "rm -rf repo" &&
268 + git init repo --initial-branch=main &&
269 + (
270 + cd repo &&
271 + test_commit base &&
272 + git branch branch &&
273 + test_commit middle &&
274 + test_commit ours &&
275 + git switch branch &&
276 + test_commit theirs &&
277 +
278 + git refs list >refs-expect &&
279 + git history drop --dry-run main~ >updates &&
280 + git refs list >refs-actual &&
281 + test_cmp refs-expect refs-actual &&
282 + test_grep "update refs/heads/main" updates &&
283 +
284 + git update-ref --stdin <updates &&
285 + expect_log main <<-\EOF
286 + ours
287 + base
288 + EOF
289 + )
290 +'
291 +
292 +test_expect_success '--dry-run detects conflicts with modified working tree' '
293 + test_when_finished "rm -rf repo" &&
294 + git init repo --initial-branch=main &&
295 + (
296 + cd repo &&
297 + test_commit first &&
298 + test_commit second modify-me &&
299 + echo modified >modify-me &&
300 +
301 + git refs list >refs-expect &&
302 + git diff >diff-expect &&
303 + test_must_fail git history drop --dry-run HEAD 2>err &&
304 + test_grep "dropping this commit would overwrite local changes" err &&
305 + git diff >diff-actual &&
306 + git refs list >refs-actual &&
307 +
308 + test_cmp diff-expect diff-actual &&
309 + test_cmp refs-expect refs-actual
310 + )
311 +'
312 +
313 +test_expect_success '--update-refs=head updates only HEAD' '
314 + test_when_finished "rm -rf repo" &&
315 + git init repo --initial-branch=main &&
316 + (
317 + cd repo &&
318 + test_commit base &&
319 + test_commit target &&
320 + git branch theirs &&
321 + test_commit ours &&
322 + git switch theirs &&
323 + test_commit theirs &&
324 +
325 + # When told to update HEAD only, the command refuses to
326 + # rewrite commits that are not an ancestor of HEAD.
327 + test_must_fail git history drop --update-refs=head main 2>err &&
328 + test_grep "rewritten commit must be an ancestor of HEAD" err &&
329 +
330 + expect_graph --branches <<-\EOF &&
331 + * theirs
332 + | * ours
333 + |/
334 + * target
335 + * base
336 + EOF
337 +
338 + git switch main &&
339 + git history drop --update-refs=head target &&
340 +
341 + expect_graph --branches <<-\EOF
342 + * ours
343 + | * theirs
344 + | * target
345 + |/
346 + * base
347 + EOF
348 + )
349 +'
350 +
351 +test_expect_success '--update-refs=head can rewrite detached HEAD' '
352 + test_when_finished "rm -rf repo" &&
353 + git init repo --initial-branch=main &&
354 + (
355 + cd repo &&
356 + test_commit first &&
357 + test_commit second &&
358 + test_commit third &&
359 + git switch --detach HEAD &&
360 +
361 + git history drop --update-refs=head second &&
362 +
363 + expect_log HEAD <<-\EOF &&
364 + third
365 + first
366 + EOF
367 + expect_log main <<-\EOF
368 + third
369 + second
370 + first
371 + EOF
372 + )
373 +'
374 +
375 +test_expect_success 'conflict with replayed commit aborts cleanly' '
376 + test_when_finished "rm -rf repo" &&
377 + git init repo &&
378 + (
379 + cd repo &&
380 + test_commit base &&
381 + test_commit conflict-a file &&
382 + test_commit conflict-b file &&
383 +
384 + git refs list >refs-expect &&
385 + test_must_fail git history drop HEAD~ 2>err &&
386 + test_grep "failed replaying descendants" err &&
387 + git refs list >refs-actual &&
388 + test_cmp refs-expect refs-actual
389 + )
390 +'
391 +
392 +# Build a history where a descendant of the drop target reverts the change
393 +# introduced by the drop target. After dropping, the descendant's diff applies
394 +# against a tree that already lacks the change, so it becomes empty.
395 +setup_empty_descendant_repo () {
396 + git init "$1" &&
397 + (
398 + cd "$1" &&
399 + echo C1 >file &&
400 + git add file &&
401 + git commit -m "base" &&
402 + git tag base &&
403 + echo C2 >file &&
404 + git add file &&
405 + git commit -m "drop-me" &&
406 + git tag drop-me &&
407 + test_commit middle &&
408 + echo C1 >file &&
409 + git add file &&
410 + git commit -m "revert-drop-me" &&
411 + git tag revert-drop-me
412 + )
413 +}
414 +
415 +test_expect_success '--empty=drop drops descendants that become empty' '
416 + test_when_finished "rm -rf repo" &&
417 + setup_empty_descendant_repo repo &&
418 + (
419 + cd repo &&
420 +
421 + git history drop --empty=drop drop-me &&
422 +
423 + expect_log <<-\EOF
424 + middle
425 + base
426 + EOF
427 + )
428 +'
429 +
430 +test_expect_success '--empty=keep keeps descendants that become empty' '
431 + test_when_finished "rm -rf repo" &&
432 + setup_empty_descendant_repo repo &&
433 + (
434 + cd repo &&
435 +
436 + git history drop --empty=keep drop-me &&
437 +
438 + expect_log <<-\EOF &&
439 + revert-drop-me
440 + middle
441 + base
442 + EOF
443 + git diff HEAD~ HEAD >diff &&
444 + test_must_be_empty diff
445 + )
446 +'
447 +
448 +test_expect_success '--empty=abort errors out when a descendant becomes empty' '
449 + test_when_finished "rm -rf repo" &&
450 + setup_empty_descendant_repo repo &&
451 + (
452 + cd repo &&
453 +
454 + test_must_fail git history drop --empty=abort drop-me 2>err &&
455 + test_grep "became empty after replay" err
456 + )
457 +'
458 +
459 +test_expect_success 'updates index and worktree when HEAD moves' '
460 + test_when_finished "rm -rf repo" &&
461 + git init repo &&
462 + (
463 + cd repo &&
464 + test_commit first &&
465 + test_commit second &&
466 + test_commit third &&
467 +
468 + git history drop second &&
469 +
470 + # Worktree should no longer contain second.t.
471 + test_path_is_missing second.t &&
472 + test_path_is_file first.t &&
473 + test_path_is_file third.t &&
474 +
475 + # Index and worktree should both match the new HEAD.
476 + git status --porcelain --untracked-files=no >status &&
477 + test_must_be_empty status
478 + )
479 +'
480 +
481 +test_expect_success 'updates worktree when dropping HEAD itself' '
482 + test_when_finished "rm -rf repo" &&
483 + git init repo &&
484 + (
485 + cd repo &&
486 + test_commit first &&
487 + test_commit second &&
488 +
489 + git history drop HEAD &&
490 +
491 + test_path_is_missing second.t &&
492 + test_path_is_file first.t &&
493 +
494 + git status --porcelain --untracked-files=no >status &&
495 + test_must_be_empty status
496 + )
497 +'
498 +
499 +test_expect_success 'preserves unrelated unstaged modifications' '
500 + test_when_finished "rm -rf repo" &&
501 + git init repo &&
502 + (
503 + cd repo &&
504 + test_commit first &&
505 + echo first-content >unrelated.txt &&
506 + git add unrelated.txt &&
507 + git commit -m "add unrelated" &&
508 + test_commit second &&
509 + test_commit third &&
510 +
511 + echo locally-modified >unrelated.txt &&
512 +
513 + git diff >diff-expect &&
514 + git history drop second &&
515 + git diff >diff-actual &&
516 + test_cmp diff-expect diff-actual &&
517 + test_path_is_missing second.t
518 + )
519 +'
520 +
521 +test_expect_success 'preserves unrelated staged changes' '
522 + test_when_finished "rm -rf repo" &&
523 + git init repo &&
524 + (
525 + cd repo &&
526 + test_commit first &&
527 + echo first-content >unrelated.txt &&
528 + git add unrelated.txt &&
529 + git commit -m "add unrelated" &&
530 + test_commit second &&
531 + test_commit third &&
532 +
533 + echo staged-change >unrelated.txt &&
534 + git add unrelated.txt &&
535 +
536 + git diff --cached >diff-expect &&
537 + git history drop second &&
538 + git diff --cached >diff-actual &&
539 + test_cmp diff-expect diff-actual &&
540 + test_path_is_missing second.t
541 + )
542 +'
543 +
544 +test_expect_success 'aborts when local modifications would be overwritten' '
545 + test_when_finished "rm -rf repo" &&
546 + git init repo &&
547 + (
548 + cd repo &&
549 + test_commit base &&
550 + test_commit conflict &&
551 +
552 + echo local-edit >conflict.t &&
553 + git diff >diff-expect &&
554 + test_must_fail git history drop HEAD 2>err &&
555 + test_grep "would overwrite local changes" err &&
556 + git diff >diff-actual &&
557 + test_cmp diff-expect diff-actual
558 + )
559 +'
560 +
561 +test_done