{"thread":{"id":"65534","subject":"[PATCH 0/2] builtin/history: introduce \"fixup\" subcommand","startedAt":"2026-04-22T10:29:05Z","lastAt":"2026-05-12T06:42:02Z","messageCount":17,"participants":["Patrick Steinhardt","Tian Yuchen","D. Ben Knoble","Junio C Hamano"],"isPatch":true,"patchVersion":1,"patchTotal":2},"messages":[{"id":"542102","messageId":"20260422-b4-pks-history-fixup-v1-0-48d4484243de@pks.im","threadId":"65534","inReplyTo":null,"subject":"[PATCH 0/2] builtin/history: introduce \"fixup\" subcommand","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-22T10:28:57Z","receivedAt":"2026-04-22T10:29:05Z","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\nThanks!\n\nPatrick\n\n---\nPatrick Steinhardt (2):\n      builtin/history: generalize function to commit trees\n      builtin/history: introduce \"fixup\" subcommand\n\n Documentation/git-history.adoc |  52 ++++-\n builtin/history.c              | 198 ++++++++++++++--\n t/meson.build                  |   1 +\n t/t3453-history-fixup.sh       | 500 +++++++++++++++++++++++++++++++++++++++++\n 4 files changed, 730 insertions(+), 21 deletions(-)\n\n\n---\nbase-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0\nchange-id: 20260422-b4-pks-history-fixup-be27e0c4a03e\n\n"},{"id":"542103","messageId":"20260422-b4-pks-history-fixup-v1-1-48d4484243de@pks.im","threadId":"65534","inReplyTo":"20260422-b4-pks-history-fixup-v1-0-48d4484243de@pks.im","subject":"[PATCH 1/2] builtin/history: generalize function to commit trees","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-22T10:28:58Z","receivedAt":"2026-04-22T10:29:07Z","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":"542104","messageId":"20260422-b4-pks-history-fixup-v1-2-48d4484243de@pks.im","threadId":"65534","inReplyTo":"20260422-b4-pks-history-fixup-v1-0-48d4484243de@pks.im","subject":"[PATCH 2/2] builtin/history: introduce \"fixup\" subcommand","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-22T10:28:59Z","receivedAt":"2026-04-22T10:29: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 |  52 ++++-\n builtin/history.c              | 153 +++++++++++++\n t/meson.build                  |   1 +\n t/t3453-history-fixup.sh       | 500 +++++++++++++++++++++++++++++++++++++++++\n 4 files changed, 704 insertions(+), 2 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 24dc907033..3cdfc8ba02 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]\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@@ -53,6 +55,19 @@ COMMANDS\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++\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 +102,9 @@ 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 `--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 +114,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..6299f0dfa9 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]\")\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@@ -434,6 +437,154 @@ static int handle_reference_updates(struct rev_info *revs,\n \treturn ret;\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 ref_action action = REF_ACTION_DEFAULT;\n+\tint dry_run = 0;\n+\tenum commit_tree_flags flags = 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_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+\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 = 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+\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+\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@@ -745,12 +896,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..0012b1f052\n--- /dev/null\n+++ b/t/t3453-history-fixup.sh\n@@ -0,0 +1,500 @@\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 '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_done\n\n-- \n2.54.0.545.g6539524ca2.dirty\n\n"},{"id":"542151","messageId":"d2b19306-71e9-4e17-a0c0-83309a00bd45@malon.dev","threadId":"65534","inReplyTo":"20260422-b4-pks-history-fixup-v1-0-48d4484243de@pks.im","subject":"Re: [PATCH 0/2] builtin/history: introduce \"fixup\" subcommand","fromName":"Tian Yuchen","fromEmail":"cat@malon.dev","sentAt":"2026-04-22T18:18:16Z","receivedAt":"2026-04-22T18:18:29Z","isPatch":true,"body":"Hi Patrick,\n\nOn 4/22/26 18:28, Patrick Steinhardt wrote:\n\n> Hi,\n> \n> this short patch series introduces a new \"fixup\" subcommand. This\n> command is the first one that I felt is missing in my day to day work,\n> as I end up doing fixup commits quite often.\n> \n> The flow is rather simple: the user stages some changes, and then they\n> execute `git history fixup <commit>` to amend those changes to the given\n> commit. As with the other subcommands, dependent branches will then be\n> rebased automatically.\n> \n> This is the first command that may result in merge conflicts. For now we\n> simply abort in such cases, but there are plans to introduce first-class\n> conflicts into Git. So once we have them, we'll also be able to handle\n> such cases more gracefully. I still think that the command is useful\n> even without that conflict handling.\n\nThank you for developing this feature. Godsend for lazy people like me ;)\n\nNevertheless, I seem to have come across what appears to be a bug. I \ncarried out the following steps:\n\n\tcreate a.txt -> git add -> git commit -m \"base\" ->\n\n\tcreate b.txt -> git add -> git commit -m \"feature\" ->\n\n\tcreate c.txt -> git add -> git commit -m \"tip\" ->\n\n\trm b.txt -> git add ->\n\n\tgit history fixup HEAD~ ->\n\n\tgit log --oneline --stat...\n\nAnd the output looks like:\n\n\t3096a65 (HEAD -> master) tip\n\t c.txt | 1 +\n\t 1 file changed, 1 insertion(+)\n\t699f610 feature\n\t0be07e6 base\n\t a.txt | 1 +\n  \t1 file changed, 1 insertion(+)\n\nMore specifically, the output of\n\n\tgit show HEAD~\n\nis:\n\n\tAuthor: Tian Yuchen <cat@malon.dev>\n\tDate:   Thu Apr 23 01:57:17 2026 +0800\n\n  \t   feature\n\nwhich is an empty commit. Is it what we expect to see? Sorry that I \ndon't have enough time to look at the code in detail :P\n\nThanks, Yuchen\n"},{"id":"542157","messageId":"CALnO6CCBA=OSvKT8D6-YR1S=x3VOa_MpzWfK6FJWPSXq0ysMPg@mail.gmail.com","threadId":"65534","inReplyTo":"20260422-b4-pks-history-fixup-v1-2-48d4484243de@pks.im","subject":"Re: [PATCH 2/2] builtin/history: introduce \"fixup\" subcommand","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-04-22T19:06:12Z","receivedAt":"2026-04-22T19:06:28Z","isPatch":true,"body":"Yahoo, fixup!\n\nOn Wed, Apr 22, 2026 at 6:30 AM Patrick Steinhardt <ps@pks.im> wrote:\n>\n> The newly introduced git-history(1) command provides functionality to\n> easily edit commit history while also rebasing dependent branches. The\n> functionality exposed by this command is still somewhat limited though.\n>\n> One common use case when editing commit history that is not yet covered\n> is fixing up a specific commit. Introduce a new subcommand that allows\n> the user to do exactly that by performing a three-way merge into the\n> target's commit tree, using HEAD's tree as the merge base. The flow is\n> thus essentially:\n>\n>     $ echo changes >file\n>     $ git add file\n>     $ git history fixup HEAD~\n>\n> Like with the other commands, this will automatically rebase dependent\n> branches, 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>\n> Especially the second item limits the usefulness of this command a bit.\n> But there are plans to introduce first-class conflicts into Git, which\n> will help use cases like this one.\n>\n> Signed-off-by: Patrick Steinhardt <ps@pks.im>\n> ---\n>  Documentation/git-history.adoc |  52 ++++-\n>  builtin/history.c              | 153 +++++++++++++\n>  t/meson.build                  |   1 +\n>  t/t3453-history-fixup.sh       | 500 +++++++++++++++++++++++++++++++++++++++++\n>  4 files changed, 704 insertions(+), 2 deletions(-)\n>\n> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\n> index 24dc907033..3cdfc8ba02 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]\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> @@ -53,6 +55,19 @@ COMMANDS\n>\n>  The following commands are available to rewrite history in different ways:\n>\n> +`fixup <commit>`::\n> +       Apply the currently staged changes to the specified commit. The staged\n> +       changes are incorporated into the target commit's tree via a three-way\n> +       merge, using HEAD's tree as the merge base, which is equivalent to\n> +       linkgit:git-cherry-pick[1].\n\nI'm not quite sure what, as a user of \"git history fixup,\" I'm\nsupposed to take from this. Does it make conflicts less likely when\ncreating the new fixup? I imagine it doesn't help with conflicts\nbetween <commit> and HEAD that newly arise.\n\nAnyway, I'd think the mechanics are less relevant than the end-user\nbehavior at this point in the doc, unless the equivalence with\ncherry-pick is supposed to tell me something about that behavior.\n\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>         Rewrite the commit message of the specified commit. All the other\n>         details of this commit remain unchanged. This command will spawn an\n> @@ -87,6 +102,9 @@ OPTIONS\n>         objects will be written into the repository, so applying these printed\n>         ref updates is generally safe.\n>\n> +`--reedit-message`::\n> +       Open an editor to modify the target commit's message.\n> +\n>  `--update-refs=(branches|head)`::\n>         Control which references will be updated by the command, if any. With\n>         `branches`, all local branches that point to commits which are\n> @@ -96,6 +114,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>\n> diff --git a/builtin/history.c b/builtin/history.c\n> index 549e352c74..6299f0dfa9 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> +       N_(\"git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]\")\n>  #define GIT_HISTORY_REWORD_USAGE \\\n>         N_(\"git history reword <commit> [--dry-run] [--update-refs=(branches|head)]\")\n>  #define GIT_HISTORY_SPLIT_USAGE \\\n> @@ -434,6 +437,154 @@ static int handle_reference_updates(struct rev_info *revs,\n>         return ret;\n>  }\n>\n> +static int cmd_history_fixup(int argc,\n> +                            const char **argv,\n> +                            const char *prefix,\n> +                            struct repository *repo)\n> +{\n> +       const char * const usage[] = {\n> +               GIT_HISTORY_FIXUP_USAGE,\n> +               NULL,\n> +       };\n> +       enum ref_action action = REF_ACTION_DEFAULT;\n> +       int dry_run = 0;\n> +       enum commit_tree_flags flags = 0;\n> +       struct option options[] = {\n> +               OPT_CALLBACK_F(0, \"update-refs\", &action, \"(branches|head)\",\n> +                              N_(\"control which refs should be updated\"),\n> +                              PARSE_OPT_NONEG, parse_ref_action),\n> +               OPT_BOOL('n', \"dry-run\", &dry_run,\n> +                        N_(\"perform a dry-run without updating any refs\")),\n> +               OPT_BIT(0, \"reedit-message\", &flags,\n> +                       N_(\"open an editor to modify the commit message\"),\n> +                       COMMIT_TREE_EDIT_MESSAGE),\n> +               OPT_END(),\n> +       };\n> +       struct merge_result merge_result = { 0 };\n> +       struct merge_options merge_opts = { 0 };\n> +       struct strbuf reflog_msg = STRBUF_INIT;\n> +       struct commit *head_commit, *original, *rewritten;\n> +       struct tree *head_tree, *original_tree, *index_tree;\n> +       struct rev_info revs = { 0 };\n> +       int ret;\n> +\n> +       argc = parse_options(argc, argv, prefix, options, usage, 0);\n> +       if (argc != 1) {\n> +               ret = error(_(\"command expects a single revision\"));\n> +               goto out;\n> +       }\n> +       repo_config(repo, git_default_config, NULL);\n> +\n> +       if (action == REF_ACTION_DEFAULT)\n> +               action = REF_ACTION_BRANCHES;\n> +\n> +       if (is_bare_repository()) {\n> +               ret = error(_(\"cannot run fixup in a bare repository\"));\n> +               goto out;\n> +       }\n> +\n> +       /* Resolve the original commit, which is the one we want to fix up. */\n> +       original = lookup_commit_reference_by_name(argv[0]);\n> +       if (!original) {\n> +               ret = error(_(\"commit cannot be found: %s\"), argv[0]);\n> +               goto out;\n> +       }\n> +\n> +       /*\n> +        * Resolve HEAD so we can use its tree as the merge base: the staged\n> +        * changes are expressed as a diff from HEAD's tree to the index tree.\n> +        */\n> +       head_commit = lookup_commit_reference_by_name(\"HEAD\");\n> +       if (!head_commit) {\n> +               ret = error(_(\"cannot look up HEAD\"));\n> +               goto out;\n> +       }\n> +\n> +       head_tree = repo_get_commit_tree(repo, head_commit);\n> +       if (!head_tree) {\n> +               ret = error(_(\"cannot get tree for HEAD\"));\n> +               goto out;\n> +       }\n> +\n> +       if (repo_read_index(repo) < 0) {\n> +               ret = error(_(\"unable to read index\"));\n> +               goto out;\n> +       }\n> +\n> +       if (!repo_index_has_changes(repo, head_tree, NULL)) {\n> +               ret = error(_(\"nothing to fixup: no staged changes\"));\n> +               goto out;\n> +       }\n> +\n> +       /*\n> +        * Write the index as a tree object. This is the \"theirs\" side of the\n> +        * three-way merge: it is HEAD's tree with the staged changes applied.\n> +        */\n> +       index_tree = write_in_core_index_as_tree(repo, repo->index);\n> +       if (!index_tree) {\n> +               ret = error(_(\"unable to write index as a tree\"));\n> +               goto out;\n> +       }\n> +\n> +       original_tree = repo_get_commit_tree(repo, original);\n> +       if (!original_tree) {\n> +               ret = error(_(\"cannot get tree for commit %s\"), argv[0]);\n> +               goto out;\n> +       }\n> +\n> +       /*\n> +        * Perform the three-way merge to reapply changes in the index onto the\n> +        * target commit. This is using basically the same logic as a\n> +        * cherry-pick, where the base commit is our HEAD, ours is the original\n> +        * tree and theirs is the index tree.\n> +        */\n\nOTOH, this explanation helps quite a bit here :)\n\n> +       init_basic_merge_options(&merge_opts, repo);\n> +       merge_opts.ancestor = \"HEAD\";\n> +       merge_opts.branch1 = argv[0];\n> +       merge_opts.branch2 = \"staged\";\n> +       merge_incore_nonrecursive(&merge_opts, head_tree,\n> +                                 original_tree, index_tree, &merge_result);\n> +\n> +       if (merge_result.clean < 0) {\n> +               ret = error(_(\"merge failed while applying fixup\"));\n> +               goto out;\n> +       }\n> +\n> +       if (!merge_result.clean) {\n> +               ret = error(_(\"fixup would produce conflicts; aborting\"));\n> +               goto out;\n> +       }\n> +\n> +       ret = setup_revwalk(repo, action, original, &revs);\n> +       if (ret)\n> +               goto out;\n> +\n> +       ret = commit_tree_ext(repo, \"fixup\", original, original->parents,\n> +                             &original_tree->object.oid, &merge_result.tree->object.oid,\n> +                             &rewritten, flags);\n> +       if (ret < 0) {\n> +               ret = error(_(\"failed writing fixed-up commit\"));\n> +               goto out;\n> +       }\n> +\n> +       strbuf_addf(&reflog_msg, \"fixup: updating %s\", argv[0]);\n> +\n> +       ret = handle_reference_updates(&revs, action, original, rewritten,\n> +                                      reflog_msg.buf, dry_run);\n> +       if (ret < 0) {\n> +               ret = error(_(\"failed replaying descendants\"));\n> +               goto out;\n> +       }\n> +\n> +       ret = 0;\n> +\n> +out:\n> +       merge_finalize(&merge_opts, &merge_result);\n> +       strbuf_release(&reflog_msg);\n> +       release_revisions(&revs);\n> +       return ret;\n> +}\n> +\n>  static int cmd_history_reword(int argc,\n>                               const char **argv,\n>                               const char *prefix,\n> @@ -745,12 +896,14 @@ int cmd_history(int argc,\n>                 struct repository *repo)\n>  {\n>         const char * const usage[] = {\n> +               GIT_HISTORY_FIXUP_USAGE,\n>                 GIT_HISTORY_REWORD_USAGE,\n>                 GIT_HISTORY_SPLIT_USAGE,\n>                 NULL,\n>         };\n>         parse_opt_subcommand_fn *fn = NULL;\n>         struct option options[] = {\n> +               OPT_SUBCOMMAND(\"fixup\", &fn, cmd_history_fixup),\n>                 OPT_SUBCOMMAND(\"reword\", &fn, cmd_history_reword),\n>                 OPT_SUBCOMMAND(\"split\", &fn, cmd_history_split),\n>                 OPT_END(),\n> diff --git a/t/meson.build b/t/meson.build\n> index 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',\n> diff --git a/t/t3453-history-fixup.sh b/t/t3453-history-fixup.sh\n> new file mode 100755\n> index 0000000000..0012b1f052\n> --- /dev/null\n> +++ b/t/t3453-history-fixup.sh\n> @@ -0,0 +1,500 @@\n> +#!/bin/sh\n> +\n> +test_description='tests for git-history fixup subcommand'\n> +\n> +. ./test-lib.sh\n> +\n> +fixup_with_message () {\n> +       cat >message &&\n> +       write_script fake-editor.sh <<-\\EOF &&\n> +       cp message \"$1\"\n> +       EOF\n> +       test_set_editor \"$(pwd)\"/fake-editor.sh &&\n> +       git history fixup --reedit-message \"$@\" &&\n> +       rm fake-editor.sh message\n> +}\n> +\n> +expect_changes () {\n> +       git log --format=\"%s\" --numstat \"$@\" >actual.raw &&\n> +       sed '/^$/d' <actual.raw >actual &&\n> +       cat >expect &&\n> +       test_cmp expect actual\n> +}\n> +\n> +test_expect_success 'errors on missing commit argument' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +               test_commit initial &&\n> +               test_must_fail git history fixup 2>err &&\n> +               test_grep \"command expects a single revision\" err\n> +       )\n> +'\n> +\n> +test_expect_success 'errors on too many arguments' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +               test_commit initial &&\n> +               test_must_fail git history fixup HEAD HEAD 2>err &&\n> +               test_grep \"command expects a single revision\" err\n> +       )\n> +'\n> +\n> +test_expect_success 'errors on unknown revision' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +               test_commit initial &&\n> +               test_must_fail git history fixup does-not-exist 2>err &&\n> +               test_grep \"commit cannot be found: does-not-exist\" err\n> +       )\n> +'\n> +\n> +test_expect_success 'errors when nothing is staged' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +               test_commit initial &&\n> +               test_must_fail git history fixup HEAD 2>err &&\n> +               test_grep \"nothing to fixup: no staged changes\" err\n> +       )\n> +'\n> +\n> +test_expect_success 'errors in a bare repository' '\n> +       test_when_finished \"rm -rf repo repo.git\" &&\n> +       git init repo &&\n> +       test_commit -C repo initial &&\n> +       git clone --bare repo repo.git &&\n> +       test_must_fail git -C repo.git history fixup HEAD 2>err &&\n> +       test_grep \"cannot run fixup in a bare repository\" err\n> +'\n> +\n> +test_expect_success 'can fixup the tip commit' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +               test_commit initial &&\n> +               echo content >file.txt &&\n> +               git add file.txt &&\n> +               git commit -m \"add file\" &&\n> +\n> +               echo fix >>file.txt &&\n> +               git add file.txt &&\n> +\n> +               expect_changes <<-\\EOF &&\n> +               add file\n> +               1       0       file.txt\n> +               initial\n> +               1       0       initial.t\n> +               EOF\n> +\n> +               git symbolic-ref HEAD >branch-expect &&\n> +               git history fixup HEAD &&\n> +               git symbolic-ref HEAD >branch-actual &&\n> +               test_cmp branch-expect branch-actual &&\n> +\n> +               expect_changes <<-\\EOF &&\n> +               add file\n> +               2       0       file.txt\n> +               initial\n> +               1       0       initial.t\n> +               EOF\n> +\n> +               # Verify the fix is in the tip commit tree\n> +               git show HEAD:file.txt >actual &&\n> +               printf \"content\\nfix\\n\" >expect &&\n> +               test_cmp expect actual &&\n> +\n> +               git reflog >reflog &&\n> +               test_grep \"fixup: updating HEAD\" reflog\n> +       )\n> +'\n> +\n> +test_expect_success 'can fixup a commit in the middle of history' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +               test_commit first &&\n> +               echo content >file.txt &&\n> +               git add file.txt &&\n> +               git commit -m \"add file\" &&\n> +               test_commit third &&\n> +\n> +               echo fix >>file.txt &&\n> +               git add file.txt &&\n> +\n> +               expect_changes <<-\\EOF &&\n> +               third\n> +               1       0       third.t\n> +               add file\n> +               1       0       file.txt\n> +               first\n> +               1       0       first.t\n> +               EOF\n> +\n> +               git history fixup HEAD~ &&\n> +\n> +               expect_changes <<-\\EOF &&\n> +               third\n> +               1       0       third.t\n> +               add file\n> +               2       0       file.txt\n> +               first\n> +               1       0       first.t\n> +               EOF\n> +\n> +               # Verify the fix landed in the \"add file\" commit.\n> +               git show HEAD~:file.txt >actual &&\n> +               printf \"content\\nfix\\n\" >expect &&\n> +               test_cmp expect actual &&\n> +\n> +               # And verify that the replayed commit also has the change.\n> +               git show HEAD:file.txt >actual &&\n> +               printf \"content\\nfix\\n\" >expect &&\n> +               test_cmp expect actual\n> +       )\n> +'\n> +\n> +test_expect_success 'can fixup root commit' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +               echo initial >root.txt &&\n> +               git add root.txt &&\n> +               git commit -m \"root\" &&\n> +               test_commit second &&\n> +\n> +               expect_changes <<-\\EOF &&\n> +               second\n> +               1       0       second.t\n> +               root\n> +               1       0       root.txt\n> +               EOF\n> +\n> +               echo fix >>root.txt &&\n> +               git add root.txt &&\n> +               git history fixup HEAD~ &&\n> +\n> +               expect_changes <<-\\EOF &&\n> +               second\n> +               1       0       second.t\n> +               root\n> +               2       0       root.txt\n> +               EOF\n> +\n> +               git show HEAD~:root.txt >actual &&\n> +               printf \"initial\\nfix\\n\" >expect &&\n> +               test_cmp expect actual\n> +       )\n> +'\n> +\n> +test_expect_success 'preserves commit message and authorship' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +               test_commit initial &&\n> +               echo content >file.txt &&\n> +               git add file.txt &&\n> +               git commit --author=\"Original <original@example.com>\" -m \"original message\" &&\n> +\n> +               echo fix >>file.txt &&\n> +               git add file.txt &&\n> +               git history fixup HEAD &&\n> +\n> +               # Message preserved\n> +               git log -1 --format=\"%s\" >actual &&\n> +               echo \"original message\" >expect &&\n> +               test_cmp expect actual &&\n> +\n> +               # Authorship preserved\n> +               git log -1 --format=\"%an <%ae>\" >actual &&\n> +               echo \"Original <original@example.com>\" >expect &&\n> +               test_cmp expect actual\n> +       )\n> +'\n> +\n> +test_expect_success 'updates all descendant branches by default' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo --initial-branch=main &&\n> +       (\n> +               cd repo &&\n> +               test_commit base &&\n> +               git branch branch &&\n> +               test_commit ours &&\n> +               git switch branch &&\n> +               test_commit theirs &&\n> +               git switch main &&\n> +\n> +               echo fix >fix.txt &&\n> +               git add fix.txt &&\n> +               git history fixup base &&\n> +\n> +               expect_changes --branches <<-\\EOF &&\n> +               theirs\n> +               1       0       theirs.t\n> +               ours\n> +               1       0       ours.t\n> +               base\n> +               1       0       base.t\n> +               1       0       fix.txt\n> +               EOF\n> +\n> +               # Both branches should have the fix in the base\n> +               git show main~:fix.txt >actual &&\n> +               echo fix >expect &&\n> +               test_cmp expect actual &&\n> +               git show branch~:fix.txt >actual &&\n> +               test_cmp expect actual\n> +       )\n> +'\n> +\n> +test_expect_success 'can fixup commit on a different branch' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +               test_commit base &&\n> +               git branch theirs &&\n> +               test_commit ours &&\n> +               git switch theirs &&\n> +               test_commit theirs &&\n> +\n> +               # Stage a change while on \"theirs\"\n> +               echo fix >fix.txt &&\n> +               git add fix.txt &&\n> +\n> +               # Ensure that \"ours\" does not change, as it does not contain\n> +               # the commit in question.\n> +               git rev-parse ours >ours-before &&\n> +               git history fixup theirs &&\n> +               git rev-parse ours >ours-after &&\n> +               test_cmp ours-before ours-after &&\n> +\n> +               git show HEAD:fix.txt >actual &&\n> +               echo fix >expect &&\n> +               test_cmp expect actual\n> +       )\n> +'\n> +\n> +test_expect_success '--dry-run prints ref updates without modifying repo' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo --initial-branch=main &&\n> +       (\n> +               cd repo &&\n> +               test_commit base &&\n> +               git branch branch &&\n> +               test_commit main-tip &&\n> +               git switch branch &&\n> +               test_commit branch-tip &&\n> +               git switch main &&\n> +\n> +               echo fix >fix.txt &&\n> +               git add fix.txt &&\n> +\n> +               git refs list >refs-before &&\n> +               git history fixup --dry-run base >updates &&\n> +               git refs list >refs-after &&\n> +               test_cmp refs-before refs-after &&\n> +\n> +               test_grep \"update refs/heads/main\" updates &&\n> +               test_grep \"update refs/heads/branch\" updates &&\n> +\n> +               expect_changes --branches <<-\\EOF &&\n> +               branch-tip\n> +               1       0       branch-tip.t\n> +               main-tip\n> +               1       0       main-tip.t\n> +               base\n> +               1       0       base.t\n> +               EOF\n> +\n> +               git update-ref --stdin <updates &&\n> +               expect_changes --branches <<-\\EOF\n> +               branch-tip\n> +               1       0       branch-tip.t\n> +               main-tip\n> +               1       0       main-tip.t\n> +               base\n> +               1       0       base.t\n> +               1       0       fix.txt\n> +               EOF\n> +       )\n> +'\n> +\n> +test_expect_success '--update-refs=head updates only HEAD' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo --initial-branch=main &&\n> +       (\n> +               cd repo &&\n> +               test_commit base &&\n> +               git branch branch &&\n> +               test_commit main-tip &&\n> +               git switch branch &&\n> +               test_commit branch-tip &&\n> +\n> +               echo fix >fix.txt &&\n> +               git add fix.txt &&\n> +\n> +               # Only HEAD (branch) should be updated\n> +               git history fixup --update-refs=head base &&\n> +\n> +               # The main branch should be unaffected.\n> +               expect_changes main <<-\\EOF &&\n> +               main-tip\n> +               1       0       main-tip.t\n> +               base\n> +               1       0       base.t\n> +               EOF\n> +\n> +               # But the currently checked out branch should be modified.\n> +               expect_changes branch <<-\\EOF\n> +               branch-tip\n> +               1       0       branch-tip.t\n> +               base\n> +               1       0       base.t\n> +               1       0       fix.txt\n> +               EOF\n> +       )\n> +'\n> +\n> +test_expect_success '--update-refs=head refuses to rewrite commits not in HEAD ancestry' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo --initial-branch=main &&\n> +       (\n> +               cd repo &&\n> +               test_commit base &&\n> +               git branch other &&\n> +               test_commit main-tip &&\n> +               git switch other &&\n> +               test_commit other-tip &&\n> +\n> +               echo fix >fix.txt &&\n> +               git add fix.txt &&\n> +\n> +               test_must_fail git history fixup --update-refs=head main-tip 2>err &&\n> +               test_grep \"rewritten commit must be an ancestor of HEAD\" err\n> +       )\n> +'\n> +\n> +test_expect_success 'aborts when fixup would produce conflicts' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +\n> +               echo \"line one\" >file.txt &&\n> +               git add file.txt &&\n> +               git commit -m \"first\" &&\n> +\n> +               echo \"line two\" >file.txt &&\n> +               git add file.txt &&\n> +               git commit -m \"second\" &&\n> +\n> +               echo \"conflicting change\" >file.txt &&\n> +               git add file.txt &&\n> +\n> +               git refs list >refs-before &&\n> +               test_must_fail git history fixup HEAD~ 2>err &&\n> +               test_grep \"fixup would produce conflicts\" err &&\n> +               git refs list >refs-after &&\n> +               test_cmp refs-before refs-after\n> +       )\n> +'\n> +\n> +test_expect_success '--reedit-message opens editor for the commit message' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +               test_commit initial &&\n> +               echo content >file.txt &&\n> +               git add file.txt &&\n> +               git commit -m \"add file\" &&\n> +\n> +               echo fix >>file.txt &&\n> +               git add file.txt &&\n> +\n> +               fixup_with_message HEAD <<-\\EOF &&\n> +               add file with fix\n> +               EOF\n> +\n> +               expect_changes --branches <<-\\EOF\n> +               add file with fix\n> +               2       0       file.txt\n> +               initial\n> +               1       0       initial.t\n> +               EOF\n> +       )\n> +'\n> +\n> +test_expect_success 'retains unstaged working tree changes after fixup' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +               touch a b &&\n> +               git add . &&\n> +               git commit -m \"initial commit\" &&\n> +               echo staged >a &&\n> +               echo unstaged >b &&\n> +               git add a &&\n> +               git history fixup HEAD &&\n> +\n> +               # b is still modified in the worktree but not staged\n> +               cat >expect <<-\\EOF &&\n> +                M b\n> +               EOF\n> +               git status --porcelain --untracked-files=no >actual &&\n> +               test_cmp expect actual\n> +       )\n> +'\n> +\n> +test_expect_success 'index is clean after fixup when target is HEAD' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +\n> +               test_commit initial &&\n> +               echo fix >fix.txt &&\n> +               git add fix.txt &&\n> +               git history fixup HEAD &&\n> +\n> +               git status --porcelain --untracked-files=no >actual &&\n> +               test_must_be_empty actual\n> +       )\n> +'\n> +\n> +test_expect_success 'index is unchanged on conflict' '\n> +       test_when_finished \"rm -rf repo\" &&\n> +       git init repo &&\n> +       (\n> +               cd repo &&\n> +\n> +               echo base >file.txt &&\n> +               git add file.txt &&\n> +               git commit -m base &&\n> +               echo change >file.txt &&\n> +               git add file.txt &&\n> +               git commit -m change &&\n> +\n> +               echo conflict >file.txt &&\n> +               git add file.txt &&\n> +\n> +               git diff --cached >index-before &&\n> +               test_must_fail git history fixup HEAD~ &&\n> +               git diff --cached >index-after &&\n> +               test_cmp index-before index-after\n> +       )\n> +'\n> +\n> +test_done\n>\n> --\n> 2.54.0.545.g6539524ca2.dirty\n>\n>\n\nThanks\n\n-- \nD. Ben Knoble\n"},{"id":"542178","messageId":"aenCRKxak1l6GE3H@pks.im","threadId":"65534","inReplyTo":"CALnO6CCBA=OSvKT8D6-YR1S=x3VOa_MpzWfK6FJWPSXq0ysMPg@mail.gmail.com","subject":"Re: [PATCH 2/2] builtin/history: introduce \"fixup\" subcommand","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-23T06:55:00Z","receivedAt":"2026-04-23T06:55:06Z","isPatch":true,"body":"On Wed, Apr 22, 2026 at 03:06:12PM -0400, D. Ben Knoble wrote:\n> On Wed, Apr 22, 2026 at 6:30 AM Patrick Steinhardt <ps@pks.im> wrote:\n> > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\n> > index 24dc907033..3cdfc8ba02 100644\n> > --- a/Documentation/git-history.adoc\n> > +++ b/Documentation/git-history.adoc\n> > @@ -53,6 +55,19 @@ COMMANDS\n> >\n> >  The following commands are available to rewrite history in different ways:\n> >\n> > +`fixup <commit>`::\n> > +       Apply the currently staged changes to the specified commit. The staged\n> > +       changes are incorporated into the target commit's tree via a three-way\n> > +       merge, using HEAD's tree as the merge base, which is equivalent to\n> > +       linkgit:git-cherry-pick[1].\n> \n> I'm not quite sure what, as a user of \"git history fixup,\" I'm\n> supposed to take from this. Does it make conflicts less likely when\n> creating the new fixup? I imagine it doesn't help with conflicts\n> between <commit> and HEAD that newly arise.\n> \n> Anyway, I'd think the mechanics are less relevant than the end-user\n> behavior at this point in the doc, unless the equivalence with\n> cherry-pick is supposed to tell me something about that behavior.\n\nThere's at least two more or less obvious variants to do this:\n\n  - You generate the diff between HEAD and index and then try to reapply\n    the patch on top of the target commit.\n\n  - You perform the three-way merge.\n\nThe second item is definitely more robust compared to generating the\ndiff and reapplying it, and we use the exact same strategy to perform\ncherry-picks nowadays.\n\n> > diff --git a/builtin/history.c b/builtin/history.c\n> > index 549e352c74..6299f0dfa9 100644\n> > --- a/builtin/history.c\n> > +++ b/builtin/history.c\n[snip]\n> > +       /*\n> > +        * Perform the three-way merge to reapply changes in the index onto the\n> > +        * target commit. This is using basically the same logic as a\n> > +        * cherry-pick, where the base commit is our HEAD, ours is the original\n> > +        * tree and theirs is the index tree.\n> > +        */\n> \n> OTOH, this explanation helps quite a bit here :)\n\nHm, okay. I felt that this explanation here is even more technical. How\nabout:\n\n    `fixup <commit>`::\n        Apply the currently staged changes to the specified commit. This\n        is done by performing a three-way merge between the HEAD commit,\n        the target commit and the tree generated from staged changes.\n        This is using the same logic as linkgit:git-cherry-pick[1].\n\nNot sure that this is an improvement? Happy to hear other suggestions.\n\nThanks!\n\nPatrick\n"},{"id":"542179","messageId":"aenCV2w4qzGj5t-8@pks.im","threadId":"65534","inReplyTo":"d2b19306-71e9-4e17-a0c0-83309a00bd45@malon.dev","subject":"Re: [PATCH 0/2] builtin/history: introduce \"fixup\" subcommand","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-23T06:55:19Z","receivedAt":"2026-04-23T06:55:24Z","isPatch":true,"body":"On Thu, Apr 23, 2026 at 02:18:16AM +0800, Tian Yuchen wrote:\n> Hi Patrick,\n> \n> On 4/22/26 18:28, Patrick Steinhardt wrote:\n> \n> > Hi,\n> > \n> > this short patch series introduces a new \"fixup\" subcommand. This\n> > command is the first one that I felt is missing in my day to day work,\n> > as I end up doing fixup commits quite often.\n> > \n> > The flow is rather simple: the user stages some changes, and then they\n> > execute `git history fixup <commit>` to amend those changes to the given\n> > commit. As with the other subcommands, dependent branches will then be\n> > rebased automatically.\n> > \n> > This is the first command that may result in merge conflicts. For now we\n> > simply abort in such cases, but there are plans to introduce first-class\n> > conflicts into Git. So once we have them, we'll also be able to handle\n> > such cases more gracefully. I still think that the command is useful\n> > even without that conflict handling.\n> \n> Thank you for developing this feature. Godsend for lazy people like me ;)\n> \n> Nevertheless, I seem to have come across what appears to be a bug. I carried\n> out the following steps:\n> \n> \tcreate a.txt -> git add -> git commit -m \"base\" ->\n> \n> \tcreate b.txt -> git add -> git commit -m \"feature\" ->\n> \n> \tcreate c.txt -> git add -> git commit -m \"tip\" ->\n> \n> \trm b.txt -> git add ->\n> \n> \tgit history fixup HEAD~ ->\n> \n> \tgit log --oneline --stat...\n> \n> And the output looks like:\n> \n> \t3096a65 (HEAD -> master) tip\n> \t c.txt | 1 +\n> \t 1 file changed, 1 insertion(+)\n> \t699f610 feature\n> \t0be07e6 base\n> \t a.txt | 1 +\n>  \t1 file changed, 1 insertion(+)\n> \n> More specifically, the output of\n> \n> \tgit show HEAD~\n> \n> is:\n> \n> \tAuthor: Tian Yuchen <cat@malon.dev>\n> \tDate:   Thu Apr 23 01:57:17 2026 +0800\n> \n>  \t   feature\n> \n> which is an empty commit. Is it what we expect to see? Sorry that I don't\n> have enough time to look at the code in detail :P\n\nI guess the answer is \"maybe\". I think it would most sense if we had the\nequivalent of `--empty=(drop|keep|stop)` that we also have in\ngit-rebase(1).\n\nThanks!\n\nPatrick\n"},{"id":"542227","messageId":"CALnO6CAZQxvqEqDhahFs7NcjENrU=Dg=cbFDkEeAE3+h_3R+8g@mail.gmail.com","threadId":"65534","inReplyTo":"aenCRKxak1l6GE3H@pks.im","subject":"Re: [PATCH 2/2] builtin/history: introduce \"fixup\" subcommand","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-04-23T21:18:50Z","receivedAt":"2026-04-23T21:19:02Z","isPatch":true,"body":"On Thu, Apr 23, 2026 at 2:55 AM Patrick Steinhardt <ps@pks.im> wrote:\n>\n> On Wed, Apr 22, 2026 at 03:06:12PM -0400, D. Ben Knoble wrote:\n> > On Wed, Apr 22, 2026 at 6:30 AM Patrick Steinhardt <ps@pks.im> wrote:\n> > > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\n> > > index 24dc907033..3cdfc8ba02 100644\n> > > --- a/Documentation/git-history.adoc\n> > > +++ b/Documentation/git-history.adoc\n> > > @@ -53,6 +55,19 @@ COMMANDS\n> > >\n> > >  The following commands are available to rewrite history in different ways:\n> > >\n> > > +`fixup <commit>`::\n> > > +       Apply the currently staged changes to the specified commit. The staged\n> > > +       changes are incorporated into the target commit's tree via a three-way\n> > > +       merge, using HEAD's tree as the merge base, which is equivalent to\n> > > +       linkgit:git-cherry-pick[1].\n> >\n> > I'm not quite sure what, as a user of \"git history fixup,\" I'm\n> > supposed to take from this. Does it make conflicts less likely when\n> > creating the new fixup? I imagine it doesn't help with conflicts\n> > between <commit> and HEAD that newly arise.\n> >\n> > Anyway, I'd think the mechanics are less relevant than the end-user\n> > behavior at this point in the doc, unless the equivalence with\n> > cherry-pick is supposed to tell me something about that behavior.\n>\n> There's at least two more or less obvious variants to do this:\n>\n>   - You generate the diff between HEAD and index and then try to reapply\n>     the patch on top of the target commit.\n>\n>   - You perform the three-way merge.\n>\n> The second item is definitely more robust compared to generating the\n> diff and reapplying it, and we use the exact same strategy to perform\n> cherry-picks nowadays.\n>\n> > > diff --git a/builtin/history.c b/builtin/history.c\n> > > index 549e352c74..6299f0dfa9 100644\n> > > --- a/builtin/history.c\n> > > +++ b/builtin/history.c\n> [snip]\n> > > +       /*\n> > > +        * Perform the three-way merge to reapply changes in the index onto the\n> > > +        * target commit. This is using basically the same logic as a\n> > > +        * cherry-pick, where the base commit is our HEAD, ours is the original\n> > > +        * tree and theirs is the index tree.\n> > > +        */\n> >\n> > OTOH, this explanation helps quite a bit here :)\n>\n> Hm, okay. I felt that this explanation here is even more technical. How\n> about:\n>\n>     `fixup <commit>`::\n>         Apply the currently staged changes to the specified commit. This\n>         is done by performing a three-way merge between the HEAD commit,\n>         the target commit and the tree generated from staged changes.\n>         This is using the same logic as linkgit:git-cherry-pick[1].\n>\n> Not sure that this is an improvement? Happy to hear other suggestions.\n>\n> Thanks!\n>\n> Patrick\n\nHm. I think what I meant is that the in-code comment makes sense to\ndescribe internals; for users, I'm not sure what I should get out of\nthat description of fixup.\n\nWhat I (think I) really care about is that it behaves a bit like `git\nrebase -i` with a \"fixup\" command (modulo conflicts). Especially since\nthis is quite a bit more porcelain than plumbing, no?\n\nIdk. If the 3-way merge is valuable to keep, maybe it belongs in a\nsecond paragraph just to push it out of the way of the primary\ndescription (\"Apply the currently staged changes to the specified\ncommit\")?\n\nThanks.\n\n-- \nD. Ben Knoble\n"},{"id":"542237","messageId":"aesTeWQqMTFd4gy8@pks.im","threadId":"65534","inReplyTo":"CALnO6CAZQxvqEqDhahFs7NcjENrU=Dg=cbFDkEeAE3+h_3R+8g@mail.gmail.com","subject":"Re: [PATCH 2/2] builtin/history: introduce \"fixup\" subcommand","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-24T06:53:45Z","receivedAt":"2026-04-24T06:53:52Z","isPatch":true,"body":"On Thu, Apr 23, 2026 at 05:18:50PM -0400, D. Ben Knoble wrote:\n> On Thu, Apr 23, 2026 at 2:55 AM Patrick Steinhardt <ps@pks.im> wrote:\n> > On Wed, Apr 22, 2026 at 03:06:12PM -0400, D. Ben Knoble wrote:\n> > > On Wed, Apr 22, 2026 at 6:30 AM Patrick Steinhardt <ps@pks.im> wrote:\n> > > > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\n> > > > index 24dc907033..3cdfc8ba02 100644\n> > > > --- a/Documentation/git-history.adoc\n> > > > +++ b/Documentation/git-history.adoc\n> > > > @@ -53,6 +55,19 @@ COMMANDS\n> > > >\n> > > >  The following commands are available to rewrite history in different ways:\n> > > >\n> > > > +`fixup <commit>`::\n> > > > +       Apply the currently staged changes to the specified commit. The staged\n> > > > +       changes are incorporated into the target commit's tree via a three-way\n> > > > +       merge, using HEAD's tree as the merge base, which is equivalent to\n> > > > +       linkgit:git-cherry-pick[1].\n> > >\n> > > I'm not quite sure what, as a user of \"git history fixup,\" I'm\n> > > supposed to take from this. Does it make conflicts less likely when\n> > > creating the new fixup? I imagine it doesn't help with conflicts\n> > > between <commit> and HEAD that newly arise.\n> > >\n> > > Anyway, I'd think the mechanics are less relevant than the end-user\n> > > behavior at this point in the doc, unless the equivalence with\n> > > cherry-pick is supposed to tell me something about that behavior.\n> >\n> > There's at least two more or less obvious variants to do this:\n> >\n> >   - You generate the diff between HEAD and index and then try to reapply\n> >     the patch on top of the target commit.\n> >\n> >   - You perform the three-way merge.\n> >\n> > The second item is definitely more robust compared to generating the\n> > diff and reapplying it, and we use the exact same strategy to perform\n> > cherry-picks nowadays.\n> >\n> > > > diff --git a/builtin/history.c b/builtin/history.c\n> > > > index 549e352c74..6299f0dfa9 100644\n> > > > --- a/builtin/history.c\n> > > > +++ b/builtin/history.c\n> > [snip]\n> > > > +       /*\n> > > > +        * Perform the three-way merge to reapply changes in the index onto the\n> > > > +        * target commit. This is using basically the same logic as a\n> > > > +        * cherry-pick, where the base commit is our HEAD, ours is the original\n> > > > +        * tree and theirs is the index tree.\n> > > > +        */\n> > >\n> > > OTOH, this explanation helps quite a bit here :)\n> >\n> > Hm, okay. I felt that this explanation here is even more technical. How\n> > about:\n> >\n> >     `fixup <commit>`::\n> >         Apply the currently staged changes to the specified commit. This\n> >         is done by performing a three-way merge between the HEAD commit,\n> >         the target commit and the tree generated from staged changes.\n> >         This is using the same logic as linkgit:git-cherry-pick[1].\n> >\n> > Not sure that this is an improvement? Happy to hear other suggestions.\n> >\n> > Thanks!\n> >\n> > Patrick\n> \n> Hm. I think what I meant is that the in-code comment makes sense to\n> describe internals; for users, I'm not sure what I should get out of\n> that description of fixup.\n> \n> What I (think I) really care about is that it behaves a bit like `git\n> rebase -i` with a \"fixup\" command (modulo conflicts). Especially since\n> this is quite a bit more porcelain than plumbing, no?\n> \n> Idk. If the 3-way merge is valuable to keep, maybe it belongs in a\n> second paragraph just to push it out of the way of the primary\n> description (\"Apply the currently staged changes to the specified\n> commit\")?\n\nAh, that's what you're getting at! I totally misunderstood what you\nwanted to say, this makes a lot more sense. How about this:\n\n    `fixup <commit>`::\n        Apply the currently staged changes to the specified commit. This\n        is similar in nature to `git commit --fixup=<commit>` followed\n        by `git rebase --autosquash <commit>~`. Changes are applied to\n        the target commit by performing a three-way merge between the\n        HEAD commit, the target commit and the tree generated from\n        staged changes.\n\nMaybe there should be a new paragraph before we start talking about the\ntechnical details?\n\nThanks!\n\nPatrick\n"},{"id":"542249","messageId":"CALnO6CCkdoCFfyq97hUNzx2DaBNSHBpCUWfuq2LW1Jfzt2jfEw@mail.gmail.com","threadId":"65534","inReplyTo":"aesTeWQqMTFd4gy8@pks.im","subject":"Re: [PATCH 2/2] builtin/history: introduce \"fixup\" subcommand","fromName":"D. Ben Knoble","fromEmail":"ben.knoble@gmail.com","sentAt":"2026-04-24T14:43:35Z","receivedAt":"2026-04-24T14:43:48Z","isPatch":true,"body":"On Fri, Apr 24, 2026 at 2:53 AM Patrick Steinhardt <ps@pks.im> wrote:\n>\n> On Thu, Apr 23, 2026 at 05:18:50PM -0400, D. Ben Knoble wrote:\n> > On Thu, Apr 23, 2026 at 2:55 AM Patrick Steinhardt <ps@pks.im> wrote:\n> > > On Wed, Apr 22, 2026 at 03:06:12PM -0400, D. Ben Knoble wrote:\n> > > > On Wed, Apr 22, 2026 at 6:30 AM Patrick Steinhardt <ps@pks.im> wrote:\n> > > > > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\n> > > > > index 24dc907033..3cdfc8ba02 100644\n> > > > > --- a/Documentation/git-history.adoc\n> > > > > +++ b/Documentation/git-history.adoc\n> > > > > @@ -53,6 +55,19 @@ COMMANDS\n> > > > >\n> > > > >  The following commands are available to rewrite history in different ways:\n> > > > >\n> > > > > +`fixup <commit>`::\n> > > > > +       Apply the currently staged changes to the specified commit. The staged\n> > > > > +       changes are incorporated into the target commit's tree via a three-way\n> > > > > +       merge, using HEAD's tree as the merge base, which is equivalent to\n> > > > > +       linkgit:git-cherry-pick[1].\n> > > >\n> > > > I'm not quite sure what, as a user of \"git history fixup,\" I'm\n> > > > supposed to take from this. Does it make conflicts less likely when\n> > > > creating the new fixup? I imagine it doesn't help with conflicts\n> > > > between <commit> and HEAD that newly arise.\n> > > >\n> > > > Anyway, I'd think the mechanics are less relevant than the end-user\n> > > > behavior at this point in the doc, unless the equivalence with\n> > > > cherry-pick is supposed to tell me something about that behavior.\n> > >\n> > > There's at least two more or less obvious variants to do this:\n> > >\n> > >   - You generate the diff between HEAD and index and then try to reapply\n> > >     the patch on top of the target commit.\n> > >\n> > >   - You perform the three-way merge.\n> > >\n> > > The second item is definitely more robust compared to generating the\n> > > diff and reapplying it, and we use the exact same strategy to perform\n> > > cherry-picks nowadays.\n> > >\n> > > > > diff --git a/builtin/history.c b/builtin/history.c\n> > > > > index 549e352c74..6299f0dfa9 100644\n> > > > > --- a/builtin/history.c\n> > > > > +++ b/builtin/history.c\n> > > [snip]\n> > > > > +       /*\n> > > > > +        * Perform the three-way merge to reapply changes in the index onto the\n> > > > > +        * target commit. This is using basically the same logic as a\n> > > > > +        * cherry-pick, where the base commit is our HEAD, ours is the original\n> > > > > +        * tree and theirs is the index tree.\n> > > > > +        */\n> > > >\n> > > > OTOH, this explanation helps quite a bit here :)\n> > >\n> > > Hm, okay. I felt that this explanation here is even more technical. How\n> > > about:\n> > >\n> > >     `fixup <commit>`::\n> > >         Apply the currently staged changes to the specified commit. This\n> > >         is done by performing a three-way merge between the HEAD commit,\n> > >         the target commit and the tree generated from staged changes.\n> > >         This is using the same logic as linkgit:git-cherry-pick[1].\n> > >\n> > > Not sure that this is an improvement? Happy to hear other suggestions.\n> > >\n> > > Thanks!\n> > >\n> > > Patrick\n> >\n> > Hm. I think what I meant is that the in-code comment makes sense to\n> > describe internals; for users, I'm not sure what I should get out of\n> > that description of fixup.\n> >\n> > What I (think I) really care about is that it behaves a bit like `git\n> > rebase -i` with a \"fixup\" command (modulo conflicts). Especially since\n> > this is quite a bit more porcelain than plumbing, no?\n> >\n> > Idk. If the 3-way merge is valuable to keep, maybe it belongs in a\n> > second paragraph just to push it out of the way of the primary\n> > description (\"Apply the currently staged changes to the specified\n> > commit\")?\n>\n> Ah, that's what you're getting at! I totally misunderstood what you\n> wanted to say, this makes a lot more sense. How about this:\n\nYep, sorry!\n\n>     `fixup <commit>`::\n>         Apply the currently staged changes to the specified commit. This\n>         is similar in nature to `git commit --fixup=<commit>` followed\n>         by `git rebase --autosquash <commit>~`. Changes are applied to\n>         the target commit by performing a three-way merge between the\n>         HEAD commit, the target commit and the tree generated from\n>         staged changes.\n\nI think that's much better.\n\n> Maybe there should be a new paragraph before we start talking about the\n> technical details?\n\nWith this version I could go either way :)\n\n> Thanks!\n>\n> Patrick\n\nThank you!\n\n-- \nD. Ben Knoble\n"},{"id":"542342","messageId":"20260427-b4-pks-history-fixup-v3-0-cb908f06264b@pks.im","threadId":"65534","inReplyTo":"20260422-b4-pks-history-fixup-v1-0-48d4484243de@pks.im","subject":"[PATCH v3 0/3] builtin/history: introduce \"fixup\" subcommand","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-27T05:53:50Z","receivedAt":"2026-04-27T05:54:00Z","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 v3:\n  - Some more polishing of the command's description.\n  - Link to v2: https://patch.msgid.link/20260423-b4-pks-history-fixup-v2-0-d7571c6d36eb@pks.im\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 |  78 ++++-\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, 1068 insertions(+), 30 deletions(-)\n\nRange-diff versus v2:\n\n1:  8840b18095 = 1:  81240d1959 replay: allow callers to control what happens with empty commits\n2:  b078354b5a = 2:  4f35bba868 builtin/history: generalize function to commit trees\n3:  3d1fec55c7 ! 3:  ecaded9415 builtin/history: introduce \"fixup\" subcommand\n    @@ Documentation/git-history.adoc: conflicts. This limitation is by design as histo\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    ++\tApply the currently staged changes to the specified commit. This is\n    ++\tsimilar in nature to `git commit --fixup=<commit>` followed by `git\n    ++\trebase --autosquash <commit>~`. Changes are applied to the target\n    ++\tcommit by performing a three-way merge between the HEAD commit, the\n    ++\ttarget commit and the tree generated from staged changes.\n     ++\n     +The commit message and authorship of the target commit are preserved by\n     +default, unless you specify `--reedit-message`.\n\n---\nbase-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0\nchange-id: 20260422-b4-pks-history-fixup-be27e0c4a03e\n\n"},{"id":"542343","messageId":"20260427-b4-pks-history-fixup-v3-1-cb908f06264b@pks.im","threadId":"65534","inReplyTo":"20260427-b4-pks-history-fixup-v3-0-cb908f06264b@pks.im","subject":"[PATCH v3 1/3] replay: allow callers to control what happens with empty commits","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-27T05:53:51Z","receivedAt":"2026-04-27T05:54:01Z","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":"542344","messageId":"20260427-b4-pks-history-fixup-v3-2-cb908f06264b@pks.im","threadId":"65534","inReplyTo":"20260427-b4-pks-history-fixup-v3-0-cb908f06264b@pks.im","subject":"[PATCH v3 2/3] builtin/history: generalize function to commit trees","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-27T05:53:52Z","receivedAt":"2026-04-27T05:54:04Z","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":"542345","messageId":"20260427-b4-pks-history-fixup-v3-3-cb908f06264b@pks.im","threadId":"65534","inReplyTo":"20260427-b4-pks-history-fixup-v3-0-cb908f06264b@pks.im","subject":"[PATCH v3 3/3] builtin/history: introduce \"fixup\" subcommand","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-04-27T05:53:53Z","receivedAt":"2026-04-27T05:54:07Z","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 |  78 ++++-\n builtin/history.c              | 246 ++++++++++++++-\n t/meson.build                  |   1 +\n t/t3453-history-fixup.sh       | 680 +++++++++++++++++++++++++++++++++++++++++\n 4 files changed, 999 insertions(+), 6 deletions(-)\n\ndiff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc\nindex 24dc907033..2ba8121795 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,28 @@ 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 is\n+\tsimilar in nature to `git commit --fixup=<commit>` followed by `git\n+\trebase --autosquash <commit>~`. Changes are applied to the target\n+\tcommit by performing a three-way merge between the HEAD commit, the\n+\ttarget commit and the tree generated from staged changes.\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 +106,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 +140,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"},{"id":"543132","messageId":"xmqq7bp9p6wz.fsf@gitster.g","threadId":"65534","inReplyTo":"20260427-b4-pks-history-fixup-v3-1-cb908f06264b@pks.im","subject":"Re: [PATCH v3 1/3] replay: allow callers to control what happens with empty commits","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-12T04:51:08Z","receivedAt":"2026-05-12T04:51:11Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\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\nOK.  merge-ort.h clearly explains what negative values in .clean\nmember means, so this is a good way to signal a failure up the\ncall chain.\n\n> +\t\t\treturn NULL;\n> +\t\t}\n> +\t}\n"},{"id":"543134","messageId":"xmqq33zxp4aq.fsf@gitster.g","threadId":"65534","inReplyTo":"20260427-b4-pks-history-fixup-v3-0-cb908f06264b@pks.im","subject":"Re: [PATCH v3 0/3] builtin/history: introduce \"fixup\" subcommand","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-12T05:47:41Z","receivedAt":"2026-05-12T05:47:46Z","isPatch":true,"body":"Patrick Steinhardt <ps@pks.im> writes:\n\n> this short patch series introduces a new \"fixup\" subcommand. This\n> command is the first one that I felt is missing in my day to day work,\n> as I end up doing fixup commits quite often.\n>\n> The flow is rather simple: the user stages some changes, and then they\n> execute `git history fixup <commit>` to amend those changes to the given\n> commit. As with the other subcommands, dependent branches will then be\n> rebased automatically.\n>\n> This is the first command that may result in merge conflicts. For now we\n> simply abort in such cases, but there are plans to introduce first-class\n> conflicts into Git. So once we have them, we'll also be able to handle\n> such cases more gracefully. I still think that the command is useful\n> even without that conflict handling.\n>\n> Changes in v3:\n>   - Some more polishing of the command's description.\n>   - Link to v2: https://patch.msgid.link/20260423-b4-pks-history-fixup-v2-0-d7571c6d36eb@pks.im\n>\n> Changes 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\nThe iterations v2 and v3 saw no comments, unfortunately.  I just\ngave three patches in v3 a cursory look and nothing stood out as\ncurious or fishy.  Shall we mark the topic for 'next' now?\n\n"},{"id":"543145","messageId":"agLLsbPx1zH7klvj@pks.im","threadId":"65534","inReplyTo":"xmqq33zxp4aq.fsf@gitster.g","subject":"Re: [PATCH v3 0/3] builtin/history: introduce \"fixup\" subcommand","fromName":"Patrick Steinhardt","fromEmail":"ps@pks.im","sentAt":"2026-05-12T06:41:53Z","receivedAt":"2026-05-12T06:42:02Z","isPatch":true,"body":"On Tue, May 12, 2026 at 02:47:41PM +0900, Junio C Hamano wrote:\n> Patrick Steinhardt <ps@pks.im> writes:\n> \n> > this short patch series introduces a new \"fixup\" subcommand. This\n> > command is the first one that I felt is missing in my day to day work,\n> > as I end up doing fixup commits quite often.\n> >\n> > The flow is rather simple: the user stages some changes, and then they\n> > execute `git history fixup <commit>` to amend those changes to the given\n> > commit. As with the other subcommands, dependent branches will then be\n> > rebased automatically.\n> >\n> > This is the first command that may result in merge conflicts. For now we\n> > simply abort in such cases, but there are plans to introduce first-class\n> > conflicts into Git. So once we have them, we'll also be able to handle\n> > such cases more gracefully. I still think that the command is useful\n> > even without that conflict handling.\n> >\n> > Changes in v3:\n> >   - Some more polishing of the command's description.\n> >   - Link to v2: https://patch.msgid.link/20260423-b4-pks-history-fixup-v2-0-d7571c6d36eb@pks.im\n> >\n> > Changes 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> \n> The iterations v2 and v3 saw no comments, unfortunately.  I just\n> gave three patches in v3 a cursory look and nothing stood out as\n> curious or fishy.  Shall we mark the topic for 'next' now?\n\nI didn't plan to post another iteration, so this works for me. Thanks!\n\nPatrick\n"}]}