Skip to content

Commit a6faa20

Browse files
pks-tgitster
authored andcommitted
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>
1 parent bb7d88f commit a6faa20

4 files changed

Lines changed: 761 additions & 1 deletion

File tree

Documentation/git-history.adoc

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history
88
SYNOPSIS
99
--------
1010
[synopsis]
11+
git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)]
1112
git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
1213
git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
1314
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
5152
first-class conflicts.
5253

5354
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.
5556

5657
COMMANDS
5758
--------
5859

5960
The following commands are available to rewrite history in different ways:
6061

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+
6177
`fixup <commit>`::
6278
Apply the currently staged changes to the specified commit. This is
6379
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`
170186
commit. All descendant commits have been replayed on top of the rewritten
171187
history.
172188

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+
173209
Split a commit
174210
~~~~~~~~~~~~~~
175211

builtin/history.c

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@
1717
#include "read-cache.h"
1818
#include "refs.h"
1919
#include "replay.h"
20+
#include "reset.h"
2021
#include "revision.h"
2122
#include "sequencer.h"
2223
#include "strvec.h"
2324
#include "tree.h"
25+
#include "tree-walk.h"
2426
#include "unpack-trees.h"
2527
#include "wt-status.h"
2628

29+
#define GIT_HISTORY_DROP_USAGE \
30+
N_("git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)]")
2731
#define GIT_HISTORY_FIXUP_USAGE \
2832
N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]")
2933
#define GIT_HISTORY_REWORD_USAGE \
@@ -1001,19 +1005,201 @@ static int cmd_history_split(int argc,
10011005
return ret;
10021006
}
10031007

1008+
static int update_worktree(struct repository *repo,
1009+
const struct commit *old_head,
1010+
const struct commit *new_head,
1011+
bool dry_run)
1012+
{
1013+
struct reset_working_tree_options opts = {
1014+
.oid_from = &old_head->object.oid,
1015+
.oid = &new_head->object.oid,
1016+
};
1017+
if (dry_run)
1018+
opts.flags |= RESET_WORKING_TREE_DRY_RUN;
1019+
return reset_working_tree(repo, &opts);
1020+
}
1021+
1022+
static int find_head_tree_change(struct repository *repo,
1023+
const struct replay_result *result,
1024+
struct commit **old_head,
1025+
struct commit **new_head,
1026+
bool *changed)
1027+
{
1028+
const struct replay_ref_update *head_update = NULL;
1029+
struct commit *old_head_commit, *new_head_commit;
1030+
struct tree *old_head_tree, *new_head_tree;
1031+
const char *head_target;
1032+
int head_flags;
1033+
1034+
*changed = false;
1035+
1036+
head_target = refs_resolve_ref_unsafe(get_main_ref_store(repo),
1037+
"HEAD", RESOLVE_REF_NO_RECURSE,
1038+
NULL, &head_flags);
1039+
if (!head_target)
1040+
return error(_("cannot look up HEAD"));
1041+
if (!(head_flags & REF_ISSYMREF))
1042+
head_target = "HEAD";
1043+
1044+
for (size_t i = 0; i < result->updates_nr; i++) {
1045+
if (!strcmp(result->updates[i].refname, head_target)) {
1046+
head_update = &result->updates[i];
1047+
break;
1048+
}
1049+
}
1050+
1051+
if (!head_update)
1052+
return 0;
1053+
1054+
old_head_commit = lookup_commit_reference(repo, &head_update->old_oid);
1055+
new_head_commit = lookup_commit_reference(repo, &head_update->new_oid);
1056+
if (!old_head_commit || !new_head_commit)
1057+
return error(_("cannot resolve HEAD commit"));
1058+
1059+
old_head_tree = repo_get_commit_tree(repo, old_head_commit);
1060+
new_head_tree = repo_get_commit_tree(repo, new_head_commit);
1061+
if (!old_head_tree || !new_head_tree)
1062+
return error(_("cannot resolve tree for HEAD"));
1063+
1064+
if (oideq(&old_head_tree->object.oid, &new_head_tree->object.oid))
1065+
return 0;
1066+
1067+
*old_head = old_head_commit;
1068+
*new_head = new_head_commit;
1069+
*changed = true;
1070+
1071+
return 0;
1072+
}
1073+
1074+
static int cmd_history_drop(int argc,
1075+
const char **argv,
1076+
const char *prefix,
1077+
struct repository *repo)
1078+
{
1079+
const char * const usage[] = {
1080+
GIT_HISTORY_DROP_USAGE,
1081+
NULL,
1082+
};
1083+
enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP;
1084+
enum ref_action action = REF_ACTION_DEFAULT;
1085+
int dry_run = 0;
1086+
struct option options[] = {
1087+
OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
1088+
N_("control which refs should be updated"),
1089+
PARSE_OPT_NONEG, parse_ref_action),
1090+
OPT_BOOL('n', "dry-run", &dry_run,
1091+
N_("perform a dry-run without updating any refs")),
1092+
OPT_CALLBACK_F(0, "empty", &empty, "(drop|keep|abort)",
1093+
N_("how to handle descendants that become empty"),
1094+
PARSE_OPT_NONEG, parse_opt_empty),
1095+
OPT_END(),
1096+
};
1097+
struct strbuf reflog_msg = STRBUF_INIT;
1098+
struct commit *original, *rewritten;
1099+
struct rev_info revs = { 0 };
1100+
struct replay_result result = { 0 };
1101+
struct commit *old_head, *new_head;
1102+
bool head_moves = false;
1103+
int ret;
1104+
1105+
argc = parse_options(argc, argv, prefix, options, usage, 0);
1106+
if (argc != 1) {
1107+
ret = error(_("command expects a single revision"));
1108+
goto out;
1109+
}
1110+
repo_config(repo, git_default_config, NULL);
1111+
1112+
if (action == REF_ACTION_DEFAULT)
1113+
action = REF_ACTION_BRANCHES;
1114+
1115+
original = lookup_commit_reference_by_name(argv[0]);
1116+
if (!original) {
1117+
ret = error(_("commit cannot be found: %s"), argv[0]);
1118+
goto out;
1119+
}
1120+
1121+
if (!original->parents) {
1122+
ret = error(_("cannot drop root commit %s: "
1123+
"it has no parent to replay onto"),
1124+
argv[0]);
1125+
goto out;
1126+
} else if (original->parents->next) {
1127+
ret = error(_("cannot drop merge commit: %s"), argv[0]);
1128+
goto out;
1129+
}
1130+
1131+
ret = setup_revwalk(repo, action, original, &revs);
1132+
if (ret)
1133+
goto out;
1134+
1135+
rewritten = original->parents->item;
1136+
1137+
ret = compute_pending_ref_updates(&revs, action, original, rewritten,
1138+
empty, &result);
1139+
if (ret) {
1140+
ret = error(_("failed replaying descendants"));
1141+
goto out;
1142+
}
1143+
1144+
/*
1145+
* If HEAD will move as a result of the rewrite then we'll have to
1146+
* merge in the changes into the worktree and index. This merge can of
1147+
* course conflict, which will cause the whole operation to abort.
1148+
*
1149+
* If we had already updated the refs at that point then we'd have an
1150+
* inconsistent repository state. So we first perform a dry-run merge
1151+
* here before updating refs.
1152+
*/
1153+
if (!is_bare_repository()) {
1154+
ret = find_head_tree_change(repo, &result, &old_head,
1155+
&new_head, &head_moves);
1156+
if (ret < 0)
1157+
goto out;
1158+
1159+
if (head_moves && update_worktree(repo, old_head, new_head, true) < 0) {
1160+
ret = error(_("dropping this commit would "
1161+
"overwrite local changes; aborting"));
1162+
goto out;
1163+
}
1164+
}
1165+
1166+
strbuf_addf(&reflog_msg, "drop: dropping %s", argv[0]);
1167+
ret = apply_pending_ref_updates(repo, &result, reflog_msg.buf, dry_run);
1168+
if (ret < 0) {
1169+
ret = error(_("failed to update references"));
1170+
goto out;
1171+
}
1172+
1173+
if (!dry_run && head_moves && update_worktree(repo, old_head, new_head, false) < 0) {
1174+
ret = error(_("could not update working tree to new commit %s"),
1175+
oid_to_hex(&new_head->object.oid));
1176+
goto out;
1177+
}
1178+
1179+
ret = 0;
1180+
1181+
out:
1182+
replay_result_release(&result);
1183+
strbuf_release(&reflog_msg);
1184+
release_revisions(&revs);
1185+
return ret;
1186+
}
1187+
10041188
int cmd_history(int argc,
10051189
const char **argv,
10061190
const char *prefix,
10071191
struct repository *repo)
10081192
{
10091193
const char * const usage[] = {
1194+
GIT_HISTORY_DROP_USAGE,
10101195
GIT_HISTORY_FIXUP_USAGE,
10111196
GIT_HISTORY_REWORD_USAGE,
10121197
GIT_HISTORY_SPLIT_USAGE,
10131198
NULL,
10141199
};
10151200
parse_opt_subcommand_fn *fn = NULL;
10161201
struct option options[] = {
1202+
OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
10171203
OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
10181204
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
10191205
OPT_SUBCOMMAND("split", &fn, cmd_history_split),

t/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ integration_tests = [
399399
't3451-history-reword.sh',
400400
't3452-history-split.sh',
401401
't3453-history-fixup.sh',
402+
't3454-history-drop.sh',
402403
't3500-cherry.sh',
403404
't3501-revert-cherry-pick.sh',
404405
't3502-cherry-pick-merge.sh',

0 commit comments

Comments
 (0)