{"thread":{"id":"65541","subject":"[PATCH v2 1/3] replay: allow callers to control what happens with empty commits","startedAt":"2026-04-23T14:22:05Z","lastAt":"2026-04-23T14:22:10Z","messageCount":4,"participants":["Patrick Steinhardt"],"isPatch":true,"patchVersion":2,"patchTotal":3},"messages":[{"id":"542198","messageId":"20260423-b4-pks-history-fixup-v2-0-d7571c6d36eb@pks.im","threadId":"65541","inReplyTo":"20260422-b4-pks-history-fixup-v1-0-48d4484243de@pks.im","subject":"[PATCH v2 0/3] builtin/history: introduce \"fixup\" subcommand","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-23T14:21:55Z","receivedAt":"2026-04-23T14:22:04Z","isPatch":true,"body":"Hi,\n\nthis short patch series introduces a new \"fixup\" subcommand. This\ncommand is the first one that I felt is missing in my day to day work,\nas I end up doing fixup commits quite often.\n\nThe flow is rather simple: the user stages some changes, and then they\nexecute `git history fixup <commit>` to amend those changes to the given\ncommit. As with the other subcommands, dependent branches will then be\nrebased automatically.\n\nThis is the first command that may result in merge conflicts. For now we\nsimply abort in such cases, but there are plans to introduce first-class\nconflicts into Git. So once we have them, we'll also be able to handle\nsuch cases more gracefully. I still think that the command is useful\neven without that conflict handling.\n\nChanges in v2:\n  - Introduce \"--empty=(keep|drop|abort)\" to specify what happens with\n    empty commits.\n  - Adapt documentation a bit to hopefully clarify how changes are\n    backported.\n  - Link to v1: https://patch.msgid.link/20260422-b4-pks-history-fixup-v1-0-48d4484243de@pks.im\n\nThanks!\n\nPatrick\n\n---\nPatrick Steinhardt (3):\n      replay: allow callers to control what happens with empty commits\n      builtin/history: generalize function to commit trees\n      builtin/history: introduce \"fixup\" subcommand\n\n Documentation/git-history.adoc |  77 ++++-\n builtin/history.c              | 291 ++++++++++++++++--\n replay.c                       |  29 +-\n replay.h                       |  19 ++\n t/meson.build                  |   1 +\n t/t3453-history-fixup.sh       | 680 +++++++++++++++++++++++++++++++++++++++++\n 6 files changed, 1067 insertions(+), 30 deletions(-)\n\nRange-diff versus v1:\n\n-:  ---------- > 1:  79b53c5c27 replay: allow callers to control what happens with empty commits\n1:  3bbe1f8b98 = 2:  79573cb5bf builtin/history: generalize function to commit trees\n2:  44f22df21e ! 3:  afdfd49f96 builtin/history: introduce \"fixup\" subcommand\n    @@ Documentation/git-history.adoc: git-history - EXPERIMENTAL: Rewrite history\n      SYNOPSIS\n      --------\n      [synopsis]\n    -+git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\n    ++git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n      git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n      git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n      \n    @@ Documentation/git-history.adoc: THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY C\n      * linkgit:git-history[1] does not execute any linkgit:githooks[5] at the\n        current point in time. This may change in the future.\n      * linkgit:git-history[1] by default updates all branches that are descendants\n    -@@ Documentation/git-history.adoc: COMMANDS\n    +@@ Documentation/git-history.adoc: conflicts. This limitation is by design as history rewrites are not intended to\n    + be stateful operations. The limitation can be lifted once (if) Git learns about\n    + first-class conflicts.\n    + \n    ++When using `fixup` with `--empty=drop`, dropping the root commit is not yet\n    ++supported.\n    ++\n    + COMMANDS\n    + --------\n      \n      The following commands are available to rewrite history in different ways:\n      \n     +`fixup <commit>`::\n    -+\tApply the currently staged changes to the specified commit. The staged\n    -+\tchanges are incorporated into the target commit's tree via a three-way\n    -+\tmerge, using HEAD's tree as the merge base, which is equivalent to\n    -+\tlinkgit:git-cherry-pick[1].\n    ++\tApply the currently staged changes to the specified commit. This\n    ++\tis done by performing a three-way merge between the HEAD commit,\n    ++\tthe target commit and the tree generated from staged changes.\n    ++\tThis is using the same logic as linkgit:git-cherry-pick[1].\n     ++\n     +The commit message and authorship of the target commit are preserved by\n     +default, unless you specify `--reedit-message`.\n    @@ Documentation/git-history.adoc: OPTIONS\n      \n     +`--reedit-message`::\n     +\tOpen an editor to modify the target commit's message.\n    ++\n    ++`--empty=(drop|keep|abort)`::\n    ++\tControl what happens when a commit becomes empty as a result of the\n    ++\tfixup. This can happen in two situations:\n    +++\n    ++--\n    ++* The fixup target itself becomes empty because the staged changes exactly\n    ++  cancel out all changes introduced by that commit.\n    ++\n    ++* A descendant commit becomes empty during replay because it introduced the\n    ++  same change that was just fixed up into an ancestor.\n    ++--\n    +++\n    ++With `drop` (the default), empty commits are removed from the rewritten\n    ++history. Descendants of a dropped target commit are replayed directly onto\n    ++the target's parent. Note that dropping the root commit is not supported;\n    ++see LIMITATIONS.\n    +++\n    ++With `keep`, empty commits are retained in the rewritten history as-is.\n    +++\n    ++With `abort`, the command stops with an error if any commit would become\n    ++empty.\n     +\n      `--update-refs=(branches|head)`::\n      \tControl which references will be updated by the command, if any. With\n    @@ builtin/history.c\n      #include \"wt-status.h\"\n      \n     +#define GIT_HISTORY_FIXUP_USAGE \\\n    -+\tN_(\"git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\")\n    ++\tN_(\"git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\")\n      #define GIT_HISTORY_REWORD_USAGE \\\n      \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n      #define GIT_HISTORY_SPLIT_USAGE \\\n    +@@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,\n    + \t\t\t\t    struct commit *original,\n    + \t\t\t\t    struct commit *rewritten,\n    + \t\t\t\t    const char *reflog_msg,\n    +-\t\t\t\t    int dry_run)\n    ++\t\t\t\t    int dry_run,\n    ++\t\t\t\t    enum replay_empty_commit_action empty)\n    + {\n    + \tconst struct name_decoration *decoration;\n    +-\tstruct replay_revisions_options opts = { 0 };\n    ++\tstruct replay_revisions_options opts = {\n    ++\t\t.empty = empty,\n    ++\t};\n    + \tstruct replay_result result = { 0 };\n    + \tstruct ref_transaction *transaction = NULL;\n    + \tstruct strbuf err = STRBUF_INIT;\n     @@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,\n      \treturn ret;\n      }\n      \n    ++static int commit_became_empty(struct repository *repo,\n    ++\t\t\t       struct commit *original,\n    ++\t\t\t       struct tree *result)\n    ++{\n    ++\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n    ++\tstruct object_id parent_tree_oid;\n    ++\n    ++\tif (parent) {\n    ++\t\tif (repo_parse_commit(repo, parent))\n    ++\t\t\treturn error(_(\"unable to parse parent of %s\"),\n    ++\t\t\t\t     oid_to_hex(&original->object.oid));\n    ++\n    ++\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n    ++\t} else {\n    ++\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n    ++\t}\n    ++\n    ++\treturn oideq(&result->object.oid, &parent_tree_oid);\n    ++}\n    ++\n    ++static int parse_opt_empty(const struct option *opt, const char *arg, int unset)\n    ++{\n    ++\tenum replay_empty_commit_action *value = opt->value;\n    ++\n    ++\tBUG_ON_OPT_NEG(unset);\n    ++\n    ++\tif (!strcmp(arg, \"drop\"))\n    ++\t\t*value = REPLAY_EMPTY_COMMIT_DROP;\n    ++\telse if (!strcmp(arg, \"keep\"))\n    ++\t\t*value = REPLAY_EMPTY_COMMIT_KEEP;\n    ++\telse if (!strcmp(arg, \"abort\"))\n    ++\t\t*value = REPLAY_EMPTY_COMMIT_ABORT;\n    ++\telse\n    ++\t\tdie(_(\"unrecognized '--empty=' action '%s'; \"\n    ++\t\t      \"valid values are \\\"drop\\\", \\\"keep\\\", and \\\"abort\\\".\"), arg);\n    ++\n    ++\treturn 0;\n    ++}\n    ++\n     +static int cmd_history_fixup(int argc,\n     +\t\t\t     const char **argv,\n     +\t\t\t     const char *prefix,\n    @@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,\n     +\t\tGIT_HISTORY_FIXUP_USAGE,\n     +\t\tNULL,\n     +\t};\n    ++\tenum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP;\n     +\tenum ref_action action = REF_ACTION_DEFAULT;\n    -+\tint dry_run = 0;\n     +\tenum commit_tree_flags flags = 0;\n    ++\tint dry_run = 0;\n     +\tstruct option options[] = {\n     +\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n     +\t\t\t       N_(\"control which refs should be updated\"),\n    @@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,\n     +\t\tOPT_BIT(0, \"reedit-message\", &flags,\n     +\t\t\tN_(\"open an editor to modify the commit message\"),\n     +\t\t\tCOMMIT_TREE_EDIT_MESSAGE),\n    ++\t\tOPT_CALLBACK_F(0, \"empty\", &empty, \"(drop|keep|abort)\",\n    ++\t\t\t       N_(\"how to handle commits that become empty\"),\n    ++\t\t\t       PARSE_OPT_NONEG, parse_opt_empty),\n     +\t\tOPT_END(),\n     +\t};\n     +\tstruct merge_result merge_result = { 0 };\n    @@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,\n     +\tstruct commit *head_commit, *original, *rewritten;\n     +\tstruct tree *head_tree, *original_tree, *index_tree;\n     +\tstruct rev_info revs = { 0 };\n    ++\tbool skip_commit = false;\n     +\tint ret;\n     +\n     +\targc = parse_options(argc, argv, prefix, options, usage, 0);\n    @@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,\n     +\t\tgoto out;\n     +\t}\n     +\n    ++\tret = commit_became_empty(repo, original, merge_result.tree);\n    ++\tif (ret < 0)\n    ++\t\tgoto out;\n    ++\tif (ret > 0) {\n    ++\t\tswitch (empty) {\n    ++\t\tcase REPLAY_EMPTY_COMMIT_DROP:\n    ++\t\t\t/*\n    ++\t\t\t * Drop the target commit by replaying its descendants\n    ++\t\t\t * directly onto its parent.\n    ++\t\t\t */\n    ++\t\t\trewritten = original->parents ? original->parents->item : NULL;\n    ++\n    ++\t\t\t/*\n    ++\t\t\t * TODO: we don't yet have the ability to drop root\n    ++\t\t\t * commits, but there's ultimately no good reason for\n    ++\t\t\t * this restriction to exist other than a technical\n    ++\t\t\t * limitation.\n    ++\t\t\t */\n    ++\t\t\tif (!rewritten) {\n    ++\t\t\t\tret = error(_(\"cannot drop root commit %s: \"\n    ++\t\t\t\t\t      \"it has no parent to replay onto\"),\n    ++\t\t\t\t\t    argv[0]);\n    ++\t\t\t\tgoto out;\n    ++\t\t\t}\n    ++\n    ++\t\t\tskip_commit = true;\n    ++\t\t\tbreak;\n    ++\t\tcase REPLAY_EMPTY_COMMIT_KEEP:\n    ++\t\t\t/* Proceed and record the empty commit. */\n    ++\t\t\tbreak;\n    ++\t\tcase REPLAY_EMPTY_COMMIT_ABORT:\n    ++\t\t\tret = error(_(\"fixup makes commit %s empty\"), argv[0]);\n    ++\t\t\tgoto out;\n    ++\t\t}\n    ++\t}\n    ++\n     +\tret = setup_revwalk(repo, action, original, &revs);\n     +\tif (ret)\n     +\t\tgoto out;\n     +\n    -+\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n    -+\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n    -+\t\t\t      &rewritten, flags);\n    -+\tif (ret < 0) {\n    -+\t\tret = error(_(\"failed writing fixed-up commit\"));\n    -+\t\tgoto out;\n    ++\tif (!skip_commit) {\n    ++\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n    ++\t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n    ++\t\t\t\t      &rewritten, flags);\n    ++\t\tif (ret < 0) {\n    ++\t\t\tret = error(_(\"failed writing fixed-up commit\"));\n    ++\t\t\tgoto out;\n    ++\t\t}\n     +\t}\n     +\n     +\tstrbuf_addf(&reflog_msg, \"fixup: updating %s\", argv[0]);\n     +\n     +\tret = handle_reference_updates(&revs, action, original, rewritten,\n    -+\t\t\t\t       reflog_msg.buf, dry_run);\n    ++\t\t\t\t       reflog_msg.buf, dry_run, empty);\n     +\tif (ret < 0) {\n     +\t\tret = error(_(\"failed replaying descendants\"));\n     +\t\tgoto out;\n    @@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,\n      static int cmd_history_reword(int argc,\n      \t\t\t      const char **argv,\n      \t\t\t      const char *prefix,\n    +@@ builtin/history.c: static int cmd_history_reword(int argc,\n    + \tstrbuf_addf(&reflog_msg, \"reword: updating %s\", argv[0]);\n    + \n    + \tret = handle_reference_updates(&revs, action, original, rewritten,\n    +-\t\t\t\t       reflog_msg.buf, dry_run);\n    ++\t\t\t\t       reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);\n    + \tif (ret < 0) {\n    + \t\tret = error(_(\"failed replaying descendants\"));\n    + \t\tgoto out;\n    +@@ builtin/history.c: static int cmd_history_split(int argc,\n    + \tstrbuf_addf(&reflog_msg, \"split: updating %s\", argv[0]);\n    + \n    + \tret = handle_reference_updates(&revs, action, original, rewritten,\n    +-\t\t\t\t       reflog_msg.buf, dry_run);\n    ++\t\t\t\t       reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);\n    + \tif (ret < 0) {\n    + \t\tret = error(_(\"failed replaying descendants\"));\n    + \t\tgoto out;\n     @@ builtin/history.c: int cmd_history(int argc,\n      \t\tstruct repository *repo)\n      {\n    @@ t/t3453-history-fixup.sh (new)\n     +\ttest_grep \"cannot run fixup in a bare repository\" err\n     +'\n     +\n    ++test_expect_success 'errors with invalid --empty= value' '\n    ++\ttest_when_finished \"rm -rf repo\" &&\n    ++\tgit init repo &&\n    ++\ttest_must_fail git -C repo history fixup --empty=bogus HEAD 2>err &&\n    ++\ttest_grep \"unrecognized.*--empty.*bogus\" err\n    ++'\n    ++\n     +test_expect_success 'can fixup the tip commit' '\n     +\ttest_when_finished \"rm -rf repo\" &&\n     +\tgit init repo &&\n    @@ t/t3453-history-fixup.sh (new)\n     +\t)\n     +'\n     +\n    ++test_expect_success '--empty=drop removes target commit and replays descendants onto its parent' '\n    ++\ttest_when_finished \"rm -rf repo\" &&\n    ++\tgit init repo --initial-branch=main &&\n    ++\t(\n    ++\t\tcd repo &&\n    ++\n    ++\t\ttest_commit first &&\n    ++\t\ttest_commit second &&\n    ++\t\ttest_commit third &&\n    ++\n    ++\t\tgit rm second.t &&\n    ++\t\tgit history fixup --empty=drop HEAD~ &&\n    ++\n    ++\t\texpect_changes <<-\\EOF &&\n    ++\t\tthird\n    ++\t\t1\t0\tthird.t\n    ++\t\tfirst\n    ++\t\t1\t0\tfirst.t\n    ++\t\tEOF\n    ++\t\ttest_must_fail git show HEAD:second.t\n    ++\t)\n    ++'\n    ++\n    ++test_expect_success '--empty=drop errors out when dropping the root commit' '\n    ++\ttest_when_finished \"rm -rf repo\" &&\n    ++\tgit init repo &&\n    ++\t(\n    ++\t\tcd repo &&\n    ++\n    ++\t\ttest_commit first &&\n    ++\t\ttest_commit second &&\n    ++\n    ++\t\tgit rm first.t &&\n    ++\t\ttest_must_fail git history fixup --empty=drop HEAD~ 2>err &&\n    ++\t\ttest_grep \"cannot drop root commit\" err\n    ++\t)\n    ++'\n    ++\n    ++test_expect_success '--empty=drop can drop the HEAD commit' '\n    ++\ttest_when_finished \"rm -rf repo\" &&\n    ++\tgit init repo &&\n    ++\t(\n    ++\t\tcd repo &&\n    ++\n    ++\t\ttest_commit first &&\n    ++\t\ttest_commit second &&\n    ++\n    ++\t\tgit rm second.t &&\n    ++\t\tgit history fixup --empty=drop HEAD &&\n    ++\n    ++\t\texpect_changes <<-\\EOF\n    ++\t\tfirst\n    ++\t\t1\t0\tfirst.t\n    ++\t\tEOF\n    ++\t)\n    ++'\n    ++\n    ++test_expect_success '--empty=drop drops empty replayed commits' '\n    ++\ttest_when_finished \"rm -rf repo\" &&\n    ++\tgit init repo &&\n    ++\t(\n    ++\t\tcd repo &&\n    ++\n    ++\t\ttouch base remove-me &&\n    ++\t\tgit add . &&\n    ++\t\tgit commit -m \"base\" &&\n    ++\t\tgit rm remove-me &&\n    ++\t\tgit commit -m \"remove\" &&\n    ++\t\ttouch reintroduce remove-me &&\n    ++\t\tgit add . &&\n    ++\t\tgit commit -m \"reintroduce\" &&\n    ++\n    ++\t\tgit rm remove-me &&\n    ++\t\tgit history fixup --empty=drop HEAD~2 &&\n    ++\n    ++\t\texpect_changes <<-\\EOF\n    ++\t\treintroduce\n    ++\t\t0\t0\treintroduce\n    ++\t\t0\t0\tremove-me\n    ++\t\tbase\n    ++\t\t0\t0\tbase\n    ++\t\tEOF\n    ++\t)\n    ++'\n    ++\n    ++test_expect_success '--empty=keep keeps commit when fixup target becomes empty' '\n    ++\ttest_when_finished \"rm -rf repo\" &&\n    ++\tgit init repo &&\n    ++\t(\n    ++\t\tcd repo &&\n    ++\n    ++\t\ttest_commit first &&\n    ++\t\ttest_commit second &&\n    ++\t\ttest_commit third &&\n    ++\n    ++\t\tgit rm second.t &&\n    ++\t\tgit history fixup --empty=keep HEAD~ &&\n    ++\n    ++\t\texpect_changes <<-\\EOF\n    ++\t\tthird\n    ++\t\t1\t0\tthird.t\n    ++\t\tsecond\n    ++\t\tfirst\n    ++\t\t1\t0\tfirst.t\n    ++\t\tEOF\n    ++\t)\n    ++'\n    ++\n    ++test_expect_success '--empty=keep keeps commit when replayed commit becomes empty' '\n    ++\ttest_when_finished \"rm -rf repo\" &&\n    ++\tgit init repo &&\n    ++\t(\n    ++\t\tcd repo &&\n    ++\n    ++\t\ttouch base remove-me &&\n    ++\t\tgit add . &&\n    ++\t\tgit commit -m \"base\" &&\n    ++\t\tgit rm remove-me &&\n    ++\t\tgit commit -m \"remove\" &&\n    ++\t\ttouch reintroduce remove-me &&\n    ++\t\tgit add . &&\n    ++\t\tgit commit -m \"reintroduce\" &&\n    ++\n    ++\t\tgit rm remove-me &&\n    ++\t\tgit history fixup --empty=keep HEAD~2 &&\n    ++\n    ++\t\texpect_changes <<-\\EOF\n    ++\t\treintroduce\n    ++\t\t0\t0\treintroduce\n    ++\t\t0\t0\tremove-me\n    ++\t\tremove\n    ++\t\tbase\n    ++\t\t0\t0\tbase\n    ++\t\tEOF\n    ++\t)\n    ++'\n    ++\n    ++test_expect_success '--empty=abort errors out when fixup target becomes empty' '\n    ++\ttest_when_finished \"rm -rf repo\" &&\n    ++\tgit init repo &&\n    ++\t(\n    ++\t\tcd repo &&\n    ++\n    ++\t\ttest_commit first &&\n    ++\t\ttest_commit second &&\n    ++\n    ++\t\tgit rm first.t &&\n    ++\t\ttest_must_fail git history fixup --empty=abort HEAD~ 2>err &&\n    ++\t\ttest_grep \"fixup makes commit.*empty\" err\n    ++\t)\n    ++'\n    ++\n    ++test_expect_success '--empty=abort errors out when a descendant becomes empty during replay' '\n    ++\ttest_when_finished \"rm -rf repo\" &&\n    ++\tgit init repo --initial-branch=main &&\n    ++\t(\n    ++\t\tcd repo &&\n    ++\n    ++\t\ttouch base remove-me &&\n    ++\t\tgit add . &&\n    ++\t\tgit commit -m \"base\" &&\n    ++\t\tgit rm remove-me &&\n    ++\t\tgit commit -m \"remove\" &&\n    ++\t\ttouch reintroduce remove-me &&\n    ++\t\tgit add . &&\n    ++\t\tgit commit -m \"reintroduce\" &&\n    ++\n    ++\t\tgit rm remove-me &&\n    ++\t\ttest_must_fail git history fixup --empty=abort HEAD~2 2>err &&\n    ++\t\ttest_grep \"became empty after replay\" err\n    ++\t)\n    ++'\n    ++\n     +test_done\n\n---\nbase-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0\nchange-id: 20260422-b4-pks-history-fixup-be27e0c4a03e\n\n"},{"id":"542197","messageId":"20260423-b4-pks-history-fixup-v2-1-d7571c6d36eb@pks.im","threadId":"65541","inReplyTo":"20260423-b4-pks-history-fixup-v2-0-d7571c6d36eb@pks.im","subject":"[PATCH v2 1/3] replay: allow callers to control what happens with empty commits","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-23T14:21:56Z","receivedAt":"2026-04-23T14:22:05Z","isPatch":true,"body":"When replaying commits it may happen that some of the commits become\nempty relative to their parent. Such commits are for now automatically\ndropped by the replay subsystem without much control from the user.\n\nIntroduce a new enum that allows the caller to drop, keep or abort in\nthis case.\n\nSigned-off-by: Patrick Steinhardt <ps@pks.im>\n---\n replay.c | 29 ++++++++++++++++++++++++-----\n replay.h | 19 +++++++++++++++++++\n 2 files changed, 43 insertions(+), 5 deletions(-)\n\ndiff --git a/replay.c b/replay.c\nindex f96f1f6551..4ef8abb607 100644\n--- a/replay.c\n+++ b/replay.c\n@@ -269,7 +269,8 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t\t\t\t\t  struct commit *onto,\n \t\t\t\t\t  struct merge_options *merge_opt,\n \t\t\t\t\t  struct merge_result *result,\n-\t\t\t\t\t  enum replay_mode mode)\n+\t\t\t\t\t  enum replay_mode mode,\n+\t\t\t\t\t  enum replay_empty_commit_action empty)\n {\n \tstruct commit *base, *replayed_base;\n \tstruct tree *pickme_tree, *base_tree, *replayed_base_tree;\n@@ -321,12 +322,25 @@ static struct commit *pick_regular_commit(struct repository *repo,\n \t}\n \tmerge_opt->ancestor = NULL;\n \tmerge_opt->branch2 = NULL;\n+\n \tif (!result->clean)\n \t\treturn NULL;\n-\t/* Drop commits that become empty */\n+\n+\t/* Handle commits that become empty */\n \tif (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) &&\n-\t    !oideq(&pickme_tree->object.oid, &base_tree->object.oid))\n-\t\treturn replayed_base;\n+\t    !oideq(&pickme_tree->object.oid, &base_tree->object.oid)) {\n+\t\tswitch (empty) {\n+\t\tcase REPLAY_EMPTY_COMMIT_DROP:\n+\t\t\treturn replayed_base;\n+\t\tcase REPLAY_EMPTY_COMMIT_KEEP:\n+\t\t\tbreak;\n+\t\tcase REPLAY_EMPTY_COMMIT_ABORT:\n+\t\t\tresult->clean = error(_(\"commit %s became empty after replay\"),\n+\t\t\t\t\t      oid_to_hex(&pickme->object.oid));\n+\t\t\treturn NULL;\n+\t\t}\n+\t}\n+\n \treturn create_commit(repo, result->tree, pickme, replayed_base, mode);\n }\n \n@@ -417,7 +431,7 @@ int replay_revisions(struct rev_info *revs,\n \n \t\tlast_commit = pick_regular_commit(revs->repo, commit, replayed_commits,\n \t\t\t\t\t\t  mode == REPLAY_MODE_REVERT ? last_commit : onto,\n-\t\t\t\t\t\t  &merge_opt, &result, mode);\n+\t\t\t\t\t\t  &merge_opt, &result, mode, opts->empty);\n \t\tif (!last_commit)\n \t\t\tbreak;\n \n@@ -458,6 +472,11 @@ int replay_revisions(struct rev_info *revs,\n \t\t}\n \t}\n \n+\tif (result.clean < 0) {\n+\t\tret = -1;\n+\t\tgoto out;\n+\t}\n+\n \tif (!result.clean) {\n \t\tret = 1;\n \t\tgoto out;\ndiff --git a/replay.h b/replay.h\nindex 0ab74b9805..1851a07705 100644\n--- a/replay.h\n+++ b/replay.h\n@@ -6,6 +6,19 @@\n struct repository;\n struct rev_info;\n \n+/*\n+ * Controls what happens when a replayed commit becomes empty (i.e. its tree\n+ * is identical to its parent's tree after the replay).\n+ */\n+enum replay_empty_commit_action {\n+\t/* Silently discard the empty commit. */\n+\tREPLAY_EMPTY_COMMIT_DROP,\n+\t/* Keep the empty commit as-is. */\n+\tREPLAY_EMPTY_COMMIT_KEEP,\n+\t/* Abort with an error. */\n+\tREPLAY_EMPTY_COMMIT_ABORT,\n+};\n+\n /*\n  * A set of options that can be passed to `replay_revisions()`.\n  */\n@@ -43,6 +56,12 @@ struct replay_revisions_options {\n \t * Requires `onto` to be set.\n \t */\n \tint contained;\n+\n+\t/*\n+\t * Controls what to do when a replayed commit becomes empty.\n+\t * Defaults to REPLAY_EMPTY_COMMIT_DROP.\n+\t */\n+\tenum replay_empty_commit_action empty;\n };\n \n /* This struct is used as an out-parameter by `replay_revisions()`. */\n\n-- \n2.54.0.545.g6539524ca2.dirty\n\n"},{"id":"542199","messageId":"20260423-b4-pks-history-fixup-v2-2-d7571c6d36eb@pks.im","threadId":"65541","inReplyTo":"20260423-b4-pks-history-fixup-v2-0-d7571c6d36eb@pks.im","subject":"[PATCH v2 2/3] builtin/history: generalize function to commit trees","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-23T14:21:57Z","receivedAt":"2026-04-23T14:22:08Z","isPatch":true,"body":"The function `commit_tree_with_edited_message_ext()` can be used to\ncommit a tree with a specific list of parents with an edited commit\nmessage. This function is useful outside of editing the commit message\nthough, as it also performs the plumbing to extract the original commit\nmessage and strip some headers from it.\n\nRefactor the function to receive a flags field that allows the caller to\ncontrol whether or not the commit message should be edited, or whether\nit should be retained as-is. This will be used in a subsequent commit.\n\nSigned-off-by: Patrick Steinhardt <ps@pks.im>\n---\n builtin/history.c | 45 ++++++++++++++++++++++++++-------------------\n 1 file changed, 26 insertions(+), 19 deletions(-)\n\ndiff --git a/builtin/history.c b/builtin/history.c\nindex 9526938085..549e352c74 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -91,13 +91,18 @@ static int fill_commit_message(struct repository *repo,\n \treturn 0;\n }\n \n-static int commit_tree_with_edited_message_ext(struct repository *repo,\n-\t\t\t\t\t       const char *action,\n-\t\t\t\t\t       struct commit *commit_with_message,\n-\t\t\t\t\t       const struct commit_list *parents,\n-\t\t\t\t\t       const struct object_id *old_tree,\n-\t\t\t\t\t       const struct object_id *new_tree,\n-\t\t\t\t\t       struct commit **out)\n+enum commit_tree_flags {\n+\tCOMMIT_TREE_EDIT_MESSAGE = (1 << 0),\n+};\n+\n+static int commit_tree_ext(struct repository *repo,\n+\t\t\t   const char *action,\n+\t\t\t   struct commit *commit_with_message,\n+\t\t\t   const struct commit_list *parents,\n+\t\t\t   const struct object_id *old_tree,\n+\t\t\t   const struct object_id *new_tree,\n+\t\t\t   struct commit **out,\n+\t\t\t   enum commit_tree_flags flags)\n {\n \tconst char *exclude_gpgsig[] = {\n \t\t/* We reencode the message, so the encoding needs to be stripped. */\n@@ -122,10 +127,14 @@ static int commit_tree_with_edited_message_ext(struct repository *repo,\n \t\toriginal_author = xmemdupz(ptr, len);\n \tfind_commit_subject(original_message, &original_body);\n \n-\tret = fill_commit_message(repo, old_tree, new_tree,\n-\t\t\t\t  original_body, action, &commit_message);\n-\tif (ret < 0)\n-\t\tgoto out;\n+\tif (flags & COMMIT_TREE_EDIT_MESSAGE) {\n+\t\tret = fill_commit_message(repo, old_tree, new_tree,\n+\t\t\t\t\t  original_body, action, &commit_message);\n+\t\tif (ret < 0)\n+\t\t\tgoto out;\n+\t} else {\n+\t\tstrbuf_addstr(&commit_message, original_body);\n+\t}\n \n \toriginal_extra_headers = read_commit_extra_headers(commit_with_message,\n \t\t\t\t\t\t\t   exclude_gpgsig);\n@@ -168,8 +177,8 @@ static int commit_tree_with_edited_message(struct repository *repo,\n \t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n \t}\n \n-\treturn commit_tree_with_edited_message_ext(repo, action, original, original->parents,\n-\t\t\t\t\t\t   &parent_tree_oid, tree_oid, out);\n+\treturn commit_tree_ext(repo, action, original, original->parents,\n+\t\t\t       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);\n }\n \n enum ref_action {\n@@ -616,9 +625,8 @@ static int split_commit(struct repository *repo,\n \t * The first commit is constructed from the split-out tree. The base\n \t * that shall be diffed against is the parent of the original commit.\n \t */\n-\tret = commit_tree_with_edited_message_ext(repo, \"split-out\", original,\n-\t\t\t\t\t\t  original->parents, &parent_tree_oid,\n-\t\t\t\t\t\t  &split_tree->object.oid, &first_commit);\n+\tret = commit_tree_ext(repo, \"split-out\", original, original->parents, &parent_tree_oid,\n+\t\t\t      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing first commit\"));\n \t\tgoto out;\n@@ -634,9 +642,8 @@ static int split_commit(struct repository *repo,\n \told_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;\n \tnew_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;\n \n-\tret = commit_tree_with_edited_message_ext(repo, \"split-out\", original,\n-\t\t\t\t\t\t  parents, old_tree_oid,\n-\t\t\t\t\t\t  new_tree_oid, &second_commit);\n+\tret = commit_tree_ext(repo, \"split-out\", original, parents, old_tree_oid,\n+\t\t\t      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed writing second commit\"));\n \t\tgoto out;\n\n-- \n2.54.0.545.g6539524ca2.dirty\n\n"},{"id":"542200","messageId":"20260423-b4-pks-history-fixup-v2-3-d7571c6d36eb@pks.im","threadId":"65541","inReplyTo":"20260423-b4-pks-history-fixup-v2-0-d7571c6d36eb@pks.im","subject":"[PATCH v2 3/3] builtin/history: introduce \"fixup\" subcommand","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-23T14:21:58Z","receivedAt":"2026-04-23T14:22:10Z","isPatch":true,"body":"The newly introduced git-history(1) command provides functionality to\neasily edit commit history while also rebasing dependent branches. The\nfunctionality exposed by this command is still somewhat limited though.\n\nOne common use case when editing commit history that is not yet covered\nis fixing up a specific commit. Introduce a new subcommand that allows\nthe user to do exactly that by performing a three-way merge into the\ntarget's commit tree, using HEAD's tree as the merge base. The flow is\nthus essentially:\n\n    $ echo changes >file\n    $ git add file\n    $ git history fixup HEAD~\n\nLike with the other commands, this will automatically rebase dependent\nbranches, as well. Unlike the other commands though:\n\n  - The command does not work in a bare repository as it interacts with\n    the index.\n\n  - The command may run into merge conflicts. If so, the command will\n    simply abort.\n\nEspecially the second item limits the usefulness of this command a bit.\nBut there are plans to introduce first-class conflicts into Git, which\nwill help use cases like this one.\n\nSigned-off-by: Patrick Steinhardt <ps@pks.im>\n---\n Documentation/git-history.adoc |  77 ++++-\n builtin/history.c              | 246 ++++++++++++++-\n t/meson.build                  |   1 +\n t/t3453-history-fixup.sh       | 680 +++++++++++++++++++++++++++++++++++++++++\n 4 files changed, 998 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 24dc907033..6576379f77 100644\n--- a/Documentation/git-history.adoc\n+++ b/Documentation/git-history.adoc\n@@ -8,6 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history\n SYNOPSIS\n --------\n [synopsis]\n+git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\n git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\n git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]\n \n@@ -22,8 +23,9 @@ THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.\n This command is related to linkgit:git-rebase[1] in that both commands can be\n used to rewrite history. There are a couple of major differences though:\n \n-* linkgit:git-history[1] can work in a bare repository as it does not need to\n-  touch either the index or the worktree.\n+* Most subcommands of linkgit:git-history[1] can work in a bare repository as\n+  they do not need to touch either the index or the worktree. The `fixup`\n+  subcommand is an exception to this, as it reads staged changes from the index.\n * linkgit:git-history[1] does not execute any linkgit:githooks[5] at the\n   current point in time. This may change in the future.\n * linkgit:git-history[1] by default updates all branches that are descendants\n@@ -48,11 +50,27 @@ conflicts. This limitation is by design as history rewrites are not intended to\n be stateful operations. The limitation can be lifted once (if) Git learns about\n first-class conflicts.\n \n+When using `fixup` with `--empty=drop`, dropping the root commit is not yet\n+supported.\n+\n COMMANDS\n --------\n \n The following commands are available to rewrite history in different ways:\n \n+`fixup <commit>`::\n+\tApply the currently staged changes to the specified commit. This\n+\tis done by performing a three-way merge between the HEAD commit,\n+\tthe target commit and the tree generated from staged changes.\n+\tThis is using the same logic as linkgit:git-cherry-pick[1].\n++\n+The commit message and authorship of the target commit are preserved by\n+default, unless you specify `--reedit-message`.\n++\n+If applying the staged changes would result in a conflict, the command\n+aborts with an error. All branches that are descendants of the original\n+commit are updated to point to the rewritten history.\n+\n `reword <commit>`::\n \tRewrite the commit message of the specified commit. All the other\n \tdetails of this commit remain unchanged. This command will spawn an\n@@ -87,6 +105,31 @@ OPTIONS\n \tobjects will be written into the repository, so applying these printed\n \tref updates is generally safe.\n \n+`--reedit-message`::\n+\tOpen an editor to modify the target commit's message.\n+\n+`--empty=(drop|keep|abort)`::\n+\tControl what happens when a commit becomes empty as a result of the\n+\tfixup. This can happen in two situations:\n++\n+--\n+* The fixup target itself becomes empty because the staged changes exactly\n+  cancel out all changes introduced by that commit.\n+\n+* A descendant commit becomes empty during replay because it introduced the\n+  same change that was just fixed up into an ancestor.\n+--\n++\n+With `drop` (the default), empty commits are removed from the rewritten\n+history. Descendants of a dropped target commit are replayed directly onto\n+the target's parent. Note that dropping the root commit is not supported;\n+see LIMITATIONS.\n++\n+With `keep`, empty commits are retained in the rewritten history as-is.\n++\n+With `abort`, the command stops with an error if any commit would become\n+empty.\n+\n `--update-refs=(branches|head)`::\n \tControl which references will be updated by the command, if any. With\n \t`branches`, all local branches that point to commits which are\n@@ -96,6 +139,36 @@ OPTIONS\n EXAMPLES\n --------\n \n+Fixup a commit\n+~~~~~~~~~~~~~~\n+\n+----------\n+$ git log --oneline --stat\n+abc1234 (HEAD -> main) third\n+ third.txt | 1 +\n+def5678 second\n+ second.txt | 1 +\n+ghi9012 first\n+ first.txt | 1 +\n+\n+$ echo \"change\" >>unrelated.txt\n+$ git add unrelated.txt\n+$ git history fixup ghi9012\n+\n+$ git log --oneline --stat\n+jkl3456 (HEAD -> main) third\n+ third.txt | 1 +\n+mno7890 second\n+ second.txt | 1 +\n+pqr1234 first\n+ first.txt     | 1 +\n+ unrelated.txt | 1 +\n+----------\n+\n+The staged addition of `unrelated.txt` has been incorporated into the `first`\n+commit. All descendant commits have been replayed on top of the rewritten\n+history.\n+\n Split a commit\n ~~~~~~~~~~~~~~\n \ndiff --git a/builtin/history.c b/builtin/history.c\nindex 549e352c74..0fc06fb204 100644\n--- a/builtin/history.c\n+++ b/builtin/history.c\n@@ -10,6 +10,7 @@\n #include \"gettext.h\"\n #include \"hex.h\"\n #include \"lockfile.h\"\n+#include \"merge-ort.h\"\n #include \"oidmap.h\"\n #include \"parse-options.h\"\n #include \"path.h\"\n@@ -23,6 +24,8 @@\n #include \"unpack-trees.h\"\n #include \"wt-status.h\"\n \n+#define GIT_HISTORY_FIXUP_USAGE \\\n+\tN_(\"git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]\")\n #define GIT_HISTORY_REWORD_USAGE \\\n \tN_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n #define GIT_HISTORY_SPLIT_USAGE \\\n@@ -335,10 +338,13 @@ static int handle_reference_updates(struct rev_info *revs,\n \t\t\t\t    struct commit *original,\n \t\t\t\t    struct commit *rewritten,\n \t\t\t\t    const char *reflog_msg,\n-\t\t\t\t    int dry_run)\n+\t\t\t\t    int dry_run,\n+\t\t\t\t    enum replay_empty_commit_action empty)\n {\n \tconst struct name_decoration *decoration;\n-\tstruct replay_revisions_options opts = { 0 };\n+\tstruct replay_revisions_options opts = {\n+\t\t.empty = empty,\n+\t};\n \tstruct replay_result result = { 0 };\n \tstruct ref_transaction *transaction = NULL;\n \tstruct strbuf err = STRBUF_INIT;\n@@ -434,6 +440,236 @@ static int handle_reference_updates(struct rev_info *revs,\n \treturn ret;\n }\n \n+static int commit_became_empty(struct repository *repo,\n+\t\t\t       struct commit *original,\n+\t\t\t       struct tree *result)\n+{\n+\tstruct commit *parent = original->parents ? original->parents->item : NULL;\n+\tstruct object_id parent_tree_oid;\n+\n+\tif (parent) {\n+\t\tif (repo_parse_commit(repo, parent))\n+\t\t\treturn error(_(\"unable to parse parent of %s\"),\n+\t\t\t\t     oid_to_hex(&original->object.oid));\n+\n+\t\tparent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;\n+\t} else {\n+\t\toidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);\n+\t}\n+\n+\treturn oideq(&result->object.oid, &parent_tree_oid);\n+}\n+\n+static int parse_opt_empty(const struct option *opt, const char *arg, int unset)\n+{\n+\tenum replay_empty_commit_action *value = opt->value;\n+\n+\tBUG_ON_OPT_NEG(unset);\n+\n+\tif (!strcmp(arg, \"drop\"))\n+\t\t*value = REPLAY_EMPTY_COMMIT_DROP;\n+\telse if (!strcmp(arg, \"keep\"))\n+\t\t*value = REPLAY_EMPTY_COMMIT_KEEP;\n+\telse if (!strcmp(arg, \"abort\"))\n+\t\t*value = REPLAY_EMPTY_COMMIT_ABORT;\n+\telse\n+\t\tdie(_(\"unrecognized '--empty=' action '%s'; \"\n+\t\t      \"valid values are \\\"drop\\\", \\\"keep\\\", and \\\"abort\\\".\"), arg);\n+\n+\treturn 0;\n+}\n+\n+static int cmd_history_fixup(int argc,\n+\t\t\t     const char **argv,\n+\t\t\t     const char *prefix,\n+\t\t\t     struct repository *repo)\n+{\n+\tconst char * const usage[] = {\n+\t\tGIT_HISTORY_FIXUP_USAGE,\n+\t\tNULL,\n+\t};\n+\tenum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP;\n+\tenum ref_action action = REF_ACTION_DEFAULT;\n+\tenum commit_tree_flags flags = 0;\n+\tint dry_run = 0;\n+\tstruct option options[] = {\n+\t\tOPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n+\t\t\t       N_(\"control which refs should be updated\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_ref_action),\n+\t\tOPT_BOOL('n', \"dry-run\", &dry_run,\n+\t\t\t N_(\"perform a dry-run without updating any refs\")),\n+\t\tOPT_BIT(0, \"reedit-message\", &flags,\n+\t\t\tN_(\"open an editor to modify the commit message\"),\n+\t\t\tCOMMIT_TREE_EDIT_MESSAGE),\n+\t\tOPT_CALLBACK_F(0, \"empty\", &empty, \"(drop|keep|abort)\",\n+\t\t\t       N_(\"how to handle commits that become empty\"),\n+\t\t\t       PARSE_OPT_NONEG, parse_opt_empty),\n+\t\tOPT_END(),\n+\t};\n+\tstruct merge_result merge_result = { 0 };\n+\tstruct merge_options merge_opts = { 0 };\n+\tstruct strbuf reflog_msg = STRBUF_INIT;\n+\tstruct commit *head_commit, *original, *rewritten;\n+\tstruct tree *head_tree, *original_tree, *index_tree;\n+\tstruct rev_info revs = { 0 };\n+\tbool skip_commit = false;\n+\tint ret;\n+\n+\targc = parse_options(argc, argv, prefix, options, usage, 0);\n+\tif (argc != 1) {\n+\t\tret = error(_(\"command expects a single revision\"));\n+\t\tgoto out;\n+\t}\n+\trepo_config(repo, git_default_config, NULL);\n+\n+\tif (action == REF_ACTION_DEFAULT)\n+\t\taction = REF_ACTION_BRANCHES;\n+\n+\tif (is_bare_repository()) {\n+\t\tret = error(_(\"cannot run fixup in a bare repository\"));\n+\t\tgoto out;\n+\t}\n+\n+\t/* Resolve the original commit, which is the one we want to fix up. */\n+\toriginal = lookup_commit_reference_by_name(argv[0]);\n+\tif (!original) {\n+\t\tret = error(_(\"commit cannot be found: %s\"), argv[0]);\n+\t\tgoto out;\n+\t}\n+\n+\t/*\n+\t * Resolve HEAD so we can use its tree as the merge base: the staged\n+\t * changes are expressed as a diff from HEAD's tree to the index tree.\n+\t */\n+\thead_commit = lookup_commit_reference_by_name(\"HEAD\");\n+\tif (!head_commit) {\n+\t\tret = error(_(\"cannot look up HEAD\"));\n+\t\tgoto out;\n+\t}\n+\n+\thead_tree = repo_get_commit_tree(repo, head_commit);\n+\tif (!head_tree) {\n+\t\tret = error(_(\"cannot get tree for HEAD\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (repo_read_index(repo) < 0) {\n+\t\tret = error(_(\"unable to read index\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (!repo_index_has_changes(repo, head_tree, NULL)) {\n+\t\tret = error(_(\"nothing to fixup: no staged changes\"));\n+\t\tgoto out;\n+\t}\n+\n+\t/*\n+\t * Write the index as a tree object. This is the \"theirs\" side of the\n+\t * three-way merge: it is HEAD's tree with the staged changes applied.\n+\t */\n+\tindex_tree = write_in_core_index_as_tree(repo, repo->index);\n+\tif (!index_tree) {\n+\t\tret = error(_(\"unable to write index as a tree\"));\n+\t\tgoto out;\n+\t}\n+\n+\toriginal_tree = repo_get_commit_tree(repo, original);\n+\tif (!original_tree) {\n+\t\tret = error(_(\"cannot get tree for commit %s\"), argv[0]);\n+\t\tgoto out;\n+\t}\n+\n+\t/*\n+\t * Perform the three-way merge to reapply changes in the index onto the\n+\t * target commit. This is using basically the same logic as a\n+\t * cherry-pick, where the base commit is our HEAD, ours is the original\n+\t * tree and theirs is the index tree.\n+\t */\n+\tinit_basic_merge_options(&merge_opts, repo);\n+\tmerge_opts.ancestor = \"HEAD\";\n+\tmerge_opts.branch1 = argv[0];\n+\tmerge_opts.branch2 = \"staged\";\n+\tmerge_incore_nonrecursive(&merge_opts, head_tree,\n+\t\t\t\t  original_tree, index_tree, &merge_result);\n+\n+\tif (merge_result.clean < 0) {\n+\t\tret = error(_(\"merge failed while applying fixup\"));\n+\t\tgoto out;\n+\t}\n+\n+\tif (!merge_result.clean) {\n+\t\tret = error(_(\"fixup would produce conflicts; aborting\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = commit_became_empty(repo, original, merge_result.tree);\n+\tif (ret < 0)\n+\t\tgoto out;\n+\tif (ret > 0) {\n+\t\tswitch (empty) {\n+\t\tcase REPLAY_EMPTY_COMMIT_DROP:\n+\t\t\t/*\n+\t\t\t * Drop the target commit by replaying its descendants\n+\t\t\t * directly onto its parent.\n+\t\t\t */\n+\t\t\trewritten = original->parents ? original->parents->item : NULL;\n+\n+\t\t\t/*\n+\t\t\t * TODO: we don't yet have the ability to drop root\n+\t\t\t * commits, but there's ultimately no good reason for\n+\t\t\t * this restriction to exist other than a technical\n+\t\t\t * limitation.\n+\t\t\t */\n+\t\t\tif (!rewritten) {\n+\t\t\t\tret = error(_(\"cannot drop root commit %s: \"\n+\t\t\t\t\t      \"it has no parent to replay onto\"),\n+\t\t\t\t\t    argv[0]);\n+\t\t\t\tgoto out;\n+\t\t\t}\n+\n+\t\t\tskip_commit = true;\n+\t\t\tbreak;\n+\t\tcase REPLAY_EMPTY_COMMIT_KEEP:\n+\t\t\t/* Proceed and record the empty commit. */\n+\t\t\tbreak;\n+\t\tcase REPLAY_EMPTY_COMMIT_ABORT:\n+\t\t\tret = error(_(\"fixup makes commit %s empty\"), argv[0]);\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\tret = setup_revwalk(repo, action, original, &revs);\n+\tif (ret)\n+\t\tgoto out;\n+\n+\tif (!skip_commit) {\n+\t\tret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n+\t\t\t\t      &original_tree->object.oid, &merge_result.tree->object.oid,\n+\t\t\t\t      &rewritten, flags);\n+\t\tif (ret < 0) {\n+\t\t\tret = error(_(\"failed writing fixed-up commit\"));\n+\t\t\tgoto out;\n+\t\t}\n+\t}\n+\n+\tstrbuf_addf(&reflog_msg, \"fixup: updating %s\", argv[0]);\n+\n+\tret = handle_reference_updates(&revs, action, original, rewritten,\n+\t\t\t\t       reflog_msg.buf, dry_run, empty);\n+\tif (ret < 0) {\n+\t\tret = error(_(\"failed replaying descendants\"));\n+\t\tgoto out;\n+\t}\n+\n+\tret = 0;\n+\n+out:\n+\tmerge_finalize(&merge_opts, &merge_result);\n+\tstrbuf_release(&reflog_msg);\n+\trelease_revisions(&revs);\n+\treturn ret;\n+}\n+\n static int cmd_history_reword(int argc,\n \t\t\t      const char **argv,\n \t\t\t      const char *prefix,\n@@ -487,7 +723,7 @@ static int cmd_history_reword(int argc,\n \tstrbuf_addf(&reflog_msg, \"reword: updating %s\", argv[0]);\n \n \tret = handle_reference_updates(&revs, action, original, rewritten,\n-\t\t\t\t       reflog_msg.buf, dry_run);\n+\t\t\t\t       reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed replaying descendants\"));\n \t\tgoto out;\n@@ -724,7 +960,7 @@ static int cmd_history_split(int argc,\n \tstrbuf_addf(&reflog_msg, \"split: updating %s\", argv[0]);\n \n \tret = handle_reference_updates(&revs, action, original, rewritten,\n-\t\t\t\t       reflog_msg.buf, dry_run);\n+\t\t\t\t       reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);\n \tif (ret < 0) {\n \t\tret = error(_(\"failed replaying descendants\"));\n \t\tgoto out;\n@@ -745,12 +981,14 @@ int cmd_history(int argc,\n \t\tstruct repository *repo)\n {\n \tconst char * const usage[] = {\n+\t\tGIT_HISTORY_FIXUP_USAGE,\n \t\tGIT_HISTORY_REWORD_USAGE,\n \t\tGIT_HISTORY_SPLIT_USAGE,\n \t\tNULL,\n \t};\n \tparse_opt_subcommand_fn *fn = NULL;\n \tstruct option options[] = {\n+\t\tOPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n \t\tOPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n \t\tOPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n \t\tOPT_END(),\ndiff --git a/t/meson.build b/t/meson.build\nindex 7528e5cda5..f502ad8ec9 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -397,6 +397,7 @@ integration_tests = [\n   't3450-history.sh',\n   't3451-history-reword.sh',\n   't3452-history-split.sh',\n+  't3453-history-fixup.sh',\n   't3500-cherry.sh',\n   't3501-revert-cherry-pick.sh',\n   't3502-cherry-pick-merge.sh',\ndiff --git a/t/t3453-history-fixup.sh b/t/t3453-history-fixup.sh\nnew file mode 100755\nindex 0000000000..868298e248\n--- /dev/null\n+++ b/t/t3453-history-fixup.sh\n@@ -0,0 +1,680 @@\n+#!/bin/sh\n+\n+test_description='tests for git-history fixup subcommand'\n+\n+. ./test-lib.sh\n+\n+fixup_with_message () {\n+\tcat >message &&\n+\twrite_script fake-editor.sh <<-\\EOF &&\n+\tcp message \"$1\"\n+\tEOF\n+\ttest_set_editor \"$(pwd)\"/fake-editor.sh &&\n+\tgit history fixup --reedit-message \"$@\" &&\n+\trm fake-editor.sh message\n+}\n+\n+expect_changes () {\n+\tgit log --format=\"%s\" --numstat \"$@\" >actual.raw &&\n+\tsed '/^$/d' <actual.raw >actual &&\n+\tcat >expect &&\n+\ttest_cmp expect actual\n+}\n+\n+test_expect_success 'errors on missing commit argument' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit initial &&\n+\t\ttest_must_fail git history fixup 2>err &&\n+\t\ttest_grep \"command expects a single revision\" err\n+\t)\n+'\n+\n+test_expect_success 'errors on too many arguments' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit initial &&\n+\t\ttest_must_fail git history fixup HEAD HEAD 2>err &&\n+\t\ttest_grep \"command expects a single revision\" err\n+\t)\n+'\n+\n+test_expect_success 'errors on unknown revision' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit initial &&\n+\t\ttest_must_fail git history fixup does-not-exist 2>err &&\n+\t\ttest_grep \"commit cannot be found: does-not-exist\" err\n+\t)\n+'\n+\n+test_expect_success 'errors when nothing is staged' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit initial &&\n+\t\ttest_must_fail git history fixup HEAD 2>err &&\n+\t\ttest_grep \"nothing to fixup: no staged changes\" err\n+\t)\n+'\n+\n+test_expect_success 'errors in a bare repository' '\n+\ttest_when_finished \"rm -rf repo repo.git\" &&\n+\tgit init repo &&\n+\ttest_commit -C repo initial &&\n+\tgit clone --bare repo repo.git &&\n+\ttest_must_fail git -C repo.git history fixup HEAD 2>err &&\n+\ttest_grep \"cannot run fixup in a bare repository\" err\n+'\n+\n+test_expect_success 'errors with invalid --empty= value' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\ttest_must_fail git -C repo history fixup --empty=bogus HEAD 2>err &&\n+\ttest_grep \"unrecognized.*--empty.*bogus\" err\n+'\n+\n+test_expect_success 'can fixup the tip commit' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit initial &&\n+\t\techo content >file.txt &&\n+\t\tgit add file.txt &&\n+\t\tgit commit -m \"add file\" &&\n+\n+\t\techo fix >>file.txt &&\n+\t\tgit add file.txt &&\n+\n+\t\texpect_changes <<-\\EOF &&\n+\t\tadd file\n+\t\t1\t0\tfile.txt\n+\t\tinitial\n+\t\t1\t0\tinitial.t\n+\t\tEOF\n+\n+\t\tgit symbolic-ref HEAD >branch-expect &&\n+\t\tgit history fixup HEAD &&\n+\t\tgit symbolic-ref HEAD >branch-actual &&\n+\t\ttest_cmp branch-expect branch-actual &&\n+\n+\t\texpect_changes <<-\\EOF &&\n+\t\tadd file\n+\t\t2\t0\tfile.txt\n+\t\tinitial\n+\t\t1\t0\tinitial.t\n+\t\tEOF\n+\n+\t\t# Verify the fix is in the tip commit tree\n+\t\tgit show HEAD:file.txt >actual &&\n+\t\tprintf \"content\\nfix\\n\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\tgit reflog >reflog &&\n+\t\ttest_grep \"fixup: updating HEAD\" reflog\n+\t)\n+'\n+\n+test_expect_success 'can fixup a commit in the middle of history' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit first &&\n+\t\techo content >file.txt &&\n+\t\tgit add file.txt &&\n+\t\tgit commit -m \"add file\" &&\n+\t\ttest_commit third &&\n+\n+\t\techo fix >>file.txt &&\n+\t\tgit add file.txt &&\n+\n+\t\texpect_changes <<-\\EOF &&\n+\t\tthird\n+\t\t1\t0\tthird.t\n+\t\tadd file\n+\t\t1\t0\tfile.txt\n+\t\tfirst\n+\t\t1\t0\tfirst.t\n+\t\tEOF\n+\n+\t\tgit history fixup HEAD~ &&\n+\n+\t\texpect_changes <<-\\EOF &&\n+\t\tthird\n+\t\t1\t0\tthird.t\n+\t\tadd file\n+\t\t2\t0\tfile.txt\n+\t\tfirst\n+\t\t1\t0\tfirst.t\n+\t\tEOF\n+\n+\t\t# Verify the fix landed in the \"add file\" commit.\n+\t\tgit show HEAD~:file.txt >actual &&\n+\t\tprintf \"content\\nfix\\n\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\t# And verify that the replayed commit also has the change.\n+\t\tgit show HEAD:file.txt >actual &&\n+\t\tprintf \"content\\nfix\\n\" >expect &&\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success 'can fixup root commit' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\techo initial >root.txt &&\n+\t\tgit add root.txt &&\n+\t\tgit commit -m \"root\" &&\n+\t\ttest_commit second &&\n+\n+\t\texpect_changes <<-\\EOF &&\n+\t\tsecond\n+\t\t1\t0\tsecond.t\n+\t\troot\n+\t\t1\t0\troot.txt\n+\t\tEOF\n+\n+\t\techo fix >>root.txt &&\n+\t\tgit add root.txt &&\n+\t\tgit history fixup HEAD~ &&\n+\n+\t\texpect_changes <<-\\EOF &&\n+\t\tsecond\n+\t\t1\t0\tsecond.t\n+\t\troot\n+\t\t2\t0\troot.txt\n+\t\tEOF\n+\n+\t\tgit show HEAD~:root.txt >actual &&\n+\t\tprintf \"initial\\nfix\\n\" >expect &&\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success 'preserves commit message and authorship' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit initial &&\n+\t\techo content >file.txt &&\n+\t\tgit add file.txt &&\n+\t\tgit commit --author=\"Original <original@example.com>\" -m \"original message\" &&\n+\n+\t\techo fix >>file.txt &&\n+\t\tgit add file.txt &&\n+\t\tgit history fixup HEAD &&\n+\n+\t\t# Message preserved\n+\t\tgit log -1 --format=\"%s\" >actual &&\n+\t\techo \"original message\" >expect &&\n+\t\ttest_cmp expect actual &&\n+\n+\t\t# Authorship preserved\n+\t\tgit log -1 --format=\"%an <%ae>\" >actual &&\n+\t\techo \"Original <original@example.com>\" >expect &&\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success 'updates all descendant branches by default' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo --initial-branch=main &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit base &&\n+\t\tgit branch branch &&\n+\t\ttest_commit ours &&\n+\t\tgit switch branch &&\n+\t\ttest_commit theirs &&\n+\t\tgit switch main &&\n+\n+\t\techo fix >fix.txt &&\n+\t\tgit add fix.txt &&\n+\t\tgit history fixup base &&\n+\n+\t\texpect_changes --branches <<-\\EOF &&\n+\t\ttheirs\n+\t\t1\t0\ttheirs.t\n+\t\tours\n+\t\t1\t0\tours.t\n+\t\tbase\n+\t\t1\t0\tbase.t\n+\t\t1\t0\tfix.txt\n+\t\tEOF\n+\n+\t\t# Both branches should have the fix in the base\n+\t\tgit show main~:fix.txt >actual &&\n+\t\techo fix >expect &&\n+\t\ttest_cmp expect actual &&\n+\t\tgit show branch~:fix.txt >actual &&\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success 'can fixup commit on a different branch' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit base &&\n+\t\tgit branch theirs &&\n+\t\ttest_commit ours &&\n+\t\tgit switch theirs &&\n+\t\ttest_commit theirs &&\n+\n+\t\t# Stage a change while on \"theirs\"\n+\t\techo fix >fix.txt &&\n+\t\tgit add fix.txt &&\n+\n+\t\t# Ensure that \"ours\" does not change, as it does not contain\n+\t\t# the commit in question.\n+\t\tgit rev-parse ours >ours-before &&\n+\t\tgit history fixup theirs &&\n+\t\tgit rev-parse ours >ours-after &&\n+\t\ttest_cmp ours-before ours-after &&\n+\n+\t\tgit show HEAD:fix.txt >actual &&\n+\t\techo fix >expect &&\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success '--dry-run prints ref updates without modifying repo' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo --initial-branch=main &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit base &&\n+\t\tgit branch branch &&\n+\t\ttest_commit main-tip &&\n+\t\tgit switch branch &&\n+\t\ttest_commit branch-tip &&\n+\t\tgit switch main &&\n+\n+\t\techo fix >fix.txt &&\n+\t\tgit add fix.txt &&\n+\n+\t\tgit refs list >refs-before &&\n+\t\tgit history fixup --dry-run base >updates &&\n+\t\tgit refs list >refs-after &&\n+\t\ttest_cmp refs-before refs-after &&\n+\n+\t\ttest_grep \"update refs/heads/main\" updates &&\n+\t\ttest_grep \"update refs/heads/branch\" updates &&\n+\n+\t\texpect_changes --branches <<-\\EOF &&\n+\t\tbranch-tip\n+\t\t1\t0\tbranch-tip.t\n+\t\tmain-tip\n+\t\t1\t0\tmain-tip.t\n+\t\tbase\n+\t\t1\t0\tbase.t\n+\t\tEOF\n+\n+\t\tgit update-ref --stdin <updates &&\n+\t\texpect_changes --branches <<-\\EOF\n+\t\tbranch-tip\n+\t\t1\t0\tbranch-tip.t\n+\t\tmain-tip\n+\t\t1\t0\tmain-tip.t\n+\t\tbase\n+\t\t1\t0\tbase.t\n+\t\t1\t0\tfix.txt\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--update-refs=head updates only HEAD' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo --initial-branch=main &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit base &&\n+\t\tgit branch branch &&\n+\t\ttest_commit main-tip &&\n+\t\tgit switch branch &&\n+\t\ttest_commit branch-tip &&\n+\n+\t\techo fix >fix.txt &&\n+\t\tgit add fix.txt &&\n+\n+\t\t# Only HEAD (branch) should be updated\n+\t\tgit history fixup --update-refs=head base &&\n+\n+\t\t# The main branch should be unaffected.\n+\t\texpect_changes main <<-\\EOF &&\n+\t\tmain-tip\n+\t\t1\t0\tmain-tip.t\n+\t\tbase\n+\t\t1\t0\tbase.t\n+\t\tEOF\n+\n+\t\t# But the currently checked out branch should be modified.\n+\t\texpect_changes branch <<-\\EOF\n+\t\tbranch-tip\n+\t\t1\t0\tbranch-tip.t\n+\t\tbase\n+\t\t1\t0\tbase.t\n+\t\t1\t0\tfix.txt\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--update-refs=head refuses to rewrite commits not in HEAD ancestry' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo --initial-branch=main &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit base &&\n+\t\tgit branch other &&\n+\t\ttest_commit main-tip &&\n+\t\tgit switch other &&\n+\t\ttest_commit other-tip &&\n+\n+\t\techo fix >fix.txt &&\n+\t\tgit add fix.txt &&\n+\n+\t\ttest_must_fail git history fixup --update-refs=head main-tip 2>err &&\n+\t\ttest_grep \"rewritten commit must be an ancestor of HEAD\" err\n+\t)\n+'\n+\n+test_expect_success 'aborts when fixup would produce conflicts' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\n+\t\techo \"line one\" >file.txt &&\n+\t\tgit add file.txt &&\n+\t\tgit commit -m \"first\" &&\n+\n+\t\techo \"line two\" >file.txt &&\n+\t\tgit add file.txt &&\n+\t\tgit commit -m \"second\" &&\n+\n+\t\techo \"conflicting change\" >file.txt &&\n+\t\tgit add file.txt &&\n+\n+\t\tgit refs list >refs-before &&\n+\t\ttest_must_fail git history fixup HEAD~ 2>err &&\n+\t\ttest_grep \"fixup would produce conflicts\" err &&\n+\t\tgit refs list >refs-after &&\n+\t\ttest_cmp refs-before refs-after\n+\t)\n+'\n+\n+test_expect_success '--reedit-message opens editor for the commit message' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\ttest_commit initial &&\n+\t\techo content >file.txt &&\n+\t\tgit add file.txt &&\n+\t\tgit commit -m \"add file\" &&\n+\n+\t\techo fix >>file.txt &&\n+\t\tgit add file.txt &&\n+\n+\t\tfixup_with_message HEAD <<-\\EOF &&\n+\t\tadd file with fix\n+\t\tEOF\n+\n+\t\texpect_changes --branches <<-\\EOF\n+\t\tadd file with fix\n+\t\t2\t0\tfile.txt\n+\t\tinitial\n+\t\t1\t0\tinitial.t\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success 'retains unstaged working tree changes after fixup' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\t\ttouch a b &&\n+\t\tgit add . &&\n+\t\tgit commit -m \"initial commit\" &&\n+\t\techo staged >a &&\n+\t\techo unstaged >b &&\n+\t\tgit add a &&\n+\t\tgit history fixup HEAD &&\n+\n+\t\t# b is still modified in the worktree but not staged\n+\t\tcat >expect <<-\\EOF &&\n+\t\t M b\n+\t\tEOF\n+\t\tgit status --porcelain --untracked-files=no >actual &&\n+\t\ttest_cmp expect actual\n+\t)\n+'\n+\n+test_expect_success 'index is clean after fixup when target is HEAD' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\n+\t\ttest_commit initial &&\n+\t\techo fix >fix.txt &&\n+\t\tgit add fix.txt &&\n+\t\tgit history fixup HEAD &&\n+\n+\t\tgit status --porcelain --untracked-files=no >actual &&\n+\t\ttest_must_be_empty actual\n+\t)\n+'\n+\n+test_expect_success 'index is unchanged on conflict' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\n+\t\techo base >file.txt &&\n+\t\tgit add file.txt &&\n+\t\tgit commit -m base &&\n+\t\techo change >file.txt &&\n+\t\tgit add file.txt &&\n+\t\tgit commit -m change &&\n+\n+\t\techo conflict >file.txt &&\n+\t\tgit add file.txt &&\n+\n+\t\tgit diff --cached >index-before &&\n+\t\ttest_must_fail git history fixup HEAD~ &&\n+\t\tgit diff --cached >index-after &&\n+\t\ttest_cmp index-before index-after\n+\t)\n+'\n+\n+test_expect_success '--empty=drop removes target commit and replays descendants onto its parent' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo --initial-branch=main &&\n+\t(\n+\t\tcd repo &&\n+\n+\t\ttest_commit first &&\n+\t\ttest_commit second &&\n+\t\ttest_commit third &&\n+\n+\t\tgit rm second.t &&\n+\t\tgit history fixup --empty=drop HEAD~ &&\n+\n+\t\texpect_changes <<-\\EOF &&\n+\t\tthird\n+\t\t1\t0\tthird.t\n+\t\tfirst\n+\t\t1\t0\tfirst.t\n+\t\tEOF\n+\t\ttest_must_fail git show HEAD:second.t\n+\t)\n+'\n+\n+test_expect_success '--empty=drop errors out when dropping the root commit' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\n+\t\ttest_commit first &&\n+\t\ttest_commit second &&\n+\n+\t\tgit rm first.t &&\n+\t\ttest_must_fail git history fixup --empty=drop HEAD~ 2>err &&\n+\t\ttest_grep \"cannot drop root commit\" err\n+\t)\n+'\n+\n+test_expect_success '--empty=drop can drop the HEAD commit' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\n+\t\ttest_commit first &&\n+\t\ttest_commit second &&\n+\n+\t\tgit rm second.t &&\n+\t\tgit history fixup --empty=drop HEAD &&\n+\n+\t\texpect_changes <<-\\EOF\n+\t\tfirst\n+\t\t1\t0\tfirst.t\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--empty=drop drops empty replayed commits' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\n+\t\ttouch base remove-me &&\n+\t\tgit add . &&\n+\t\tgit commit -m \"base\" &&\n+\t\tgit rm remove-me &&\n+\t\tgit commit -m \"remove\" &&\n+\t\ttouch reintroduce remove-me &&\n+\t\tgit add . &&\n+\t\tgit commit -m \"reintroduce\" &&\n+\n+\t\tgit rm remove-me &&\n+\t\tgit history fixup --empty=drop HEAD~2 &&\n+\n+\t\texpect_changes <<-\\EOF\n+\t\treintroduce\n+\t\t0\t0\treintroduce\n+\t\t0\t0\tremove-me\n+\t\tbase\n+\t\t0\t0\tbase\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--empty=keep keeps commit when fixup target becomes empty' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\n+\t\ttest_commit first &&\n+\t\ttest_commit second &&\n+\t\ttest_commit third &&\n+\n+\t\tgit rm second.t &&\n+\t\tgit history fixup --empty=keep HEAD~ &&\n+\n+\t\texpect_changes <<-\\EOF\n+\t\tthird\n+\t\t1\t0\tthird.t\n+\t\tsecond\n+\t\tfirst\n+\t\t1\t0\tfirst.t\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--empty=keep keeps commit when replayed commit becomes empty' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\n+\t\ttouch base remove-me &&\n+\t\tgit add . &&\n+\t\tgit commit -m \"base\" &&\n+\t\tgit rm remove-me &&\n+\t\tgit commit -m \"remove\" &&\n+\t\ttouch reintroduce remove-me &&\n+\t\tgit add . &&\n+\t\tgit commit -m \"reintroduce\" &&\n+\n+\t\tgit rm remove-me &&\n+\t\tgit history fixup --empty=keep HEAD~2 &&\n+\n+\t\texpect_changes <<-\\EOF\n+\t\treintroduce\n+\t\t0\t0\treintroduce\n+\t\t0\t0\tremove-me\n+\t\tremove\n+\t\tbase\n+\t\t0\t0\tbase\n+\t\tEOF\n+\t)\n+'\n+\n+test_expect_success '--empty=abort errors out when fixup target becomes empty' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo &&\n+\t(\n+\t\tcd repo &&\n+\n+\t\ttest_commit first &&\n+\t\ttest_commit second &&\n+\n+\t\tgit rm first.t &&\n+\t\ttest_must_fail git history fixup --empty=abort HEAD~ 2>err &&\n+\t\ttest_grep \"fixup makes commit.*empty\" err\n+\t)\n+'\n+\n+test_expect_success '--empty=abort errors out when a descendant becomes empty during replay' '\n+\ttest_when_finished \"rm -rf repo\" &&\n+\tgit init repo --initial-branch=main &&\n+\t(\n+\t\tcd repo &&\n+\n+\t\ttouch base remove-me &&\n+\t\tgit add . &&\n+\t\tgit commit -m \"base\" &&\n+\t\tgit rm remove-me &&\n+\t\tgit commit -m \"remove\" &&\n+\t\ttouch reintroduce remove-me &&\n+\t\tgit add . &&\n+\t\tgit commit -m \"reintroduce\" &&\n+\n+\t\tgit rm remove-me &&\n+\t\ttest_must_fail git history fixup --empty=abort HEAD~2 2>err &&\n+\t\ttest_grep \"became empty after replay\" err\n+\t)\n+'\n+\n+test_done\n\n-- \n2.54.0.545.g6539524ca2.dirty\n\n"}]}