[PATCH v3 0/9] builtin/history: introduce "drop" subcommand
From: Patrick Steinhardt <hidden>
Date: 2026-06-08 10:23:35
Hi,
this small patch series introduces the new "drop" subcommand for
git-history(1). As a reader might guess, the command does exactly that:
given a commit, it will drop that commit from the commit history and
replay descendant branches on top of it.
Changes in v3:
- Fix commit message typos.
- Make `update_orig_head` and `skip_ref_updates` mutually exclusive.
- Use fancy revisions to specify the commit to drop in the example
section.
- Detect conflicting changes in the index/working tree in dry-run
mode.
- Consistently use a subshell.
- Rename `RESET_HEAD_ORIG_HEAD` to `RESET_HEAD_UPDATE_ORIG_HEAD`.
-
- Link to v2: https://patch.msgid.link/20260603-b4-pks-history-drop-v2-0-742cb5b5176d@pks.im
Changes in v2:
- Reworked `update_worktree()` to use `reset_head()`, which required a
bunch of changes to `reset_head()`.
- Consistently mention the commit that cannot be dropped as part of
error messages.
- Adapt error message to not use backticks anymore.
- Drop redundant "--graph" flag in a test helper.
- Link to v1: https://patch.msgid.link/20260601-b4-pks-history-drop-v1-0-643e32340d55@pks.im
Thanks!
Patrick
---
Patrick Steinhardt (9):
read-cache: split out function to drop unmerged entries to stage 0
reset: drop `USE_THE_REPOSITORY_VARIABLE`
reset: modernize flags passed to `reset_head()`
reset: introduce dry-run mode
reset: introduce ability to skip reference updates
reset: allow the caller to specify the current HEAD object
reset: stop assuming that the caller passes in a clean index
builtin/history: split handling of ref updates into two phases
builtin/history: implement "drop" subcommand
Documentation/git-history.adoc | 38 ++-
builtin/history.c | 289 +++++++++++++++++++---
builtin/rebase.c | 2 +-
read-cache-ll.h | 1 +
read-cache.c | 12 +-
reset.c | 91 ++++---
reset.h | 44 +++-
sequencer.c | 2 +-
t/meson.build | 1 +
t/t3454-history-drop.sh | 537 +++++++++++++++++++++++++++++++++++++++++
10 files changed, 929 insertions(+), 88 deletions(-)
Range-diff versus v2:
1: a93e804936 = 1: 41a723b3d0 read-cache: split out function to drop unmerged entries to stage 0
2: d8f39e7dc4 = 2: db850730ef reset: drop `USE_THE_REPOSITORY_VARIABLE`
3: fdec5a57b4 ! 3: bd18736141 reset: modernize flags passed to `reset_head()`
@@ builtin/rebase.c: int cmd_rebase(int argc,
ropts.oid = &options.onto->object.oid;
ropts.orig_head = &options.orig_head->object.oid;
- ropts.flags = RESET_HEAD_DETACH | RESET_ORIG_HEAD |
-+ ropts.flags = RESET_HEAD_DETACH | RESET_HEAD_ORIG_HEAD |
++ ropts.flags = RESET_HEAD_DETACH | RESET_HEAD_UPDATE_ORIG_HEAD |
RESET_HEAD_RUN_POST_CHECKOUT_HOOK;
ropts.head_msg = msg.buf;
ropts.default_reflog_action = options.reflog_action;
@@ reset.c: static int update_refs(struct repository *repo,
unsigned detach_head = opts->flags & RESET_HEAD_DETACH;
unsigned run_hook = opts->flags & RESET_HEAD_RUN_POST_CHECKOUT_HOOK;
- unsigned update_orig_head = opts->flags & RESET_ORIG_HEAD;
-+ unsigned update_orig_head = opts->flags & RESET_HEAD_ORIG_HEAD;
++ unsigned update_orig_head = opts->flags & RESET_HEAD_UPDATE_ORIG_HEAD;
const struct object_id *orig_head = opts->orig_head;
const char *switch_to_branch = opts->branch;
const char *reflog_branch = opts->branch_msg;
@@ reset.c: int reset_head(struct repository *r, const struct reset_head_opts *opts
unsigned reset_hard = opts->flags & RESET_HEAD_HARD;
unsigned refs_only = opts->flags & RESET_HEAD_REFS_ONLY;
- unsigned update_orig_head = opts->flags & RESET_ORIG_HEAD;
-+ unsigned update_orig_head = opts->flags & RESET_HEAD_ORIG_HEAD;
++ unsigned update_orig_head = opts->flags & RESET_HEAD_UPDATE_ORIG_HEAD;
struct object_id *head = NULL, head_oid;
struct tree_desc desc[2] = { { NULL }, { NULL } };
struct lock_file lock = LOCK_INIT;
@@ reset.h
+ RESET_HEAD_REFS_ONLY = (1 << 3),
+
+ /* Update ORIG_HEAD as well as HEAD */
-+ RESET_HEAD_ORIG_HEAD = (1 << 4),
++ RESET_HEAD_UPDATE_ORIG_HEAD = (1 << 4),
+};
struct reset_head_opts {
@@ reset.h: struct reset_head_opts {
/*
* Optional reflog message for ORIG_HEAD, if this omitted and flags
- * contains RESET_ORIG_HEAD then default_reflog_action must be given.
-+ * contains RESET_HEAD_ORIG_HEAD then default_reflog_action must be given.
++ * contains RESET_HEAD_UPDATE_ORIG_HEAD then default_reflog_action must be given.
*/
const char *orig_head_msg;
/*
@@ sequencer.c: static int checkout_onto(struct repository *r, struct replay_opts *
.oid = onto,
.orig_head = orig_head,
- .flags = RESET_HEAD_DETACH | RESET_ORIG_HEAD |
-+ .flags = RESET_HEAD_DETACH | RESET_HEAD_ORIG_HEAD |
++ .flags = RESET_HEAD_DETACH | RESET_HEAD_UPDATE_ORIG_HEAD |
RESET_HEAD_RUN_POST_CHECKOUT_HOOK,
.head_msg = reflog_message(opts, "start", "checkout %s",
onto_name),
4: 3af7c9a4fd ! 4: 8c79e56076 reset: introduce dry-run mode
@@ Metadata
## Commit message ##
reset: introduce dry-run mode
- In a subsequent commit we'll add add another caller to `reset_head()`
- that wants to perform a dry-run check of whether it would be possible to
- udpate the index and working tree when moving to a new commit. Introduce
+ In a subsequent commit we'll add another caller to `reset_head()` that
+ wants to perform a dry-run check of whether it would be possible to
+ update the index and working tree when moving to a new commit. Introduce
a new flag that lets the caller perform this operation.
Signed-off-by: Patrick Steinhardt [off-list ref]
@@ reset.c
@@ reset.c: int reset_head(struct repository *r, const struct reset_head_opts *opts)
unsigned reset_hard = opts->flags & RESET_HEAD_HARD;
unsigned refs_only = opts->flags & RESET_HEAD_REFS_ONLY;
- unsigned update_orig_head = opts->flags & RESET_HEAD_ORIG_HEAD;
+ unsigned update_orig_head = opts->flags & RESET_HEAD_UPDATE_ORIG_HEAD;
+ unsigned dry_run = opts->flags & RESET_HEAD_DRY_RUN;
struct object_id *head = NULL, head_oid;
struct tree_desc desc[2] = { { NULL }, { NULL } };
@@ reset.h
@@ reset.h: enum reset_head_flags {
/* Update ORIG_HEAD as well as HEAD */
- RESET_HEAD_ORIG_HEAD = (1 << 4),
+ RESET_HEAD_UPDATE_ORIG_HEAD = (1 << 4),
+
+ /*
+ * Perform a dry-run by performing the operation without updating
5: 31d1ff1d4c ! 5: c112fbbd14 reset: introduce ability to skip reference updates
@@ Commit message
## reset.c ##
@@ reset.c: int reset_head(struct repository *r, const struct reset_head_opts *opts)
unsigned refs_only = opts->flags & RESET_HEAD_REFS_ONLY;
- unsigned update_orig_head = opts->flags & RESET_HEAD_ORIG_HEAD;
+ unsigned update_orig_head = opts->flags & RESET_HEAD_UPDATE_ORIG_HEAD;
unsigned dry_run = opts->flags & RESET_HEAD_DRY_RUN;
+ unsigned skip_ref_updates = opts->flags & RESET_HEAD_SKIP_REF_UPDATES;
struct object_id *head = NULL, head_oid;
@@ reset.c: int reset_head(struct repository *r, const struct reset_head_opts *opts
if (opts->branch_msg && !opts->branch)
BUG("branch reflog message given without a branch");
-+ if (skip_ref_updates && (opts->branch || refs_only))
++ if (skip_ref_updates && (opts->branch || refs_only || update_orig_head))
+ BUG("asked to perform ref updates and skip them at the same time");
+
if (!refs_only && !dry_run && repo_hold_locked_index(r, &lock, LOCK_REPORT_ON_ERROR) < 0) {
6: 21b2d4d281 = 6: 9550257dd1 reset: allow the caller to specify the current HEAD object
7: 7c032ca1e3 = 7: 8a3d517020 reset: stop assuming that the caller passes in a clean index
8: fcd4479178 = 8: a09be2ffc0 builtin/history: split handling of ref updates into two phases
9: 6b1c17a8df ! 9: 682b11af93 builtin/history: implement "drop" subcommand
@@ Documentation/git-history.adoc: The staged addition of `unrelated.txt` has been
+def5678 second
+ghi9012 first
+
-+$ git history drop def5678
++$ git history drop 'main^{/second}'
+
+$ git log --oneline
+jkl3456 (HEAD -> main) third
@@ builtin/history.c: static int cmd_history_split(int argc,
+ * inconsistent repository state. So we first perform a dry-run merge
+ * here before updating refs.
+ */
-+ if (!dry_run && !is_bare_repository()) {
++ if (!is_bare_repository()) {
+ ret = find_head_tree_change(repo, &result, &old_head,
+ &new_head, &head_moves);
+ if (ret < 0)
@@ builtin/history.c: static int cmd_history_split(int argc,
+ goto out;
+ }
+
-+ if (head_moves && update_worktree(repo, old_head, new_head, false) < 0) {
++ if (!dry_run && head_moves && update_worktree(repo, old_head, new_head, false) < 0) {
+ ret = error(_("could not update working tree to new commit %s"),
+ oid_to_hex(&new_head->object.oid));
+ goto out;
@@ t/t3454-history-drop.sh (new)
+test_expect_success 'errors with invalid --empty= value' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
-+ test_commit -C repo initial &&
-+ test_commit -C repo second &&
-+ test_must_fail git -C repo history drop --empty=bogus HEAD 2>err &&
-+ test_grep "unrecognized.*--empty.*bogus" err
++ (
++ cd repo &&
++ test_commit initial &&
++ test_commit second &&
++ test_must_fail git history drop --empty=bogus HEAD 2>err &&
++ test_grep "unrecognized.*--empty.*bogus" err
++ )
+'
+
+test_expect_success 'drops a commit in the middle and replays descendants' '
@@ t/t3454-history-drop.sh (new)
+ )
+'
+
++test_expect_success '--dry-run detects conflicts with modified working tree' '
++ test_when_finished "rm -rf repo" &&
++ git init repo --initial-branch=main &&
++ (
++ cd repo &&
++ test_commit first &&
++ test_commit second modify-me &&
++ echo modified >modify-me &&
++
++ git refs list >refs-expect &&
++ git diff >diff-expect &&
++ test_must_fail git history drop --dry-run HEAD 2>err &&
++ test_grep "dropping this commit would overwrite local changes" err &&
++ git diff >diff-actual &&
++ git refs list >refs-actual &&
++
++ test_cmp diff-expect diff-actual &&
++ test_cmp refs-expect refs-actual
++ )
++'
++
+test_expect_success '--update-refs=head updates only HEAD' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
---
base-commit: 1666c1265231b0bc5f613fbbf3f0a9896cdef76e
change-id: 20260601-b4-pks-history-drop-28f6c6399e7b