* [PATCH 1/2] builtin/history: generalize function to commit trees
2026-04-22 10:28 [PATCH 0/2] builtin/history: introduce "fixup" subcommand Patrick Steinhardt
@ 2026-04-22 10:28 ` Patrick Steinhardt
2026-04-22 10:28 ` [PATCH 2/2] builtin/history: introduce "fixup" subcommand Patrick Steinhardt
` (3 subsequent siblings)
4 siblings, 0 replies; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-22 10:28 UTC (permalink / raw)
To: git; +Cc: Elijah Newren
The function `commit_tree_with_edited_message_ext()` can be used to
commit a tree with a specific list of parents with an edited commit
message. This function is useful outside of editing the commit message
though, as it also performs the plumbing to extract the original commit
message and strip some headers from it.
Refactor the function to receive a flags field that allows the caller to
control whether or not the commit message should be edited, or whether
it should be retained as-is. This will be used in a subsequent commit.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
builtin/history.c | 45 ++++++++++++++++++++++++++-------------------
1 file changed, 26 insertions(+), 19 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index 9526938085..549e352c74 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -91,13 +91,18 @@ static int fill_commit_message(struct repository *repo,
return 0;
}
-static int commit_tree_with_edited_message_ext(struct repository *repo,
- const char *action,
- struct commit *commit_with_message,
- const struct commit_list *parents,
- const struct object_id *old_tree,
- const struct object_id *new_tree,
- struct commit **out)
+enum commit_tree_flags {
+ COMMIT_TREE_EDIT_MESSAGE = (1 << 0),
+};
+
+static int commit_tree_ext(struct repository *repo,
+ const char *action,
+ struct commit *commit_with_message,
+ const struct commit_list *parents,
+ const struct object_id *old_tree,
+ const struct object_id *new_tree,
+ struct commit **out,
+ enum commit_tree_flags flags)
{
const char *exclude_gpgsig[] = {
/* We reencode the message, so the encoding needs to be stripped. */
@@ -122,10 +127,14 @@ static int commit_tree_with_edited_message_ext(struct repository *repo,
original_author = xmemdupz(ptr, len);
find_commit_subject(original_message, &original_body);
- ret = fill_commit_message(repo, old_tree, new_tree,
- original_body, action, &commit_message);
- if (ret < 0)
- goto out;
+ if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+ ret = fill_commit_message(repo, old_tree, new_tree,
+ original_body, action, &commit_message);
+ if (ret < 0)
+ goto out;
+ } else {
+ strbuf_addstr(&commit_message, original_body);
+ }
original_extra_headers = read_commit_extra_headers(commit_with_message,
exclude_gpgsig);
@@ -168,8 +177,8 @@ static int commit_tree_with_edited_message(struct repository *repo,
oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
}
- return commit_tree_with_edited_message_ext(repo, action, original, original->parents,
- &parent_tree_oid, tree_oid, out);
+ return commit_tree_ext(repo, action, original, original->parents,
+ &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
}
enum ref_action {
@@ -616,9 +625,8 @@ static int split_commit(struct repository *repo,
* The first commit is constructed from the split-out tree. The base
* that shall be diffed against is the parent of the original commit.
*/
- ret = commit_tree_with_edited_message_ext(repo, "split-out", original,
- original->parents, &parent_tree_oid,
- &split_tree->object.oid, &first_commit);
+ ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
+ &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing first commit"));
goto out;
@@ -634,9 +642,8 @@ static int split_commit(struct repository *repo,
old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;
new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- ret = commit_tree_with_edited_message_ext(repo, "split-out", original,
- parents, old_tree_oid,
- new_tree_oid, &second_commit);
+ ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
+ new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing second commit"));
goto out;
--
2.54.0.545.g6539524ca2.dirty
^ permalink raw reply related [flat|nested] 21+ messages in thread* [PATCH 2/2] builtin/history: introduce "fixup" subcommand
2026-04-22 10:28 [PATCH 0/2] builtin/history: introduce "fixup" subcommand Patrick Steinhardt
2026-04-22 10:28 ` [PATCH 1/2] builtin/history: generalize function to commit trees Patrick Steinhardt
@ 2026-04-22 10:28 ` Patrick Steinhardt
2026-04-22 19:06 ` D. Ben Knoble
2026-04-22 18:18 ` [PATCH 0/2] " Tian Yuchen
` (2 subsequent siblings)
4 siblings, 1 reply; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-22 10:28 UTC (permalink / raw)
To: git; +Cc: Elijah Newren
The newly introduced git-history(1) command provides functionality to
easily edit commit history while also rebasing dependent branches. The
functionality exposed by this command is still somewhat limited though.
One common use case when editing commit history that is not yet covered
is fixing up a specific commit. Introduce a new subcommand that allows
the user to do exactly that by performing a three-way merge into the
target's commit tree, using HEAD's tree as the merge base. The flow is
thus essentially:
$ echo changes >file
$ git add file
$ git history fixup HEAD~
Like with the other commands, this will automatically rebase dependent
branches, as well. Unlike the other commands though:
- The command does not work in a bare repository as it interacts with
the index.
- The command may run into merge conflicts. If so, the command will
simply abort.
Especially the second item limits the usefulness of this command a bit.
But there are plans to introduce first-class conflicts into Git, which
will help use cases like this one.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
Documentation/git-history.adoc | 52 ++++-
builtin/history.c | 153 +++++++++++++
t/meson.build | 1 +
t/t3453-history-fixup.sh | 500 +++++++++++++++++++++++++++++++++++++++++
4 files changed, 704 insertions(+), 2 deletions(-)
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 24dc907033..3cdfc8ba02 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -8,6 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history
SYNOPSIS
--------
[synopsis]
+git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]
git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
@@ -22,8 +23,9 @@ THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
This command is related to linkgit:git-rebase[1] in that both commands can be
used to rewrite history. There are a couple of major differences though:
-* linkgit:git-history[1] can work in a bare repository as it does not need to
- touch either the index or the worktree.
+* Most subcommands of linkgit:git-history[1] can work in a bare repository as
+ they do not need to touch either the index or the worktree. The `fixup`
+ subcommand is an exception to this, as it reads staged changes from the index.
* linkgit:git-history[1] does not execute any linkgit:githooks[5] at the
current point in time. This may change in the future.
* linkgit:git-history[1] by default updates all branches that are descendants
@@ -53,6 +55,19 @@ COMMANDS
The following commands are available to rewrite history in different ways:
+`fixup <commit>`::
+ Apply the currently staged changes to the specified commit. The staged
+ changes are incorporated into the target commit's tree via a three-way
+ merge, using HEAD's tree as the merge base, which is equivalent to
+ linkgit:git-cherry-pick[1].
++
+The commit message and authorship of the target commit are preserved by
+default, unless you specify `--reedit-message`.
++
+If applying the staged changes would result in a conflict, the command
+aborts with an error. All branches that are descendants of the original
+commit are updated to point to the rewritten history.
+
`reword <commit>`::
Rewrite the commit message of the specified commit. All the other
details of this commit remain unchanged. This command will spawn an
@@ -87,6 +102,9 @@ OPTIONS
objects will be written into the repository, so applying these printed
ref updates is generally safe.
+`--reedit-message`::
+ Open an editor to modify the target commit's message.
+
`--update-refs=(branches|head)`::
Control which references will be updated by the command, if any. With
`branches`, all local branches that point to commits which are
@@ -96,6 +114,36 @@ OPTIONS
EXAMPLES
--------
+Fixup a commit
+~~~~~~~~~~~~~~
+
+----------
+$ git log --oneline --stat
+abc1234 (HEAD -> main) third
+ third.txt | 1 +
+def5678 second
+ second.txt | 1 +
+ghi9012 first
+ first.txt | 1 +
+
+$ echo "change" >>unrelated.txt
+$ git add unrelated.txt
+$ git history fixup ghi9012
+
+$ git log --oneline --stat
+jkl3456 (HEAD -> main) third
+ third.txt | 1 +
+mno7890 second
+ second.txt | 1 +
+pqr1234 first
+ first.txt | 1 +
+ unrelated.txt | 1 +
+----------
+
+The staged addition of `unrelated.txt` has been incorporated into the `first`
+commit. All descendant commits have been replayed on top of the rewritten
+history.
+
Split a commit
~~~~~~~~~~~~~~
diff --git a/builtin/history.c b/builtin/history.c
index 549e352c74..6299f0dfa9 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -10,6 +10,7 @@
#include "gettext.h"
#include "hex.h"
#include "lockfile.h"
+#include "merge-ort.h"
#include "oidmap.h"
#include "parse-options.h"
#include "path.h"
@@ -23,6 +24,8 @@
#include "unpack-trees.h"
#include "wt-status.h"
+#define GIT_HISTORY_FIXUP_USAGE \
+ N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]")
#define GIT_HISTORY_REWORD_USAGE \
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
#define GIT_HISTORY_SPLIT_USAGE \
@@ -434,6 +437,154 @@ static int handle_reference_updates(struct rev_info *revs,
return ret;
}
+static int cmd_history_fixup(int argc,
+ const char **argv,
+ const char *prefix,
+ struct repository *repo)
+{
+ const char * const usage[] = {
+ GIT_HISTORY_FIXUP_USAGE,
+ NULL,
+ };
+ enum ref_action action = REF_ACTION_DEFAULT;
+ int dry_run = 0;
+ enum commit_tree_flags flags = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+ N_("control which refs should be updated"),
+ PARSE_OPT_NONEG, parse_ref_action),
+ OPT_BOOL('n', "dry-run", &dry_run,
+ N_("perform a dry-run without updating any refs")),
+ OPT_BIT(0, "reedit-message", &flags,
+ N_("open an editor to modify the commit message"),
+ COMMIT_TREE_EDIT_MESSAGE),
+ OPT_END(),
+ };
+ struct merge_result merge_result = { 0 };
+ struct merge_options merge_opts = { 0 };
+ struct strbuf reflog_msg = STRBUF_INIT;
+ struct commit *head_commit, *original, *rewritten;
+ struct tree *head_tree, *original_tree, *index_tree;
+ struct rev_info revs = { 0 };
+ int ret;
+
+ argc = parse_options(argc, argv, prefix, options, usage, 0);
+ if (argc != 1) {
+ ret = error(_("command expects a single revision"));
+ goto out;
+ }
+ repo_config(repo, git_default_config, NULL);
+
+ if (action == REF_ACTION_DEFAULT)
+ action = REF_ACTION_BRANCHES;
+
+ if (is_bare_repository()) {
+ ret = error(_("cannot run fixup in a bare repository"));
+ goto out;
+ }
+
+ /* Resolve the original commit, which is the one we want to fix up. */
+ original = lookup_commit_reference_by_name(argv[0]);
+ if (!original) {
+ ret = error(_("commit cannot be found: %s"), argv[0]);
+ goto out;
+ }
+
+ /*
+ * Resolve HEAD so we can use its tree as the merge base: the staged
+ * changes are expressed as a diff from HEAD's tree to the index tree.
+ */
+ head_commit = lookup_commit_reference_by_name("HEAD");
+ if (!head_commit) {
+ ret = error(_("cannot look up HEAD"));
+ goto out;
+ }
+
+ head_tree = repo_get_commit_tree(repo, head_commit);
+ if (!head_tree) {
+ ret = error(_("cannot get tree for HEAD"));
+ goto out;
+ }
+
+ if (repo_read_index(repo) < 0) {
+ ret = error(_("unable to read index"));
+ goto out;
+ }
+
+ if (!repo_index_has_changes(repo, head_tree, NULL)) {
+ ret = error(_("nothing to fixup: no staged changes"));
+ goto out;
+ }
+
+ /*
+ * Write the index as a tree object. This is the "theirs" side of the
+ * three-way merge: it is HEAD's tree with the staged changes applied.
+ */
+ index_tree = write_in_core_index_as_tree(repo, repo->index);
+ if (!index_tree) {
+ ret = error(_("unable to write index as a tree"));
+ goto out;
+ }
+
+ original_tree = repo_get_commit_tree(repo, original);
+ if (!original_tree) {
+ ret = error(_("cannot get tree for commit %s"), argv[0]);
+ goto out;
+ }
+
+ /*
+ * Perform the three-way merge to reapply changes in the index onto the
+ * target commit. This is using basically the same logic as a
+ * cherry-pick, where the base commit is our HEAD, ours is the original
+ * tree and theirs is the index tree.
+ */
+ init_basic_merge_options(&merge_opts, repo);
+ merge_opts.ancestor = "HEAD";
+ merge_opts.branch1 = argv[0];
+ merge_opts.branch2 = "staged";
+ merge_incore_nonrecursive(&merge_opts, head_tree,
+ original_tree, index_tree, &merge_result);
+
+ if (merge_result.clean < 0) {
+ ret = error(_("merge failed while applying fixup"));
+ goto out;
+ }
+
+ if (!merge_result.clean) {
+ ret = error(_("fixup would produce conflicts; aborting"));
+ goto out;
+ }
+
+ ret = setup_revwalk(repo, action, original, &revs);
+ if (ret)
+ goto out;
+
+ ret = commit_tree_ext(repo, "fixup", original, original->parents,
+ &original_tree->object.oid, &merge_result.tree->object.oid,
+ &rewritten, flags);
+ if (ret < 0) {
+ ret = error(_("failed writing fixed-up commit"));
+ goto out;
+ }
+
+ strbuf_addf(&reflog_msg, "fixup: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, original, rewritten,
+ reflog_msg.buf, dry_run);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+ }
+
+ ret = 0;
+
+out:
+ merge_finalize(&merge_opts, &merge_result);
+ strbuf_release(&reflog_msg);
+ release_revisions(&revs);
+ return ret;
+}
+
static int cmd_history_reword(int argc,
const char **argv,
const char *prefix,
@@ -745,12 +896,14 @@ int cmd_history(int argc,
struct repository *repo)
{
const char * const usage[] = {
+ GIT_HISTORY_FIXUP_USAGE,
GIT_HISTORY_REWORD_USAGE,
GIT_HISTORY_SPLIT_USAGE,
NULL,
};
parse_opt_subcommand_fn *fn = NULL;
struct option options[] = {
+ OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
OPT_SUBCOMMAND("split", &fn, cmd_history_split),
OPT_END(),
diff --git a/t/meson.build b/t/meson.build
index 7528e5cda5..f502ad8ec9 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -397,6 +397,7 @@ integration_tests = [
't3450-history.sh',
't3451-history-reword.sh',
't3452-history-split.sh',
+ 't3453-history-fixup.sh',
't3500-cherry.sh',
't3501-revert-cherry-pick.sh',
't3502-cherry-pick-merge.sh',
diff --git a/t/t3453-history-fixup.sh b/t/t3453-history-fixup.sh
new file mode 100755
index 0000000000..0012b1f052
--- /dev/null
+++ b/t/t3453-history-fixup.sh
@@ -0,0 +1,500 @@
+#!/bin/sh
+
+test_description='tests for git-history fixup subcommand'
+
+. ./test-lib.sh
+
+fixup_with_message () {
+ cat >message &&
+ write_script fake-editor.sh <<-\EOF &&
+ cp message "$1"
+ EOF
+ test_set_editor "$(pwd)"/fake-editor.sh &&
+ git history fixup --reedit-message "$@" &&
+ rm fake-editor.sh message
+}
+
+expect_changes () {
+ git log --format="%s" --numstat "$@" >actual.raw &&
+ sed '/^$/d' <actual.raw >actual &&
+ cat >expect &&
+ test_cmp expect actual
+}
+
+test_expect_success 'errors on missing commit argument' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ test_must_fail git history fixup 2>err &&
+ test_grep "command expects a single revision" err
+ )
+'
+
+test_expect_success 'errors on too many arguments' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ test_must_fail git history fixup HEAD HEAD 2>err &&
+ test_grep "command expects a single revision" err
+ )
+'
+
+test_expect_success 'errors on unknown revision' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ test_must_fail git history fixup does-not-exist 2>err &&
+ test_grep "commit cannot be found: does-not-exist" err
+ )
+'
+
+test_expect_success 'errors when nothing is staged' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ test_must_fail git history fixup HEAD 2>err &&
+ test_grep "nothing to fixup: no staged changes" err
+ )
+'
+
+test_expect_success 'errors in a bare repository' '
+ test_when_finished "rm -rf repo repo.git" &&
+ git init repo &&
+ test_commit -C repo initial &&
+ git clone --bare repo repo.git &&
+ test_must_fail git -C repo.git history fixup HEAD 2>err &&
+ test_grep "cannot run fixup in a bare repository" err
+'
+
+test_expect_success 'can fixup the tip commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ echo content >file.txt &&
+ git add file.txt &&
+ git commit -m "add file" &&
+
+ echo fix >>file.txt &&
+ git add file.txt &&
+
+ expect_changes <<-\EOF &&
+ add file
+ 1 0 file.txt
+ initial
+ 1 0 initial.t
+ EOF
+
+ git symbolic-ref HEAD >branch-expect &&
+ git history fixup HEAD &&
+ git symbolic-ref HEAD >branch-actual &&
+ test_cmp branch-expect branch-actual &&
+
+ expect_changes <<-\EOF &&
+ add file
+ 2 0 file.txt
+ initial
+ 1 0 initial.t
+ EOF
+
+ # Verify the fix is in the tip commit tree
+ git show HEAD:file.txt >actual &&
+ printf "content\nfix\n" >expect &&
+ test_cmp expect actual &&
+
+ git reflog >reflog &&
+ test_grep "fixup: updating HEAD" reflog
+ )
+'
+
+test_expect_success 'can fixup a commit in the middle of history' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit first &&
+ echo content >file.txt &&
+ git add file.txt &&
+ git commit -m "add file" &&
+ test_commit third &&
+
+ echo fix >>file.txt &&
+ git add file.txt &&
+
+ expect_changes <<-\EOF &&
+ third
+ 1 0 third.t
+ add file
+ 1 0 file.txt
+ first
+ 1 0 first.t
+ EOF
+
+ git history fixup HEAD~ &&
+
+ expect_changes <<-\EOF &&
+ third
+ 1 0 third.t
+ add file
+ 2 0 file.txt
+ first
+ 1 0 first.t
+ EOF
+
+ # Verify the fix landed in the "add file" commit.
+ git show HEAD~:file.txt >actual &&
+ printf "content\nfix\n" >expect &&
+ test_cmp expect actual &&
+
+ # And verify that the replayed commit also has the change.
+ git show HEAD:file.txt >actual &&
+ printf "content\nfix\n" >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'can fixup root commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ echo initial >root.txt &&
+ git add root.txt &&
+ git commit -m "root" &&
+ test_commit second &&
+
+ expect_changes <<-\EOF &&
+ second
+ 1 0 second.t
+ root
+ 1 0 root.txt
+ EOF
+
+ echo fix >>root.txt &&
+ git add root.txt &&
+ git history fixup HEAD~ &&
+
+ expect_changes <<-\EOF &&
+ second
+ 1 0 second.t
+ root
+ 2 0 root.txt
+ EOF
+
+ git show HEAD~:root.txt >actual &&
+ printf "initial\nfix\n" >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'preserves commit message and authorship' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ echo content >file.txt &&
+ git add file.txt &&
+ git commit --author="Original <original@example.com>" -m "original message" &&
+
+ echo fix >>file.txt &&
+ git add file.txt &&
+ git history fixup HEAD &&
+
+ # Message preserved
+ git log -1 --format="%s" >actual &&
+ echo "original message" >expect &&
+ test_cmp expect actual &&
+
+ # Authorship preserved
+ git log -1 --format="%an <%ae>" >actual &&
+ echo "Original <original@example.com>" >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'updates all descendant branches by default' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch branch &&
+ test_commit ours &&
+ git switch branch &&
+ test_commit theirs &&
+ git switch main &&
+
+ echo fix >fix.txt &&
+ git add fix.txt &&
+ git history fixup base &&
+
+ expect_changes --branches <<-\EOF &&
+ theirs
+ 1 0 theirs.t
+ ours
+ 1 0 ours.t
+ base
+ 1 0 base.t
+ 1 0 fix.txt
+ EOF
+
+ # Both branches should have the fix in the base
+ git show main~:fix.txt >actual &&
+ echo fix >expect &&
+ test_cmp expect actual &&
+ git show branch~:fix.txt >actual &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'can fixup commit on a different branch' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch theirs &&
+ test_commit ours &&
+ git switch theirs &&
+ test_commit theirs &&
+
+ # Stage a change while on "theirs"
+ echo fix >fix.txt &&
+ git add fix.txt &&
+
+ # Ensure that "ours" does not change, as it does not contain
+ # the commit in question.
+ git rev-parse ours >ours-before &&
+ git history fixup theirs &&
+ git rev-parse ours >ours-after &&
+ test_cmp ours-before ours-after &&
+
+ git show HEAD:fix.txt >actual &&
+ echo fix >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success '--dry-run prints ref updates without modifying repo' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch branch &&
+ test_commit main-tip &&
+ git switch branch &&
+ test_commit branch-tip &&
+ git switch main &&
+
+ echo fix >fix.txt &&
+ git add fix.txt &&
+
+ git refs list >refs-before &&
+ git history fixup --dry-run base >updates &&
+ git refs list >refs-after &&
+ test_cmp refs-before refs-after &&
+
+ test_grep "update refs/heads/main" updates &&
+ test_grep "update refs/heads/branch" updates &&
+
+ expect_changes --branches <<-\EOF &&
+ branch-tip
+ 1 0 branch-tip.t
+ main-tip
+ 1 0 main-tip.t
+ base
+ 1 0 base.t
+ EOF
+
+ git update-ref --stdin <updates &&
+ expect_changes --branches <<-\EOF
+ branch-tip
+ 1 0 branch-tip.t
+ main-tip
+ 1 0 main-tip.t
+ base
+ 1 0 base.t
+ 1 0 fix.txt
+ EOF
+ )
+'
+
+test_expect_success '--update-refs=head updates only HEAD' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch branch &&
+ test_commit main-tip &&
+ git switch branch &&
+ test_commit branch-tip &&
+
+ echo fix >fix.txt &&
+ git add fix.txt &&
+
+ # Only HEAD (branch) should be updated
+ git history fixup --update-refs=head base &&
+
+ # The main branch should be unaffected.
+ expect_changes main <<-\EOF &&
+ main-tip
+ 1 0 main-tip.t
+ base
+ 1 0 base.t
+ EOF
+
+ # But the currently checked out branch should be modified.
+ expect_changes branch <<-\EOF
+ branch-tip
+ 1 0 branch-tip.t
+ base
+ 1 0 base.t
+ 1 0 fix.txt
+ EOF
+ )
+'
+
+test_expect_success '--update-refs=head refuses to rewrite commits not in HEAD ancestry' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch other &&
+ test_commit main-tip &&
+ git switch other &&
+ test_commit other-tip &&
+
+ echo fix >fix.txt &&
+ git add fix.txt &&
+
+ test_must_fail git history fixup --update-refs=head main-tip 2>err &&
+ test_grep "rewritten commit must be an ancestor of HEAD" err
+ )
+'
+
+test_expect_success 'aborts when fixup would produce conflicts' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ echo "line one" >file.txt &&
+ git add file.txt &&
+ git commit -m "first" &&
+
+ echo "line two" >file.txt &&
+ git add file.txt &&
+ git commit -m "second" &&
+
+ echo "conflicting change" >file.txt &&
+ git add file.txt &&
+
+ git refs list >refs-before &&
+ test_must_fail git history fixup HEAD~ 2>err &&
+ test_grep "fixup would produce conflicts" err &&
+ git refs list >refs-after &&
+ test_cmp refs-before refs-after
+ )
+'
+
+test_expect_success '--reedit-message opens editor for the commit message' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ echo content >file.txt &&
+ git add file.txt &&
+ git commit -m "add file" &&
+
+ echo fix >>file.txt &&
+ git add file.txt &&
+
+ fixup_with_message HEAD <<-\EOF &&
+ add file with fix
+ EOF
+
+ expect_changes --branches <<-\EOF
+ add file with fix
+ 2 0 file.txt
+ initial
+ 1 0 initial.t
+ EOF
+ )
+'
+
+test_expect_success 'retains unstaged working tree changes after fixup' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ touch a b &&
+ git add . &&
+ git commit -m "initial commit" &&
+ echo staged >a &&
+ echo unstaged >b &&
+ git add a &&
+ git history fixup HEAD &&
+
+ # b is still modified in the worktree but not staged
+ cat >expect <<-\EOF &&
+ M b
+ EOF
+ git status --porcelain --untracked-files=no >actual &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'index is clean after fixup when target is HEAD' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ test_commit initial &&
+ echo fix >fix.txt &&
+ git add fix.txt &&
+ git history fixup HEAD &&
+
+ git status --porcelain --untracked-files=no >actual &&
+ test_must_be_empty actual
+ )
+'
+
+test_expect_success 'index is unchanged on conflict' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ echo base >file.txt &&
+ git add file.txt &&
+ git commit -m base &&
+ echo change >file.txt &&
+ git add file.txt &&
+ git commit -m change &&
+
+ echo conflict >file.txt &&
+ git add file.txt &&
+
+ git diff --cached >index-before &&
+ test_must_fail git history fixup HEAD~ &&
+ git diff --cached >index-after &&
+ test_cmp index-before index-after
+ )
+'
+
+test_done
--
2.54.0.545.g6539524ca2.dirty
^ permalink raw reply related [flat|nested] 21+ messages in thread* Re: [PATCH 2/2] builtin/history: introduce "fixup" subcommand
2026-04-22 10:28 ` [PATCH 2/2] builtin/history: introduce "fixup" subcommand Patrick Steinhardt
@ 2026-04-22 19:06 ` D. Ben Knoble
2026-04-23 6:55 ` Patrick Steinhardt
0 siblings, 1 reply; 21+ messages in thread
From: D. Ben Knoble @ 2026-04-22 19:06 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: git, Elijah Newren
Yahoo, fixup!
On Wed, Apr 22, 2026 at 6:30 AM Patrick Steinhardt <ps@pks.im> wrote:
>
> The newly introduced git-history(1) command provides functionality to
> easily edit commit history while also rebasing dependent branches. The
> functionality exposed by this command is still somewhat limited though.
>
> One common use case when editing commit history that is not yet covered
> is fixing up a specific commit. Introduce a new subcommand that allows
> the user to do exactly that by performing a three-way merge into the
> target's commit tree, using HEAD's tree as the merge base. The flow is
> thus essentially:
>
> $ echo changes >file
> $ git add file
> $ git history fixup HEAD~
>
> Like with the other commands, this will automatically rebase dependent
> branches, as well. Unlike the other commands though:
>
> - The command does not work in a bare repository as it interacts with
> the index.
>
> - The command may run into merge conflicts. If so, the command will
> simply abort.
>
> Especially the second item limits the usefulness of this command a bit.
> But there are plans to introduce first-class conflicts into Git, which
> will help use cases like this one.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
> Documentation/git-history.adoc | 52 ++++-
> builtin/history.c | 153 +++++++++++++
> t/meson.build | 1 +
> t/t3453-history-fixup.sh | 500 +++++++++++++++++++++++++++++++++++++++++
> 4 files changed, 704 insertions(+), 2 deletions(-)
>
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> index 24dc907033..3cdfc8ba02 100644
> --- a/Documentation/git-history.adoc
> +++ b/Documentation/git-history.adoc
> @@ -8,6 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history
> SYNOPSIS
> --------
> [synopsis]
> +git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]
> git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
> git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
>
> @@ -22,8 +23,9 @@ THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
> This command is related to linkgit:git-rebase[1] in that both commands can be
> used to rewrite history. There are a couple of major differences though:
>
> -* linkgit:git-history[1] can work in a bare repository as it does not need to
> - touch either the index or the worktree.
> +* Most subcommands of linkgit:git-history[1] can work in a bare repository as
> + they do not need to touch either the index or the worktree. The `fixup`
> + subcommand is an exception to this, as it reads staged changes from the index.
> * linkgit:git-history[1] does not execute any linkgit:githooks[5] at the
> current point in time. This may change in the future.
> * linkgit:git-history[1] by default updates all branches that are descendants
> @@ -53,6 +55,19 @@ COMMANDS
>
> The following commands are available to rewrite history in different ways:
>
> +`fixup <commit>`::
> + Apply the currently staged changes to the specified commit. The staged
> + changes are incorporated into the target commit's tree via a three-way
> + merge, using HEAD's tree as the merge base, which is equivalent to
> + linkgit:git-cherry-pick[1].
I'm not quite sure what, as a user of "git history fixup," I'm
supposed to take from this. Does it make conflicts less likely when
creating the new fixup? I imagine it doesn't help with conflicts
between <commit> and HEAD that newly arise.
Anyway, I'd think the mechanics are less relevant than the end-user
behavior at this point in the doc, unless the equivalence with
cherry-pick is supposed to tell me something about that behavior.
> ++
> +The commit message and authorship of the target commit are preserved by
> +default, unless you specify `--reedit-message`.
> ++
> +If applying the staged changes would result in a conflict, the command
> +aborts with an error. All branches that are descendants of the original
> +commit are updated to point to the rewritten history.
> +
> `reword <commit>`::
> Rewrite the commit message of the specified commit. All the other
> details of this commit remain unchanged. This command will spawn an
> @@ -87,6 +102,9 @@ OPTIONS
> objects will be written into the repository, so applying these printed
> ref updates is generally safe.
>
> +`--reedit-message`::
> + Open an editor to modify the target commit's message.
> +
> `--update-refs=(branches|head)`::
> Control which references will be updated by the command, if any. With
> `branches`, all local branches that point to commits which are
> @@ -96,6 +114,36 @@ OPTIONS
> EXAMPLES
> --------
>
> +Fixup a commit
> +~~~~~~~~~~~~~~
> +
> +----------
> +$ git log --oneline --stat
> +abc1234 (HEAD -> main) third
> + third.txt | 1 +
> +def5678 second
> + second.txt | 1 +
> +ghi9012 first
> + first.txt | 1 +
> +
> +$ echo "change" >>unrelated.txt
> +$ git add unrelated.txt
> +$ git history fixup ghi9012
> +
> +$ git log --oneline --stat
> +jkl3456 (HEAD -> main) third
> + third.txt | 1 +
> +mno7890 second
> + second.txt | 1 +
> +pqr1234 first
> + first.txt | 1 +
> + unrelated.txt | 1 +
> +----------
> +
> +The staged addition of `unrelated.txt` has been incorporated into the `first`
> +commit. All descendant commits have been replayed on top of the rewritten
> +history.
> +
> Split a commit
> ~~~~~~~~~~~~~~
>
> diff --git a/builtin/history.c b/builtin/history.c
> index 549e352c74..6299f0dfa9 100644
> --- a/builtin/history.c
> +++ b/builtin/history.c
> @@ -10,6 +10,7 @@
> #include "gettext.h"
> #include "hex.h"
> #include "lockfile.h"
> +#include "merge-ort.h"
> #include "oidmap.h"
> #include "parse-options.h"
> #include "path.h"
> @@ -23,6 +24,8 @@
> #include "unpack-trees.h"
> #include "wt-status.h"
>
> +#define GIT_HISTORY_FIXUP_USAGE \
> + N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]")
> #define GIT_HISTORY_REWORD_USAGE \
> N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
> #define GIT_HISTORY_SPLIT_USAGE \
> @@ -434,6 +437,154 @@ static int handle_reference_updates(struct rev_info *revs,
> return ret;
> }
>
> +static int cmd_history_fixup(int argc,
> + const char **argv,
> + const char *prefix,
> + struct repository *repo)
> +{
> + const char * const usage[] = {
> + GIT_HISTORY_FIXUP_USAGE,
> + NULL,
> + };
> + enum ref_action action = REF_ACTION_DEFAULT;
> + int dry_run = 0;
> + enum commit_tree_flags flags = 0;
> + struct option options[] = {
> + OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
> + N_("control which refs should be updated"),
> + PARSE_OPT_NONEG, parse_ref_action),
> + OPT_BOOL('n', "dry-run", &dry_run,
> + N_("perform a dry-run without updating any refs")),
> + OPT_BIT(0, "reedit-message", &flags,
> + N_("open an editor to modify the commit message"),
> + COMMIT_TREE_EDIT_MESSAGE),
> + OPT_END(),
> + };
> + struct merge_result merge_result = { 0 };
> + struct merge_options merge_opts = { 0 };
> + struct strbuf reflog_msg = STRBUF_INIT;
> + struct commit *head_commit, *original, *rewritten;
> + struct tree *head_tree, *original_tree, *index_tree;
> + struct rev_info revs = { 0 };
> + int ret;
> +
> + argc = parse_options(argc, argv, prefix, options, usage, 0);
> + if (argc != 1) {
> + ret = error(_("command expects a single revision"));
> + goto out;
> + }
> + repo_config(repo, git_default_config, NULL);
> +
> + if (action == REF_ACTION_DEFAULT)
> + action = REF_ACTION_BRANCHES;
> +
> + if (is_bare_repository()) {
> + ret = error(_("cannot run fixup in a bare repository"));
> + goto out;
> + }
> +
> + /* Resolve the original commit, which is the one we want to fix up. */
> + original = lookup_commit_reference_by_name(argv[0]);
> + if (!original) {
> + ret = error(_("commit cannot be found: %s"), argv[0]);
> + goto out;
> + }
> +
> + /*
> + * Resolve HEAD so we can use its tree as the merge base: the staged
> + * changes are expressed as a diff from HEAD's tree to the index tree.
> + */
> + head_commit = lookup_commit_reference_by_name("HEAD");
> + if (!head_commit) {
> + ret = error(_("cannot look up HEAD"));
> + goto out;
> + }
> +
> + head_tree = repo_get_commit_tree(repo, head_commit);
> + if (!head_tree) {
> + ret = error(_("cannot get tree for HEAD"));
> + goto out;
> + }
> +
> + if (repo_read_index(repo) < 0) {
> + ret = error(_("unable to read index"));
> + goto out;
> + }
> +
> + if (!repo_index_has_changes(repo, head_tree, NULL)) {
> + ret = error(_("nothing to fixup: no staged changes"));
> + goto out;
> + }
> +
> + /*
> + * Write the index as a tree object. This is the "theirs" side of the
> + * three-way merge: it is HEAD's tree with the staged changes applied.
> + */
> + index_tree = write_in_core_index_as_tree(repo, repo->index);
> + if (!index_tree) {
> + ret = error(_("unable to write index as a tree"));
> + goto out;
> + }
> +
> + original_tree = repo_get_commit_tree(repo, original);
> + if (!original_tree) {
> + ret = error(_("cannot get tree for commit %s"), argv[0]);
> + goto out;
> + }
> +
> + /*
> + * Perform the three-way merge to reapply changes in the index onto the
> + * target commit. This is using basically the same logic as a
> + * cherry-pick, where the base commit is our HEAD, ours is the original
> + * tree and theirs is the index tree.
> + */
OTOH, this explanation helps quite a bit here :)
> + init_basic_merge_options(&merge_opts, repo);
> + merge_opts.ancestor = "HEAD";
> + merge_opts.branch1 = argv[0];
> + merge_opts.branch2 = "staged";
> + merge_incore_nonrecursive(&merge_opts, head_tree,
> + original_tree, index_tree, &merge_result);
> +
> + if (merge_result.clean < 0) {
> + ret = error(_("merge failed while applying fixup"));
> + goto out;
> + }
> +
> + if (!merge_result.clean) {
> + ret = error(_("fixup would produce conflicts; aborting"));
> + goto out;
> + }
> +
> + ret = setup_revwalk(repo, action, original, &revs);
> + if (ret)
> + goto out;
> +
> + ret = commit_tree_ext(repo, "fixup", original, original->parents,
> + &original_tree->object.oid, &merge_result.tree->object.oid,
> + &rewritten, flags);
> + if (ret < 0) {
> + ret = error(_("failed writing fixed-up commit"));
> + goto out;
> + }
> +
> + strbuf_addf(&reflog_msg, "fixup: updating %s", argv[0]);
> +
> + ret = handle_reference_updates(&revs, action, original, rewritten,
> + reflog_msg.buf, dry_run);
> + if (ret < 0) {
> + ret = error(_("failed replaying descendants"));
> + goto out;
> + }
> +
> + ret = 0;
> +
> +out:
> + merge_finalize(&merge_opts, &merge_result);
> + strbuf_release(&reflog_msg);
> + release_revisions(&revs);
> + return ret;
> +}
> +
> static int cmd_history_reword(int argc,
> const char **argv,
> const char *prefix,
> @@ -745,12 +896,14 @@ int cmd_history(int argc,
> struct repository *repo)
> {
> const char * const usage[] = {
> + GIT_HISTORY_FIXUP_USAGE,
> GIT_HISTORY_REWORD_USAGE,
> GIT_HISTORY_SPLIT_USAGE,
> NULL,
> };
> parse_opt_subcommand_fn *fn = NULL;
> struct option options[] = {
> + OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
> OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
> OPT_SUBCOMMAND("split", &fn, cmd_history_split),
> OPT_END(),
> diff --git a/t/meson.build b/t/meson.build
> index 7528e5cda5..f502ad8ec9 100644
> --- a/t/meson.build
> +++ b/t/meson.build
> @@ -397,6 +397,7 @@ integration_tests = [
> 't3450-history.sh',
> 't3451-history-reword.sh',
> 't3452-history-split.sh',
> + 't3453-history-fixup.sh',
> 't3500-cherry.sh',
> 't3501-revert-cherry-pick.sh',
> 't3502-cherry-pick-merge.sh',
> diff --git a/t/t3453-history-fixup.sh b/t/t3453-history-fixup.sh
> new file mode 100755
> index 0000000000..0012b1f052
> --- /dev/null
> +++ b/t/t3453-history-fixup.sh
> @@ -0,0 +1,500 @@
> +#!/bin/sh
> +
> +test_description='tests for git-history fixup subcommand'
> +
> +. ./test-lib.sh
> +
> +fixup_with_message () {
> + cat >message &&
> + write_script fake-editor.sh <<-\EOF &&
> + cp message "$1"
> + EOF
> + test_set_editor "$(pwd)"/fake-editor.sh &&
> + git history fixup --reedit-message "$@" &&
> + rm fake-editor.sh message
> +}
> +
> +expect_changes () {
> + git log --format="%s" --numstat "$@" >actual.raw &&
> + sed '/^$/d' <actual.raw >actual &&
> + cat >expect &&
> + test_cmp expect actual
> +}
> +
> +test_expect_success 'errors on missing commit argument' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + test_commit initial &&
> + test_must_fail git history fixup 2>err &&
> + test_grep "command expects a single revision" err
> + )
> +'
> +
> +test_expect_success 'errors on too many arguments' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + test_commit initial &&
> + test_must_fail git history fixup HEAD HEAD 2>err &&
> + test_grep "command expects a single revision" err
> + )
> +'
> +
> +test_expect_success 'errors on unknown revision' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + test_commit initial &&
> + test_must_fail git history fixup does-not-exist 2>err &&
> + test_grep "commit cannot be found: does-not-exist" err
> + )
> +'
> +
> +test_expect_success 'errors when nothing is staged' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + test_commit initial &&
> + test_must_fail git history fixup HEAD 2>err &&
> + test_grep "nothing to fixup: no staged changes" err
> + )
> +'
> +
> +test_expect_success 'errors in a bare repository' '
> + test_when_finished "rm -rf repo repo.git" &&
> + git init repo &&
> + test_commit -C repo initial &&
> + git clone --bare repo repo.git &&
> + test_must_fail git -C repo.git history fixup HEAD 2>err &&
> + test_grep "cannot run fixup in a bare repository" err
> +'
> +
> +test_expect_success 'can fixup the tip commit' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + test_commit initial &&
> + echo content >file.txt &&
> + git add file.txt &&
> + git commit -m "add file" &&
> +
> + echo fix >>file.txt &&
> + git add file.txt &&
> +
> + expect_changes <<-\EOF &&
> + add file
> + 1 0 file.txt
> + initial
> + 1 0 initial.t
> + EOF
> +
> + git symbolic-ref HEAD >branch-expect &&
> + git history fixup HEAD &&
> + git symbolic-ref HEAD >branch-actual &&
> + test_cmp branch-expect branch-actual &&
> +
> + expect_changes <<-\EOF &&
> + add file
> + 2 0 file.txt
> + initial
> + 1 0 initial.t
> + EOF
> +
> + # Verify the fix is in the tip commit tree
> + git show HEAD:file.txt >actual &&
> + printf "content\nfix\n" >expect &&
> + test_cmp expect actual &&
> +
> + git reflog >reflog &&
> + test_grep "fixup: updating HEAD" reflog
> + )
> +'
> +
> +test_expect_success 'can fixup a commit in the middle of history' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + test_commit first &&
> + echo content >file.txt &&
> + git add file.txt &&
> + git commit -m "add file" &&
> + test_commit third &&
> +
> + echo fix >>file.txt &&
> + git add file.txt &&
> +
> + expect_changes <<-\EOF &&
> + third
> + 1 0 third.t
> + add file
> + 1 0 file.txt
> + first
> + 1 0 first.t
> + EOF
> +
> + git history fixup HEAD~ &&
> +
> + expect_changes <<-\EOF &&
> + third
> + 1 0 third.t
> + add file
> + 2 0 file.txt
> + first
> + 1 0 first.t
> + EOF
> +
> + # Verify the fix landed in the "add file" commit.
> + git show HEAD~:file.txt >actual &&
> + printf "content\nfix\n" >expect &&
> + test_cmp expect actual &&
> +
> + # And verify that the replayed commit also has the change.
> + git show HEAD:file.txt >actual &&
> + printf "content\nfix\n" >expect &&
> + test_cmp expect actual
> + )
> +'
> +
> +test_expect_success 'can fixup root commit' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + echo initial >root.txt &&
> + git add root.txt &&
> + git commit -m "root" &&
> + test_commit second &&
> +
> + expect_changes <<-\EOF &&
> + second
> + 1 0 second.t
> + root
> + 1 0 root.txt
> + EOF
> +
> + echo fix >>root.txt &&
> + git add root.txt &&
> + git history fixup HEAD~ &&
> +
> + expect_changes <<-\EOF &&
> + second
> + 1 0 second.t
> + root
> + 2 0 root.txt
> + EOF
> +
> + git show HEAD~:root.txt >actual &&
> + printf "initial\nfix\n" >expect &&
> + test_cmp expect actual
> + )
> +'
> +
> +test_expect_success 'preserves commit message and authorship' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + test_commit initial &&
> + echo content >file.txt &&
> + git add file.txt &&
> + git commit --author="Original <original@example.com>" -m "original message" &&
> +
> + echo fix >>file.txt &&
> + git add file.txt &&
> + git history fixup HEAD &&
> +
> + # Message preserved
> + git log -1 --format="%s" >actual &&
> + echo "original message" >expect &&
> + test_cmp expect actual &&
> +
> + # Authorship preserved
> + git log -1 --format="%an <%ae>" >actual &&
> + echo "Original <original@example.com>" >expect &&
> + test_cmp expect actual
> + )
> +'
> +
> +test_expect_success 'updates all descendant branches by default' '
> + test_when_finished "rm -rf repo" &&
> + git init repo --initial-branch=main &&
> + (
> + cd repo &&
> + test_commit base &&
> + git branch branch &&
> + test_commit ours &&
> + git switch branch &&
> + test_commit theirs &&
> + git switch main &&
> +
> + echo fix >fix.txt &&
> + git add fix.txt &&
> + git history fixup base &&
> +
> + expect_changes --branches <<-\EOF &&
> + theirs
> + 1 0 theirs.t
> + ours
> + 1 0 ours.t
> + base
> + 1 0 base.t
> + 1 0 fix.txt
> + EOF
> +
> + # Both branches should have the fix in the base
> + git show main~:fix.txt >actual &&
> + echo fix >expect &&
> + test_cmp expect actual &&
> + git show branch~:fix.txt >actual &&
> + test_cmp expect actual
> + )
> +'
> +
> +test_expect_success 'can fixup commit on a different branch' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + test_commit base &&
> + git branch theirs &&
> + test_commit ours &&
> + git switch theirs &&
> + test_commit theirs &&
> +
> + # Stage a change while on "theirs"
> + echo fix >fix.txt &&
> + git add fix.txt &&
> +
> + # Ensure that "ours" does not change, as it does not contain
> + # the commit in question.
> + git rev-parse ours >ours-before &&
> + git history fixup theirs &&
> + git rev-parse ours >ours-after &&
> + test_cmp ours-before ours-after &&
> +
> + git show HEAD:fix.txt >actual &&
> + echo fix >expect &&
> + test_cmp expect actual
> + )
> +'
> +
> +test_expect_success '--dry-run prints ref updates without modifying repo' '
> + test_when_finished "rm -rf repo" &&
> + git init repo --initial-branch=main &&
> + (
> + cd repo &&
> + test_commit base &&
> + git branch branch &&
> + test_commit main-tip &&
> + git switch branch &&
> + test_commit branch-tip &&
> + git switch main &&
> +
> + echo fix >fix.txt &&
> + git add fix.txt &&
> +
> + git refs list >refs-before &&
> + git history fixup --dry-run base >updates &&
> + git refs list >refs-after &&
> + test_cmp refs-before refs-after &&
> +
> + test_grep "update refs/heads/main" updates &&
> + test_grep "update refs/heads/branch" updates &&
> +
> + expect_changes --branches <<-\EOF &&
> + branch-tip
> + 1 0 branch-tip.t
> + main-tip
> + 1 0 main-tip.t
> + base
> + 1 0 base.t
> + EOF
> +
> + git update-ref --stdin <updates &&
> + expect_changes --branches <<-\EOF
> + branch-tip
> + 1 0 branch-tip.t
> + main-tip
> + 1 0 main-tip.t
> + base
> + 1 0 base.t
> + 1 0 fix.txt
> + EOF
> + )
> +'
> +
> +test_expect_success '--update-refs=head updates only HEAD' '
> + test_when_finished "rm -rf repo" &&
> + git init repo --initial-branch=main &&
> + (
> + cd repo &&
> + test_commit base &&
> + git branch branch &&
> + test_commit main-tip &&
> + git switch branch &&
> + test_commit branch-tip &&
> +
> + echo fix >fix.txt &&
> + git add fix.txt &&
> +
> + # Only HEAD (branch) should be updated
> + git history fixup --update-refs=head base &&
> +
> + # The main branch should be unaffected.
> + expect_changes main <<-\EOF &&
> + main-tip
> + 1 0 main-tip.t
> + base
> + 1 0 base.t
> + EOF
> +
> + # But the currently checked out branch should be modified.
> + expect_changes branch <<-\EOF
> + branch-tip
> + 1 0 branch-tip.t
> + base
> + 1 0 base.t
> + 1 0 fix.txt
> + EOF
> + )
> +'
> +
> +test_expect_success '--update-refs=head refuses to rewrite commits not in HEAD ancestry' '
> + test_when_finished "rm -rf repo" &&
> + git init repo --initial-branch=main &&
> + (
> + cd repo &&
> + test_commit base &&
> + git branch other &&
> + test_commit main-tip &&
> + git switch other &&
> + test_commit other-tip &&
> +
> + echo fix >fix.txt &&
> + git add fix.txt &&
> +
> + test_must_fail git history fixup --update-refs=head main-tip 2>err &&
> + test_grep "rewritten commit must be an ancestor of HEAD" err
> + )
> +'
> +
> +test_expect_success 'aborts when fixup would produce conflicts' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> +
> + echo "line one" >file.txt &&
> + git add file.txt &&
> + git commit -m "first" &&
> +
> + echo "line two" >file.txt &&
> + git add file.txt &&
> + git commit -m "second" &&
> +
> + echo "conflicting change" >file.txt &&
> + git add file.txt &&
> +
> + git refs list >refs-before &&
> + test_must_fail git history fixup HEAD~ 2>err &&
> + test_grep "fixup would produce conflicts" err &&
> + git refs list >refs-after &&
> + test_cmp refs-before refs-after
> + )
> +'
> +
> +test_expect_success '--reedit-message opens editor for the commit message' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + test_commit initial &&
> + echo content >file.txt &&
> + git add file.txt &&
> + git commit -m "add file" &&
> +
> + echo fix >>file.txt &&
> + git add file.txt &&
> +
> + fixup_with_message HEAD <<-\EOF &&
> + add file with fix
> + EOF
> +
> + expect_changes --branches <<-\EOF
> + add file with fix
> + 2 0 file.txt
> + initial
> + 1 0 initial.t
> + EOF
> + )
> +'
> +
> +test_expect_success 'retains unstaged working tree changes after fixup' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> + touch a b &&
> + git add . &&
> + git commit -m "initial commit" &&
> + echo staged >a &&
> + echo unstaged >b &&
> + git add a &&
> + git history fixup HEAD &&
> +
> + # b is still modified in the worktree but not staged
> + cat >expect <<-\EOF &&
> + M b
> + EOF
> + git status --porcelain --untracked-files=no >actual &&
> + test_cmp expect actual
> + )
> +'
> +
> +test_expect_success 'index is clean after fixup when target is HEAD' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> +
> + test_commit initial &&
> + echo fix >fix.txt &&
> + git add fix.txt &&
> + git history fixup HEAD &&
> +
> + git status --porcelain --untracked-files=no >actual &&
> + test_must_be_empty actual
> + )
> +'
> +
> +test_expect_success 'index is unchanged on conflict' '
> + test_when_finished "rm -rf repo" &&
> + git init repo &&
> + (
> + cd repo &&
> +
> + echo base >file.txt &&
> + git add file.txt &&
> + git commit -m base &&
> + echo change >file.txt &&
> + git add file.txt &&
> + git commit -m change &&
> +
> + echo conflict >file.txt &&
> + git add file.txt &&
> +
> + git diff --cached >index-before &&
> + test_must_fail git history fixup HEAD~ &&
> + git diff --cached >index-after &&
> + test_cmp index-before index-after
> + )
> +'
> +
> +test_done
>
> --
> 2.54.0.545.g6539524ca2.dirty
>
>
Thanks
--
D. Ben Knoble
^ permalink raw reply [flat|nested] 21+ messages in thread* Re: [PATCH 2/2] builtin/history: introduce "fixup" subcommand
2026-04-22 19:06 ` D. Ben Knoble
@ 2026-04-23 6:55 ` Patrick Steinhardt
2026-04-23 21:18 ` D. Ben Knoble
0 siblings, 1 reply; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-23 6:55 UTC (permalink / raw)
To: D. Ben Knoble; +Cc: git, Elijah Newren
On Wed, Apr 22, 2026 at 03:06:12PM -0400, D. Ben Knoble wrote:
> On Wed, Apr 22, 2026 at 6:30 AM Patrick Steinhardt <ps@pks.im> wrote:
> > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > index 24dc907033..3cdfc8ba02 100644
> > --- a/Documentation/git-history.adoc
> > +++ b/Documentation/git-history.adoc
> > @@ -53,6 +55,19 @@ COMMANDS
> >
> > The following commands are available to rewrite history in different ways:
> >
> > +`fixup <commit>`::
> > + Apply the currently staged changes to the specified commit. The staged
> > + changes are incorporated into the target commit's tree via a three-way
> > + merge, using HEAD's tree as the merge base, which is equivalent to
> > + linkgit:git-cherry-pick[1].
>
> I'm not quite sure what, as a user of "git history fixup," I'm
> supposed to take from this. Does it make conflicts less likely when
> creating the new fixup? I imagine it doesn't help with conflicts
> between <commit> and HEAD that newly arise.
>
> Anyway, I'd think the mechanics are less relevant than the end-user
> behavior at this point in the doc, unless the equivalence with
> cherry-pick is supposed to tell me something about that behavior.
There's at least two more or less obvious variants to do this:
- You generate the diff between HEAD and index and then try to reapply
the patch on top of the target commit.
- You perform the three-way merge.
The second item is definitely more robust compared to generating the
diff and reapplying it, and we use the exact same strategy to perform
cherry-picks nowadays.
> > diff --git a/builtin/history.c b/builtin/history.c
> > index 549e352c74..6299f0dfa9 100644
> > --- a/builtin/history.c
> > +++ b/builtin/history.c
[snip]
> > + /*
> > + * Perform the three-way merge to reapply changes in the index onto the
> > + * target commit. This is using basically the same logic as a
> > + * cherry-pick, where the base commit is our HEAD, ours is the original
> > + * tree and theirs is the index tree.
> > + */
>
> OTOH, this explanation helps quite a bit here :)
Hm, okay. I felt that this explanation here is even more technical. How
about:
`fixup <commit>`::
Apply the currently staged changes to the specified commit. This
is done by performing a three-way merge between the HEAD commit,
the target commit and the tree generated from staged changes.
This is using the same logic as linkgit:git-cherry-pick[1].
Not sure that this is an improvement? Happy to hear other suggestions.
Thanks!
Patrick
^ permalink raw reply [flat|nested] 21+ messages in thread* Re: [PATCH 2/2] builtin/history: introduce "fixup" subcommand
2026-04-23 6:55 ` Patrick Steinhardt
@ 2026-04-23 21:18 ` D. Ben Knoble
2026-04-24 6:53 ` Patrick Steinhardt
0 siblings, 1 reply; 21+ messages in thread
From: D. Ben Knoble @ 2026-04-23 21:18 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: git, Elijah Newren
On Thu, Apr 23, 2026 at 2:55 AM Patrick Steinhardt <ps@pks.im> wrote:
>
> On Wed, Apr 22, 2026 at 03:06:12PM -0400, D. Ben Knoble wrote:
> > On Wed, Apr 22, 2026 at 6:30 AM Patrick Steinhardt <ps@pks.im> wrote:
> > > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > > index 24dc907033..3cdfc8ba02 100644
> > > --- a/Documentation/git-history.adoc
> > > +++ b/Documentation/git-history.adoc
> > > @@ -53,6 +55,19 @@ COMMANDS
> > >
> > > The following commands are available to rewrite history in different ways:
> > >
> > > +`fixup <commit>`::
> > > + Apply the currently staged changes to the specified commit. The staged
> > > + changes are incorporated into the target commit's tree via a three-way
> > > + merge, using HEAD's tree as the merge base, which is equivalent to
> > > + linkgit:git-cherry-pick[1].
> >
> > I'm not quite sure what, as a user of "git history fixup," I'm
> > supposed to take from this. Does it make conflicts less likely when
> > creating the new fixup? I imagine it doesn't help with conflicts
> > between <commit> and HEAD that newly arise.
> >
> > Anyway, I'd think the mechanics are less relevant than the end-user
> > behavior at this point in the doc, unless the equivalence with
> > cherry-pick is supposed to tell me something about that behavior.
>
> There's at least two more or less obvious variants to do this:
>
> - You generate the diff between HEAD and index and then try to reapply
> the patch on top of the target commit.
>
> - You perform the three-way merge.
>
> The second item is definitely more robust compared to generating the
> diff and reapplying it, and we use the exact same strategy to perform
> cherry-picks nowadays.
>
> > > diff --git a/builtin/history.c b/builtin/history.c
> > > index 549e352c74..6299f0dfa9 100644
> > > --- a/builtin/history.c
> > > +++ b/builtin/history.c
> [snip]
> > > + /*
> > > + * Perform the three-way merge to reapply changes in the index onto the
> > > + * target commit. This is using basically the same logic as a
> > > + * cherry-pick, where the base commit is our HEAD, ours is the original
> > > + * tree and theirs is the index tree.
> > > + */
> >
> > OTOH, this explanation helps quite a bit here :)
>
> Hm, okay. I felt that this explanation here is even more technical. How
> about:
>
> `fixup <commit>`::
> Apply the currently staged changes to the specified commit. This
> is done by performing a three-way merge between the HEAD commit,
> the target commit and the tree generated from staged changes.
> This is using the same logic as linkgit:git-cherry-pick[1].
>
> Not sure that this is an improvement? Happy to hear other suggestions.
>
> Thanks!
>
> Patrick
Hm. I think what I meant is that the in-code comment makes sense to
describe internals; for users, I'm not sure what I should get out of
that description of fixup.
What I (think I) really care about is that it behaves a bit like `git
rebase -i` with a "fixup" command (modulo conflicts). Especially since
this is quite a bit more porcelain than plumbing, no?
Idk. If the 3-way merge is valuable to keep, maybe it belongs in a
second paragraph just to push it out of the way of the primary
description ("Apply the currently staged changes to the specified
commit")?
Thanks.
--
D. Ben Knoble
^ permalink raw reply [flat|nested] 21+ messages in thread* Re: [PATCH 2/2] builtin/history: introduce "fixup" subcommand
2026-04-23 21:18 ` D. Ben Knoble
@ 2026-04-24 6:53 ` Patrick Steinhardt
2026-04-24 14:43 ` D. Ben Knoble
0 siblings, 1 reply; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-24 6:53 UTC (permalink / raw)
To: D. Ben Knoble; +Cc: git, Elijah Newren
On Thu, Apr 23, 2026 at 05:18:50PM -0400, D. Ben Knoble wrote:
> On Thu, Apr 23, 2026 at 2:55 AM Patrick Steinhardt <ps@pks.im> wrote:
> > On Wed, Apr 22, 2026 at 03:06:12PM -0400, D. Ben Knoble wrote:
> > > On Wed, Apr 22, 2026 at 6:30 AM Patrick Steinhardt <ps@pks.im> wrote:
> > > > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > > > index 24dc907033..3cdfc8ba02 100644
> > > > --- a/Documentation/git-history.adoc
> > > > +++ b/Documentation/git-history.adoc
> > > > @@ -53,6 +55,19 @@ COMMANDS
> > > >
> > > > The following commands are available to rewrite history in different ways:
> > > >
> > > > +`fixup <commit>`::
> > > > + Apply the currently staged changes to the specified commit. The staged
> > > > + changes are incorporated into the target commit's tree via a three-way
> > > > + merge, using HEAD's tree as the merge base, which is equivalent to
> > > > + linkgit:git-cherry-pick[1].
> > >
> > > I'm not quite sure what, as a user of "git history fixup," I'm
> > > supposed to take from this. Does it make conflicts less likely when
> > > creating the new fixup? I imagine it doesn't help with conflicts
> > > between <commit> and HEAD that newly arise.
> > >
> > > Anyway, I'd think the mechanics are less relevant than the end-user
> > > behavior at this point in the doc, unless the equivalence with
> > > cherry-pick is supposed to tell me something about that behavior.
> >
> > There's at least two more or less obvious variants to do this:
> >
> > - You generate the diff between HEAD and index and then try to reapply
> > the patch on top of the target commit.
> >
> > - You perform the three-way merge.
> >
> > The second item is definitely more robust compared to generating the
> > diff and reapplying it, and we use the exact same strategy to perform
> > cherry-picks nowadays.
> >
> > > > diff --git a/builtin/history.c b/builtin/history.c
> > > > index 549e352c74..6299f0dfa9 100644
> > > > --- a/builtin/history.c
> > > > +++ b/builtin/history.c
> > [snip]
> > > > + /*
> > > > + * Perform the three-way merge to reapply changes in the index onto the
> > > > + * target commit. This is using basically the same logic as a
> > > > + * cherry-pick, where the base commit is our HEAD, ours is the original
> > > > + * tree and theirs is the index tree.
> > > > + */
> > >
> > > OTOH, this explanation helps quite a bit here :)
> >
> > Hm, okay. I felt that this explanation here is even more technical. How
> > about:
> >
> > `fixup <commit>`::
> > Apply the currently staged changes to the specified commit. This
> > is done by performing a three-way merge between the HEAD commit,
> > the target commit and the tree generated from staged changes.
> > This is using the same logic as linkgit:git-cherry-pick[1].
> >
> > Not sure that this is an improvement? Happy to hear other suggestions.
> >
> > Thanks!
> >
> > Patrick
>
> Hm. I think what I meant is that the in-code comment makes sense to
> describe internals; for users, I'm not sure what I should get out of
> that description of fixup.
>
> What I (think I) really care about is that it behaves a bit like `git
> rebase -i` with a "fixup" command (modulo conflicts). Especially since
> this is quite a bit more porcelain than plumbing, no?
>
> Idk. If the 3-way merge is valuable to keep, maybe it belongs in a
> second paragraph just to push it out of the way of the primary
> description ("Apply the currently staged changes to the specified
> commit")?
Ah, that's what you're getting at! I totally misunderstood what you
wanted to say, this makes a lot more sense. How about this:
`fixup <commit>`::
Apply the currently staged changes to the specified commit. This
is similar in nature to `git commit --fixup=<commit>` followed
by `git rebase --autosquash <commit>~`. Changes are applied to
the target commit by performing a three-way merge between the
HEAD commit, the target commit and the tree generated from
staged changes.
Maybe there should be a new paragraph before we start talking about the
technical details?
Thanks!
Patrick
^ permalink raw reply [flat|nested] 21+ messages in thread* Re: [PATCH 2/2] builtin/history: introduce "fixup" subcommand
2026-04-24 6:53 ` Patrick Steinhardt
@ 2026-04-24 14:43 ` D. Ben Knoble
0 siblings, 0 replies; 21+ messages in thread
From: D. Ben Knoble @ 2026-04-24 14:43 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: git, Elijah Newren
On Fri, Apr 24, 2026 at 2:53 AM Patrick Steinhardt <ps@pks.im> wrote:
>
> On Thu, Apr 23, 2026 at 05:18:50PM -0400, D. Ben Knoble wrote:
> > On Thu, Apr 23, 2026 at 2:55 AM Patrick Steinhardt <ps@pks.im> wrote:
> > > On Wed, Apr 22, 2026 at 03:06:12PM -0400, D. Ben Knoble wrote:
> > > > On Wed, Apr 22, 2026 at 6:30 AM Patrick Steinhardt <ps@pks.im> wrote:
> > > > > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > > > > index 24dc907033..3cdfc8ba02 100644
> > > > > --- a/Documentation/git-history.adoc
> > > > > +++ b/Documentation/git-history.adoc
> > > > > @@ -53,6 +55,19 @@ COMMANDS
> > > > >
> > > > > The following commands are available to rewrite history in different ways:
> > > > >
> > > > > +`fixup <commit>`::
> > > > > + Apply the currently staged changes to the specified commit. The staged
> > > > > + changes are incorporated into the target commit's tree via a three-way
> > > > > + merge, using HEAD's tree as the merge base, which is equivalent to
> > > > > + linkgit:git-cherry-pick[1].
> > > >
> > > > I'm not quite sure what, as a user of "git history fixup," I'm
> > > > supposed to take from this. Does it make conflicts less likely when
> > > > creating the new fixup? I imagine it doesn't help with conflicts
> > > > between <commit> and HEAD that newly arise.
> > > >
> > > > Anyway, I'd think the mechanics are less relevant than the end-user
> > > > behavior at this point in the doc, unless the equivalence with
> > > > cherry-pick is supposed to tell me something about that behavior.
> > >
> > > There's at least two more or less obvious variants to do this:
> > >
> > > - You generate the diff between HEAD and index and then try to reapply
> > > the patch on top of the target commit.
> > >
> > > - You perform the three-way merge.
> > >
> > > The second item is definitely more robust compared to generating the
> > > diff and reapplying it, and we use the exact same strategy to perform
> > > cherry-picks nowadays.
> > >
> > > > > diff --git a/builtin/history.c b/builtin/history.c
> > > > > index 549e352c74..6299f0dfa9 100644
> > > > > --- a/builtin/history.c
> > > > > +++ b/builtin/history.c
> > > [snip]
> > > > > + /*
> > > > > + * Perform the three-way merge to reapply changes in the index onto the
> > > > > + * target commit. This is using basically the same logic as a
> > > > > + * cherry-pick, where the base commit is our HEAD, ours is the original
> > > > > + * tree and theirs is the index tree.
> > > > > + */
> > > >
> > > > OTOH, this explanation helps quite a bit here :)
> > >
> > > Hm, okay. I felt that this explanation here is even more technical. How
> > > about:
> > >
> > > `fixup <commit>`::
> > > Apply the currently staged changes to the specified commit. This
> > > is done by performing a three-way merge between the HEAD commit,
> > > the target commit and the tree generated from staged changes.
> > > This is using the same logic as linkgit:git-cherry-pick[1].
> > >
> > > Not sure that this is an improvement? Happy to hear other suggestions.
> > >
> > > Thanks!
> > >
> > > Patrick
> >
> > Hm. I think what I meant is that the in-code comment makes sense to
> > describe internals; for users, I'm not sure what I should get out of
> > that description of fixup.
> >
> > What I (think I) really care about is that it behaves a bit like `git
> > rebase -i` with a "fixup" command (modulo conflicts). Especially since
> > this is quite a bit more porcelain than plumbing, no?
> >
> > Idk. If the 3-way merge is valuable to keep, maybe it belongs in a
> > second paragraph just to push it out of the way of the primary
> > description ("Apply the currently staged changes to the specified
> > commit")?
>
> Ah, that's what you're getting at! I totally misunderstood what you
> wanted to say, this makes a lot more sense. How about this:
Yep, sorry!
> `fixup <commit>`::
> Apply the currently staged changes to the specified commit. This
> is similar in nature to `git commit --fixup=<commit>` followed
> by `git rebase --autosquash <commit>~`. Changes are applied to
> the target commit by performing a three-way merge between the
> HEAD commit, the target commit and the tree generated from
> staged changes.
I think that's much better.
> Maybe there should be a new paragraph before we start talking about the
> technical details?
With this version I could go either way :)
> Thanks!
>
> Patrick
Thank you!
--
D. Ben Knoble
^ permalink raw reply [flat|nested] 21+ messages in thread
* Re: [PATCH 0/2] builtin/history: introduce "fixup" subcommand
2026-04-22 10:28 [PATCH 0/2] builtin/history: introduce "fixup" subcommand Patrick Steinhardt
2026-04-22 10:28 ` [PATCH 1/2] builtin/history: generalize function to commit trees Patrick Steinhardt
2026-04-22 10:28 ` [PATCH 2/2] builtin/history: introduce "fixup" subcommand Patrick Steinhardt
@ 2026-04-22 18:18 ` Tian Yuchen
2026-04-23 6:55 ` Patrick Steinhardt
2026-04-23 14:21 ` [PATCH v2 0/3] " Patrick Steinhardt
2026-04-27 5:53 ` [PATCH v3 0/3] " Patrick Steinhardt
4 siblings, 1 reply; 21+ messages in thread
From: Tian Yuchen @ 2026-04-22 18:18 UTC (permalink / raw)
To: Patrick Steinhardt, git; +Cc: Elijah Newren
Hi Patrick,
On 4/22/26 18:28, Patrick Steinhardt wrote:
> Hi,
>
> this short patch series introduces a new "fixup" subcommand. This
> command is the first one that I felt is missing in my day to day work,
> as I end up doing fixup commits quite often.
>
> The flow is rather simple: the user stages some changes, and then they
> execute `git history fixup <commit>` to amend those changes to the given
> commit. As with the other subcommands, dependent branches will then be
> rebased automatically.
>
> This is the first command that may result in merge conflicts. For now we
> simply abort in such cases, but there are plans to introduce first-class
> conflicts into Git. So once we have them, we'll also be able to handle
> such cases more gracefully. I still think that the command is useful
> even without that conflict handling.
Thank you for developing this feature. Godsend for lazy people like me ;)
Nevertheless, I seem to have come across what appears to be a bug. I
carried out the following steps:
create a.txt -> git add -> git commit -m "base" ->
create b.txt -> git add -> git commit -m "feature" ->
create c.txt -> git add -> git commit -m "tip" ->
rm b.txt -> git add ->
git history fixup HEAD~ ->
git log --oneline --stat...
And the output looks like:
3096a65 (HEAD -> master) tip
c.txt | 1 +
1 file changed, 1 insertion(+)
699f610 feature
0be07e6 base
a.txt | 1 +
1 file changed, 1 insertion(+)
More specifically, the output of
git show HEAD~
is:
Author: Tian Yuchen <cat@malon.dev>
Date: Thu Apr 23 01:57:17 2026 +0800
feature
which is an empty commit. Is it what we expect to see? Sorry that I
don't have enough time to look at the code in detail :P
Thanks, Yuchen
^ permalink raw reply [flat|nested] 21+ messages in thread
* Re: [PATCH 0/2] builtin/history: introduce "fixup" subcommand
2026-04-22 18:18 ` [PATCH 0/2] " Tian Yuchen
@ 2026-04-23 6:55 ` Patrick Steinhardt
0 siblings, 0 replies; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-23 6:55 UTC (permalink / raw)
To: Tian Yuchen; +Cc: git, Elijah Newren
On Thu, Apr 23, 2026 at 02:18:16AM +0800, Tian Yuchen wrote:
> Hi Patrick,
>
> On 4/22/26 18:28, Patrick Steinhardt wrote:
>
> > Hi,
> >
> > this short patch series introduces a new "fixup" subcommand. This
> > command is the first one that I felt is missing in my day to day work,
> > as I end up doing fixup commits quite often.
> >
> > The flow is rather simple: the user stages some changes, and then they
> > execute `git history fixup <commit>` to amend those changes to the given
> > commit. As with the other subcommands, dependent branches will then be
> > rebased automatically.
> >
> > This is the first command that may result in merge conflicts. For now we
> > simply abort in such cases, but there are plans to introduce first-class
> > conflicts into Git. So once we have them, we'll also be able to handle
> > such cases more gracefully. I still think that the command is useful
> > even without that conflict handling.
>
> Thank you for developing this feature. Godsend for lazy people like me ;)
>
> Nevertheless, I seem to have come across what appears to be a bug. I carried
> out the following steps:
>
> create a.txt -> git add -> git commit -m "base" ->
>
> create b.txt -> git add -> git commit -m "feature" ->
>
> create c.txt -> git add -> git commit -m "tip" ->
>
> rm b.txt -> git add ->
>
> git history fixup HEAD~ ->
>
> git log --oneline --stat...
>
> And the output looks like:
>
> 3096a65 (HEAD -> master) tip
> c.txt | 1 +
> 1 file changed, 1 insertion(+)
> 699f610 feature
> 0be07e6 base
> a.txt | 1 +
> 1 file changed, 1 insertion(+)
>
> More specifically, the output of
>
> git show HEAD~
>
> is:
>
> Author: Tian Yuchen <cat@malon.dev>
> Date: Thu Apr 23 01:57:17 2026 +0800
>
> feature
>
> which is an empty commit. Is it what we expect to see? Sorry that I don't
> have enough time to look at the code in detail :P
I guess the answer is "maybe". I think it would most sense if we had the
equivalent of `--empty=(drop|keep|stop)` that we also have in
git-rebase(1).
Thanks!
Patrick
^ permalink raw reply [flat|nested] 21+ messages in thread
* [PATCH v2 0/3] builtin/history: introduce "fixup" subcommand
2026-04-22 10:28 [PATCH 0/2] builtin/history: introduce "fixup" subcommand Patrick Steinhardt
` (2 preceding siblings ...)
2026-04-22 18:18 ` [PATCH 0/2] " Tian Yuchen
@ 2026-04-23 14:21 ` Patrick Steinhardt
2026-04-23 14:21 ` [PATCH v2 1/3] replay: allow callers to control what happens with empty commits Patrick Steinhardt
` (2 more replies)
2026-04-27 5:53 ` [PATCH v3 0/3] " Patrick Steinhardt
4 siblings, 3 replies; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-23 14:21 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, D. Ben Knoble, Tian Yuchen
Hi,
this short patch series introduces a new "fixup" subcommand. This
command is the first one that I felt is missing in my day to day work,
as I end up doing fixup commits quite often.
The flow is rather simple: the user stages some changes, and then they
execute `git history fixup <commit>` to amend those changes to the given
commit. As with the other subcommands, dependent branches will then be
rebased automatically.
This is the first command that may result in merge conflicts. For now we
simply abort in such cases, but there are plans to introduce first-class
conflicts into Git. So once we have them, we'll also be able to handle
such cases more gracefully. I still think that the command is useful
even without that conflict handling.
Changes in v2:
- Introduce "--empty=(keep|drop|abort)" to specify what happens with
empty commits.
- Adapt documentation a bit to hopefully clarify how changes are
backported.
- Link to v1: https://patch.msgid.link/20260422-b4-pks-history-fixup-v1-0-48d4484243de@pks.im
Thanks!
Patrick
---
Patrick Steinhardt (3):
replay: allow callers to control what happens with empty commits
builtin/history: generalize function to commit trees
builtin/history: introduce "fixup" subcommand
Documentation/git-history.adoc | 77 ++++-
builtin/history.c | 291 ++++++++++++++++--
replay.c | 29 +-
replay.h | 19 ++
t/meson.build | 1 +
t/t3453-history-fixup.sh | 680 +++++++++++++++++++++++++++++++++++++++++
6 files changed, 1067 insertions(+), 30 deletions(-)
Range-diff versus v1:
-: ---------- > 1: 79b53c5c27 replay: allow callers to control what happens with empty commits
1: 3bbe1f8b98 = 2: 79573cb5bf builtin/history: generalize function to commit trees
2: 44f22df21e ! 3: afdfd49f96 builtin/history: introduce "fixup" subcommand
@@ Documentation/git-history.adoc: git-history - EXPERIMENTAL: Rewrite history
SYNOPSIS
--------
[synopsis]
-+git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]
++git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
@@ Documentation/git-history.adoc: THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY C
* linkgit:git-history[1] does not execute any linkgit:githooks[5] at the
current point in time. This may change in the future.
* linkgit:git-history[1] by default updates all branches that are descendants
-@@ Documentation/git-history.adoc: COMMANDS
+@@ Documentation/git-history.adoc: conflicts. This limitation is by design as history rewrites are not intended to
+ be stateful operations. The limitation can be lifted once (if) Git learns about
+ first-class conflicts.
+
++When using `fixup` with `--empty=drop`, dropping the root commit is not yet
++supported.
++
+ COMMANDS
+ --------
The following commands are available to rewrite history in different ways:
+`fixup <commit>`::
-+ Apply the currently staged changes to the specified commit. The staged
-+ changes are incorporated into the target commit's tree via a three-way
-+ merge, using HEAD's tree as the merge base, which is equivalent to
-+ linkgit:git-cherry-pick[1].
++ Apply the currently staged changes to the specified commit. This
++ is done by performing a three-way merge between the HEAD commit,
++ the target commit and the tree generated from staged changes.
++ This is using the same logic as linkgit:git-cherry-pick[1].
++
+The commit message and authorship of the target commit are preserved by
+default, unless you specify `--reedit-message`.
@@ Documentation/git-history.adoc: OPTIONS
+`--reedit-message`::
+ Open an editor to modify the target commit's message.
++
++`--empty=(drop|keep|abort)`::
++ Control what happens when a commit becomes empty as a result of the
++ fixup. This can happen in two situations:
+++
++--
++* The fixup target itself becomes empty because the staged changes exactly
++ cancel out all changes introduced by that commit.
++
++* A descendant commit becomes empty during replay because it introduced the
++ same change that was just fixed up into an ancestor.
++--
+++
++With `drop` (the default), empty commits are removed from the rewritten
++history. Descendants of a dropped target commit are replayed directly onto
++the target's parent. Note that dropping the root commit is not supported;
++see LIMITATIONS.
+++
++With `keep`, empty commits are retained in the rewritten history as-is.
+++
++With `abort`, the command stops with an error if any commit would become
++empty.
+
`--update-refs=(branches|head)`::
Control which references will be updated by the command, if any. With
@@ builtin/history.c
#include "wt-status.h"
+#define GIT_HISTORY_FIXUP_USAGE \
-+ N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message]")
++ N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]")
#define GIT_HISTORY_REWORD_USAGE \
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
#define GIT_HISTORY_SPLIT_USAGE \
+@@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,
+ struct commit *original,
+ struct commit *rewritten,
+ const char *reflog_msg,
+- int dry_run)
++ int dry_run,
++ enum replay_empty_commit_action empty)
+ {
+ const struct name_decoration *decoration;
+- struct replay_revisions_options opts = { 0 };
++ struct replay_revisions_options opts = {
++ .empty = empty,
++ };
+ struct replay_result result = { 0 };
+ struct ref_transaction *transaction = NULL;
+ struct strbuf err = STRBUF_INIT;
@@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,
return ret;
}
++static int commit_became_empty(struct repository *repo,
++ struct commit *original,
++ struct tree *result)
++{
++ struct commit *parent = original->parents ? original->parents->item : NULL;
++ struct object_id parent_tree_oid;
++
++ if (parent) {
++ if (repo_parse_commit(repo, parent))
++ return error(_("unable to parse parent of %s"),
++ oid_to_hex(&original->object.oid));
++
++ parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
++ } else {
++ oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
++ }
++
++ return oideq(&result->object.oid, &parent_tree_oid);
++}
++
++static int parse_opt_empty(const struct option *opt, const char *arg, int unset)
++{
++ enum replay_empty_commit_action *value = opt->value;
++
++ BUG_ON_OPT_NEG(unset);
++
++ if (!strcmp(arg, "drop"))
++ *value = REPLAY_EMPTY_COMMIT_DROP;
++ else if (!strcmp(arg, "keep"))
++ *value = REPLAY_EMPTY_COMMIT_KEEP;
++ else if (!strcmp(arg, "abort"))
++ *value = REPLAY_EMPTY_COMMIT_ABORT;
++ else
++ die(_("unrecognized '--empty=' action '%s'; "
++ "valid values are \"drop\", \"keep\", and \"abort\"."), arg);
++
++ return 0;
++}
++
+static int cmd_history_fixup(int argc,
+ const char **argv,
+ const char *prefix,
@@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,
+ GIT_HISTORY_FIXUP_USAGE,
+ NULL,
+ };
++ enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP;
+ enum ref_action action = REF_ACTION_DEFAULT;
-+ int dry_run = 0;
+ enum commit_tree_flags flags = 0;
++ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+ N_("control which refs should be updated"),
@@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,
+ OPT_BIT(0, "reedit-message", &flags,
+ N_("open an editor to modify the commit message"),
+ COMMIT_TREE_EDIT_MESSAGE),
++ OPT_CALLBACK_F(0, "empty", &empty, "(drop|keep|abort)",
++ N_("how to handle commits that become empty"),
++ PARSE_OPT_NONEG, parse_opt_empty),
+ OPT_END(),
+ };
+ struct merge_result merge_result = { 0 };
@@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,
+ struct commit *head_commit, *original, *rewritten;
+ struct tree *head_tree, *original_tree, *index_tree;
+ struct rev_info revs = { 0 };
++ bool skip_commit = false;
+ int ret;
+
+ argc = parse_options(argc, argv, prefix, options, usage, 0);
@@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,
+ goto out;
+ }
+
++ ret = commit_became_empty(repo, original, merge_result.tree);
++ if (ret < 0)
++ goto out;
++ if (ret > 0) {
++ switch (empty) {
++ case REPLAY_EMPTY_COMMIT_DROP:
++ /*
++ * Drop the target commit by replaying its descendants
++ * directly onto its parent.
++ */
++ rewritten = original->parents ? original->parents->item : NULL;
++
++ /*
++ * TODO: we don't yet have the ability to drop root
++ * commits, but there's ultimately no good reason for
++ * this restriction to exist other than a technical
++ * limitation.
++ */
++ if (!rewritten) {
++ ret = error(_("cannot drop root commit %s: "
++ "it has no parent to replay onto"),
++ argv[0]);
++ goto out;
++ }
++
++ skip_commit = true;
++ break;
++ case REPLAY_EMPTY_COMMIT_KEEP:
++ /* Proceed and record the empty commit. */
++ break;
++ case REPLAY_EMPTY_COMMIT_ABORT:
++ ret = error(_("fixup makes commit %s empty"), argv[0]);
++ goto out;
++ }
++ }
++
+ ret = setup_revwalk(repo, action, original, &revs);
+ if (ret)
+ goto out;
+
-+ ret = commit_tree_ext(repo, "fixup", original, original->parents,
-+ &original_tree->object.oid, &merge_result.tree->object.oid,
-+ &rewritten, flags);
-+ if (ret < 0) {
-+ ret = error(_("failed writing fixed-up commit"));
-+ goto out;
++ if (!skip_commit) {
++ ret = commit_tree_ext(repo, "fixup", original, original->parents,
++ &original_tree->object.oid, &merge_result.tree->object.oid,
++ &rewritten, flags);
++ if (ret < 0) {
++ ret = error(_("failed writing fixed-up commit"));
++ goto out;
++ }
+ }
+
+ strbuf_addf(&reflog_msg, "fixup: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, original, rewritten,
-+ reflog_msg.buf, dry_run);
++ reflog_msg.buf, dry_run, empty);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
@@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,
static int cmd_history_reword(int argc,
const char **argv,
const char *prefix,
+@@ builtin/history.c: static int cmd_history_reword(int argc,
+ strbuf_addf(&reflog_msg, "reword: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, original, rewritten,
+- reflog_msg.buf, dry_run);
++ reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+@@ builtin/history.c: static int cmd_history_split(int argc,
+ strbuf_addf(&reflog_msg, "split: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, original, rewritten,
+- reflog_msg.buf, dry_run);
++ reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
@@ builtin/history.c: int cmd_history(int argc,
struct repository *repo)
{
@@ t/t3453-history-fixup.sh (new)
+ test_grep "cannot run fixup in a bare repository" err
+'
+
++test_expect_success 'errors with invalid --empty= value' '
++ test_when_finished "rm -rf repo" &&
++ git init repo &&
++ test_must_fail git -C repo history fixup --empty=bogus HEAD 2>err &&
++ test_grep "unrecognized.*--empty.*bogus" err
++'
++
+test_expect_success 'can fixup the tip commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
@@ t/t3453-history-fixup.sh (new)
+ )
+'
+
++test_expect_success '--empty=drop removes target commit and replays descendants onto its parent' '
++ test_when_finished "rm -rf repo" &&
++ git init repo --initial-branch=main &&
++ (
++ cd repo &&
++
++ test_commit first &&
++ test_commit second &&
++ test_commit third &&
++
++ git rm second.t &&
++ git history fixup --empty=drop HEAD~ &&
++
++ expect_changes <<-\EOF &&
++ third
++ 1 0 third.t
++ first
++ 1 0 first.t
++ EOF
++ test_must_fail git show HEAD:second.t
++ )
++'
++
++test_expect_success '--empty=drop errors out when dropping the root commit' '
++ test_when_finished "rm -rf repo" &&
++ git init repo &&
++ (
++ cd repo &&
++
++ test_commit first &&
++ test_commit second &&
++
++ git rm first.t &&
++ test_must_fail git history fixup --empty=drop HEAD~ 2>err &&
++ test_grep "cannot drop root commit" err
++ )
++'
++
++test_expect_success '--empty=drop can drop the HEAD commit' '
++ test_when_finished "rm -rf repo" &&
++ git init repo &&
++ (
++ cd repo &&
++
++ test_commit first &&
++ test_commit second &&
++
++ git rm second.t &&
++ git history fixup --empty=drop HEAD &&
++
++ expect_changes <<-\EOF
++ first
++ 1 0 first.t
++ EOF
++ )
++'
++
++test_expect_success '--empty=drop drops empty replayed commits' '
++ test_when_finished "rm -rf repo" &&
++ git init repo &&
++ (
++ cd repo &&
++
++ touch base remove-me &&
++ git add . &&
++ git commit -m "base" &&
++ git rm remove-me &&
++ git commit -m "remove" &&
++ touch reintroduce remove-me &&
++ git add . &&
++ git commit -m "reintroduce" &&
++
++ git rm remove-me &&
++ git history fixup --empty=drop HEAD~2 &&
++
++ expect_changes <<-\EOF
++ reintroduce
++ 0 0 reintroduce
++ 0 0 remove-me
++ base
++ 0 0 base
++ EOF
++ )
++'
++
++test_expect_success '--empty=keep keeps commit when fixup target becomes empty' '
++ test_when_finished "rm -rf repo" &&
++ git init repo &&
++ (
++ cd repo &&
++
++ test_commit first &&
++ test_commit second &&
++ test_commit third &&
++
++ git rm second.t &&
++ git history fixup --empty=keep HEAD~ &&
++
++ expect_changes <<-\EOF
++ third
++ 1 0 third.t
++ second
++ first
++ 1 0 first.t
++ EOF
++ )
++'
++
++test_expect_success '--empty=keep keeps commit when replayed commit becomes empty' '
++ test_when_finished "rm -rf repo" &&
++ git init repo &&
++ (
++ cd repo &&
++
++ touch base remove-me &&
++ git add . &&
++ git commit -m "base" &&
++ git rm remove-me &&
++ git commit -m "remove" &&
++ touch reintroduce remove-me &&
++ git add . &&
++ git commit -m "reintroduce" &&
++
++ git rm remove-me &&
++ git history fixup --empty=keep HEAD~2 &&
++
++ expect_changes <<-\EOF
++ reintroduce
++ 0 0 reintroduce
++ 0 0 remove-me
++ remove
++ base
++ 0 0 base
++ EOF
++ )
++'
++
++test_expect_success '--empty=abort errors out when fixup target becomes empty' '
++ test_when_finished "rm -rf repo" &&
++ git init repo &&
++ (
++ cd repo &&
++
++ test_commit first &&
++ test_commit second &&
++
++ git rm first.t &&
++ test_must_fail git history fixup --empty=abort HEAD~ 2>err &&
++ test_grep "fixup makes commit.*empty" err
++ )
++'
++
++test_expect_success '--empty=abort errors out when a descendant becomes empty during replay' '
++ test_when_finished "rm -rf repo" &&
++ git init repo --initial-branch=main &&
++ (
++ cd repo &&
++
++ touch base remove-me &&
++ git add . &&
++ git commit -m "base" &&
++ git rm remove-me &&
++ git commit -m "remove" &&
++ touch reintroduce remove-me &&
++ git add . &&
++ git commit -m "reintroduce" &&
++
++ git rm remove-me &&
++ test_must_fail git history fixup --empty=abort HEAD~2 2>err &&
++ test_grep "became empty after replay" err
++ )
++'
++
+test_done
---
base-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0
change-id: 20260422-b4-pks-history-fixup-be27e0c4a03e
^ permalink raw reply [flat|nested] 21+ messages in thread* [PATCH v2 1/3] replay: allow callers to control what happens with empty commits
2026-04-23 14:21 ` [PATCH v2 0/3] " Patrick Steinhardt
@ 2026-04-23 14:21 ` Patrick Steinhardt
2026-04-23 14:21 ` [PATCH v2 2/3] builtin/history: generalize function to commit trees Patrick Steinhardt
2026-04-23 14:21 ` [PATCH v2 3/3] builtin/history: introduce "fixup" subcommand Patrick Steinhardt
2 siblings, 0 replies; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-23 14:21 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, D. Ben Knoble, Tian Yuchen
When replaying commits it may happen that some of the commits become
empty relative to their parent. Such commits are for now automatically
dropped by the replay subsystem without much control from the user.
Introduce a new enum that allows the caller to drop, keep or abort in
this case.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
replay.c | 29 ++++++++++++++++++++++++-----
replay.h | 19 +++++++++++++++++++
2 files changed, 43 insertions(+), 5 deletions(-)
diff --git a/replay.c b/replay.c
index f96f1f6551..4ef8abb607 100644
--- a/replay.c
+++ b/replay.c
@@ -269,7 +269,8 @@ static struct commit *pick_regular_commit(struct repository *repo,
struct commit *onto,
struct merge_options *merge_opt,
struct merge_result *result,
- enum replay_mode mode)
+ enum replay_mode mode,
+ enum replay_empty_commit_action empty)
{
struct commit *base, *replayed_base;
struct tree *pickme_tree, *base_tree, *replayed_base_tree;
@@ -321,12 +322,25 @@ static struct commit *pick_regular_commit(struct repository *repo,
}
merge_opt->ancestor = NULL;
merge_opt->branch2 = NULL;
+
if (!result->clean)
return NULL;
- /* Drop commits that become empty */
+
+ /* Handle commits that become empty */
if (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) &&
- !oideq(&pickme_tree->object.oid, &base_tree->object.oid))
- return replayed_base;
+ !oideq(&pickme_tree->object.oid, &base_tree->object.oid)) {
+ switch (empty) {
+ case REPLAY_EMPTY_COMMIT_DROP:
+ return replayed_base;
+ case REPLAY_EMPTY_COMMIT_KEEP:
+ break;
+ case REPLAY_EMPTY_COMMIT_ABORT:
+ result->clean = error(_("commit %s became empty after replay"),
+ oid_to_hex(&pickme->object.oid));
+ return NULL;
+ }
+ }
+
return create_commit(repo, result->tree, pickme, replayed_base, mode);
}
@@ -417,7 +431,7 @@ int replay_revisions(struct rev_info *revs,
last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
mode == REPLAY_MODE_REVERT ? last_commit : onto,
- &merge_opt, &result, mode);
+ &merge_opt, &result, mode, opts->empty);
if (!last_commit)
break;
@@ -458,6 +472,11 @@ int replay_revisions(struct rev_info *revs,
}
}
+ if (result.clean < 0) {
+ ret = -1;
+ goto out;
+ }
+
if (!result.clean) {
ret = 1;
goto out;
diff --git a/replay.h b/replay.h
index 0ab74b9805..1851a07705 100644
--- a/replay.h
+++ b/replay.h
@@ -6,6 +6,19 @@
struct repository;
struct rev_info;
+/*
+ * Controls what happens when a replayed commit becomes empty (i.e. its tree
+ * is identical to its parent's tree after the replay).
+ */
+enum replay_empty_commit_action {
+ /* Silently discard the empty commit. */
+ REPLAY_EMPTY_COMMIT_DROP,
+ /* Keep the empty commit as-is. */
+ REPLAY_EMPTY_COMMIT_KEEP,
+ /* Abort with an error. */
+ REPLAY_EMPTY_COMMIT_ABORT,
+};
+
/*
* A set of options that can be passed to `replay_revisions()`.
*/
@@ -43,6 +56,12 @@ struct replay_revisions_options {
* Requires `onto` to be set.
*/
int contained;
+
+ /*
+ * Controls what to do when a replayed commit becomes empty.
+ * Defaults to REPLAY_EMPTY_COMMIT_DROP.
+ */
+ enum replay_empty_commit_action empty;
};
/* This struct is used as an out-parameter by `replay_revisions()`. */
--
2.54.0.545.g6539524ca2.dirty
^ permalink raw reply related [flat|nested] 21+ messages in thread* [PATCH v2 2/3] builtin/history: generalize function to commit trees
2026-04-23 14:21 ` [PATCH v2 0/3] " Patrick Steinhardt
2026-04-23 14:21 ` [PATCH v2 1/3] replay: allow callers to control what happens with empty commits Patrick Steinhardt
@ 2026-04-23 14:21 ` Patrick Steinhardt
2026-04-23 14:21 ` [PATCH v2 3/3] builtin/history: introduce "fixup" subcommand Patrick Steinhardt
2 siblings, 0 replies; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-23 14:21 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, D. Ben Knoble, Tian Yuchen
The function `commit_tree_with_edited_message_ext()` can be used to
commit a tree with a specific list of parents with an edited commit
message. This function is useful outside of editing the commit message
though, as it also performs the plumbing to extract the original commit
message and strip some headers from it.
Refactor the function to receive a flags field that allows the caller to
control whether or not the commit message should be edited, or whether
it should be retained as-is. This will be used in a subsequent commit.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
builtin/history.c | 45 ++++++++++++++++++++++++++-------------------
1 file changed, 26 insertions(+), 19 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index 9526938085..549e352c74 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -91,13 +91,18 @@ static int fill_commit_message(struct repository *repo,
return 0;
}
-static int commit_tree_with_edited_message_ext(struct repository *repo,
- const char *action,
- struct commit *commit_with_message,
- const struct commit_list *parents,
- const struct object_id *old_tree,
- const struct object_id *new_tree,
- struct commit **out)
+enum commit_tree_flags {
+ COMMIT_TREE_EDIT_MESSAGE = (1 << 0),
+};
+
+static int commit_tree_ext(struct repository *repo,
+ const char *action,
+ struct commit *commit_with_message,
+ const struct commit_list *parents,
+ const struct object_id *old_tree,
+ const struct object_id *new_tree,
+ struct commit **out,
+ enum commit_tree_flags flags)
{
const char *exclude_gpgsig[] = {
/* We reencode the message, so the encoding needs to be stripped. */
@@ -122,10 +127,14 @@ static int commit_tree_with_edited_message_ext(struct repository *repo,
original_author = xmemdupz(ptr, len);
find_commit_subject(original_message, &original_body);
- ret = fill_commit_message(repo, old_tree, new_tree,
- original_body, action, &commit_message);
- if (ret < 0)
- goto out;
+ if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+ ret = fill_commit_message(repo, old_tree, new_tree,
+ original_body, action, &commit_message);
+ if (ret < 0)
+ goto out;
+ } else {
+ strbuf_addstr(&commit_message, original_body);
+ }
original_extra_headers = read_commit_extra_headers(commit_with_message,
exclude_gpgsig);
@@ -168,8 +177,8 @@ static int commit_tree_with_edited_message(struct repository *repo,
oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
}
- return commit_tree_with_edited_message_ext(repo, action, original, original->parents,
- &parent_tree_oid, tree_oid, out);
+ return commit_tree_ext(repo, action, original, original->parents,
+ &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
}
enum ref_action {
@@ -616,9 +625,8 @@ static int split_commit(struct repository *repo,
* The first commit is constructed from the split-out tree. The base
* that shall be diffed against is the parent of the original commit.
*/
- ret = commit_tree_with_edited_message_ext(repo, "split-out", original,
- original->parents, &parent_tree_oid,
- &split_tree->object.oid, &first_commit);
+ ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
+ &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing first commit"));
goto out;
@@ -634,9 +642,8 @@ static int split_commit(struct repository *repo,
old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;
new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- ret = commit_tree_with_edited_message_ext(repo, "split-out", original,
- parents, old_tree_oid,
- new_tree_oid, &second_commit);
+ ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
+ new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing second commit"));
goto out;
--
2.54.0.545.g6539524ca2.dirty
^ permalink raw reply related [flat|nested] 21+ messages in thread* [PATCH v2 3/3] builtin/history: introduce "fixup" subcommand
2026-04-23 14:21 ` [PATCH v2 0/3] " Patrick Steinhardt
2026-04-23 14:21 ` [PATCH v2 1/3] replay: allow callers to control what happens with empty commits Patrick Steinhardt
2026-04-23 14:21 ` [PATCH v2 2/3] builtin/history: generalize function to commit trees Patrick Steinhardt
@ 2026-04-23 14:21 ` Patrick Steinhardt
2 siblings, 0 replies; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-23 14:21 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, D. Ben Knoble, Tian Yuchen
The newly introduced git-history(1) command provides functionality to
easily edit commit history while also rebasing dependent branches. The
functionality exposed by this command is still somewhat limited though.
One common use case when editing commit history that is not yet covered
is fixing up a specific commit. Introduce a new subcommand that allows
the user to do exactly that by performing a three-way merge into the
target's commit tree, using HEAD's tree as the merge base. The flow is
thus essentially:
$ echo changes >file
$ git add file
$ git history fixup HEAD~
Like with the other commands, this will automatically rebase dependent
branches, as well. Unlike the other commands though:
- The command does not work in a bare repository as it interacts with
the index.
- The command may run into merge conflicts. If so, the command will
simply abort.
Especially the second item limits the usefulness of this command a bit.
But there are plans to introduce first-class conflicts into Git, which
will help use cases like this one.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
Documentation/git-history.adoc | 77 ++++-
builtin/history.c | 246 ++++++++++++++-
t/meson.build | 1 +
t/t3453-history-fixup.sh | 680 +++++++++++++++++++++++++++++++++++++++++
4 files changed, 998 insertions(+), 6 deletions(-)
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 24dc907033..6576379f77 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -8,6 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history
SYNOPSIS
--------
[synopsis]
+git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
@@ -22,8 +23,9 @@ THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
This command is related to linkgit:git-rebase[1] in that both commands can be
used to rewrite history. There are a couple of major differences though:
-* linkgit:git-history[1] can work in a bare repository as it does not need to
- touch either the index or the worktree.
+* Most subcommands of linkgit:git-history[1] can work in a bare repository as
+ they do not need to touch either the index or the worktree. The `fixup`
+ subcommand is an exception to this, as it reads staged changes from the index.
* linkgit:git-history[1] does not execute any linkgit:githooks[5] at the
current point in time. This may change in the future.
* linkgit:git-history[1] by default updates all branches that are descendants
@@ -48,11 +50,27 @@ conflicts. This limitation is by design as history rewrites are not intended to
be stateful operations. The limitation can be lifted once (if) Git learns about
first-class conflicts.
+When using `fixup` with `--empty=drop`, dropping the root commit is not yet
+supported.
+
COMMANDS
--------
The following commands are available to rewrite history in different ways:
+`fixup <commit>`::
+ Apply the currently staged changes to the specified commit. This
+ is done by performing a three-way merge between the HEAD commit,
+ the target commit and the tree generated from staged changes.
+ This is using the same logic as linkgit:git-cherry-pick[1].
++
+The commit message and authorship of the target commit are preserved by
+default, unless you specify `--reedit-message`.
++
+If applying the staged changes would result in a conflict, the command
+aborts with an error. All branches that are descendants of the original
+commit are updated to point to the rewritten history.
+
`reword <commit>`::
Rewrite the commit message of the specified commit. All the other
details of this commit remain unchanged. This command will spawn an
@@ -87,6 +105,31 @@ OPTIONS
objects will be written into the repository, so applying these printed
ref updates is generally safe.
+`--reedit-message`::
+ Open an editor to modify the target commit's message.
+
+`--empty=(drop|keep|abort)`::
+ Control what happens when a commit becomes empty as a result of the
+ fixup. This can happen in two situations:
++
+--
+* The fixup target itself becomes empty because the staged changes exactly
+ cancel out all changes introduced by that commit.
+
+* A descendant commit becomes empty during replay because it introduced the
+ same change that was just fixed up into an ancestor.
+--
++
+With `drop` (the default), empty commits are removed from the rewritten
+history. Descendants of a dropped target commit are replayed directly onto
+the target's parent. Note that dropping the root commit is not supported;
+see LIMITATIONS.
++
+With `keep`, empty commits are retained in the rewritten history as-is.
++
+With `abort`, the command stops with an error if any commit would become
+empty.
+
`--update-refs=(branches|head)`::
Control which references will be updated by the command, if any. With
`branches`, all local branches that point to commits which are
@@ -96,6 +139,36 @@ OPTIONS
EXAMPLES
--------
+Fixup a commit
+~~~~~~~~~~~~~~
+
+----------
+$ git log --oneline --stat
+abc1234 (HEAD -> main) third
+ third.txt | 1 +
+def5678 second
+ second.txt | 1 +
+ghi9012 first
+ first.txt | 1 +
+
+$ echo "change" >>unrelated.txt
+$ git add unrelated.txt
+$ git history fixup ghi9012
+
+$ git log --oneline --stat
+jkl3456 (HEAD -> main) third
+ third.txt | 1 +
+mno7890 second
+ second.txt | 1 +
+pqr1234 first
+ first.txt | 1 +
+ unrelated.txt | 1 +
+----------
+
+The staged addition of `unrelated.txt` has been incorporated into the `first`
+commit. All descendant commits have been replayed on top of the rewritten
+history.
+
Split a commit
~~~~~~~~~~~~~~
diff --git a/builtin/history.c b/builtin/history.c
index 549e352c74..0fc06fb204 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -10,6 +10,7 @@
#include "gettext.h"
#include "hex.h"
#include "lockfile.h"
+#include "merge-ort.h"
#include "oidmap.h"
#include "parse-options.h"
#include "path.h"
@@ -23,6 +24,8 @@
#include "unpack-trees.h"
#include "wt-status.h"
+#define GIT_HISTORY_FIXUP_USAGE \
+ N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]")
#define GIT_HISTORY_REWORD_USAGE \
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
#define GIT_HISTORY_SPLIT_USAGE \
@@ -335,10 +338,13 @@ static int handle_reference_updates(struct rev_info *revs,
struct commit *original,
struct commit *rewritten,
const char *reflog_msg,
- int dry_run)
+ int dry_run,
+ enum replay_empty_commit_action empty)
{
const struct name_decoration *decoration;
- struct replay_revisions_options opts = { 0 };
+ struct replay_revisions_options opts = {
+ .empty = empty,
+ };
struct replay_result result = { 0 };
struct ref_transaction *transaction = NULL;
struct strbuf err = STRBUF_INIT;
@@ -434,6 +440,236 @@ static int handle_reference_updates(struct rev_info *revs,
return ret;
}
+static int commit_became_empty(struct repository *repo,
+ struct commit *original,
+ struct tree *result)
+{
+ struct commit *parent = original->parents ? original->parents->item : NULL;
+ struct object_id parent_tree_oid;
+
+ if (parent) {
+ if (repo_parse_commit(repo, parent))
+ return error(_("unable to parse parent of %s"),
+ oid_to_hex(&original->object.oid));
+
+ parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
+ } else {
+ oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+ }
+
+ return oideq(&result->object.oid, &parent_tree_oid);
+}
+
+static int parse_opt_empty(const struct option *opt, const char *arg, int unset)
+{
+ enum replay_empty_commit_action *value = opt->value;
+
+ BUG_ON_OPT_NEG(unset);
+
+ if (!strcmp(arg, "drop"))
+ *value = REPLAY_EMPTY_COMMIT_DROP;
+ else if (!strcmp(arg, "keep"))
+ *value = REPLAY_EMPTY_COMMIT_KEEP;
+ else if (!strcmp(arg, "abort"))
+ *value = REPLAY_EMPTY_COMMIT_ABORT;
+ else
+ die(_("unrecognized '--empty=' action '%s'; "
+ "valid values are \"drop\", \"keep\", and \"abort\"."), arg);
+
+ return 0;
+}
+
+static int cmd_history_fixup(int argc,
+ const char **argv,
+ const char *prefix,
+ struct repository *repo)
+{
+ const char * const usage[] = {
+ GIT_HISTORY_FIXUP_USAGE,
+ NULL,
+ };
+ enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP;
+ enum ref_action action = REF_ACTION_DEFAULT;
+ enum commit_tree_flags flags = 0;
+ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+ N_("control which refs should be updated"),
+ PARSE_OPT_NONEG, parse_ref_action),
+ OPT_BOOL('n', "dry-run", &dry_run,
+ N_("perform a dry-run without updating any refs")),
+ OPT_BIT(0, "reedit-message", &flags,
+ N_("open an editor to modify the commit message"),
+ COMMIT_TREE_EDIT_MESSAGE),
+ OPT_CALLBACK_F(0, "empty", &empty, "(drop|keep|abort)",
+ N_("how to handle commits that become empty"),
+ PARSE_OPT_NONEG, parse_opt_empty),
+ OPT_END(),
+ };
+ struct merge_result merge_result = { 0 };
+ struct merge_options merge_opts = { 0 };
+ struct strbuf reflog_msg = STRBUF_INIT;
+ struct commit *head_commit, *original, *rewritten;
+ struct tree *head_tree, *original_tree, *index_tree;
+ struct rev_info revs = { 0 };
+ bool skip_commit = false;
+ int ret;
+
+ argc = parse_options(argc, argv, prefix, options, usage, 0);
+ if (argc != 1) {
+ ret = error(_("command expects a single revision"));
+ goto out;
+ }
+ repo_config(repo, git_default_config, NULL);
+
+ if (action == REF_ACTION_DEFAULT)
+ action = REF_ACTION_BRANCHES;
+
+ if (is_bare_repository()) {
+ ret = error(_("cannot run fixup in a bare repository"));
+ goto out;
+ }
+
+ /* Resolve the original commit, which is the one we want to fix up. */
+ original = lookup_commit_reference_by_name(argv[0]);
+ if (!original) {
+ ret = error(_("commit cannot be found: %s"), argv[0]);
+ goto out;
+ }
+
+ /*
+ * Resolve HEAD so we can use its tree as the merge base: the staged
+ * changes are expressed as a diff from HEAD's tree to the index tree.
+ */
+ head_commit = lookup_commit_reference_by_name("HEAD");
+ if (!head_commit) {
+ ret = error(_("cannot look up HEAD"));
+ goto out;
+ }
+
+ head_tree = repo_get_commit_tree(repo, head_commit);
+ if (!head_tree) {
+ ret = error(_("cannot get tree for HEAD"));
+ goto out;
+ }
+
+ if (repo_read_index(repo) < 0) {
+ ret = error(_("unable to read index"));
+ goto out;
+ }
+
+ if (!repo_index_has_changes(repo, head_tree, NULL)) {
+ ret = error(_("nothing to fixup: no staged changes"));
+ goto out;
+ }
+
+ /*
+ * Write the index as a tree object. This is the "theirs" side of the
+ * three-way merge: it is HEAD's tree with the staged changes applied.
+ */
+ index_tree = write_in_core_index_as_tree(repo, repo->index);
+ if (!index_tree) {
+ ret = error(_("unable to write index as a tree"));
+ goto out;
+ }
+
+ original_tree = repo_get_commit_tree(repo, original);
+ if (!original_tree) {
+ ret = error(_("cannot get tree for commit %s"), argv[0]);
+ goto out;
+ }
+
+ /*
+ * Perform the three-way merge to reapply changes in the index onto the
+ * target commit. This is using basically the same logic as a
+ * cherry-pick, where the base commit is our HEAD, ours is the original
+ * tree and theirs is the index tree.
+ */
+ init_basic_merge_options(&merge_opts, repo);
+ merge_opts.ancestor = "HEAD";
+ merge_opts.branch1 = argv[0];
+ merge_opts.branch2 = "staged";
+ merge_incore_nonrecursive(&merge_opts, head_tree,
+ original_tree, index_tree, &merge_result);
+
+ if (merge_result.clean < 0) {
+ ret = error(_("merge failed while applying fixup"));
+ goto out;
+ }
+
+ if (!merge_result.clean) {
+ ret = error(_("fixup would produce conflicts; aborting"));
+ goto out;
+ }
+
+ ret = commit_became_empty(repo, original, merge_result.tree);
+ if (ret < 0)
+ goto out;
+ if (ret > 0) {
+ switch (empty) {
+ case REPLAY_EMPTY_COMMIT_DROP:
+ /*
+ * Drop the target commit by replaying its descendants
+ * directly onto its parent.
+ */
+ rewritten = original->parents ? original->parents->item : NULL;
+
+ /*
+ * TODO: we don't yet have the ability to drop root
+ * commits, but there's ultimately no good reason for
+ * this restriction to exist other than a technical
+ * limitation.
+ */
+ if (!rewritten) {
+ ret = error(_("cannot drop root commit %s: "
+ "it has no parent to replay onto"),
+ argv[0]);
+ goto out;
+ }
+
+ skip_commit = true;
+ break;
+ case REPLAY_EMPTY_COMMIT_KEEP:
+ /* Proceed and record the empty commit. */
+ break;
+ case REPLAY_EMPTY_COMMIT_ABORT:
+ ret = error(_("fixup makes commit %s empty"), argv[0]);
+ goto out;
+ }
+ }
+
+ ret = setup_revwalk(repo, action, original, &revs);
+ if (ret)
+ goto out;
+
+ if (!skip_commit) {
+ ret = commit_tree_ext(repo, "fixup", original, original->parents,
+ &original_tree->object.oid, &merge_result.tree->object.oid,
+ &rewritten, flags);
+ if (ret < 0) {
+ ret = error(_("failed writing fixed-up commit"));
+ goto out;
+ }
+ }
+
+ strbuf_addf(&reflog_msg, "fixup: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, original, rewritten,
+ reflog_msg.buf, dry_run, empty);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+ }
+
+ ret = 0;
+
+out:
+ merge_finalize(&merge_opts, &merge_result);
+ strbuf_release(&reflog_msg);
+ release_revisions(&revs);
+ return ret;
+}
+
static int cmd_history_reword(int argc,
const char **argv,
const char *prefix,
@@ -487,7 +723,7 @@ static int cmd_history_reword(int argc,
strbuf_addf(&reflog_msg, "reword: updating %s", argv[0]);
ret = handle_reference_updates(&revs, action, original, rewritten,
- reflog_msg.buf, dry_run);
+ reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);
if (ret < 0) {
ret = error(_("failed replaying descendants"));
goto out;
@@ -724,7 +960,7 @@ static int cmd_history_split(int argc,
strbuf_addf(&reflog_msg, "split: updating %s", argv[0]);
ret = handle_reference_updates(&revs, action, original, rewritten,
- reflog_msg.buf, dry_run);
+ reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);
if (ret < 0) {
ret = error(_("failed replaying descendants"));
goto out;
@@ -745,12 +981,14 @@ int cmd_history(int argc,
struct repository *repo)
{
const char * const usage[] = {
+ GIT_HISTORY_FIXUP_USAGE,
GIT_HISTORY_REWORD_USAGE,
GIT_HISTORY_SPLIT_USAGE,
NULL,
};
parse_opt_subcommand_fn *fn = NULL;
struct option options[] = {
+ OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
OPT_SUBCOMMAND("split", &fn, cmd_history_split),
OPT_END(),
diff --git a/t/meson.build b/t/meson.build
index 7528e5cda5..f502ad8ec9 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -397,6 +397,7 @@ integration_tests = [
't3450-history.sh',
't3451-history-reword.sh',
't3452-history-split.sh',
+ 't3453-history-fixup.sh',
't3500-cherry.sh',
't3501-revert-cherry-pick.sh',
't3502-cherry-pick-merge.sh',
diff --git a/t/t3453-history-fixup.sh b/t/t3453-history-fixup.sh
new file mode 100755
index 0000000000..868298e248
--- /dev/null
+++ b/t/t3453-history-fixup.sh
@@ -0,0 +1,680 @@
+#!/bin/sh
+
+test_description='tests for git-history fixup subcommand'
+
+. ./test-lib.sh
+
+fixup_with_message () {
+ cat >message &&
+ write_script fake-editor.sh <<-\EOF &&
+ cp message "$1"
+ EOF
+ test_set_editor "$(pwd)"/fake-editor.sh &&
+ git history fixup --reedit-message "$@" &&
+ rm fake-editor.sh message
+}
+
+expect_changes () {
+ git log --format="%s" --numstat "$@" >actual.raw &&
+ sed '/^$/d' <actual.raw >actual &&
+ cat >expect &&
+ test_cmp expect actual
+}
+
+test_expect_success 'errors on missing commit argument' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ test_must_fail git history fixup 2>err &&
+ test_grep "command expects a single revision" err
+ )
+'
+
+test_expect_success 'errors on too many arguments' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ test_must_fail git history fixup HEAD HEAD 2>err &&
+ test_grep "command expects a single revision" err
+ )
+'
+
+test_expect_success 'errors on unknown revision' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ test_must_fail git history fixup does-not-exist 2>err &&
+ test_grep "commit cannot be found: does-not-exist" err
+ )
+'
+
+test_expect_success 'errors when nothing is staged' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ test_must_fail git history fixup HEAD 2>err &&
+ test_grep "nothing to fixup: no staged changes" err
+ )
+'
+
+test_expect_success 'errors in a bare repository' '
+ test_when_finished "rm -rf repo repo.git" &&
+ git init repo &&
+ test_commit -C repo initial &&
+ git clone --bare repo repo.git &&
+ test_must_fail git -C repo.git history fixup HEAD 2>err &&
+ test_grep "cannot run fixup in a bare repository" err
+'
+
+test_expect_success 'errors with invalid --empty= value' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ test_must_fail git -C repo history fixup --empty=bogus HEAD 2>err &&
+ test_grep "unrecognized.*--empty.*bogus" err
+'
+
+test_expect_success 'can fixup the tip commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ echo content >file.txt &&
+ git add file.txt &&
+ git commit -m "add file" &&
+
+ echo fix >>file.txt &&
+ git add file.txt &&
+
+ expect_changes <<-\EOF &&
+ add file
+ 1 0 file.txt
+ initial
+ 1 0 initial.t
+ EOF
+
+ git symbolic-ref HEAD >branch-expect &&
+ git history fixup HEAD &&
+ git symbolic-ref HEAD >branch-actual &&
+ test_cmp branch-expect branch-actual &&
+
+ expect_changes <<-\EOF &&
+ add file
+ 2 0 file.txt
+ initial
+ 1 0 initial.t
+ EOF
+
+ # Verify the fix is in the tip commit tree
+ git show HEAD:file.txt >actual &&
+ printf "content\nfix\n" >expect &&
+ test_cmp expect actual &&
+
+ git reflog >reflog &&
+ test_grep "fixup: updating HEAD" reflog
+ )
+'
+
+test_expect_success 'can fixup a commit in the middle of history' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit first &&
+ echo content >file.txt &&
+ git add file.txt &&
+ git commit -m "add file" &&
+ test_commit third &&
+
+ echo fix >>file.txt &&
+ git add file.txt &&
+
+ expect_changes <<-\EOF &&
+ third
+ 1 0 third.t
+ add file
+ 1 0 file.txt
+ first
+ 1 0 first.t
+ EOF
+
+ git history fixup HEAD~ &&
+
+ expect_changes <<-\EOF &&
+ third
+ 1 0 third.t
+ add file
+ 2 0 file.txt
+ first
+ 1 0 first.t
+ EOF
+
+ # Verify the fix landed in the "add file" commit.
+ git show HEAD~:file.txt >actual &&
+ printf "content\nfix\n" >expect &&
+ test_cmp expect actual &&
+
+ # And verify that the replayed commit also has the change.
+ git show HEAD:file.txt >actual &&
+ printf "content\nfix\n" >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'can fixup root commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ echo initial >root.txt &&
+ git add root.txt &&
+ git commit -m "root" &&
+ test_commit second &&
+
+ expect_changes <<-\EOF &&
+ second
+ 1 0 second.t
+ root
+ 1 0 root.txt
+ EOF
+
+ echo fix >>root.txt &&
+ git add root.txt &&
+ git history fixup HEAD~ &&
+
+ expect_changes <<-\EOF &&
+ second
+ 1 0 second.t
+ root
+ 2 0 root.txt
+ EOF
+
+ git show HEAD~:root.txt >actual &&
+ printf "initial\nfix\n" >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'preserves commit message and authorship' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ echo content >file.txt &&
+ git add file.txt &&
+ git commit --author="Original <original@example.com>" -m "original message" &&
+
+ echo fix >>file.txt &&
+ git add file.txt &&
+ git history fixup HEAD &&
+
+ # Message preserved
+ git log -1 --format="%s" >actual &&
+ echo "original message" >expect &&
+ test_cmp expect actual &&
+
+ # Authorship preserved
+ git log -1 --format="%an <%ae>" >actual &&
+ echo "Original <original@example.com>" >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'updates all descendant branches by default' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch branch &&
+ test_commit ours &&
+ git switch branch &&
+ test_commit theirs &&
+ git switch main &&
+
+ echo fix >fix.txt &&
+ git add fix.txt &&
+ git history fixup base &&
+
+ expect_changes --branches <<-\EOF &&
+ theirs
+ 1 0 theirs.t
+ ours
+ 1 0 ours.t
+ base
+ 1 0 base.t
+ 1 0 fix.txt
+ EOF
+
+ # Both branches should have the fix in the base
+ git show main~:fix.txt >actual &&
+ echo fix >expect &&
+ test_cmp expect actual &&
+ git show branch~:fix.txt >actual &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'can fixup commit on a different branch' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch theirs &&
+ test_commit ours &&
+ git switch theirs &&
+ test_commit theirs &&
+
+ # Stage a change while on "theirs"
+ echo fix >fix.txt &&
+ git add fix.txt &&
+
+ # Ensure that "ours" does not change, as it does not contain
+ # the commit in question.
+ git rev-parse ours >ours-before &&
+ git history fixup theirs &&
+ git rev-parse ours >ours-after &&
+ test_cmp ours-before ours-after &&
+
+ git show HEAD:fix.txt >actual &&
+ echo fix >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success '--dry-run prints ref updates without modifying repo' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch branch &&
+ test_commit main-tip &&
+ git switch branch &&
+ test_commit branch-tip &&
+ git switch main &&
+
+ echo fix >fix.txt &&
+ git add fix.txt &&
+
+ git refs list >refs-before &&
+ git history fixup --dry-run base >updates &&
+ git refs list >refs-after &&
+ test_cmp refs-before refs-after &&
+
+ test_grep "update refs/heads/main" updates &&
+ test_grep "update refs/heads/branch" updates &&
+
+ expect_changes --branches <<-\EOF &&
+ branch-tip
+ 1 0 branch-tip.t
+ main-tip
+ 1 0 main-tip.t
+ base
+ 1 0 base.t
+ EOF
+
+ git update-ref --stdin <updates &&
+ expect_changes --branches <<-\EOF
+ branch-tip
+ 1 0 branch-tip.t
+ main-tip
+ 1 0 main-tip.t
+ base
+ 1 0 base.t
+ 1 0 fix.txt
+ EOF
+ )
+'
+
+test_expect_success '--update-refs=head updates only HEAD' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch branch &&
+ test_commit main-tip &&
+ git switch branch &&
+ test_commit branch-tip &&
+
+ echo fix >fix.txt &&
+ git add fix.txt &&
+
+ # Only HEAD (branch) should be updated
+ git history fixup --update-refs=head base &&
+
+ # The main branch should be unaffected.
+ expect_changes main <<-\EOF &&
+ main-tip
+ 1 0 main-tip.t
+ base
+ 1 0 base.t
+ EOF
+
+ # But the currently checked out branch should be modified.
+ expect_changes branch <<-\EOF
+ branch-tip
+ 1 0 branch-tip.t
+ base
+ 1 0 base.t
+ 1 0 fix.txt
+ EOF
+ )
+'
+
+test_expect_success '--update-refs=head refuses to rewrite commits not in HEAD ancestry' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch other &&
+ test_commit main-tip &&
+ git switch other &&
+ test_commit other-tip &&
+
+ echo fix >fix.txt &&
+ git add fix.txt &&
+
+ test_must_fail git history fixup --update-refs=head main-tip 2>err &&
+ test_grep "rewritten commit must be an ancestor of HEAD" err
+ )
+'
+
+test_expect_success 'aborts when fixup would produce conflicts' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ echo "line one" >file.txt &&
+ git add file.txt &&
+ git commit -m "first" &&
+
+ echo "line two" >file.txt &&
+ git add file.txt &&
+ git commit -m "second" &&
+
+ echo "conflicting change" >file.txt &&
+ git add file.txt &&
+
+ git refs list >refs-before &&
+ test_must_fail git history fixup HEAD~ 2>err &&
+ test_grep "fixup would produce conflicts" err &&
+ git refs list >refs-after &&
+ test_cmp refs-before refs-after
+ )
+'
+
+test_expect_success '--reedit-message opens editor for the commit message' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ echo content >file.txt &&
+ git add file.txt &&
+ git commit -m "add file" &&
+
+ echo fix >>file.txt &&
+ git add file.txt &&
+
+ fixup_with_message HEAD <<-\EOF &&
+ add file with fix
+ EOF
+
+ expect_changes --branches <<-\EOF
+ add file with fix
+ 2 0 file.txt
+ initial
+ 1 0 initial.t
+ EOF
+ )
+'
+
+test_expect_success 'retains unstaged working tree changes after fixup' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ touch a b &&
+ git add . &&
+ git commit -m "initial commit" &&
+ echo staged >a &&
+ echo unstaged >b &&
+ git add a &&
+ git history fixup HEAD &&
+
+ # b is still modified in the worktree but not staged
+ cat >expect <<-\EOF &&
+ M b
+ EOF
+ git status --porcelain --untracked-files=no >actual &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'index is clean after fixup when target is HEAD' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ test_commit initial &&
+ echo fix >fix.txt &&
+ git add fix.txt &&
+ git history fixup HEAD &&
+
+ git status --porcelain --untracked-files=no >actual &&
+ test_must_be_empty actual
+ )
+'
+
+test_expect_success 'index is unchanged on conflict' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ echo base >file.txt &&
+ git add file.txt &&
+ git commit -m base &&
+ echo change >file.txt &&
+ git add file.txt &&
+ git commit -m change &&
+
+ echo conflict >file.txt &&
+ git add file.txt &&
+
+ git diff --cached >index-before &&
+ test_must_fail git history fixup HEAD~ &&
+ git diff --cached >index-after &&
+ test_cmp index-before index-after
+ )
+'
+
+test_expect_success '--empty=drop removes target commit and replays descendants onto its parent' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+
+ test_commit first &&
+ test_commit second &&
+ test_commit third &&
+
+ git rm second.t &&
+ git history fixup --empty=drop HEAD~ &&
+
+ expect_changes <<-\EOF &&
+ third
+ 1 0 third.t
+ first
+ 1 0 first.t
+ EOF
+ test_must_fail git show HEAD:second.t
+ )
+'
+
+test_expect_success '--empty=drop errors out when dropping the root commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ test_commit first &&
+ test_commit second &&
+
+ git rm first.t &&
+ test_must_fail git history fixup --empty=drop HEAD~ 2>err &&
+ test_grep "cannot drop root commit" err
+ )
+'
+
+test_expect_success '--empty=drop can drop the HEAD commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ test_commit first &&
+ test_commit second &&
+
+ git rm second.t &&
+ git history fixup --empty=drop HEAD &&
+
+ expect_changes <<-\EOF
+ first
+ 1 0 first.t
+ EOF
+ )
+'
+
+test_expect_success '--empty=drop drops empty replayed commits' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ touch base remove-me &&
+ git add . &&
+ git commit -m "base" &&
+ git rm remove-me &&
+ git commit -m "remove" &&
+ touch reintroduce remove-me &&
+ git add . &&
+ git commit -m "reintroduce" &&
+
+ git rm remove-me &&
+ git history fixup --empty=drop HEAD~2 &&
+
+ expect_changes <<-\EOF
+ reintroduce
+ 0 0 reintroduce
+ 0 0 remove-me
+ base
+ 0 0 base
+ EOF
+ )
+'
+
+test_expect_success '--empty=keep keeps commit when fixup target becomes empty' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ test_commit first &&
+ test_commit second &&
+ test_commit third &&
+
+ git rm second.t &&
+ git history fixup --empty=keep HEAD~ &&
+
+ expect_changes <<-\EOF
+ third
+ 1 0 third.t
+ second
+ first
+ 1 0 first.t
+ EOF
+ )
+'
+
+test_expect_success '--empty=keep keeps commit when replayed commit becomes empty' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ touch base remove-me &&
+ git add . &&
+ git commit -m "base" &&
+ git rm remove-me &&
+ git commit -m "remove" &&
+ touch reintroduce remove-me &&
+ git add . &&
+ git commit -m "reintroduce" &&
+
+ git rm remove-me &&
+ git history fixup --empty=keep HEAD~2 &&
+
+ expect_changes <<-\EOF
+ reintroduce
+ 0 0 reintroduce
+ 0 0 remove-me
+ remove
+ base
+ 0 0 base
+ EOF
+ )
+'
+
+test_expect_success '--empty=abort errors out when fixup target becomes empty' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ test_commit first &&
+ test_commit second &&
+
+ git rm first.t &&
+ test_must_fail git history fixup --empty=abort HEAD~ 2>err &&
+ test_grep "fixup makes commit.*empty" err
+ )
+'
+
+test_expect_success '--empty=abort errors out when a descendant becomes empty during replay' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+
+ touch base remove-me &&
+ git add . &&
+ git commit -m "base" &&
+ git rm remove-me &&
+ git commit -m "remove" &&
+ touch reintroduce remove-me &&
+ git add . &&
+ git commit -m "reintroduce" &&
+
+ git rm remove-me &&
+ test_must_fail git history fixup --empty=abort HEAD~2 2>err &&
+ test_grep "became empty after replay" err
+ )
+'
+
+test_done
--
2.54.0.545.g6539524ca2.dirty
^ permalink raw reply related [flat|nested] 21+ messages in thread
* [PATCH v3 0/3] builtin/history: introduce "fixup" subcommand
2026-04-22 10:28 [PATCH 0/2] builtin/history: introduce "fixup" subcommand Patrick Steinhardt
` (3 preceding siblings ...)
2026-04-23 14:21 ` [PATCH v2 0/3] " Patrick Steinhardt
@ 2026-04-27 5:53 ` Patrick Steinhardt
2026-04-27 5:53 ` [PATCH v3 1/3] replay: allow callers to control what happens with empty commits Patrick Steinhardt
` (3 more replies)
4 siblings, 4 replies; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-27 5:53 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, D. Ben Knoble, Tian Yuchen
Hi,
this short patch series introduces a new "fixup" subcommand. This
command is the first one that I felt is missing in my day to day work,
as I end up doing fixup commits quite often.
The flow is rather simple: the user stages some changes, and then they
execute `git history fixup <commit>` to amend those changes to the given
commit. As with the other subcommands, dependent branches will then be
rebased automatically.
This is the first command that may result in merge conflicts. For now we
simply abort in such cases, but there are plans to introduce first-class
conflicts into Git. So once we have them, we'll also be able to handle
such cases more gracefully. I still think that the command is useful
even without that conflict handling.
Changes in v3:
- Some more polishing of the command's description.
- Link to v2: https://patch.msgid.link/20260423-b4-pks-history-fixup-v2-0-d7571c6d36eb@pks.im
Changes in v2:
- Introduce "--empty=(keep|drop|abort)" to specify what happens with
empty commits.
- Adapt documentation a bit to hopefully clarify how changes are
backported.
- Link to v1: https://patch.msgid.link/20260422-b4-pks-history-fixup-v1-0-48d4484243de@pks.im
Thanks!
Patrick
---
Patrick Steinhardt (3):
replay: allow callers to control what happens with empty commits
builtin/history: generalize function to commit trees
builtin/history: introduce "fixup" subcommand
Documentation/git-history.adoc | 78 ++++-
builtin/history.c | 291 ++++++++++++++++--
replay.c | 29 +-
replay.h | 19 ++
t/meson.build | 1 +
t/t3453-history-fixup.sh | 680 +++++++++++++++++++++++++++++++++++++++++
6 files changed, 1068 insertions(+), 30 deletions(-)
Range-diff versus v2:
1: 8840b18095 = 1: 81240d1959 replay: allow callers to control what happens with empty commits
2: b078354b5a = 2: 4f35bba868 builtin/history: generalize function to commit trees
3: 3d1fec55c7 ! 3: ecaded9415 builtin/history: introduce "fixup" subcommand
@@ Documentation/git-history.adoc: conflicts. This limitation is by design as histo
The following commands are available to rewrite history in different ways:
+`fixup <commit>`::
-+ Apply the currently staged changes to the specified commit. This
-+ is done by performing a three-way merge between the HEAD commit,
-+ the target commit and the tree generated from staged changes.
-+ This is using the same logic as linkgit:git-cherry-pick[1].
++ Apply the currently staged changes to the specified commit. This is
++ similar in nature to `git commit --fixup=<commit>` followed by `git
++ rebase --autosquash <commit>~`. Changes are applied to the target
++ commit by performing a three-way merge between the HEAD commit, the
++ target commit and the tree generated from staged changes.
++
+The commit message and authorship of the target commit are preserved by
+default, unless you specify `--reedit-message`.
---
base-commit: 94f057755b7941b321fd11fec1b2e3ca5313a4e0
change-id: 20260422-b4-pks-history-fixup-be27e0c4a03e
^ permalink raw reply [flat|nested] 21+ messages in thread* [PATCH v3 1/3] replay: allow callers to control what happens with empty commits
2026-04-27 5:53 ` [PATCH v3 0/3] " Patrick Steinhardt
@ 2026-04-27 5:53 ` Patrick Steinhardt
2026-05-12 4:51 ` Junio C Hamano
2026-04-27 5:53 ` [PATCH v3 2/3] builtin/history: generalize function to commit trees Patrick Steinhardt
` (2 subsequent siblings)
3 siblings, 1 reply; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-27 5:53 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, D. Ben Knoble, Tian Yuchen
When replaying commits it may happen that some of the commits become
empty relative to their parent. Such commits are for now automatically
dropped by the replay subsystem without much control from the user.
Introduce a new enum that allows the caller to drop, keep or abort in
this case.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
replay.c | 29 ++++++++++++++++++++++++-----
replay.h | 19 +++++++++++++++++++
2 files changed, 43 insertions(+), 5 deletions(-)
diff --git a/replay.c b/replay.c
index f96f1f6551..4ef8abb607 100644
--- a/replay.c
+++ b/replay.c
@@ -269,7 +269,8 @@ static struct commit *pick_regular_commit(struct repository *repo,
struct commit *onto,
struct merge_options *merge_opt,
struct merge_result *result,
- enum replay_mode mode)
+ enum replay_mode mode,
+ enum replay_empty_commit_action empty)
{
struct commit *base, *replayed_base;
struct tree *pickme_tree, *base_tree, *replayed_base_tree;
@@ -321,12 +322,25 @@ static struct commit *pick_regular_commit(struct repository *repo,
}
merge_opt->ancestor = NULL;
merge_opt->branch2 = NULL;
+
if (!result->clean)
return NULL;
- /* Drop commits that become empty */
+
+ /* Handle commits that become empty */
if (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) &&
- !oideq(&pickme_tree->object.oid, &base_tree->object.oid))
- return replayed_base;
+ !oideq(&pickme_tree->object.oid, &base_tree->object.oid)) {
+ switch (empty) {
+ case REPLAY_EMPTY_COMMIT_DROP:
+ return replayed_base;
+ case REPLAY_EMPTY_COMMIT_KEEP:
+ break;
+ case REPLAY_EMPTY_COMMIT_ABORT:
+ result->clean = error(_("commit %s became empty after replay"),
+ oid_to_hex(&pickme->object.oid));
+ return NULL;
+ }
+ }
+
return create_commit(repo, result->tree, pickme, replayed_base, mode);
}
@@ -417,7 +431,7 @@ int replay_revisions(struct rev_info *revs,
last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
mode == REPLAY_MODE_REVERT ? last_commit : onto,
- &merge_opt, &result, mode);
+ &merge_opt, &result, mode, opts->empty);
if (!last_commit)
break;
@@ -458,6 +472,11 @@ int replay_revisions(struct rev_info *revs,
}
}
+ if (result.clean < 0) {
+ ret = -1;
+ goto out;
+ }
+
if (!result.clean) {
ret = 1;
goto out;
diff --git a/replay.h b/replay.h
index 0ab74b9805..1851a07705 100644
--- a/replay.h
+++ b/replay.h
@@ -6,6 +6,19 @@
struct repository;
struct rev_info;
+/*
+ * Controls what happens when a replayed commit becomes empty (i.e. its tree
+ * is identical to its parent's tree after the replay).
+ */
+enum replay_empty_commit_action {
+ /* Silently discard the empty commit. */
+ REPLAY_EMPTY_COMMIT_DROP,
+ /* Keep the empty commit as-is. */
+ REPLAY_EMPTY_COMMIT_KEEP,
+ /* Abort with an error. */
+ REPLAY_EMPTY_COMMIT_ABORT,
+};
+
/*
* A set of options that can be passed to `replay_revisions()`.
*/
@@ -43,6 +56,12 @@ struct replay_revisions_options {
* Requires `onto` to be set.
*/
int contained;
+
+ /*
+ * Controls what to do when a replayed commit becomes empty.
+ * Defaults to REPLAY_EMPTY_COMMIT_DROP.
+ */
+ enum replay_empty_commit_action empty;
};
/* This struct is used as an out-parameter by `replay_revisions()`. */
--
2.54.0.545.g6539524ca2.dirty
^ permalink raw reply related [flat|nested] 21+ messages in thread* Re: [PATCH v3 1/3] replay: allow callers to control what happens with empty commits
2026-04-27 5:53 ` [PATCH v3 1/3] replay: allow callers to control what happens with empty commits Patrick Steinhardt
@ 2026-05-12 4:51 ` Junio C Hamano
0 siblings, 0 replies; 21+ messages in thread
From: Junio C Hamano @ 2026-05-12 4:51 UTC (permalink / raw)
To: Patrick Steinhardt; +Cc: git, Elijah Newren, D. Ben Knoble, Tian Yuchen
Patrick Steinhardt <ps@pks.im> writes:
> + /* Handle commits that become empty */
> if (oideq(&replayed_base_tree->object.oid, &result->tree->object.oid) &&
> - !oideq(&pickme_tree->object.oid, &base_tree->object.oid))
> - return replayed_base;
> + !oideq(&pickme_tree->object.oid, &base_tree->object.oid)) {
> + switch (empty) {
> + case REPLAY_EMPTY_COMMIT_DROP:
> + return replayed_base;
> + case REPLAY_EMPTY_COMMIT_KEEP:
> + break;
> + case REPLAY_EMPTY_COMMIT_ABORT:
> + result->clean = error(_("commit %s became empty after replay"),
> + oid_to_hex(&pickme->object.oid));
OK. merge-ort.h clearly explains what negative values in .clean
member means, so this is a good way to signal a failure up the
call chain.
> + return NULL;
> + }
> + }
^ permalink raw reply [flat|nested] 21+ messages in thread
* [PATCH v3 2/3] builtin/history: generalize function to commit trees
2026-04-27 5:53 ` [PATCH v3 0/3] " Patrick Steinhardt
2026-04-27 5:53 ` [PATCH v3 1/3] replay: allow callers to control what happens with empty commits Patrick Steinhardt
@ 2026-04-27 5:53 ` Patrick Steinhardt
2026-04-27 5:53 ` [PATCH v3 3/3] builtin/history: introduce "fixup" subcommand Patrick Steinhardt
2026-05-12 5:47 ` [PATCH v3 0/3] " Junio C Hamano
3 siblings, 0 replies; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-27 5:53 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, D. Ben Knoble, Tian Yuchen
The function `commit_tree_with_edited_message_ext()` can be used to
commit a tree with a specific list of parents with an edited commit
message. This function is useful outside of editing the commit message
though, as it also performs the plumbing to extract the original commit
message and strip some headers from it.
Refactor the function to receive a flags field that allows the caller to
control whether or not the commit message should be edited, or whether
it should be retained as-is. This will be used in a subsequent commit.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
builtin/history.c | 45 ++++++++++++++++++++++++++-------------------
1 file changed, 26 insertions(+), 19 deletions(-)
diff --git a/builtin/history.c b/builtin/history.c
index 9526938085..549e352c74 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -91,13 +91,18 @@ static int fill_commit_message(struct repository *repo,
return 0;
}
-static int commit_tree_with_edited_message_ext(struct repository *repo,
- const char *action,
- struct commit *commit_with_message,
- const struct commit_list *parents,
- const struct object_id *old_tree,
- const struct object_id *new_tree,
- struct commit **out)
+enum commit_tree_flags {
+ COMMIT_TREE_EDIT_MESSAGE = (1 << 0),
+};
+
+static int commit_tree_ext(struct repository *repo,
+ const char *action,
+ struct commit *commit_with_message,
+ const struct commit_list *parents,
+ const struct object_id *old_tree,
+ const struct object_id *new_tree,
+ struct commit **out,
+ enum commit_tree_flags flags)
{
const char *exclude_gpgsig[] = {
/* We reencode the message, so the encoding needs to be stripped. */
@@ -122,10 +127,14 @@ static int commit_tree_with_edited_message_ext(struct repository *repo,
original_author = xmemdupz(ptr, len);
find_commit_subject(original_message, &original_body);
- ret = fill_commit_message(repo, old_tree, new_tree,
- original_body, action, &commit_message);
- if (ret < 0)
- goto out;
+ if (flags & COMMIT_TREE_EDIT_MESSAGE) {
+ ret = fill_commit_message(repo, old_tree, new_tree,
+ original_body, action, &commit_message);
+ if (ret < 0)
+ goto out;
+ } else {
+ strbuf_addstr(&commit_message, original_body);
+ }
original_extra_headers = read_commit_extra_headers(commit_with_message,
exclude_gpgsig);
@@ -168,8 +177,8 @@ static int commit_tree_with_edited_message(struct repository *repo,
oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
}
- return commit_tree_with_edited_message_ext(repo, action, original, original->parents,
- &parent_tree_oid, tree_oid, out);
+ return commit_tree_ext(repo, action, original, original->parents,
+ &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
}
enum ref_action {
@@ -616,9 +625,8 @@ static int split_commit(struct repository *repo,
* The first commit is constructed from the split-out tree. The base
* that shall be diffed against is the parent of the original commit.
*/
- ret = commit_tree_with_edited_message_ext(repo, "split-out", original,
- original->parents, &parent_tree_oid,
- &split_tree->object.oid, &first_commit);
+ ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
+ &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing first commit"));
goto out;
@@ -634,9 +642,8 @@ static int split_commit(struct repository *repo,
old_tree_oid = &repo_get_commit_tree(repo, first_commit)->object.oid;
new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
- ret = commit_tree_with_edited_message_ext(repo, "split-out", original,
- parents, old_tree_oid,
- new_tree_oid, &second_commit);
+ ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
+ new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
if (ret < 0) {
ret = error(_("failed writing second commit"));
goto out;
--
2.54.0.545.g6539524ca2.dirty
^ permalink raw reply related [flat|nested] 21+ messages in thread* [PATCH v3 3/3] builtin/history: introduce "fixup" subcommand
2026-04-27 5:53 ` [PATCH v3 0/3] " Patrick Steinhardt
2026-04-27 5:53 ` [PATCH v3 1/3] replay: allow callers to control what happens with empty commits Patrick Steinhardt
2026-04-27 5:53 ` [PATCH v3 2/3] builtin/history: generalize function to commit trees Patrick Steinhardt
@ 2026-04-27 5:53 ` Patrick Steinhardt
2026-05-12 5:47 ` [PATCH v3 0/3] " Junio C Hamano
3 siblings, 0 replies; 21+ messages in thread
From: Patrick Steinhardt @ 2026-04-27 5:53 UTC (permalink / raw)
To: git; +Cc: Elijah Newren, D. Ben Knoble, Tian Yuchen
The newly introduced git-history(1) command provides functionality to
easily edit commit history while also rebasing dependent branches. The
functionality exposed by this command is still somewhat limited though.
One common use case when editing commit history that is not yet covered
is fixing up a specific commit. Introduce a new subcommand that allows
the user to do exactly that by performing a three-way merge into the
target's commit tree, using HEAD's tree as the merge base. The flow is
thus essentially:
$ echo changes >file
$ git add file
$ git history fixup HEAD~
Like with the other commands, this will automatically rebase dependent
branches, as well. Unlike the other commands though:
- The command does not work in a bare repository as it interacts with
the index.
- The command may run into merge conflicts. If so, the command will
simply abort.
Especially the second item limits the usefulness of this command a bit.
But there are plans to introduce first-class conflicts into Git, which
will help use cases like this one.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
Documentation/git-history.adoc | 78 ++++-
builtin/history.c | 246 ++++++++++++++-
t/meson.build | 1 +
t/t3453-history-fixup.sh | 680 +++++++++++++++++++++++++++++++++++++++++
4 files changed, 999 insertions(+), 6 deletions(-)
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 24dc907033..2ba8121795 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -8,6 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history
SYNOPSIS
--------
[synopsis]
+git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
@@ -22,8 +23,9 @@ THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
This command is related to linkgit:git-rebase[1] in that both commands can be
used to rewrite history. There are a couple of major differences though:
-* linkgit:git-history[1] can work in a bare repository as it does not need to
- touch either the index or the worktree.
+* Most subcommands of linkgit:git-history[1] can work in a bare repository as
+ they do not need to touch either the index or the worktree. The `fixup`
+ subcommand is an exception to this, as it reads staged changes from the index.
* linkgit:git-history[1] does not execute any linkgit:githooks[5] at the
current point in time. This may change in the future.
* linkgit:git-history[1] by default updates all branches that are descendants
@@ -48,11 +50,28 @@ conflicts. This limitation is by design as history rewrites are not intended to
be stateful operations. The limitation can be lifted once (if) Git learns about
first-class conflicts.
+When using `fixup` with `--empty=drop`, dropping the root commit is not yet
+supported.
+
COMMANDS
--------
The following commands are available to rewrite history in different ways:
+`fixup <commit>`::
+ Apply the currently staged changes to the specified commit. This is
+ similar in nature to `git commit --fixup=<commit>` followed by `git
+ rebase --autosquash <commit>~`. Changes are applied to the target
+ commit by performing a three-way merge between the HEAD commit, the
+ target commit and the tree generated from staged changes.
++
+The commit message and authorship of the target commit are preserved by
+default, unless you specify `--reedit-message`.
++
+If applying the staged changes would result in a conflict, the command
+aborts with an error. All branches that are descendants of the original
+commit are updated to point to the rewritten history.
+
`reword <commit>`::
Rewrite the commit message of the specified commit. All the other
details of this commit remain unchanged. This command will spawn an
@@ -87,6 +106,31 @@ OPTIONS
objects will be written into the repository, so applying these printed
ref updates is generally safe.
+`--reedit-message`::
+ Open an editor to modify the target commit's message.
+
+`--empty=(drop|keep|abort)`::
+ Control what happens when a commit becomes empty as a result of the
+ fixup. This can happen in two situations:
++
+--
+* The fixup target itself becomes empty because the staged changes exactly
+ cancel out all changes introduced by that commit.
+
+* A descendant commit becomes empty during replay because it introduced the
+ same change that was just fixed up into an ancestor.
+--
++
+With `drop` (the default), empty commits are removed from the rewritten
+history. Descendants of a dropped target commit are replayed directly onto
+the target's parent. Note that dropping the root commit is not supported;
+see LIMITATIONS.
++
+With `keep`, empty commits are retained in the rewritten history as-is.
++
+With `abort`, the command stops with an error if any commit would become
+empty.
+
`--update-refs=(branches|head)`::
Control which references will be updated by the command, if any. With
`branches`, all local branches that point to commits which are
@@ -96,6 +140,36 @@ OPTIONS
EXAMPLES
--------
+Fixup a commit
+~~~~~~~~~~~~~~
+
+----------
+$ git log --oneline --stat
+abc1234 (HEAD -> main) third
+ third.txt | 1 +
+def5678 second
+ second.txt | 1 +
+ghi9012 first
+ first.txt | 1 +
+
+$ echo "change" >>unrelated.txt
+$ git add unrelated.txt
+$ git history fixup ghi9012
+
+$ git log --oneline --stat
+jkl3456 (HEAD -> main) third
+ third.txt | 1 +
+mno7890 second
+ second.txt | 1 +
+pqr1234 first
+ first.txt | 1 +
+ unrelated.txt | 1 +
+----------
+
+The staged addition of `unrelated.txt` has been incorporated into the `first`
+commit. All descendant commits have been replayed on top of the rewritten
+history.
+
Split a commit
~~~~~~~~~~~~~~
diff --git a/builtin/history.c b/builtin/history.c
index 549e352c74..0fc06fb204 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -10,6 +10,7 @@
#include "gettext.h"
#include "hex.h"
#include "lockfile.h"
+#include "merge-ort.h"
#include "oidmap.h"
#include "parse-options.h"
#include "path.h"
@@ -23,6 +24,8 @@
#include "unpack-trees.h"
#include "wt-status.h"
+#define GIT_HISTORY_FIXUP_USAGE \
+ N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]")
#define GIT_HISTORY_REWORD_USAGE \
N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
#define GIT_HISTORY_SPLIT_USAGE \
@@ -335,10 +338,13 @@ static int handle_reference_updates(struct rev_info *revs,
struct commit *original,
struct commit *rewritten,
const char *reflog_msg,
- int dry_run)
+ int dry_run,
+ enum replay_empty_commit_action empty)
{
const struct name_decoration *decoration;
- struct replay_revisions_options opts = { 0 };
+ struct replay_revisions_options opts = {
+ .empty = empty,
+ };
struct replay_result result = { 0 };
struct ref_transaction *transaction = NULL;
struct strbuf err = STRBUF_INIT;
@@ -434,6 +440,236 @@ static int handle_reference_updates(struct rev_info *revs,
return ret;
}
+static int commit_became_empty(struct repository *repo,
+ struct commit *original,
+ struct tree *result)
+{
+ struct commit *parent = original->parents ? original->parents->item : NULL;
+ struct object_id parent_tree_oid;
+
+ if (parent) {
+ if (repo_parse_commit(repo, parent))
+ return error(_("unable to parse parent of %s"),
+ oid_to_hex(&original->object.oid));
+
+ parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
+ } else {
+ oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+ }
+
+ return oideq(&result->object.oid, &parent_tree_oid);
+}
+
+static int parse_opt_empty(const struct option *opt, const char *arg, int unset)
+{
+ enum replay_empty_commit_action *value = opt->value;
+
+ BUG_ON_OPT_NEG(unset);
+
+ if (!strcmp(arg, "drop"))
+ *value = REPLAY_EMPTY_COMMIT_DROP;
+ else if (!strcmp(arg, "keep"))
+ *value = REPLAY_EMPTY_COMMIT_KEEP;
+ else if (!strcmp(arg, "abort"))
+ *value = REPLAY_EMPTY_COMMIT_ABORT;
+ else
+ die(_("unrecognized '--empty=' action '%s'; "
+ "valid values are \"drop\", \"keep\", and \"abort\"."), arg);
+
+ return 0;
+}
+
+static int cmd_history_fixup(int argc,
+ const char **argv,
+ const char *prefix,
+ struct repository *repo)
+{
+ const char * const usage[] = {
+ GIT_HISTORY_FIXUP_USAGE,
+ NULL,
+ };
+ enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP;
+ enum ref_action action = REF_ACTION_DEFAULT;
+ enum commit_tree_flags flags = 0;
+ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+ N_("control which refs should be updated"),
+ PARSE_OPT_NONEG, parse_ref_action),
+ OPT_BOOL('n', "dry-run", &dry_run,
+ N_("perform a dry-run without updating any refs")),
+ OPT_BIT(0, "reedit-message", &flags,
+ N_("open an editor to modify the commit message"),
+ COMMIT_TREE_EDIT_MESSAGE),
+ OPT_CALLBACK_F(0, "empty", &empty, "(drop|keep|abort)",
+ N_("how to handle commits that become empty"),
+ PARSE_OPT_NONEG, parse_opt_empty),
+ OPT_END(),
+ };
+ struct merge_result merge_result = { 0 };
+ struct merge_options merge_opts = { 0 };
+ struct strbuf reflog_msg = STRBUF_INIT;
+ struct commit *head_commit, *original, *rewritten;
+ struct tree *head_tree, *original_tree, *index_tree;
+ struct rev_info revs = { 0 };
+ bool skip_commit = false;
+ int ret;
+
+ argc = parse_options(argc, argv, prefix, options, usage, 0);
+ if (argc != 1) {
+ ret = error(_("command expects a single revision"));
+ goto out;
+ }
+ repo_config(repo, git_default_config, NULL);
+
+ if (action == REF_ACTION_DEFAULT)
+ action = REF_ACTION_BRANCHES;
+
+ if (is_bare_repository()) {
+ ret = error(_("cannot run fixup in a bare repository"));
+ goto out;
+ }
+
+ /* Resolve the original commit, which is the one we want to fix up. */
+ original = lookup_commit_reference_by_name(argv[0]);
+ if (!original) {
+ ret = error(_("commit cannot be found: %s"), argv[0]);
+ goto out;
+ }
+
+ /*
+ * Resolve HEAD so we can use its tree as the merge base: the staged
+ * changes are expressed as a diff from HEAD's tree to the index tree.
+ */
+ head_commit = lookup_commit_reference_by_name("HEAD");
+ if (!head_commit) {
+ ret = error(_("cannot look up HEAD"));
+ goto out;
+ }
+
+ head_tree = repo_get_commit_tree(repo, head_commit);
+ if (!head_tree) {
+ ret = error(_("cannot get tree for HEAD"));
+ goto out;
+ }
+
+ if (repo_read_index(repo) < 0) {
+ ret = error(_("unable to read index"));
+ goto out;
+ }
+
+ if (!repo_index_has_changes(repo, head_tree, NULL)) {
+ ret = error(_("nothing to fixup: no staged changes"));
+ goto out;
+ }
+
+ /*
+ * Write the index as a tree object. This is the "theirs" side of the
+ * three-way merge: it is HEAD's tree with the staged changes applied.
+ */
+ index_tree = write_in_core_index_as_tree(repo, repo->index);
+ if (!index_tree) {
+ ret = error(_("unable to write index as a tree"));
+ goto out;
+ }
+
+ original_tree = repo_get_commit_tree(repo, original);
+ if (!original_tree) {
+ ret = error(_("cannot get tree for commit %s"), argv[0]);
+ goto out;
+ }
+
+ /*
+ * Perform the three-way merge to reapply changes in the index onto the
+ * target commit. This is using basically the same logic as a
+ * cherry-pick, where the base commit is our HEAD, ours is the original
+ * tree and theirs is the index tree.
+ */
+ init_basic_merge_options(&merge_opts, repo);
+ merge_opts.ancestor = "HEAD";
+ merge_opts.branch1 = argv[0];
+ merge_opts.branch2 = "staged";
+ merge_incore_nonrecursive(&merge_opts, head_tree,
+ original_tree, index_tree, &merge_result);
+
+ if (merge_result.clean < 0) {
+ ret = error(_("merge failed while applying fixup"));
+ goto out;
+ }
+
+ if (!merge_result.clean) {
+ ret = error(_("fixup would produce conflicts; aborting"));
+ goto out;
+ }
+
+ ret = commit_became_empty(repo, original, merge_result.tree);
+ if (ret < 0)
+ goto out;
+ if (ret > 0) {
+ switch (empty) {
+ case REPLAY_EMPTY_COMMIT_DROP:
+ /*
+ * Drop the target commit by replaying its descendants
+ * directly onto its parent.
+ */
+ rewritten = original->parents ? original->parents->item : NULL;
+
+ /*
+ * TODO: we don't yet have the ability to drop root
+ * commits, but there's ultimately no good reason for
+ * this restriction to exist other than a technical
+ * limitation.
+ */
+ if (!rewritten) {
+ ret = error(_("cannot drop root commit %s: "
+ "it has no parent to replay onto"),
+ argv[0]);
+ goto out;
+ }
+
+ skip_commit = true;
+ break;
+ case REPLAY_EMPTY_COMMIT_KEEP:
+ /* Proceed and record the empty commit. */
+ break;
+ case REPLAY_EMPTY_COMMIT_ABORT:
+ ret = error(_("fixup makes commit %s empty"), argv[0]);
+ goto out;
+ }
+ }
+
+ ret = setup_revwalk(repo, action, original, &revs);
+ if (ret)
+ goto out;
+
+ if (!skip_commit) {
+ ret = commit_tree_ext(repo, "fixup", original, original->parents,
+ &original_tree->object.oid, &merge_result.tree->object.oid,
+ &rewritten, flags);
+ if (ret < 0) {
+ ret = error(_("failed writing fixed-up commit"));
+ goto out;
+ }
+ }
+
+ strbuf_addf(&reflog_msg, "fixup: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, original, rewritten,
+ reflog_msg.buf, dry_run, empty);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+ }
+
+ ret = 0;
+
+out:
+ merge_finalize(&merge_opts, &merge_result);
+ strbuf_release(&reflog_msg);
+ release_revisions(&revs);
+ return ret;
+}
+
static int cmd_history_reword(int argc,
const char **argv,
const char *prefix,
@@ -487,7 +723,7 @@ static int cmd_history_reword(int argc,
strbuf_addf(&reflog_msg, "reword: updating %s", argv[0]);
ret = handle_reference_updates(&revs, action, original, rewritten,
- reflog_msg.buf, dry_run);
+ reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);
if (ret < 0) {
ret = error(_("failed replaying descendants"));
goto out;
@@ -724,7 +960,7 @@ static int cmd_history_split(int argc,
strbuf_addf(&reflog_msg, "split: updating %s", argv[0]);
ret = handle_reference_updates(&revs, action, original, rewritten,
- reflog_msg.buf, dry_run);
+ reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);
if (ret < 0) {
ret = error(_("failed replaying descendants"));
goto out;
@@ -745,12 +981,14 @@ int cmd_history(int argc,
struct repository *repo)
{
const char * const usage[] = {
+ GIT_HISTORY_FIXUP_USAGE,
GIT_HISTORY_REWORD_USAGE,
GIT_HISTORY_SPLIT_USAGE,
NULL,
};
parse_opt_subcommand_fn *fn = NULL;
struct option options[] = {
+ OPT_SUBCOMMAND("fixup", &fn, cmd_history_fixup),
OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
OPT_SUBCOMMAND("split", &fn, cmd_history_split),
OPT_END(),
diff --git a/t/meson.build b/t/meson.build
index 7528e5cda5..f502ad8ec9 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -397,6 +397,7 @@ integration_tests = [
't3450-history.sh',
't3451-history-reword.sh',
't3452-history-split.sh',
+ 't3453-history-fixup.sh',
't3500-cherry.sh',
't3501-revert-cherry-pick.sh',
't3502-cherry-pick-merge.sh',
diff --git a/t/t3453-history-fixup.sh b/t/t3453-history-fixup.sh
new file mode 100755
index 0000000000..868298e248
--- /dev/null
+++ b/t/t3453-history-fixup.sh
@@ -0,0 +1,680 @@
+#!/bin/sh
+
+test_description='tests for git-history fixup subcommand'
+
+. ./test-lib.sh
+
+fixup_with_message () {
+ cat >message &&
+ write_script fake-editor.sh <<-\EOF &&
+ cp message "$1"
+ EOF
+ test_set_editor "$(pwd)"/fake-editor.sh &&
+ git history fixup --reedit-message "$@" &&
+ rm fake-editor.sh message
+}
+
+expect_changes () {
+ git log --format="%s" --numstat "$@" >actual.raw &&
+ sed '/^$/d' <actual.raw >actual &&
+ cat >expect &&
+ test_cmp expect actual
+}
+
+test_expect_success 'errors on missing commit argument' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ test_must_fail git history fixup 2>err &&
+ test_grep "command expects a single revision" err
+ )
+'
+
+test_expect_success 'errors on too many arguments' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ test_must_fail git history fixup HEAD HEAD 2>err &&
+ test_grep "command expects a single revision" err
+ )
+'
+
+test_expect_success 'errors on unknown revision' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ test_must_fail git history fixup does-not-exist 2>err &&
+ test_grep "commit cannot be found: does-not-exist" err
+ )
+'
+
+test_expect_success 'errors when nothing is staged' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ test_must_fail git history fixup HEAD 2>err &&
+ test_grep "nothing to fixup: no staged changes" err
+ )
+'
+
+test_expect_success 'errors in a bare repository' '
+ test_when_finished "rm -rf repo repo.git" &&
+ git init repo &&
+ test_commit -C repo initial &&
+ git clone --bare repo repo.git &&
+ test_must_fail git -C repo.git history fixup HEAD 2>err &&
+ test_grep "cannot run fixup in a bare repository" err
+'
+
+test_expect_success 'errors with invalid --empty= value' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ test_must_fail git -C repo history fixup --empty=bogus HEAD 2>err &&
+ test_grep "unrecognized.*--empty.*bogus" err
+'
+
+test_expect_success 'can fixup the tip commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ echo content >file.txt &&
+ git add file.txt &&
+ git commit -m "add file" &&
+
+ echo fix >>file.txt &&
+ git add file.txt &&
+
+ expect_changes <<-\EOF &&
+ add file
+ 1 0 file.txt
+ initial
+ 1 0 initial.t
+ EOF
+
+ git symbolic-ref HEAD >branch-expect &&
+ git history fixup HEAD &&
+ git symbolic-ref HEAD >branch-actual &&
+ test_cmp branch-expect branch-actual &&
+
+ expect_changes <<-\EOF &&
+ add file
+ 2 0 file.txt
+ initial
+ 1 0 initial.t
+ EOF
+
+ # Verify the fix is in the tip commit tree
+ git show HEAD:file.txt >actual &&
+ printf "content\nfix\n" >expect &&
+ test_cmp expect actual &&
+
+ git reflog >reflog &&
+ test_grep "fixup: updating HEAD" reflog
+ )
+'
+
+test_expect_success 'can fixup a commit in the middle of history' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit first &&
+ echo content >file.txt &&
+ git add file.txt &&
+ git commit -m "add file" &&
+ test_commit third &&
+
+ echo fix >>file.txt &&
+ git add file.txt &&
+
+ expect_changes <<-\EOF &&
+ third
+ 1 0 third.t
+ add file
+ 1 0 file.txt
+ first
+ 1 0 first.t
+ EOF
+
+ git history fixup HEAD~ &&
+
+ expect_changes <<-\EOF &&
+ third
+ 1 0 third.t
+ add file
+ 2 0 file.txt
+ first
+ 1 0 first.t
+ EOF
+
+ # Verify the fix landed in the "add file" commit.
+ git show HEAD~:file.txt >actual &&
+ printf "content\nfix\n" >expect &&
+ test_cmp expect actual &&
+
+ # And verify that the replayed commit also has the change.
+ git show HEAD:file.txt >actual &&
+ printf "content\nfix\n" >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'can fixup root commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ echo initial >root.txt &&
+ git add root.txt &&
+ git commit -m "root" &&
+ test_commit second &&
+
+ expect_changes <<-\EOF &&
+ second
+ 1 0 second.t
+ root
+ 1 0 root.txt
+ EOF
+
+ echo fix >>root.txt &&
+ git add root.txt &&
+ git history fixup HEAD~ &&
+
+ expect_changes <<-\EOF &&
+ second
+ 1 0 second.t
+ root
+ 2 0 root.txt
+ EOF
+
+ git show HEAD~:root.txt >actual &&
+ printf "initial\nfix\n" >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'preserves commit message and authorship' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ echo content >file.txt &&
+ git add file.txt &&
+ git commit --author="Original <original@example.com>" -m "original message" &&
+
+ echo fix >>file.txt &&
+ git add file.txt &&
+ git history fixup HEAD &&
+
+ # Message preserved
+ git log -1 --format="%s" >actual &&
+ echo "original message" >expect &&
+ test_cmp expect actual &&
+
+ # Authorship preserved
+ git log -1 --format="%an <%ae>" >actual &&
+ echo "Original <original@example.com>" >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'updates all descendant branches by default' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch branch &&
+ test_commit ours &&
+ git switch branch &&
+ test_commit theirs &&
+ git switch main &&
+
+ echo fix >fix.txt &&
+ git add fix.txt &&
+ git history fixup base &&
+
+ expect_changes --branches <<-\EOF &&
+ theirs
+ 1 0 theirs.t
+ ours
+ 1 0 ours.t
+ base
+ 1 0 base.t
+ 1 0 fix.txt
+ EOF
+
+ # Both branches should have the fix in the base
+ git show main~:fix.txt >actual &&
+ echo fix >expect &&
+ test_cmp expect actual &&
+ git show branch~:fix.txt >actual &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'can fixup commit on a different branch' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch theirs &&
+ test_commit ours &&
+ git switch theirs &&
+ test_commit theirs &&
+
+ # Stage a change while on "theirs"
+ echo fix >fix.txt &&
+ git add fix.txt &&
+
+ # Ensure that "ours" does not change, as it does not contain
+ # the commit in question.
+ git rev-parse ours >ours-before &&
+ git history fixup theirs &&
+ git rev-parse ours >ours-after &&
+ test_cmp ours-before ours-after &&
+
+ git show HEAD:fix.txt >actual &&
+ echo fix >expect &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success '--dry-run prints ref updates without modifying repo' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch branch &&
+ test_commit main-tip &&
+ git switch branch &&
+ test_commit branch-tip &&
+ git switch main &&
+
+ echo fix >fix.txt &&
+ git add fix.txt &&
+
+ git refs list >refs-before &&
+ git history fixup --dry-run base >updates &&
+ git refs list >refs-after &&
+ test_cmp refs-before refs-after &&
+
+ test_grep "update refs/heads/main" updates &&
+ test_grep "update refs/heads/branch" updates &&
+
+ expect_changes --branches <<-\EOF &&
+ branch-tip
+ 1 0 branch-tip.t
+ main-tip
+ 1 0 main-tip.t
+ base
+ 1 0 base.t
+ EOF
+
+ git update-ref --stdin <updates &&
+ expect_changes --branches <<-\EOF
+ branch-tip
+ 1 0 branch-tip.t
+ main-tip
+ 1 0 main-tip.t
+ base
+ 1 0 base.t
+ 1 0 fix.txt
+ EOF
+ )
+'
+
+test_expect_success '--update-refs=head updates only HEAD' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch branch &&
+ test_commit main-tip &&
+ git switch branch &&
+ test_commit branch-tip &&
+
+ echo fix >fix.txt &&
+ git add fix.txt &&
+
+ # Only HEAD (branch) should be updated
+ git history fixup --update-refs=head base &&
+
+ # The main branch should be unaffected.
+ expect_changes main <<-\EOF &&
+ main-tip
+ 1 0 main-tip.t
+ base
+ 1 0 base.t
+ EOF
+
+ # But the currently checked out branch should be modified.
+ expect_changes branch <<-\EOF
+ branch-tip
+ 1 0 branch-tip.t
+ base
+ 1 0 base.t
+ 1 0 fix.txt
+ EOF
+ )
+'
+
+test_expect_success '--update-refs=head refuses to rewrite commits not in HEAD ancestry' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+ test_commit base &&
+ git branch other &&
+ test_commit main-tip &&
+ git switch other &&
+ test_commit other-tip &&
+
+ echo fix >fix.txt &&
+ git add fix.txt &&
+
+ test_must_fail git history fixup --update-refs=head main-tip 2>err &&
+ test_grep "rewritten commit must be an ancestor of HEAD" err
+ )
+'
+
+test_expect_success 'aborts when fixup would produce conflicts' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ echo "line one" >file.txt &&
+ git add file.txt &&
+ git commit -m "first" &&
+
+ echo "line two" >file.txt &&
+ git add file.txt &&
+ git commit -m "second" &&
+
+ echo "conflicting change" >file.txt &&
+ git add file.txt &&
+
+ git refs list >refs-before &&
+ test_must_fail git history fixup HEAD~ 2>err &&
+ test_grep "fixup would produce conflicts" err &&
+ git refs list >refs-after &&
+ test_cmp refs-before refs-after
+ )
+'
+
+test_expect_success '--reedit-message opens editor for the commit message' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ test_commit initial &&
+ echo content >file.txt &&
+ git add file.txt &&
+ git commit -m "add file" &&
+
+ echo fix >>file.txt &&
+ git add file.txt &&
+
+ fixup_with_message HEAD <<-\EOF &&
+ add file with fix
+ EOF
+
+ expect_changes --branches <<-\EOF
+ add file with fix
+ 2 0 file.txt
+ initial
+ 1 0 initial.t
+ EOF
+ )
+'
+
+test_expect_success 'retains unstaged working tree changes after fixup' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+ touch a b &&
+ git add . &&
+ git commit -m "initial commit" &&
+ echo staged >a &&
+ echo unstaged >b &&
+ git add a &&
+ git history fixup HEAD &&
+
+ # b is still modified in the worktree but not staged
+ cat >expect <<-\EOF &&
+ M b
+ EOF
+ git status --porcelain --untracked-files=no >actual &&
+ test_cmp expect actual
+ )
+'
+
+test_expect_success 'index is clean after fixup when target is HEAD' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ test_commit initial &&
+ echo fix >fix.txt &&
+ git add fix.txt &&
+ git history fixup HEAD &&
+
+ git status --porcelain --untracked-files=no >actual &&
+ test_must_be_empty actual
+ )
+'
+
+test_expect_success 'index is unchanged on conflict' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ echo base >file.txt &&
+ git add file.txt &&
+ git commit -m base &&
+ echo change >file.txt &&
+ git add file.txt &&
+ git commit -m change &&
+
+ echo conflict >file.txt &&
+ git add file.txt &&
+
+ git diff --cached >index-before &&
+ test_must_fail git history fixup HEAD~ &&
+ git diff --cached >index-after &&
+ test_cmp index-before index-after
+ )
+'
+
+test_expect_success '--empty=drop removes target commit and replays descendants onto its parent' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+
+ test_commit first &&
+ test_commit second &&
+ test_commit third &&
+
+ git rm second.t &&
+ git history fixup --empty=drop HEAD~ &&
+
+ expect_changes <<-\EOF &&
+ third
+ 1 0 third.t
+ first
+ 1 0 first.t
+ EOF
+ test_must_fail git show HEAD:second.t
+ )
+'
+
+test_expect_success '--empty=drop errors out when dropping the root commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ test_commit first &&
+ test_commit second &&
+
+ git rm first.t &&
+ test_must_fail git history fixup --empty=drop HEAD~ 2>err &&
+ test_grep "cannot drop root commit" err
+ )
+'
+
+test_expect_success '--empty=drop can drop the HEAD commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ test_commit first &&
+ test_commit second &&
+
+ git rm second.t &&
+ git history fixup --empty=drop HEAD &&
+
+ expect_changes <<-\EOF
+ first
+ 1 0 first.t
+ EOF
+ )
+'
+
+test_expect_success '--empty=drop drops empty replayed commits' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ touch base remove-me &&
+ git add . &&
+ git commit -m "base" &&
+ git rm remove-me &&
+ git commit -m "remove" &&
+ touch reintroduce remove-me &&
+ git add . &&
+ git commit -m "reintroduce" &&
+
+ git rm remove-me &&
+ git history fixup --empty=drop HEAD~2 &&
+
+ expect_changes <<-\EOF
+ reintroduce
+ 0 0 reintroduce
+ 0 0 remove-me
+ base
+ 0 0 base
+ EOF
+ )
+'
+
+test_expect_success '--empty=keep keeps commit when fixup target becomes empty' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ test_commit first &&
+ test_commit second &&
+ test_commit third &&
+
+ git rm second.t &&
+ git history fixup --empty=keep HEAD~ &&
+
+ expect_changes <<-\EOF
+ third
+ 1 0 third.t
+ second
+ first
+ 1 0 first.t
+ EOF
+ )
+'
+
+test_expect_success '--empty=keep keeps commit when replayed commit becomes empty' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ touch base remove-me &&
+ git add . &&
+ git commit -m "base" &&
+ git rm remove-me &&
+ git commit -m "remove" &&
+ touch reintroduce remove-me &&
+ git add . &&
+ git commit -m "reintroduce" &&
+
+ git rm remove-me &&
+ git history fixup --empty=keep HEAD~2 &&
+
+ expect_changes <<-\EOF
+ reintroduce
+ 0 0 reintroduce
+ 0 0 remove-me
+ remove
+ base
+ 0 0 base
+ EOF
+ )
+'
+
+test_expect_success '--empty=abort errors out when fixup target becomes empty' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+ (
+ cd repo &&
+
+ test_commit first &&
+ test_commit second &&
+
+ git rm first.t &&
+ test_must_fail git history fixup --empty=abort HEAD~ 2>err &&
+ test_grep "fixup makes commit.*empty" err
+ )
+'
+
+test_expect_success '--empty=abort errors out when a descendant becomes empty during replay' '
+ test_when_finished "rm -rf repo" &&
+ git init repo --initial-branch=main &&
+ (
+ cd repo &&
+
+ touch base remove-me &&
+ git add . &&
+ git commit -m "base" &&
+ git rm remove-me &&
+ git commit -m "remove" &&
+ touch reintroduce remove-me &&
+ git add . &&
+ git commit -m "reintroduce" &&
+
+ git rm remove-me &&
+ test_must_fail git history fixup --empty=abort HEAD~2 2>err &&
+ test_grep "became empty after replay" err
+ )
+'
+
+test_done
--
2.54.0.545.g6539524ca2.dirty
^ permalink raw reply related [flat|nested] 21+ messages in thread* Re: [PATCH v3 0/3] builtin/history: introduce "fixup" subcommand
2026-04-27 5:53 ` [PATCH v3 0/3] " Patrick Steinhardt
` (2 preceding siblings ...)
2026-04-27 5:53 ` [PATCH v3 3/3] builtin/history: introduce "fixup" subcommand Patrick Steinhardt
@ 2026-05-12 5:47 ` Junio C Hamano
2026-05-12 6:41 ` Patrick Steinhardt
3 siblings, 1 reply; 21+ messages in thread
From: Junio C Hamano @ 2026-05-12 5:47 UTC (permalink / raw)
To: Elijah Newren, D. Ben Knoble, Tian Yuchen; +Cc: Patrick Steinhardt, git
Patrick Steinhardt <ps@pks.im> writes:
> this short patch series introduces a new "fixup" subcommand. This
> command is the first one that I felt is missing in my day to day work,
> as I end up doing fixup commits quite often.
>
> The flow is rather simple: the user stages some changes, and then they
> execute `git history fixup <commit>` to amend those changes to the given
> commit. As with the other subcommands, dependent branches will then be
> rebased automatically.
>
> This is the first command that may result in merge conflicts. For now we
> simply abort in such cases, but there are plans to introduce first-class
> conflicts into Git. So once we have them, we'll also be able to handle
> such cases more gracefully. I still think that the command is useful
> even without that conflict handling.
>
> Changes in v3:
> - Some more polishing of the command's description.
> - Link to v2: https://patch.msgid.link/20260423-b4-pks-history-fixup-v2-0-d7571c6d36eb@pks.im
>
> Changes in v2:
> - Introduce "--empty=(keep|drop|abort)" to specify what happens with
> empty commits.
> - Adapt documentation a bit to hopefully clarify how changes are
> backported.
> - Link to v1: https://patch.msgid.link/20260422-b4-pks-history-fixup-v1-0-48d4484243de@pks.im
The iterations v2 and v3 saw no comments, unfortunately. I just
gave three patches in v3 a cursory look and nothing stood out as
curious or fishy. Shall we mark the topic for 'next' now?
^ permalink raw reply [flat|nested] 21+ messages in thread* Re: [PATCH v3 0/3] builtin/history: introduce "fixup" subcommand
2026-05-12 5:47 ` [PATCH v3 0/3] " Junio C Hamano
@ 2026-05-12 6:41 ` Patrick Steinhardt
0 siblings, 0 replies; 21+ messages in thread
From: Patrick Steinhardt @ 2026-05-12 6:41 UTC (permalink / raw)
To: Junio C Hamano; +Cc: Elijah Newren, D. Ben Knoble, Tian Yuchen, git
On Tue, May 12, 2026 at 02:47:41PM +0900, Junio C Hamano wrote:
> Patrick Steinhardt <ps@pks.im> writes:
>
> > this short patch series introduces a new "fixup" subcommand. This
> > command is the first one that I felt is missing in my day to day work,
> > as I end up doing fixup commits quite often.
> >
> > The flow is rather simple: the user stages some changes, and then they
> > execute `git history fixup <commit>` to amend those changes to the given
> > commit. As with the other subcommands, dependent branches will then be
> > rebased automatically.
> >
> > This is the first command that may result in merge conflicts. For now we
> > simply abort in such cases, but there are plans to introduce first-class
> > conflicts into Git. So once we have them, we'll also be able to handle
> > such cases more gracefully. I still think that the command is useful
> > even without that conflict handling.
> >
> > Changes in v3:
> > - Some more polishing of the command's description.
> > - Link to v2: https://patch.msgid.link/20260423-b4-pks-history-fixup-v2-0-d7571c6d36eb@pks.im
> >
> > Changes in v2:
> > - Introduce "--empty=(keep|drop|abort)" to specify what happens with
> > empty commits.
> > - Adapt documentation a bit to hopefully clarify how changes are
> > backported.
> > - Link to v1: https://patch.msgid.link/20260422-b4-pks-history-fixup-v1-0-48d4484243de@pks.im
>
> The iterations v2 and v3 saw no comments, unfortunately. I just
> gave three patches in v3 a cursory look and nothing stood out as
> curious or fishy. Shall we mark the topic for 'next' now?
I didn't plan to post another iteration, so this works for me. Thanks!
Patrick
^ permalink raw reply [flat|nested] 21+ messages in thread