threads / rfc / 63986

RFC patch, 11 partsIntroduce git-history(1) command for easy history editing

Subject: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

## tl;dr

325 messages between Aug 19, 2025 and Feb 5, 2026. Diffs are folded; open one to read it.

replies: 324people: 13as markdown or json

Patrick Steinhardt· Aug 19, 2025, 10:55 UTC · lore
Hi,

over recent months I've been playing around with Jujutsu quite frequently. While I still prefer using Git, there's been a couple features in it that I really like and that I'd like to have in Git, as well.

A copule of these features relate to history editing. Most importantly, I really dig the following commands:

  - jj-abandon(1) to drop a specific commit from your history.
  - jj-absorb(1) to take some changes and automatically apply them to
    commits in your history that last modified the respective hunks.
  - jj-split(1) to split a commit into two.
  - jj-new(1) to insert a new commit after or before a specific other
    commit.

Not all of these commands can be ported directly into Git. jj-new(1) for example doesn't really make a ton of sense for us, I'd claim. But some of these commands _do_ make sense.

I thus had a look at implementing some of these commands in Git itself, where the result is this patch series. Specifically, the following commands are introduced by this patch series:

  - `git history drop` to drop a specific commit. This is basically the
    same as jj-abandon(1).
  - `git history reorder` to reorder a specific commit before or after
    another commit. This is inspired by jj-new(1).
  - `git history split` takes a commit and splits it into two. This is
    basically the same as jj-split(1).

If this is something we want to have I think it'd be just a starting point. There's other commands that I think are quite common and that might make sense to introduce eventually:

  - An equivalent to jj-absorb(1) would be awesome to have.
  - `git history reword` to change only the commit message of a specific
    commit.
  - `git history squash` to squash together multiple commits into one.

In the end, I'd like us to learn from what people like about Jujutsu and apply those learnings to Git. We won't be able to apply all learnings from Jujutsu, as the workflow is quite different there due to the lack of the index. But other things we certainly can apply to Git directly.

Note: This patch series currently builds on the cherry-pick infra.
As such, when one hits a merge conflict one needs to `git cherry-pick
--continue`, which is quite suboptimal. I didn't want to overpolish this
series before getting some feedback, but it is something I'll fix in
subsequent versions. Furthermore, the command for now bails out in the
case where there's any merge commits in the history that is being
rewritten. This is another restriction that can be lifted in the future.
Thanks!
Patrick
---
Patrick Steinhardt (11):
      sequencer: optionally skip printing commit summary
      sequencer: add option to rewind HEAD after picking commits
      cache-tree: allow writing in-memory index as tree
      builtin: add new "history" command
      builtin/history: implement "drop" subcommand
      builtin/history: implement "reorder" subcommand
      add-patch: split out header from "add-interactive.h"
      add-patch: split out `struct interactive_options`
      add-patch: remove dependency on "add-interactive" subsystem
      add-patch: add support for in-memory index patching
      builtin/history: implement "split" subcommand
 .gitignore                     |   1 +
 Documentation/git-history.adoc | 159 ++++++++++
 Documentation/meson.build      |   1 +
 Makefile                       |   1 +
 add-interactive.c              | 151 +++------
 add-interactive.h              |  43 +--
 add-patch.c                    | 271 ++++++++++++++--
 add-patch.h                    |  61 ++++
 builtin.h                      |   1 +
 builtin/add.c                  |  22 +-
 builtin/checkout.c             |   7 +-
 builtin/commit.c               |  16 +-
 builtin/history.c              | 691 +++++++++++++++++++++++++++++++++++++++++
 builtin/reset.c                |  16 +-
 builtin/stash.c                |  46 +--
 cache-tree.c                   |   5 +-
 cache-tree.h                   |   3 +-
 commit.h                       |   2 +-
 git.c                          |   1 +
 meson.build                    |   1 +
 sequencer.c                    |  36 ++-
 sequencer.h                    |   4 +
 t/meson.build                  |   5 +-
 t/t3450-history-drop.sh        | 127 ++++++++
 t/t3451-history-reorder.sh     | 218 +++++++++++++
 t/t3452-history-split.sh       | 304 ++++++++++++++++++
 26 files changed, 1947 insertions(+), 246 deletions(-)

--- base-commit: c44beea485f0f2feaf460e2ac87fdd5608d63cf0 change-id: 20250819-b4-pks-history-builtin-83398f9a05f0

Patrick Steinhardt· Aug 19, 2025, 10:55 UTC · re: Patrick Steinhardt · lore

[PATCH RFC 01/11] sequencer: optionally skip printing commit summary

When picking commits by using for example git-cherry-pick(1) we end up printing a commit summary that gives the reader information around what exactly we have been picking:

```
$ git cherry-pick main
[other 76c8456] bar
 Date: Tue Aug 19 08:07:26 2025 +0200
 1 file changed, 1 insertion(+)
 create mode 100644 bar
```

While useful for some commands, we're about to introduce a new command where this output will be less so. But right now there is no way to disable printing this commit summary.

Introduce a new `skip_commit_summary` replay option that does so. Persist the option into the sequencer configuration so that it persists across different processes, e.g. when we need to stop due to a merge conflict.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 sequencer.c | 12 +++++++++---
 sequencer.h |  1 +
 2 files changed, 10 insertions(+), 3 deletions(-)
Show changes to 2 files +10 −3

sequencer.c, sequencer.h

diff --git a/sequencer.c b/sequencer.c
index aaf2e4df64..7066cdc939 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -1742,7 +1742,7 @@ static int do_commit(struct repository *r,
 			refs_delete_ref(get_main_ref_store(r), "",
 					"CHERRY_PICK_HEAD", NULL, REF_NO_DEREF);
 			unlink(git_path_merge_msg(r));
-			if (!is_rebase_i(opts))
+			if (!is_rebase_i(opts) && !opts->skip_commit_summary)
 				print_commit_summary(r, NULL, &oid,
 						SUMMARY_SHOW_AUTHOR_DATE);
 			return res;
@@ -3139,8 +3139,12 @@ static int populate_opts_cb(const char *key, const char *value,
 	else if (!strcmp(key, "options.default-msg-cleanup")) {
 		opts->explicit_cleanup = 1;
 		opts->default_msg_cleanup = get_cleanup_mode(value, 1);
-	} else
+	} else if (!strcmp(key, "options.skip-commit-summary")) {
+		opts->skip_commit_summary =
+			git_config_bool_or_int(key, value, ctx->kvi, &error_flag);
+	} else {
 		return error(_("invalid key: %s"), key);
+	}
 
 	if (!error_flag)
 		return error(_("invalid value for '%s': '%s'"), key, value);
@@ -3698,11 +3702,13 @@ static int save_opts(struct replay_opts *opts)
 				"options.allow-rerere-auto", NULL,
 				opts->allow_rerere_auto == RERERE_AUTOUPDATE ?
 				"true" : "false");
-
 	if (opts->explicit_cleanup)
 		res |= repo_config_set_in_file_gently(the_repository, opts_file,
 				"options.default-msg-cleanup", NULL,
 				describe_cleanup_mode(opts->default_msg_cleanup));
+	if (opts->skip_commit_summary)
+		res |= repo_config_set_in_file_gently(the_repository, opts_file,
+					"options.skip-commit-summary", NULL, "true");
 	return res;
 }
 
diff --git a/sequencer.h b/sequencer.h
index 304ba4b4d3..1767fd737e 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -52,6 +52,7 @@ struct replay_opts {
 	int keep_redundant_commits;
 	int verbose;
 	int quiet;
+	int skip_commit_summary;
 	int reschedule_failed_exec;
 	int committer_date_is_author_date;
 	int ignore_date;
-- 
2.51.0.261.g7ce5a0a67e.dirty
Patrick Steinhardt· Aug 19, 2025, 10:55 UTC · re: Patrick Steinhardt · lore

[PATCH RFC 02/11] sequencer: add option to rewind HEAD after picking commits

While the sequencer infrastructure knows to rewind "HEAD" to whatever it was pointing to before a rebase, it doesn't do the same for non-rebase operations like cherry-picks. This is because the expectation is that the user directly picks commits on top of whatever "HEAD" points to, and we advance the reference pointed to by "HEAD" instead of updating it directly.

We're about to introduce a new command though that needs to detach "HEAD" while being more similar to git-cherry-pick(1) rathen than to git-rebase(1). As such, we'll want to restore "HEAD" to point to the branch that we started on while not using the more heavy-weight rebase machinery.

Introduce a new option `restore_head_target` to do so. Persist the option into the sequencer configuration so that it persists across different processes, e.g. when we need to stop due to a merge conflict.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 sequencer.c | 24 ++++++++++++++++++++++++
 sequencer.h |  3 +++
 2 files changed, 27 insertions(+)
Show changes to 2 files +27 −0

sequencer.c, sequencer.h

diff --git a/sequencer.c b/sequencer.c
index 7066cdc939..b13348ba34 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -413,6 +413,7 @@ void replay_opts_release(struct replay_opts *opts)
 	struct replay_ctx *ctx = opts->ctx;
 
 	free(opts->gpg_sign);
+	free(opts->restore_head_target);
 	free(opts->reflog_action);
 	free(opts->default_strategy);
 	free(opts->strategy);
@@ -3142,6 +3143,8 @@ static int populate_opts_cb(const char *key, const char *value,
 	} else if (!strcmp(key, "options.skip-commit-summary")) {
 		opts->skip_commit_summary =
 			git_config_bool_or_int(key, value, ctx->kvi, &error_flag);
+	} else if (!strcmp(key, "options.restore-head-target")) {
+		git_config_string_dup(&opts->restore_head_target, key, value);
 	} else {
 		return error(_("invalid key: %s"), key);
 	}
@@ -3709,6 +3712,10 @@ static int save_opts(struct replay_opts *opts)
 	if (opts->skip_commit_summary)
 		res |= repo_config_set_in_file_gently(the_repository, opts_file,
 					"options.skip-commit-summary", NULL, "true");
+	if (opts->restore_head_target)
+		res |= repo_config_set_in_file_gently(the_repository, opts_file,
+				"options.restore-head-target", NULL, opts->restore_head_target);
+
 	return res;
 }
 
@@ -5177,6 +5184,23 @@ static int pick_commits(struct repository *r,
 			return -1;
 	}
 
+	if (opts->restore_head_target) {
+		struct reset_head_opts reset_opts = { 0 };
+		const char *msg;
+
+		msg = reflog_message(opts, "finish", "returning to %s", opts->restore_head_target);
+
+		reset_opts.branch = opts->restore_head_target;
+		reset_opts.flags = RESET_HEAD_REFS_ONLY;
+		reset_opts.branch_msg = msg;
+		reset_opts.head_msg = msg;
+
+		if (reset_head(r, &reset_opts)) {
+			error(_("could not switch HEAD back to %s"), opts->restore_head_target);
+			return -1;
+		}
+	}
+
 	/*
 	 * Sequence of picks finished successfully; cleanup by
 	 * removing the .git/sequencer directory
diff --git a/sequencer.h b/sequencer.h
index 1767fd737e..a905f6afc7 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -72,6 +72,9 @@ struct replay_opts {
 	/* Reflog */
 	char *reflog_action;
 
+	/* Reference to which HEAD shall be reset to after the operation. */
+	char *restore_head_target;
+
 	/* placeholder commit for -i --root */
 	struct object_id squash_onto;
 	int have_squash_onto;
-- 
2.51.0.261.g7ce5a0a67e.dirty
Patrick Steinhardt· Aug 19, 2025, 10:55 UTC · re: Patrick Steinhardt · lore

[PATCH RFC 03/11] cache-tree: allow writing in-memory index as tree

The function `write_in_core_index_as_tree()` takes a repository and writes its index into a tree object. What this function cannot do though is to take an _arbitrary_ in-memory index.

Introduce a new `struct index_state` parameter so that the caller can pass a different index than the one belonging to the repository. This will be used in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/checkout.c | 3 ++-
 cache-tree.c       | 5 ++---
 cache-tree.h       | 3 ++-
 3 files changed, 6 insertions(+), 5 deletions(-)
Show changes to 3 files +6 −5

builtin/checkout.c, cache-tree.c, cache-tree.h

diff --git a/builtin/checkout.c b/builtin/checkout.c
index f9453473fe2..43583c8d1be 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -902,7 +902,8 @@ static int merge_working_tree(const struct checkout_opts *opts,
 					   0);
 			init_ui_merge_options(&o, the_repository);
 			o.verbosity = 0;
-			work = write_in_core_index_as_tree(the_repository);
+			work = write_in_core_index_as_tree(the_repository,
+							   the_repository->index);
 
 			ret = reset_tree(new_tree,
 					 opts, 1,
diff --git a/cache-tree.c b/cache-tree.c
index 66ef2becbe0..029ec933abe 100644
--- a/cache-tree.c
+++ b/cache-tree.c
@@ -699,11 +699,11 @@ static int write_index_as_tree_internal(struct object_id *oid,
 	return 0;
 }
 
-struct tree* write_in_core_index_as_tree(struct repository *repo) {
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state) {
 	struct object_id o;
 	int was_valid, ret;
 
-	struct index_state *index_state	= repo->index;
 	was_valid = index_state->cache_tree &&
 		    cache_tree_fully_valid(index_state->cache_tree);
 
@@ -723,7 +723,6 @@ struct tree* write_in_core_index_as_tree(struct repository *repo) {
 	return lookup_tree(repo, &index_state->cache_tree->oid);
 }
 
-
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix)
 {
 	int entries, was_valid;
diff --git a/cache-tree.h b/cache-tree.h
index b82c4963e7c..f8bddae5235 100644
--- a/cache-tree.h
+++ b/cache-tree.h
@@ -47,7 +47,8 @@ int cache_tree_verify(struct repository *, struct index_state *);
 #define WRITE_TREE_UNMERGED_INDEX (-2)
 #define WRITE_TREE_PREFIX_ERROR (-3)
 
-struct tree* write_in_core_index_as_tree(struct repository *repo);
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state);
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix);
 void prime_cache_tree(struct repository *, struct index_state *, struct tree *);
 
-- 
2.51.0.261.g7ce5a0a67e.dirty
Patrick Steinhardt· Aug 19, 2025, 10:56 UTC · re: Patrick Steinhardt · lore

[PATCH RFC 04/11] builtin: add new "history" command

When rewriting history via git-rebase(1) there are a couple of very common use cases:

  - The ordering of two commits should be reversed.
  - A commit should be split up into two commits.
  - A commit should be dropped from the history completely.
  - Multiple commits should be squashed into one.

While these operations are all doable, it often feels needlessly cludgy to do so by doing an interactive rebase, using the editor to say what one wants, and then perform the actions. Furthermore, some operations like splitting up a commit into two are way more involved than that and require a whole series of commands.

Add a new "history" command to plug this gap. This command will have several different subcommands to imperatively rewrite history for common use cases like the above. These commands will be implemented in subsequent commits.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 .gitignore                     |  1 +
 Documentation/git-history.adoc | 43 ++++++++++++++++++++++++++++++++++++++++++
 Documentation/meson.build      |  1 +
 Makefile                       |  1 +
 builtin.h                      |  1 +
 builtin/history.c              | 20 ++++++++++++++++++++
 git.c                          |  1 +
 meson.build                    |  1 +
 8 files changed, 69 insertions(+)
Show changes to 8 files +69 −0

.gitignore, Documentation/git-history.adoc, Documentation/meson.build, Makefile, builtin.h, builtin/history.c, git.c, meson.build

diff --git a/.gitignore b/.gitignore
index 04c444404e..3932d4d618 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,6 +77,7 @@
 /git-grep
 /git-hash-object
 /git-help
+/git-history
 /git-hook
 /git-http-backend
 /git-http-fetch
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
new file mode 100644
index 0000000000..9dafb8fc16
--- /dev/null
+++ b/Documentation/git-history.adoc
@@ -0,0 +1,43 @@
+git-history(1)
+==============
+
+NAME
+----
+git-history - Rewrite history of the current branch
+
+SYNOPSIS
+--------
+[synopsis]
+git history [<options>]
+
+DESCRIPTION
+-----------
+
+Rewrite history by rearranging or modifying specific commits in the
+history.
+
+This command is similar to linkgit:git-rebase[1] and uses the same
+underlying machinery. You should use rebases if you either want to
+reapply a range of commits onto a different base, or interactive rebases
+if you want to edit a range of commits.
+
+Note that this command does not (yet) work with histories that contain
+merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
+flag instead.
+
+COMMANDS
+--------
+
+This command requires a subcommand. Several subcommands are available to
+rewrite history in different ways.
+
+CONFIGURATION
+-------------
+
+include::includes/cmd-config-section-all.adoc[]
+
+include::config/sequencer.adoc[]
+
+GIT
+---
+Part of the linkgit:git[1] suite
diff --git a/Documentation/meson.build b/Documentation/meson.build
index 4404c623f0..a30b5307fd 100644
--- a/Documentation/meson.build
+++ b/Documentation/meson.build
@@ -64,6 +64,7 @@ manpages = {
   'git-gui.adoc' : 1,
   'git-hash-object.adoc' : 1,
   'git-help.adoc' : 1,
+  'git-history.adoc' : 1,
   'git-hook.adoc' : 1,
   'git-http-backend.adoc' : 1,
   'git-http-fetch.adoc' : 1,
diff --git a/Makefile b/Makefile
index e11340c1ae..bed6eda5e6 100644
--- a/Makefile
+++ b/Makefile
@@ -1261,6 +1261,7 @@ BUILTIN_OBJS += builtin/get-tar-commit-id.o
 BUILTIN_OBJS += builtin/grep.o
 BUILTIN_OBJS += builtin/hash-object.o
 BUILTIN_OBJS += builtin/help.o
+BUILTIN_OBJS += builtin/history.o
 BUILTIN_OBJS += builtin/hook.o
 BUILTIN_OBJS += builtin/index-pack.o
 BUILTIN_OBJS += builtin/init-db.o
diff --git a/builtin.h b/builtin.h
index bff13e3069..2934f4479a 100644
--- a/builtin.h
+++ b/builtin.h
@@ -172,6 +172,7 @@ int cmd_get_tar_commit_id(int argc, const char **argv, const char *prefix, struc
 int cmd_grep(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hash_object(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_help(int argc, const char **argv, const char *prefix, struct repository *repo);
+int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hook(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);
diff --git a/builtin/history.c b/builtin/history.c
new file mode 100644
index 0000000000..d1a40368e0
--- /dev/null
+++ b/builtin/history.c
@@ -0,0 +1,20 @@
+#include "builtin.h"
+#include "gettext.h"
+#include "parse-options.h"
+
+int cmd_history(int argc,
+		const char **argv,
+		const char *prefix,
+		struct repository *repo UNUSED)
+{
+	const char * const usage[] = {
+		N_("git history [<options>]"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	return 0;
+}
diff --git a/git.c b/git.c
index 83eac0aeab..9d2cba2906 100644
--- a/git.c
+++ b/git.c
@@ -560,6 +560,7 @@ static struct cmd_struct commands[] = {
 	{ "grep", cmd_grep, RUN_SETUP_GENTLY },
 	{ "hash-object", cmd_hash_object },
 	{ "help", cmd_help },
+	{ "history", cmd_history, RUN_SETUP },
 	{ "hook", cmd_hook, RUN_SETUP },
 	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
 	{ "init", cmd_init_db },
diff --git a/meson.build b/meson.build
index 5dd299b496..0e40778a23 100644
--- a/meson.build
+++ b/meson.build
@@ -603,6 +603,7 @@ builtin_sources = [
   'builtin/grep.c',
   'builtin/hash-object.c',
   'builtin/help.c',
+  'builtin/history.c',
   'builtin/hook.c',
   'builtin/index-pack.c',
   'builtin/init-db.c',
-- 
2.51.0.261.g7ce5a0a67e.dirty
Patrick Steinhardt· Aug 19, 2025, 10:56 UTC · re: Patrick Steinhardt · lore

[PATCH RFC 05/11] builtin/history: implement "drop" subcommand

It is a fairly common operation to perform an interactive rebase so that one of the commits can be dropped from history. Doing this is not very hard in general, but still requires the user to perform multiple steps:

  1. Identify the commit in question that is to be dropped.
  2. Perform an interactive rebase on top of that commit's parent.
  3. Edit the instruction sheet to drop that commit.

This is needlessly complex for such a supposedly-trivial operation. Furthermore, the second step doesn't account for certain edge cases like for example dropping the root commit.

Introduce a new "drop" subcommand to make this use case significantly simpler: all the user needs to do is to say `git history drop $COMMIT` and they're done.

Note that for now, this command only allows users to drop a single commit at once. It should be easy enough though to expand the command at a later point in time to support dropping whole commit ranges.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  27 +++-
 builtin/history.c              | 297 ++++++++++++++++++++++++++++++++++++++++-
 t/meson.build                  |   3 +-
 t/t3450-history-drop.sh        | 127 ++++++++++++++++++
 4 files changed, 449 insertions(+), 5 deletions(-)
Show changes to 4 files +449 −5

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3450-history-drop.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 9dafb8fc16..3012445ddc 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -8,7 +8,7 @@ git-history - Rewrite history of the current branch
 SYNOPSIS
 --------
 [synopsis]
-git history [<options>]
+git history drop [<options>] <revision>
 
 DESCRIPTION
 -----------
@@ -31,6 +31,31 @@ COMMANDS
 This command requires a subcommand. Several subcommands are available to
 rewrite history in different ways.
 
+drop <revision>::
+	Drop a commit from the history and reapply all children of that
+	commit on top of the commit's parent. The commit that is to be
+	dropped must be reachable from the current `HEAD` commit.
++
+Dropping the root commit converts the child of that commit into the new
+root commit. It is invalid to drop a root commit that does not have any
+child commits, as that would lead to an empty branch.
+
+EXAMPLES
+--------
+
+* Drop a commit from history.
++
+----------
+$ git log --oneline
+2d4cd6d third
+125a0f3 second
+e098c27 first
+$ git history drop HEAD~
+$ git log
+b1bc1bd third
+e098c27 first
+----------
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index d1a40368e0..183ab9d5f7 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,20 +1,311 @@
 #include "builtin.h"
+#include "commit.h"
+#include "commit-reach.h"
+#include "config.h"
+#include "environment.h"
 #include "gettext.h"
+#include "hex.h"
+#include "object-name.h"
 #include "parse-options.h"
+#include "refs.h"
+#include "reset.h"
+#include "revision.h"
+#include "sequencer.h"
+
+static int collect_commits(struct repository *repo,
+			   struct commit *old_commit,
+			   struct commit *new_commit,
+			   struct strvec *out)
+{
+	struct setup_revision_opt revision_opts = {
+		.assume_dashdash = 1,
+	};
+	struct strvec revisions = STRVEC_INIT;
+	struct commit_list *from_list = NULL;
+	struct commit *child;
+	struct rev_info rev = { 0 };
+	int ret;
+
+	/*
+	 * Check that the old actually is an ancestor of HEAD. If not
+	 * the whole request becomes nonsensical.
+	*/
+	if (old_commit) {
+		commit_list_insert(old_commit, &from_list);
+		if (!repo_is_descendant_of(repo, new_commit, from_list)) {
+			ret = error(_("commit must be reachable from current HEAD commit"));
+			goto out;
+		}
+	}
+
+	repo_init_revisions(repo, &rev, NULL);
+	strvec_push(&revisions, "");
+	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
+	if (old_commit)
+		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
+	if (setup_revisions(revisions.nr, revisions.v, &rev, &revision_opts) != 1 ||
+	    prepare_revision_walk(&rev)) {
+		ret = error(_("revision walk setup failed"));
+		goto out;
+	}
+
+	while ((child = get_revision(&rev))) {
+		if (old_commit && !child->parents)
+			BUG("revision walk did not find child commit");
+		if (child->parents && child->parents->next) {
+			ret = error(_("cannot rearrange commit history with merges"));
+			goto out;
+		}
+
+		strvec_push(out, oid_to_hex(&child->object.oid));
+
+		if (child->parents && old_commit &&
+		    commit_list_contains(old_commit, child->parents))
+			break;
+	}
+
+	/*
+	 * Revisions are in newest-order-first. We have to reverse the
+	 * array though so that we pick the oldest commits first. Note
+	 * that we keep the first string untouched, as it is the
+	 * equivalent of `argv[0]` to `setup_revisions()`.
+	 */
+	for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
+		SWAP(out->v[i], out->v[j]);
+
+	ret = 0;
+
+out:
+	free_commit_list(from_list);
+	strvec_clear(&revisions);
+	release_revisions(&rev);
+	reset_revision_walk();
+	return ret;
+}
+
+static int apply_commits(struct repository *repo,
+			 const struct strvec *commits,
+			 struct commit *head,
+			 struct commit *base,
+			 const char *action)
+{
+	struct setup_revision_opt revision_opts = {
+		.assume_dashdash = 1,
+	};
+	struct replay_opts replay_opts = REPLAY_OPTS_INIT;
+	struct reset_head_opts reset_opts = { 0 };
+	struct object_id root_commit;
+	struct strvec args = STRVEC_INIT;
+	struct strbuf buf = STRBUF_INIT;
+	char hex[GIT_MAX_HEXSZ + 1];
+	int ref_flags, ret;
+
+	/*
+	 * We have performed all safety checks, so we now prepare
+	 * replaying the commits.
+	*/
+	replay_opts.action = REPLAY_PICK;
+	sequencer_init_config(&replay_opts);
+	replay_opts.quiet = 1;
+	replay_opts.skip_commit_summary = 1;
+	if (!replay_opts.strategy && replay_opts.default_strategy) {
+		replay_opts.strategy = replay_opts.default_strategy;
+		replay_opts.default_strategy = NULL;
+	}
+
+	strvec_push(&args, "");
+	strvec_pushv(&args, commits->v);
+
+	replay_opts.revs = xmalloc(sizeof(*replay_opts.revs));
+	repo_init_revisions(repo, replay_opts.revs, NULL);
+	replay_opts.revs->no_walk = 1;
+	replay_opts.revs->unsorted_input = 1;
+	if (setup_revisions(args.nr, args.v, replay_opts.revs,
+			    &revision_opts) != 1) {
+		ret = error(_("setting up revisions failed"));
+		goto out;
+	}
+
+	/*
+	 * If we're dropping the root commit we first need to create
+	 * a new empty root. We then instruct the seqencer machinery to
+	 * squash that root commit with the first commit we're picking
+	 * onto it.
+	 */
+	if (!base) {
+		if (commit_tree("", 0, repo->hash_algo->empty_tree, NULL,
+				&root_commit, NULL, NULL) < 0) {
+			ret = error(_("Could not create new root commit"));
+			goto out;
+		}
+
+		replay_opts.squash_onto = root_commit;
+		replay_opts.have_squash_onto = 1;
+		reset_opts.oid = &root_commit;
+	} else {
+		reset_opts.oid = &base->object.oid;
+	}
+
+	replay_opts.restore_head_target =
+		xstrdup_or_null(refs_resolve_ref_unsafe(get_main_ref_store(repo),
+							"HEAD", 0, NULL, &ref_flags));
+	if (!(ref_flags & REF_ISSYMREF))
+		FREE_AND_NULL(replay_opts.restore_head_target);
+
+	/*
+	 * Perform a hard-reset to the parent of our commit that is to
+	 * be dropped. This is the new base onto which we'll pick all
+	 * the descendants.
+	 */
+	strbuf_addf(&buf, "%s (start): checkout %s", action,
+		    oid_to_hex_r(hex, reset_opts.oid));
+	reset_opts.orig_head = &head->object.oid;
+	reset_opts.flags = RESET_HEAD_DETACH | RESET_ORIG_HEAD;
+	reset_opts.head_msg = buf.buf;
+	reset_opts.default_reflog_action = action;
+	if (reset_head(repo, &reset_opts) < 0) {
+		ret = error(_("could not switch to %s"), oid_to_hex(reset_opts.oid));
+		goto out;
+	}
+
+	ret = sequencer_pick_revisions(repo, &replay_opts);
+	if (ret < 0) {
+		ret = error(_("could not pick commits"));
+		goto out;
+	} else if (ret > 0) {
+		/*
+		 * A positive return value indicates we've got a merge
+		 * conflict. Bail out, but don't print a message as
+		 * `sequencer_pick_revisions()` already printed enough
+		 * information.
+		 */
+		ret = -1;
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	replay_opts_release(&replay_opts);
+	strbuf_release(&buf);
+	strvec_clear(&args);
+	return ret;
+}
+
+static int cmd_history_drop(int argc,
+			    const char **argv,
+			    const char *prefix,
+			    struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history drop [<options>] <revision>"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct commit *commit_to_drop, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct strbuf buf = STRBUF_INIT;
+	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);
+
+	commit_to_drop = lookup_commit_reference_by_name(argv[0]);
+	if (!commit_to_drop) {
+		ret = error(_("commit to be dropped cannot be found: %s"), argv[0]);
+		goto out;
+	}
+	if (commit_to_drop->parents && commit_to_drop->parents->next) {
+		ret = error(_("commit to be dropped must not be a merge commit"));
+		goto out;
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	if (oideq(&commit_to_drop->object.oid, &head->object.oid)) {
+		/*
+		 * If we want to drop the tip of the current branch we don't
+		 * have to perform any rebase at all. Instead, we simply
+		 * perform a hard reset to the parent commit.
+		 */
+		struct reset_head_opts reset_opts = {
+			.orig_head = &head->object.oid,
+			.flags = RESET_ORIG_HEAD,
+			.default_reflog_action = "drop",
+		};
+		char hex[GIT_MAX_HEXSZ + 1];
+
+		if (!commit_to_drop->parents) {
+			ret = error(_("cannot drop the only commit on this branch"));
+			goto out;
+		}
+
+		oid_to_hex_r(hex, &commit_to_drop->parents->item->object.oid);
+		strbuf_addf(&buf, "drop (start): checkout %s", hex);
+		reset_opts.oid = &commit_to_drop->parents->item->object.oid;
+		reset_opts.head_msg = buf.buf;
+
+		if (reset_head(repo, &reset_opts) < 0) {
+			ret = error(_("could not switch to %s"), hex);
+			goto out;
+		}
+	} else {
+		/*
+		 * Prepare a revision walk from old commit to the commit that is
+		 * about to be dropped. This serves three purposes:
+		 *
+		 *   - We verify that the history doesn't contain any merges.
+		 *     For now, merges aren't yet handled by us.
+		 *
+		 *   - We need to find the child of the commit-to-be-dropped.
+		 *     This child is what will be adopted by the parent of the
+		 *     commit that we are about to drop.
+		 *
+		 *   - We compute the list of commits-to-be-picked.
+		 */
+		ret = collect_commits(repo, commit_to_drop, head, &commits);
+		if (ret < 0)
+			goto out;
+
+		ret = apply_commits(repo, &commits, head, commit_to_drop->parents ?
+				    commit_to_drop->parents->item : NULL, "drop");
+		if (ret < 0)
+			goto out;
+	}
+
+	ret = 0;
+
+out:
+	strvec_clear(&commits);
+	strbuf_release(&buf);
+	return ret;
+}
 
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
-		struct repository *repo UNUSED)
+		struct repository *repo)
 {
 	const char * const usage[] = {
-		N_("git history [<options>]"),
+		N_("git history drop [<options>] <revision>"),
 		NULL,
 	};
+	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
+		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
 		OPT_END(),
 	};
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
-	return 0;
+	return fn(argc, argv, prefix, repo);
 }
diff --git a/t/meson.build b/t/meson.build
index bbeba1a8d5..859c388987 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -376,6 +376,7 @@ integration_tests = [
   't3436-rebase-more-options.sh',
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
+  't3450-history-drop.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
@@ -1214,4 +1215,4 @@ if perl.found() and time.found()
       timeout: 0,
     )
   endforeach
-endif
\ No newline at end of file
+endif
diff --git a/t/t3450-history-drop.sh b/t/t3450-history-drop.sh
new file mode 100755
index 0000000000..4782144da0
--- /dev/null
+++ b/t/t3450-history-drop.sh
@@ -0,0 +1,127 @@
+#!/bin/sh
+
+test_description='tests for git-history drop subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history drop HEAD 2>err &&
+		test_grep "commit to be dropped must not be a merge commit" err &&
+		test_must_fail git history drop HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work when history becomes empty' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		test_must_fail git history drop HEAD 2>err &&
+		test_grep "cannot drop the only commit on this branch" err
+	)
+'
+
+test_expect_success 'can drop tip of a branch' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		git history drop HEAD &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can drop commit in the middle' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+
+		git symbolic-ref HEAD >expect &&
+		git history drop HEAD~2 &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		fifth
+		fourth
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'correct order is retained' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history drop HEAD~3 &&
+		cat >expect <<-EOF &&
+		fifth
+		fourth
+		third
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can drop root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history drop HEAD~2 &&
+		cat >expect <<-EOF &&
+		third
+		second
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.51.0.261.g7ce5a0a67e.dirty
Ben Knoble· Aug 20, 2025, 20:39 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 05/11] builtin/history: implement "drop" subcommand

> Le 19 août 2025 à 06:57, Patrick Steinhardt <ps@pks.im> a écrit :
> 
> It is a fairly common operation to perform an interactive rebase so that
> one of the commits can be dropped from history.
Show 35 quoted lines
> diff --git a/builtin/history.c b/builtin/history.c
> index d1a40368e0..183ab9d5f7 100644
> --- a/builtin/history.c
> +++ b/builtin/history.c
> @@ -1,20 +1,311 @@
> #include "builtin.h"
> +#include "commit.h"
> +#include "commit-reach.h"
> +#include "config.h"
> +#include "environment.h"
> #include "gettext.h"
> +#include "hex.h"
> +#include "object-name.h"
> #include "parse-options.h"
> +#include "refs.h"
> +#include "reset.h"
> +#include "revision.h"
> +#include "sequencer.h"
> +
> +static int collect_commits(struct repository *repo,
> +               struct commit *old_commit,
> +               struct commit *new_commit,
> +               struct strvec *out)
> +{
> +    struct setup_revision_opt revision_opts = {
> +        .assume_dashdash = 1,
> +    };
> +    struct strvec revisions = STRVEC_INIT;
> +    struct commit_list *from_list = NULL;
> +    struct commit *child;
> +    struct rev_info rev = { 0 };
> +    int ret;
> +
> +    /*
> +     * Check that the old actually is an ancestor of HEAD. If not
The “old commit” perhaps?
Show 45 quoted lines
> +     * the whole request becomes nonsensical.
> +    */
> +    if (old_commit) {
> +        commit_list_insert(old_commit, &from_list);
> +        if (!repo_is_descendant_of(repo, new_commit, from_list)) {
> +            ret = error(_("commit must be reachable from current HEAD commit"));
> +            goto out;
> +        }
> +    }
> +
> +    repo_init_revisions(repo, &rev, NULL);
> +    strvec_push(&revisions, "");
> +    strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
> +    if (old_commit)
> +        strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
> +    if (setup_revisions(revisions.nr, revisions.v, &rev, &revision_opts) != 1 ||
> +        prepare_revision_walk(&rev)) {
> +        ret = error(_("revision walk setup failed"));
> +        goto out;
> +    }
> +
> +    while ((child = get_revision(&rev))) {
> +        if (old_commit && !child->parents)
> +            BUG("revision walk did not find child commit");
> +        if (child->parents && child->parents->next) {
> +            ret = error(_("cannot rearrange commit history with merges"));
> +            goto out;
> +        }
> +
> +        strvec_push(out, oid_to_hex(&child->object.oid));
> +
> +        if (child->parents && old_commit &&
> +            commit_list_contains(old_commit, child->parents))
> +            break;
> +    }
> +
> +    /*
> +     * Revisions are in newest-order-first. We have to reverse the
> +     * array though so that we pick the oldest commits first. Note
> +     * that we keep the first string untouched, as it is the
> +     * equivalent of `argv[0]` to `setup_revisions()`.
> +     */
> +    for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
> +        SWAP(out->v[i], out->v[j]);
> +
But doesn’t this swap out->v[0] on first iteration? I only skimmed the code that built it up, but it doesn’t look the comment is right 🤔
Rest looked reasonable, but I don’t know the sequencer APIs very well. 
Patrick Steinhardt· Aug 22, 2025, 12:21 UTC · re: Ben Knoble · lore

Re: [PATCH RFC 05/11] builtin/history: implement "drop" subcommand

On Wed, Aug 20, 2025 at 04:39:34PM -0400, Ben Knoble wrote:
Show 37 quoted lines
> > diff --git a/builtin/history.c b/builtin/history.c
> > index d1a40368e0..183ab9d5f7 100644
> > --- a/builtin/history.c
> > +++ b/builtin/history.c
> > @@ -1,20 +1,311 @@
> > #include "builtin.h"
> > +#include "commit.h"
> > +#include "commit-reach.h"
> > +#include "config.h"
> > +#include "environment.h"
> > #include "gettext.h"
> > +#include "hex.h"
> > +#include "object-name.h"
> > #include "parse-options.h"
> > +#include "refs.h"
> > +#include "reset.h"
> > +#include "revision.h"
> > +#include "sequencer.h"
> > +
> > +static int collect_commits(struct repository *repo,
> > +               struct commit *old_commit,
> > +               struct commit *new_commit,
> > +               struct strvec *out)
> > +{
> > +    struct setup_revision_opt revision_opts = {
> > +        .assume_dashdash = 1,
> > +    };
> > +    struct strvec revisions = STRVEC_INIT;
> > +    struct commit_list *from_list = NULL;
> > +    struct commit *child;
> > +    struct rev_info rev = { 0 };
> > +    int ret;
> > +
> > +    /*
> > +     * Check that the old actually is an ancestor of HEAD. If not
> 
> The “old commit” perhaps?
Yup, indeed.
Show 48 quoted lines
> > +     * the whole request becomes nonsensical.
> > +    */
> > +    if (old_commit) {
> > +        commit_list_insert(old_commit, &from_list);
> > +        if (!repo_is_descendant_of(repo, new_commit, from_list)) {
> > +            ret = error(_("commit must be reachable from current HEAD commit"));
> > +            goto out;
> > +        }
> > +    }
> > +
> > +    repo_init_revisions(repo, &rev, NULL);
> > +    strvec_push(&revisions, "");
> > +    strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
> > +    if (old_commit)
> > +        strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
> > +    if (setup_revisions(revisions.nr, revisions.v, &rev, &revision_opts) != 1 ||
> > +        prepare_revision_walk(&rev)) {
> > +        ret = error(_("revision walk setup failed"));
> > +        goto out;
> > +    }
> > +
> > +    while ((child = get_revision(&rev))) {
> > +        if (old_commit && !child->parents)
> > +            BUG("revision walk did not find child commit");
> > +        if (child->parents && child->parents->next) {
> > +            ret = error(_("cannot rearrange commit history with merges"));
> > +            goto out;
> > +        }
> > +
> > +        strvec_push(out, oid_to_hex(&child->object.oid));
> > +
> > +        if (child->parents && old_commit &&
> > +            commit_list_contains(old_commit, child->parents))
> > +            break;
> > +    }
> > +
> > +    /*
> > +     * Revisions are in newest-order-first. We have to reverse the
> > +     * array though so that we pick the oldest commits first. Note
> > +     * that we keep the first string untouched, as it is the
> > +     * equivalent of `argv[0]` to `setup_revisions()`.
> > +     */
> > +    for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
> > +        SWAP(out->v[i], out->v[j]);
> > +
> 
> But doesn’t this swap out->v[0] on first iteration? I only skimmed the
> code that built it up, but it doesn’t look the comment is right 🤔

Very true. This comment is indeed stale from a previous iteration, where `out->v[0]` was indeed `argv[0]`. Will fix.

> Rest looked reasonable, but I don’t know the sequencer APIs very well.

The sequencer API is quite complex overall, I cannot blame you :) Took me quite a while to get the hang of it.

Patrick
Jean-Noël AVILA· Aug 23, 2025, 16:15 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 05/11] builtin/history: implement "drop" subcommand

On Tuesday, 19 August 2025 12:56:01 CEST Patrick Steinhardt wrote:
Show 26 quoted lines
> It is a fairly common operation to perform an interactive rebase so that
> one of the commits can be dropped from history. Doing this is not very
> hard in general, but still requires the user to perform multiple steps:
> 
>   1. Identify the commit in question that is to be dropped.
> 
>   2. Perform an interactive rebase on top of that commit's parent.
> 
>   3. Edit the instruction sheet to drop that commit.
> 
> This is needlessly complex for such a supposedly-trivial operation.
> Furthermore, the second step doesn't account for certain edge cases like
> for example dropping the root commit.
> 
> Introduce a new "drop" subcommand to make this use case significantly
> simpler: all the user needs to do is to say `git history drop $COMMIT`
> and they're done.
> 
> Note that for now, this command only allows users to drop a single
> commit at once. It should be easy enough though to expand the command at
> a later point in time to support dropping whole commit ranges.
> 
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  Documentation/git-history.adoc |  27 +++-
>  builtin/history.c              | 297 ++++++++++++++++++++++++++++++++++++++
++-
Show 15 quoted lines
>  t/meson.build                  |   3 +-
>  t/t3450-history-drop.sh        | 127 ++++++++++++++++++
>  4 files changed, 449 insertions(+), 5 deletions(-)
> 
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> index 9dafb8fc16..3012445ddc 100644
> --- a/Documentation/git-history.adoc
> +++ b/Documentation/git-history.adoc
> @@ -8,7 +8,7 @@ git-history - Rewrite history of the current branch
>  SYNOPSIS
>  --------
>  [synopsis]
> -git history [<options>]
> +git history drop [<options>] <revision>
> 

Grepping through the documentation for the <revision> placeholder does not yield a lot of matches. Can <revision> be replaced by <commit> or <commit-ish> in this context; these ones seem widely used.

Show 7 quoted lines
>  DESCRIPTION
>  -----------
> @@ -31,6 +31,31 @@ COMMANDS
>  This command requires a subcommand. Several subcommands are available to
>  rewrite history in different ways.
> 
> +drop <revision>::

My linting patch series[1] does not catch this kind of synopsis miss, but here, backticks are missing because this is a part of synopsis:

`drop <revision>`::
Show 13 quoted lines
> +	Drop a commit from the history and reapply all children of that
> +	commit on top of the commit's parent. The commit that is to be
> +	dropped must be reachable from the current `HEAD` commit.
> ++
> +Dropping the root commit converts the child of that commit into the new
> +root commit. It is invalid to drop a root commit that does not have any
> +child commits, as that would lead to an empty branch.
> +
> +EXAMPLES
> +--------
> +
> +* Drop a commit from history.
> ++

As the examples are quite long, it would make sense to declare each example as a sub-section:

Drop a commit from history ~~~~~~~~~~~~~~~~~~~~~~~~~~

Show 14 quoted lines
> +----------
> +$ git log --oneline
> +2d4cd6d third
> +125a0f3 second
> +e098c27 first
> +$ git history drop HEAD~
> +$ git log
> +b1bc1bd third
> +e098c27 first
> +----------
> +
>  CONFIGURATION
>  -------------
> 
Patrick Steinhardt· Aug 24, 2025, 16:02 UTC · re: Jean-Noël AVILA · lore

Re: [PATCH RFC 05/11] builtin/history: implement "drop" subcommand

On Sat, Aug 23, 2025 at 06:15:08PM +0200, Jean-Noël AVILA wrote:
Show 16 quoted lines
> On Tuesday, 19 August 2025 12:56:01 CEST Patrick Steinhardt wrote:
> > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > index 9dafb8fc16..3012445ddc 100644
> > --- a/Documentation/git-history.adoc
> > +++ b/Documentation/git-history.adoc
> > @@ -8,7 +8,7 @@ git-history - Rewrite history of the current branch
> >  SYNOPSIS
> >  --------
> >  [synopsis]
> > -git history [<options>]
> > +git history drop [<options>] <revision>
> > 
> 
> Grepping through the documentation for the <revision> placeholder does not 
> yield a lot of matches. Can <revision> be replaced by <commit> or <commit-ish>  
> in this context; these ones seem widely used.
Yup, makes sense.
Show 12 quoted lines
> >  DESCRIPTION
> >  -----------
> > @@ -31,6 +31,31 @@ COMMANDS
> >  This command requires a subcommand. Several subcommands are available to
> >  rewrite history in different ways.
> > 
> > +drop <revision>::
> 
> My linting patch series[1] does not catch this kind of synopsis miss, but 
> here, backticks are missing because this is a part of synopsis:
> 
> `drop <revision>`::
Okay.
Show 19 quoted lines
> > +	Drop a commit from the history and reapply all children of that
> > +	commit on top of the commit's parent. The commit that is to be
> > +	dropped must be reachable from the current `HEAD` commit.
> > ++
> > +Dropping the root commit converts the child of that commit into the new
> > +root commit. It is invalid to drop a root commit that does not have any
> > +child commits, as that would lead to an empty branch.
> > +
> > +EXAMPLES
> > +--------
> > +
> > +* Drop a commit from history.
> > ++
> 
> As the examples are quite long, it would make sense to declare each example as 
> a sub-section:
> 
> Drop a commit from history
> ~~~~~~~~~~~~~~~~~~~~~~~~~~
Makes sense.

Thanks by the way for caring about our documentation and trying to make sure that we're being more consistent. I really appreciate this kind of work!

Patrick
Patrick Steinhardt· Aug 19, 2025, 10:56 UTC · re: Patrick Steinhardt · lore

[PATCH RFC 06/11] builtin/history: implement "reorder" subcommand

When working in projects where having nice commits matters it's quite common that developers end up reordering commits a lot. Tihs is typically done via interactive rebases, where they can then rearrange commits in the instruction sheet.

Still, this operation is a frequent-enough operation to provide a more direct of doing this imperatively. As such, introduce a new "reorder" subcommand where users can reorder a commit A to come after or before another commit B:

    $ git log --oneline
    a978f73 fifth
    57594ee fourth
    04eb1c4 third
    d535e30 second
    bf7438d first
    $ git history reorder :/fourth --before=:/second
    $ git log --oneline
    1610fe0 fifth
    444f97d third
    2f90797 second
    b0ae659 fourth
    bf7438d first
    $ git history reorder :/fourth --after=:/second
    $ git log --oneline
    c48729d fifth
    f44a46e third
    26693b8 fourth
    8cb4171 second
    bf7438d first
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  32 ++++++
 builtin/history.c              | 135 +++++++++++++++++++++++++
 t/meson.build                  |   1 +
 t/t3451-history-reorder.sh     | 218 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 386 insertions(+)
Show changes to 4 files +386 −0

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3451-history-reorder.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 3012445ddc..6e8b4e1326 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -9,6 +9,7 @@ SYNOPSIS
 --------
 [synopsis]
 git history drop [<options>] <revision>
+git history reorder [<options>] <revision> --(before|after)=<revision>
 
 DESCRIPTION
 -----------
@@ -40,6 +41,12 @@ Dropping the root commit converts the child of that commit into the new
 root commit. It is invalid to drop a root commit that does not have any
 child commits, as that would lead to an empty branch.
 
+reorder <revision> (--before=<revision>|--after=<revision>)::
+	Reorder the commit so that it becomes either the parent
+	(`--before=`) or child (`--after=`) of the other specified
+	commit. The commits must be related to one another and must be
+	reachable from the current `HEAD` commit.
+
 EXAMPLES
 --------
 
@@ -56,6 +63,31 @@ b1bc1bd third
 e098c27 first
 ----------
 
+* Reorder a commit.
++
+----------
+$ git log --oneline
+a978f73 fifth
+57594ee fourth
+04eb1c4 third
+d535e30 second
+bf7438d first
+$ git history reorder :/fourth --before=:/second
+$ git log --oneline
+1610fe0 fifth
+444f97d third
+2f90797 second
+b0ae659 fourth
+bf7438d first
+$ git history reorder :/fourth --after=:/second
+$ git log --oneline
+c48729d fifth
+f44a46e third
+26693b8 fourth
+8cb4171 second
+bf7438d first
+----------
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index 183ab9d5f7..de6073f557 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -83,6 +83,33 @@ static int collect_commits(struct repository *repo,
 	return ret;
 }
 
+static void replace_commits(struct strvec *commits,
+			    const struct object_id *commit_to_replace,
+			    const struct object_id *replacements,
+			    size_t replacements_nr)
+{
+	char commit_to_replace_oid[GIT_MAX_HEXSZ + 1];
+	struct strvec replacement_oids = STRVEC_INIT;
+	bool found = false;
+	size_t i;
+
+	oid_to_hex_r(commit_to_replace_oid, commit_to_replace);
+	for (i = 0; i < replacements_nr; i++)
+		strvec_push(&replacement_oids, oid_to_hex(&replacements[i]));
+
+	for (i = 0; i < commits->nr; i++) {
+		if (strcmp(commits->v[i], commit_to_replace_oid))
+			continue;
+		strvec_splice(commits, i, 1, replacement_oids.v, replacement_oids.nr);
+		found = true;
+		break;
+	}
+	if (!found)
+		BUG("could not find commit to replace");
+
+	strvec_clear(&replacement_oids);
+}
+
 static int apply_commits(struct repository *repo,
 			 const struct strvec *commits,
 			 struct commit *head,
@@ -291,6 +318,112 @@ static int cmd_history_drop(int argc,
 	return ret;
 }
 
+static int cmd_history_reorder(int argc,
+			       const char **argv,
+			       const char *prefix,
+			       struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history reorder [<options>] <revision> (--before=<commit>|--after=<commit>)"),
+		NULL,
+	};
+	const char *before = NULL, *after = NULL;
+	struct option options[] = {
+		OPT_STRING(0, "before", &before, N_("commit"), N_("reorder before this commit")),
+		OPT_STRING(0, "after", &after, N_("commit"), N_("reorder after this commit")),
+		OPT_END(),
+	};
+	struct commit *commit_to_reorder, *head, *anchor, *old;
+	struct strvec commits = STRVEC_INIT;
+	struct object_id replacement[2];
+	struct commit_list *list = NULL;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc != 1)
+		die(_("command expects a single revision"));
+	if (!before && !after)
+		die(_("exactly one option of 'before' or 'after' must be given"));
+	die_for_incompatible_opt2(!!before, "before", !!after, "after");
+
+	repo_config(repo, git_default_config, NULL);
+
+	commit_to_reorder = lookup_commit_reference_by_name(argv[0]);
+	if (!commit_to_reorder)
+		die(_("commit to be reordered cannot be found: %s"), argv[0]);
+	if (commit_to_reorder->parents && commit_to_reorder->parents->next)
+		die(_("commit to be reordered must not be a merge commit"));
+
+	anchor = lookup_commit_reference_by_name(before ? before : after);
+	if (!commit_to_reorder)
+		die(_("anchor commit cannot be found: %s"), before ? before : after);
+
+	if (oideq(&commit_to_reorder->object.oid, &anchor->object.oid))
+		die(_("commit to reorder and anchor must not be the same"));
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head)
+		die(_("could not resolve HEAD to a commit"));
+
+	commit_list_append(commit_to_reorder, &list);
+	if (!repo_is_descendant_of(repo, commit_to_reorder, list))
+		die(_("reordered commit must be reachable from current HEAD commit"));
+
+	/*
+	 * There is no requirement for the user to have either one of the
+	 * provided commits be the parent or child. We thus have to figure out
+	 * ourselves which one is which.
+	*/
+	if (repo_is_descendant_of(repo, anchor, list))
+		old = commit_to_reorder;
+	else
+		old = anchor;
+
+	/*
+	 * Select the whole range of commits, including the boundary commit
+	 * itself. In case the old commit is the root commit we simply pass no
+	 * boundary.
+	*/
+	ret = collect_commits(repo, old->parents ? old->parents->item : NULL,
+			      head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/*
+	 * Perform the reordering of commits in the strvec. This is done by:
+	 *
+	 *   - Deleting the to-be-reordered commit from the range of commits.
+	 *
+	 *   - Replacing the anchor commit with the anchor commit plus the
+	 *     to-be-reordered commit.
+	 */
+	if (before) {
+		replacement[0] = commit_to_reorder->object.oid;
+		replacement[1] = anchor->object.oid;
+	} else {
+		replacement[0] = anchor->object.oid;
+		replacement[1] = commit_to_reorder->object.oid;
+	}
+	replace_commits(&commits, &commit_to_reorder->object.oid, NULL, 0);
+	replace_commits(&commits, &anchor->object.oid, replacement, ARRAY_SIZE(replacement));
+
+	/*
+	 * And now we pick commits in the new order on top of either the root
+	 * commit or on top the old commit's parent.
+	 */
+	ret = apply_commits(repo, &commits, head,
+			    old->parents ? old->parents->item : NULL, "reorder");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	free_commit_list(list);
+	strvec_clear(&commits);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -298,11 +431,13 @@ int cmd_history(int argc,
 {
 	const char * const usage[] = {
 		N_("git history drop [<options>] <revision>"),
+		N_("git history reorder [<options>] <revision> --(before|after)=<revision>"),
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
 		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
+		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 859c388987..8eded9ec1b 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -377,6 +377,7 @@ integration_tests = [
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
   't3450-history-drop.sh',
+  't3451-history-reorder.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3451-history-reorder.sh b/t/t3451-history-reorder.sh
new file mode 100755
index 0000000000..cc311ba190
--- /dev/null
+++ b/t/t3451-history-reorder.sh
@@ -0,0 +1,218 @@
+#!/bin/sh
+
+test_description='tests for git-history reorder subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'reorder refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history reorder HEAD --before=HEAD~ 2>err &&
+		test_grep "commit to be reordered must not be a merge commit" err &&
+		test_must_fail git history reorder HEAD~ --after=HEAD 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'reorder requires exactly one of --before or --after' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_must_fail git history reorder HEAD 2>err &&
+		test_grep "exactly one option of ${SQ}before${SQ} or ${SQ}after${SQ} must be given" err &&
+		test_must_fail git history reorder HEAD --before=a --after=b 2>err &&
+		test_grep "options ${SQ}before${SQ} and ${SQ}after${SQ} cannot be used together" err
+	)
+'
+
+test_expect_success 'reorder refuses to reorder commit with itself' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_must_fail git history reorder HEAD --after=HEAD 2>err &&
+		test_grep "commit to reorder and anchor must not be the same" err
+	)
+'
+
+test_expect_success '--before can move commit back in history' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history reorder :/fourth --before=:/second &&
+		cat >expect <<-EOF &&
+		fifth
+		third
+		second
+		fourth
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--before can move commit forward in history' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history reorder :/second --before=:/fourth &&
+		cat >expect <<-EOF &&
+		fifth
+		fourth
+		second
+		third
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--before can make a commit a root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history reorder :/third --before=:/first &&
+		cat >expect <<-EOF &&
+		second
+		first
+		third
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--after can move commit back in history' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history reorder :/fourth --after=:/second &&
+		cat >expect <<-EOF &&
+		fifth
+		third
+		fourth
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--after can move commit forward in history' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history reorder :/second --after=:/fourth &&
+		cat >expect <<-EOF &&
+		fifth
+		second
+		fourth
+		third
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--after can make commit the tip' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history reorder :/first --after=:/third &&
+		cat >expect <<-EOF &&
+		first
+		third
+		second
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'conflicts are detected' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		echo base >file &&
+		git add file &&
+		git commit -m base &&
+		echo "first edit" >file &&
+		git commit -am "first edit" &&
+		echo "second edit" >file &&
+		git commit -am "second edit" &&
+
+		git symbolic-ref HEAD >expect-head &&
+		test_must_fail git history reorder HEAD --before=HEAD~ &&
+		test_must_fail git symbolic-ref HEAD &&
+		echo "second edit" >file &&
+		git add file &&
+		test_must_fail git cherry-pick --continue &&
+		echo "first edit" >file &&
+		git add file &&
+		git cherry-pick --continue &&
+
+		cat >expect <<-EOF &&
+		first edit
+		second edit
+		base
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+
+		git symbolic-ref HEAD >actual-head &&
+		test_cmp expect-head actual-head
+	)
+'
+
+test_done
-- 
2.51.0.261.g7ce5a0a67e.dirty
Jean-Noël AVILA· Aug 23, 2025, 16:24 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 06/11] builtin/history: implement "reorder" subcommand

On Tuesday, 19 August 2025 12:56:02 CEST Patrick Steinhardt wrote:
Show 39 quoted lines
> When working in projects where having nice commits matters it's quite
> common that developers end up reordering commits a lot. Tihs is
> typically done via interactive rebases, where they can then rearrange
> commits in the instruction sheet.
> 
> Still, this operation is a frequent-enough operation to provide a more
> direct of doing this imperatively. As such, introduce a new "reorder"
> subcommand where users can reorder a commit A to come after or before
> another commit B:
> 
>     $ git log --oneline
>     a978f73 fifth
>     57594ee fourth
>     04eb1c4 third
>     d535e30 second
>     bf7438d first
> 
>     $ git history reorder :/fourth --before=:/second
>     $ git log --oneline
>     1610fe0 fifth
>     444f97d third
>     2f90797 second
>     b0ae659 fourth
>     bf7438d first
> 
>     $ git history reorder :/fourth --after=:/second
>     $ git log --oneline
>     c48729d fifth
>     f44a46e third
>     26693b8 fourth
>     8cb4171 second
>     bf7438d first
> 
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  Documentation/git-history.adoc |  32 ++++++
>  builtin/history.c              | 135 +++++++++++++++++++++++++
>  t/meson.build                  |   1 +
>  t/t3451-history-reorder.sh     | 218 ++++++++++++++++++++++++++++++++++++++
+++
Show 12 quoted lines
>  4 files changed, 386 insertions(+)
> 
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> index 3012445ddc..6e8b4e1326 100644
> --- a/Documentation/git-history.adoc
> +++ b/Documentation/git-history.adoc
> @@ -9,6 +9,7 @@ SYNOPSIS
>  --------
>  [synopsis]
>  git history drop [<options>] <revision>
> +git history reorder [<options>] <revision> --(before|after)=<revision>
> 
Same here about <revision> vs <commit>/<commit-ish>. 

Also, the form --(foo|bar) has never been used in synopsis. I think it would better be unrolled, just like in the description. Can you rename each occurrence of placeholder <revision> on the line so that the description can refer to them differently?

Show 12 quoted lines
>  DESCRIPTION
>  -----------
>@@ -40,6 +41,12 @@ Dropping the root commit converts the child of that commit 
>into the new
> root commit. It is invalid to drop a root commit that does not have any
> child commits, as that would lead to an empty branch.
> 
>+reorder <revision> (--before=<revision>|--after=<revision>)::
>+       Reorder the commit so that it becomes either the parent
>+       (`--before=`) or child (`--after=`) of the other specified
>+       commit. The commits must be related to one another and must be
>+       reachable from the current `HEAD` commit.
with the renamed placeholders, this can be turned into something like:

Move <commit> so that it becomes either the parent of <following-commit> or the child of <preceding-commit>.

Kristoffer Haugsbakk· Aug 24, 2025, 17:25 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 06/11] builtin/history: implement "reorder" subcommand

Disclaimer that I’ve never used Jujutsu.
On Tue, Aug 19, 2025, at 12:56, Patrick Steinhardt wrote:
> When working in projects where having nice commits matters it's quite
> common that developers end up reordering commits a lot. Tihs is
s/Tihs/This/
Show 5 quoted lines
> typically done via interactive rebases, where they can then rearrange
> commits in the instruction sheet.
>
> Still, this operation is a frequent-enough operation to provide a more
> direct of doing this imperatively. As such, introduce a new "reorder"
s/direct of/direct way of/

What’s a use-case for doing this imperatively? With a nice rebase frontend you get to shuffle around some lines in your preferred editor. This seems like using Ex mode in Vim. Which is legitimate but I don’t quite see when you would do it.

The thing with e.g. the Drop subcommand is that I might have some commits marked `TEMP` that I wanna quickly drop at some point. That’s sort of a semi-interactive use case; I might want to lightly script it, but I am always going to invoke it interactively. Doing that light scripting on top of git-rebase(1) sounds like a hassle though.

But in this case I don’t understand when this would save you time over a nice Rebase frontend. Because I don’t see when you want semi-interactive history reordering.

Show 12 quoted lines
> subcommand where users can reorder a commit A to come after or before
> another commit B:
>
>     $ git log --oneline
>     a978f73 fifth
>     57594ee fourth
>     04eb1c4 third
>     d535e30 second
>     bf7438d first
>
>     $ git history reorder :/fourth --before=:/second
>     $ git log --oneline

The `:/` notation makes sense here. You’ve probably just made these commits so you want to match the newest ones.

Is this example meant to demonstrate how it works or to also demonstrate how you would use it (i.e. with revision syntax)?

Show 44 quoted lines
>     1610fe0 fifth
>     444f97d third
>     2f90797 second
>     b0ae659 fourth
>     bf7438d first
>
>     $ git history reorder :/fourth --after=:/second
>     $ git log --oneline
>     c48729d fifth
>     f44a46e third
>     26693b8 fourth
>     8cb4171 second
>     bf7438d first
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  Documentation/git-history.adoc |  32 ++++++
>  builtin/history.c              | 135 +++++++++++++++++++++++++
>  t/meson.build                  |   1 +
>  t/t3451-history-reorder.sh     | 218 +++++++++++++++++++++++++++++++++++++++++
>  4 files changed, 386 insertions(+)
>
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> index 3012445ddc..6e8b4e1326 100644
> --- a/Documentation/git-history.adoc
> +++ b/Documentation/git-history.adoc
> @@ -9,6 +9,7 @@ SYNOPSIS
>  --------
>  [synopsis]
>  git history drop [<options>] <revision>
> +git history reorder [<options>] <revision> --(before|after)=<revision>
>
>  DESCRIPTION
>  -----------
> @@ -40,6 +41,12 @@ Dropping the root commit converts the child of that
> commit into the new
>  root commit. It is invalid to drop a root commit that does not have any
>  child commits, as that would lead to an empty branch.
>
> +reorder <revision> (--before=<revision>|--after=<revision>)::
> +	Reorder the commit so that it becomes either the parent
> +	(`--before=`) or child (`--after=`) of the other specified
> +	commit. The commits must be related to one another and must be
> +	reachable from the current `HEAD` commit.
s/current `HEAD` commit/current commit/ ?
> +
>  EXAMPLES
>  --------
>[snip]
Patrick Steinhardt· Aug 24, 2025, 17:34 UTC · re: Kristoffer Haugsbakk · lore

Re: [PATCH RFC 06/11] builtin/history: implement "reorder" subcommand

On Sun, Aug 24, 2025 at 07:25:48PM +0200, Kristoffer Haugsbakk wrote:
Show 28 quoted lines
> On Tue, Aug 19, 2025, at 12:56, Patrick Steinhardt wrote:
> > When working in projects where having nice commits matters it's quite
> > common that developers end up reordering commits a lot. Tihs is
> 
> s/Tihs/This/
> 
> > typically done via interactive rebases, where they can then rearrange
> > commits in the instruction sheet.
> >
> > Still, this operation is a frequent-enough operation to provide a more
> > direct of doing this imperatively. As such, introduce a new "reorder"
> 
> s/direct of/direct way of/
> 
> What’s a use-case for doing this imperatively?  With a nice rebase
> frontend you get to shuffle around some lines in your preferred editor.
> This seems like using Ex mode in Vim.  Which is legitimate but I don’t
> quite see when you would do it.
> 
> The thing with e.g. the Drop subcommand is that I might have some
> commits marked `TEMP` that I wanna quickly drop at some point.  That’s
> sort of a semi-interactive use case; I might want to lightly script it,
> but I am always going to invoke it interactively.  Doing that light
> scripting on top of git-rebase(1) sounds like a hassle though.
> 
> But in this case I don’t understand when this would save you time over a
> nice Rebase frontend.  Because I don’t see when you want
> semi-interactive history reordering.

All I can say is that I ended up using this feature quite regularly with Jujutsu. It sometimes just feels a less heavy-weight to tell Git what to do directly instead of doing so via the editor.

Most often this happens when I just created a new smallish commit because I noticed something and quickly want to reorder it somewhere earlier.

Show 18 quoted lines
> > subcommand where users can reorder a commit A to come after or before
> > another commit B:
> >
> >     $ git log --oneline
> >     a978f73 fifth
> >     57594ee fourth
> >     04eb1c4 third
> >     d535e30 second
> >     bf7438d first
> >
> >     $ git history reorder :/fourth --before=:/second
> >     $ git log --oneline
> 
> The `:/` notation makes sense here.  You’ve probably just made these
> commits so you want to match the newest ones.
> 
> Is this example meant to demonstrate how it works or to also demonstrate
> how you would use it (i.e. with revision syntax)?

Both, I guess. I often use this revision syntax myself, but in a lot of other cases, especially when you just want to rebase HEAD, it may be easier to just say "HEAD".

Show 16 quoted lines
> > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > index 3012445ddc..6e8b4e1326 100644
> > --- a/Documentation/git-history.adoc
> > +++ b/Documentation/git-history.adoc
> > @@ -40,6 +41,12 @@ Dropping the root commit converts the child of that
> > commit into the new
> >  root commit. It is invalid to drop a root commit that does not have any
> >  child commits, as that would lead to an empty branch.
> >
> > +reorder <revision> (--before=<revision>|--after=<revision>)::
> > +	Reorder the commit so that it becomes either the parent
> > +	(`--before=`) or child (`--after=`) of the other specified
> > +	commit. The commits must be related to one another and must be
> > +	reachable from the current `HEAD` commit.
> 
> s/current `HEAD` commit/current commit/ ?
Let's maybe say "currently checked-out commit".
Patrick
Patrick Steinhardt· Aug 19, 2025, 10:56 UTC · re: Patrick Steinhardt · lore

[PATCH RFC 07/11] add-patch: split out header from "add-interactive.h"

While we have a "add-patch.c" code file, its declarations are part of "add-interactive.h". This makes it somewhat harder than necessary to find relevant code and to identify clear boundaries between the two subsystems.

Split up concerns and move declarations that relate to "add-patch.c" into a new "add-patch.h" header.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.h | 23 +++--------------------
 add-patch.c       |  1 +
 add-patch.h       | 26 ++++++++++++++++++++++++++
 3 files changed, 30 insertions(+), 20 deletions(-)
Show changes to 3 files +30 −20

add-interactive.h, add-patch.c, add-patch.h

diff --git a/add-interactive.h b/add-interactive.h
index 4213dcd67b..fb95b6ee05 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -1,14 +1,11 @@
 #ifndef ADD_INTERACTIVE_H
 #define ADD_INTERACTIVE_H
 
+#include "add-patch.h"
 #include "color.h"
 
-struct add_p_opt {
-	int context;
-	int interhunkcontext;
-};
-
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+struct pathspec;
+struct repository;
 
 struct add_i_state {
 	struct repository *r;
@@ -32,21 +29,7 @@ void init_add_i_state(struct add_i_state *s, struct repository *r,
 		      struct add_p_opt *add_p_opt);
 void clear_add_i_state(struct add_i_state *s);
 
-struct repository;
-struct pathspec;
 int run_add_i(struct repository *r, const struct pathspec *ps,
 	      struct add_p_opt *add_p_opt);
 
-enum add_p_mode {
-	ADD_P_ADD,
-	ADD_P_STASH,
-	ADD_P_RESET,
-	ADD_P_CHECKOUT,
-	ADD_P_WORKTREE,
-};
-
-int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
-	      const struct pathspec *ps);
-
 #endif
diff --git a/add-patch.c b/add-patch.c
index 302e6ba7d9..e2b002fa73 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -3,6 +3,7 @@
 
 #include "git-compat-util.h"
 #include "add-interactive.h"
+#include "add-patch.h"
 #include "advice.h"
 #include "editor.h"
 #include "environment.h"
diff --git a/add-patch.h b/add-patch.h
new file mode 100644
index 0000000000..4394c74107
--- /dev/null
+++ b/add-patch.h
@@ -0,0 +1,26 @@
+#ifndef ADD_PATCH_H
+#define ADD_PATCH_H
+
+struct pathspec;
+struct repository;
+
+struct add_p_opt {
+	int context;
+	int interhunkcontext;
+};
+
+#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+
+enum add_p_mode {
+	ADD_P_ADD,
+	ADD_P_STASH,
+	ADD_P_RESET,
+	ADD_P_CHECKOUT,
+	ADD_P_WORKTREE,
+};
+
+int run_add_p(struct repository *r, enum add_p_mode mode,
+	      struct add_p_opt *o, const char *revision,
+	      const struct pathspec *ps);
+
+#endif
-- 
2.51.0.261.g7ce5a0a67e.dirty
Patrick Steinhardt· Aug 19, 2025, 10:56 UTC · re: Patrick Steinhardt · lore

[PATCH RFC 08/11] add-patch: split out `struct interactive_options`

The `struct add_p_opt` is reused both by our the infra for "git add -p" and "git add -i". Users of `run_add_i()` for example are expected to pass `struct add_p_opt`. This is somewhat confusing and raises the question which options apply to what part of the stack.

But things are even more confusing than that: while callers are expected to pass in `struct add_p_opt`, these options ultimately get used to initialize a `struct add_i_state` that is used by both subsystems. So we are basically going full circle here.

Refactor the code and split out a new `struct interactive_options` that hosts common options used by both. These options are then applied to a `struct interactive_config` that hosts common configuration.

This refactoring doesn't yet fully detangle the two subsystems from one another, as we still end up calling `init_add_i_state()` in the "git add -p" subsystem. This will be fixed in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.c  | 151 +++++++++++++----------------------------------------
 add-interactive.h  |  20 ++-----
 add-patch.c        | 145 +++++++++++++++++++++++++++++++++++++++++---------
 add-patch.h        |  33 ++++++++++--
 builtin/add.c      |  22 ++++----
 builtin/checkout.c |   4 +-
 builtin/commit.c   |  16 +++---
 builtin/reset.c    |  16 +++---
 builtin/stash.c    |  46 ++++++++--------
 commit.h           |   2 +-
 10 files changed, 243 insertions(+), 212 deletions(-)
Show changes to 10 files +243 −212

add-interactive.c, add-interactive.h, add-patch.c, add-patch.h, builtin/add.c, builtin/checkout.c, builtin/commit.c, builtin/reset.c, builtin/stash.c, commit.h

diff --git a/add-interactive.c b/add-interactive.c
index 3e692b47ec..3babc3e013 100644
--- a/add-interactive.c
+++ b/add-interactive.c
@@ -3,7 +3,6 @@
 #include "git-compat-util.h"
 #include "add-interactive.h"
 #include "color.h"
-#include "config.h"
 #include "diffcore.h"
 #include "gettext.h"
 #include "hash.h"
@@ -20,96 +19,18 @@
 #include "prompt.h"
 #include "tree.h"
 
-static void init_color(struct repository *r, struct add_i_state *s,
-		       const char *section_and_slot, char *dst,
-		       const char *default_color)
-{
-	char *key = xstrfmt("color.%s", section_and_slot);
-	const char *value;
-
-	if (!s->use_color)
-		dst[0] = '\0';
-	else if (repo_config_get_value(r, key, &value) ||
-		 color_parse(value, dst))
-		strlcpy(dst, default_color, COLOR_MAXLEN);
-
-	free(key);
-}
-
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt)
+		      struct interactive_options *opts)
 {
-	const char *value;
-
 	s->r = r;
-	s->context = -1;
-	s->interhunkcontext = -1;
-
-	if (repo_config_get_value(r, "color.interactive", &value))
-		s->use_color = -1;
-	else
-		s->use_color =
-			git_config_colorbool("color.interactive", value);
-	s->use_color = want_color(s->use_color);
-
-	init_color(r, s, "interactive.header", s->header_color, GIT_COLOR_BOLD);
-	init_color(r, s, "interactive.help", s->help_color, GIT_COLOR_BOLD_RED);
-	init_color(r, s, "interactive.prompt", s->prompt_color,
-		   GIT_COLOR_BOLD_BLUE);
-	init_color(r, s, "interactive.error", s->error_color,
-		   GIT_COLOR_BOLD_RED);
-
-	init_color(r, s, "diff.frag", s->fraginfo_color,
-		   diff_get_color(s->use_color, DIFF_FRAGINFO));
-	init_color(r, s, "diff.context", s->context_color, "fall back");
-	if (!strcmp(s->context_color, "fall back"))
-		init_color(r, s, "diff.plain", s->context_color,
-			   diff_get_color(s->use_color, DIFF_CONTEXT));
-	init_color(r, s, "diff.old", s->file_old_color,
-		diff_get_color(s->use_color, DIFF_FILE_OLD));
-	init_color(r, s, "diff.new", s->file_new_color,
-		diff_get_color(s->use_color, DIFF_FILE_NEW));
-
-	strlcpy(s->reset_color,
-		s->use_color ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
-
-	FREE_AND_NULL(s->interactive_diff_filter);
-	repo_config_get_string(r, "interactive.difffilter",
-			       &s->interactive_diff_filter);
-
-	FREE_AND_NULL(s->interactive_diff_algorithm);
-	repo_config_get_string(r, "diff.algorithm",
-			       &s->interactive_diff_algorithm);
-
-	if (!repo_config_get_int(r, "diff.context", &s->context))
-		if (s->context < 0)
-			die(_("%s cannot be negative"), "diff.context");
-	if (!repo_config_get_int(r, "diff.interHunkContext", &s->interhunkcontext))
-		if (s->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "diff.interHunkContext");
-
-	repo_config_get_bool(r, "interactive.singlekey", &s->use_single_key);
-	if (s->use_single_key)
-		setbuf(stdin, NULL);
-
-	if (add_p_opt->context != -1) {
-		if (add_p_opt->context < 0)
-			die(_("%s cannot be negative"), "--unified");
-		s->context = add_p_opt->context;
-	}
-	if (add_p_opt->interhunkcontext != -1) {
-		if (add_p_opt->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "--inter-hunk-context");
-		s->interhunkcontext = add_p_opt->interhunkcontext;
-	}
+	interactive_config_init(&s->cfg, r, opts);
 }
 
 void clear_add_i_state(struct add_i_state *s)
 {
-	FREE_AND_NULL(s->interactive_diff_filter);
-	FREE_AND_NULL(s->interactive_diff_algorithm);
+	interactive_config_clear(&s->cfg);
 	memset(s, 0, sizeof(*s));
-	s->use_color = -1;
+	interactive_config_clear(&s->cfg);
 }
 
 /*
@@ -262,7 +183,7 @@ static void list(struct add_i_state *s, struct string_list *list, int *selected,
 		return;
 
 	if (opts->header)
-		color_fprintf_ln(stdout, s->header_color,
+		color_fprintf_ln(stdout, s->cfg.header_color,
 				 "%s", opts->header);
 
 	for (i = 0; i < list->nr; i++) {
@@ -330,7 +251,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 		list(s, &items->items, items->selected, &opts->list_opts);
 
-		color_fprintf(stdout, s->prompt_color, "%s", opts->prompt);
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", opts->prompt);
 		fputs(singleton ? "> " : ">> ", stdout);
 		fflush(stdout);
 
@@ -408,7 +329,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 			if (from < 0 || from >= items->items.nr ||
 			    (singleton && from + 1 != to)) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("Huh (%s)?"), p);
 				break;
 			} else if (singleton) {
@@ -968,7 +889,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 				free(files->items.items[i].string);
 			} else if (item->index.unmerged ||
 				 item->worktree.unmerged) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("ignoring unmerged: %s"),
 						 files->items.items[i].string);
 				free(item);
@@ -990,9 +911,9 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 	opts->prompt = N_("Patch update");
 	count = list_and_choose(s, files, opts);
 	if (count > 0) {
-		struct add_p_opt add_p_opt = {
-			.context = s->context,
-			.interhunkcontext = s->interhunkcontext,
+		struct interactive_options opts = {
+			.context = s->cfg.context,
+			.interhunkcontext = s->cfg.interhunkcontext,
 		};
 		struct strvec args = STRVEC_INIT;
 		struct pathspec ps_selected = { 0 };
@@ -1004,7 +925,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 		parse_pathspec(&ps_selected,
 			       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,
 			       PATHSPEC_LITERAL_PATH, "", args.v);
-		res = run_add_p(s->r, ADD_P_ADD, &add_p_opt, NULL, &ps_selected);
+		res = run_add_p(s->r, ADD_P_ADD, &opts, NULL, &ps_selected);
 		strvec_clear(&args);
 		clear_pathspec(&ps_selected);
 	}
@@ -1040,10 +961,10 @@ static int run_diff(struct add_i_state *s, const struct pathspec *ps,
 		struct child_process cmd = CHILD_PROCESS_INIT;
 
 		strvec_pushl(&cmd.args, "git", "diff", "-p", "--cached", NULL);
-		if (s->context != -1)
-			strvec_pushf(&cmd.args, "--unified=%i", s->context);
-		if (s->interhunkcontext != -1)
-			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->interhunkcontext);
+		if (s->cfg.context != -1)
+			strvec_pushf(&cmd.args, "--unified=%i", s->cfg.context);
+		if (s->cfg.interhunkcontext != -1)
+			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
 		strvec_pushl(&cmd.args, oid_to_hex(!is_initial ? &oid :
 			     s->r->hash_algo->empty_tree), "--", NULL);
 		for (i = 0; i < files->items.nr; i++)
@@ -1061,17 +982,17 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 		    struct prefix_item_list *files UNUSED,
 		    struct list_and_choose_options *opts UNUSED)
 {
-	color_fprintf_ln(stdout, s->help_color, "status        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "status        - %s",
 			 _("show paths with changes"));
-	color_fprintf_ln(stdout, s->help_color, "update        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "update        - %s",
 			 _("add working tree state to the staged set of changes"));
-	color_fprintf_ln(stdout, s->help_color, "revert        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "revert        - %s",
 			 _("revert staged set of changes back to the HEAD version"));
-	color_fprintf_ln(stdout, s->help_color, "patch         - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "patch         - %s",
 			 _("pick hunks and update selectively"));
-	color_fprintf_ln(stdout, s->help_color, "diff          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "diff          - %s",
 			 _("view diff between HEAD and index"));
-	color_fprintf_ln(stdout, s->help_color, "add untracked - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "add untracked - %s",
 			 _("add contents of untracked files to the staged set of changes"));
 
 	return 0;
@@ -1079,21 +1000,21 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 
 static void choose_prompt_help(struct add_i_state *s)
 {
-	color_fprintf_ln(stdout, s->help_color, "%s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "%s",
 			 _("Prompt help:"));
-	color_fprintf_ln(stdout, s->help_color, "1          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "1          - %s",
 			 _("select a single item"));
-	color_fprintf_ln(stdout, s->help_color, "3-5        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "3-5        - %s",
 			 _("select a range of items"));
-	color_fprintf_ln(stdout, s->help_color, "2-3,6-9    - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "2-3,6-9    - %s",
 			 _("select multiple ranges"));
-	color_fprintf_ln(stdout, s->help_color, "foo        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "foo        - %s",
 			 _("select item based on unique prefix"));
-	color_fprintf_ln(stdout, s->help_color, "-...       - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "-...       - %s",
 			 _("unselect specified items"));
-	color_fprintf_ln(stdout, s->help_color, "*          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "*          - %s",
 			 _("choose all items"));
-	color_fprintf_ln(stdout, s->help_color, "           - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "           - %s",
 			 _("(empty) finish selecting"));
 }
 
@@ -1128,7 +1049,7 @@ static void print_command_item(int i, int selected UNUSED,
 
 static void command_prompt_help(struct add_i_state *s)
 {
-	const char *help_color = s->help_color;
+	const char *help_color = s->cfg.help_color;
 	color_fprintf_ln(stdout, help_color, "%s", _("Prompt help:"));
 	color_fprintf_ln(stdout, help_color, "1          - %s",
 			 _("select a numbered item"));
@@ -1139,7 +1060,7 @@ static void command_prompt_help(struct add_i_state *s)
 }
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt)
+	      struct interactive_options *interactive_opts)
 {
 	struct add_i_state s = { NULL };
 	struct print_command_item_data data = { "[", "]" };
@@ -1182,15 +1103,15 @@ int run_add_i(struct repository *r, const struct pathspec *ps,
 			->util = util;
 	}
 
-	init_add_i_state(&s, r, add_p_opt);
+	init_add_i_state(&s, r, interactive_opts);
 
 	/*
 	 * When color was asked for, use the prompt color for
 	 * highlighting, otherwise use square brackets.
 	 */
-	if (s.use_color) {
-		data.color = s.prompt_color;
-		data.reset = s.reset_color;
+	if (s.cfg.use_color) {
+		data.color = s.cfg.prompt_color;
+		data.reset = s.cfg.reset_color;
 	}
 	print_file_item_data.color = data.color;
 	print_file_item_data.reset = data.reset;
diff --git a/add-interactive.h b/add-interactive.h
index fb95b6ee05..eefa2edc7c 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -2,34 +2,20 @@
 #define ADD_INTERACTIVE_H
 
 #include "add-patch.h"
-#include "color.h"
 
 struct pathspec;
 struct repository;
 
 struct add_i_state {
 	struct repository *r;
-	int use_color;
-	char header_color[COLOR_MAXLEN];
-	char help_color[COLOR_MAXLEN];
-	char prompt_color[COLOR_MAXLEN];
-	char error_color[COLOR_MAXLEN];
-	char reset_color[COLOR_MAXLEN];
-	char fraginfo_color[COLOR_MAXLEN];
-	char context_color[COLOR_MAXLEN];
-	char file_old_color[COLOR_MAXLEN];
-	char file_new_color[COLOR_MAXLEN];
-
-	int use_single_key;
-	char *interactive_diff_filter, *interactive_diff_algorithm;
-	int context, interhunkcontext;
+	struct interactive_config cfg;
 };
 
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt);
+		      struct interactive_options *opts);
 void clear_add_i_state(struct add_i_state *s);
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt);
+	      struct interactive_options *opts);
 
 #endif
diff --git a/add-patch.c b/add-patch.c
index e2b002fa73..45bc254e0c 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -5,6 +5,8 @@
 #include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "config.h"
+#include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
@@ -279,6 +281,99 @@ struct add_p_state {
 	const char *revision;
 };
 
+static void init_color(struct repository *r,
+		       struct interactive_config *cfg,
+		       const char *section_and_slot, char *dst,
+		       const char *default_color)
+{
+	char *key = xstrfmt("color.%s", section_and_slot);
+	const char *value;
+
+	if (!cfg->use_color)
+		dst[0] = '\0';
+	else if (repo_config_get_value(r, key, &value) ||
+		 color_parse(value, dst))
+		strlcpy(dst, default_color, COLOR_MAXLEN);
+
+	free(key);
+}
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts)
+{
+	const char *value;
+
+	cfg->context = -1;
+	cfg->interhunkcontext = -1;
+
+	if (repo_config_get_value(r, "color.interactive", &value))
+		cfg->use_color = -1;
+	else
+		cfg->use_color =
+			git_config_colorbool("color.interactive", value);
+	cfg->use_color = want_color(cfg->use_color);
+
+	init_color(r, cfg, "interactive.header", cfg->header_color, GIT_COLOR_BOLD);
+	init_color(r, cfg, "interactive.help", cfg->help_color, GIT_COLOR_BOLD_RED);
+	init_color(r, cfg, "interactive.prompt", cfg->prompt_color,
+		   GIT_COLOR_BOLD_BLUE);
+	init_color(r, cfg, "interactive.error", cfg->error_color,
+		   GIT_COLOR_BOLD_RED);
+
+	init_color(r, cfg, "diff.frag", cfg->fraginfo_color,
+		   diff_get_color(cfg->use_color, DIFF_FRAGINFO));
+	init_color(r, cfg, "diff.context", cfg->context_color, "fall back");
+	if (!strcmp(cfg->context_color, "fall back"))
+		init_color(r, cfg, "diff.plain", cfg->context_color,
+			   diff_get_color(cfg->use_color, DIFF_CONTEXT));
+	init_color(r, cfg, "diff.old", cfg->file_old_color,
+		diff_get_color(cfg->use_color, DIFF_FILE_OLD));
+	init_color(r, cfg, "diff.new", cfg->file_new_color,
+		diff_get_color(cfg->use_color, DIFF_FILE_NEW));
+
+	strlcpy(cfg->reset_color,
+		cfg->use_color ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
+
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	repo_config_get_string(r, "interactive.difffilter",
+			       &cfg->interactive_diff_filter);
+
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	repo_config_get_string(r, "diff.algorithm",
+			       &cfg->interactive_diff_algorithm);
+
+	if (!repo_config_get_int(r, "diff.context", &cfg->context))
+		if (cfg->context < 0)
+			die(_("%s cannot be negative"), "diff.context");
+	if (!repo_config_get_int(r, "diff.interHunkContext", &cfg->interhunkcontext))
+		if (cfg->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "diff.interHunkContext");
+
+	repo_config_get_bool(r, "interactive.singlekey", &cfg->use_single_key);
+	if (cfg->use_single_key)
+		setbuf(stdin, NULL);
+
+	if (opts->context != -1) {
+		if (opts->context < 0)
+			die(_("%s cannot be negative"), "--unified");
+		cfg->context = opts->context;
+	}
+	if (opts->interhunkcontext != -1) {
+		if (opts->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "--inter-hunk-context");
+		cfg->interhunkcontext = opts->interhunkcontext;
+	}
+}
+
+void interactive_config_clear(struct interactive_config *cfg)
+{
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	memset(cfg, 0, sizeof(*cfg));
+	cfg->use_color = -1;
+}
+
 static void add_p_state_clear(struct add_p_state *s)
 {
 	size_t i;
@@ -299,9 +394,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.error_color, stdout);
+	fputs(s->s.cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.reset_color);
+	puts(s->s.cfg.reset_color);
 	va_end(args);
 }
 
@@ -424,12 +519,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.context);
-	if (s->s.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.interhunkcontext);
-	if (s->s.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.interactive_diff_algorithm);
+	if (s->s.cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
+	if (s->s.cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
+	if (s->s.cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -460,7 +555,7 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 
 	if (want_color_fd(1, -1)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.interactive_diff_filter;
+		const char *diff_filter = s->s.cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -693,7 +788,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.fraginfo_color);
+			strbuf_addstr(out, s->s.cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -715,7 +810,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.reset_color);
+			strbuf_addf(out, "%s\n", s->s.cfg.reset_color);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1103,12 +1198,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.file_old_color :
+			      s->s.cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.file_new_color :
-			      s->s.context_color);
+			      s->s.cfg.file_new_color :
+			      s->s.cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.reset_color);
+		strbuf_addstr(&s->colored, s->s.cfg.reset_color);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1227,7 +1322,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.use_single_key) {
+	if (s->s.cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1241,7 +1336,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1522,15 +1617,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.reset_color)
-			fputs(s->s.reset_color, stdout);
+		if (*s->s.cfg.reset_color)
+			fputs(s->s.cfg.reset_color, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			break;
@@ -1687,7 +1782,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.header_color,
+				color_fprintf_ln(stdout, s->s.cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1705,7 +1800,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.help_color, "%s",
+			color_fprintf(stdout, s->s.cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1723,7 +1818,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.help_color,
+				color_fprintf_ln(stdout, s->s.cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1764,7 +1859,7 @@ static int patch_update_file(struct add_p_state *s,
 }
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
@@ -1772,7 +1867,7 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, o);
+	init_add_i_state(&s.s, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
diff --git a/add-patch.h b/add-patch.h
index 4394c74107..51c0d7bce9 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -1,15 +1,42 @@
 #ifndef ADD_PATCH_H
 #define ADD_PATCH_H
 
+#include "color.h"
+
 struct pathspec;
 struct repository;
 
-struct add_p_opt {
+struct interactive_options {
 	int context;
 	int interhunkcontext;
 };
 
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+#define INTERACTIVE_OPTIONS_INIT { \
+	.context = -1, \
+	.interhunkcontext = -1, \
+}
+
+struct interactive_config {
+	int use_color;
+	char header_color[COLOR_MAXLEN];
+	char help_color[COLOR_MAXLEN];
+	char prompt_color[COLOR_MAXLEN];
+	char error_color[COLOR_MAXLEN];
+	char reset_color[COLOR_MAXLEN];
+	char fraginfo_color[COLOR_MAXLEN];
+	char context_color[COLOR_MAXLEN];
+	char file_old_color[COLOR_MAXLEN];
+	char file_new_color[COLOR_MAXLEN];
+
+	int use_single_key;
+	char *interactive_diff_filter, *interactive_diff_algorithm;
+	int context, interhunkcontext;
+};
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts);
+void interactive_config_clear(struct interactive_config *cfg);
 
 enum add_p_mode {
 	ADD_P_ADD,
@@ -20,7 +47,7 @@ enum add_p_mode {
 };
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
 #endif
diff --git a/builtin/add.c b/builtin/add.c
index 0235854f80..a94c826c14 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -30,7 +30,7 @@ static const char * const builtin_add_usage[] = {
 	NULL
 };
 static int patch_interactive, add_interactive, edit_interactive;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int take_worktree_changes;
 static int add_renormalize;
 static int pathspec_file_nul;
@@ -159,7 +159,7 @@ static int refresh(struct repository *repo, int verbose, const struct pathspec *
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt)
+		    int patch, struct interactive_options *interactive_opts)
 {
 	struct pathspec pathspec;
 	int ret;
@@ -171,9 +171,9 @@ int interactive_add(struct repository *repo,
 		       prefix, argv);
 
 	if (patch)
-		ret = !!run_add_p(repo, ADD_P_ADD, add_p_opt, NULL, &pathspec);
+		ret = !!run_add_p(repo, ADD_P_ADD, interactive_opts, NULL, &pathspec);
 	else
-		ret = !!run_add_i(repo, &pathspec, add_p_opt);
+		ret = !!run_add_i(repo, &pathspec, interactive_opts);
 
 	clear_pathspec(&pathspec);
 	return ret;
@@ -255,8 +255,8 @@ static struct option builtin_add_options[] = {
 	OPT_GROUP(""),
 	OPT_BOOL('i', "interactive", &add_interactive, N_("interactive picking")),
 	OPT_BOOL('p', "patch", &patch_interactive, N_("select hunks interactively")),
-	OPT_DIFF_UNIFIED(&add_p_opt.context),
-	OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+	OPT_DIFF_UNIFIED(&interactive_opts.context),
+	OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 	OPT_BOOL('e', "edit", &edit_interactive, N_("edit current diff and apply")),
 	OPT__FORCE(&ignored_too, N_("allow adding otherwise ignored files"), 0),
 	OPT_BOOL('u', "update", &take_worktree_changes, N_("update tracked files")),
@@ -398,9 +398,9 @@ int cmd_add(int argc,
 	prepare_repo_settings(repo);
 	repo->settings.command_requires_full_index = 0;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (patch_interactive)
@@ -410,11 +410,11 @@ int cmd_add(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--dry-run", "--interactive/--patch");
 		if (pathspec_from_file)
 			die(_("options '%s' and '%s' cannot be used together"), "--pathspec-from-file", "--interactive/--patch");
-		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &add_p_opt));
+		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &interactive_opts));
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
diff --git a/builtin/checkout.c b/builtin/checkout.c
index 43583c8d1b..0b90f398fe 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -546,7 +546,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 
 	if (opts->patch_mode) {
 		enum add_p_mode patch_mode;
-		struct add_p_opt add_p_opt = {
+		struct interactive_options interactive_opts = {
 			.context = opts->patch_context,
 			.interhunkcontext = opts->patch_interhunk_context,
 		};
@@ -575,7 +575,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 		else
 			BUG("either flag must have been set, worktree=%d, index=%d",
 			    opts->checkout_worktree, opts->checkout_index);
-		return !!run_add_p(the_repository, patch_mode, &add_p_opt,
+		return !!run_add_p(the_repository, patch_mode, &interactive_opts,
 				   rev, &opts->pathspec);
 	}
 
diff --git a/builtin/commit.c b/builtin/commit.c
index b5b9608813..767351fd87 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -123,7 +123,7 @@ static const char *edit_message, *use_message;
 static char *fixup_message, *fixup_commit, *squash_message;
 static const char *fixup_prefix;
 static int all, also, interactive, patch_interactive, only, amend, signoff;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int edit_flag = -1; /* unspecified */
 static int quiet, verbose, no_verify, allow_empty, dry_run, renew_authorship;
 static int config_commit_verbose = -1; /* unspecified */
@@ -356,9 +356,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 	const char *ret;
 	char *path = NULL;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (is_status)
@@ -407,7 +407,7 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 		setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-		if (interactive_add(the_repository, argv, prefix, patch_interactive, &add_p_opt) != 0)
+		if (interactive_add(the_repository, argv, prefix, patch_interactive, &interactive_opts) != 0)
 			die(_("interactive add failed"));
 
 		the_repository->index_file = old_repo_index_file;
@@ -432,9 +432,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		ret = get_lock_file_path(&index_lock);
 		goto out;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
@@ -1738,8 +1738,8 @@ int cmd_commit(int argc,
 		OPT_BOOL('i', "include", &also, N_("add specified files to index for commit")),
 		OPT_BOOL(0, "interactive", &interactive, N_("interactively add files")),
 		OPT_BOOL('p', "patch", &patch_interactive, N_("interactively add changes")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('o', "only", &only, N_("commit only specified files")),
 		OPT_BOOL('n', "no-verify", &no_verify, N_("bypass pre-commit and commit-msg hooks")),
 		OPT_BOOL(0, "dry-run", &dry_run, N_("show what would be committed")),
diff --git a/builtin/reset.c b/builtin/reset.c
index ed35802af1..088449e120 100644
--- a/builtin/reset.c
+++ b/builtin/reset.c
@@ -346,7 +346,7 @@ int cmd_reset(int argc,
 	struct object_id oid;
 	struct pathspec pathspec;
 	int intent_to_add = 0;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	const struct option options[] = {
 		OPT__QUIET(&quiet, N_("be quiet, only report errors")),
 		OPT_BOOL(0, "no-refresh", &no_refresh,
@@ -371,8 +371,8 @@ int cmd_reset(int argc,
 			       PARSE_OPT_OPTARG,
 			       option_parse_recurse_submodules_worktree_updater),
 		OPT_BOOL('p', "patch", &patch_mode, N_("select hunks interactively")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('N', "intent-to-add", &intent_to_add,
 				N_("record only the fact that removed paths will be added later")),
 		OPT_PATHSPEC_FROM_FILE(&pathspec_from_file),
@@ -423,9 +423,9 @@ int cmd_reset(int argc,
 		oidcpy(&oid, &tree->object.oid);
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	prepare_repo_settings(the_repository);
@@ -436,12 +436,12 @@ int cmd_reset(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--patch", "--{hard,mixed,soft}");
 		trace2_cmd_mode("patch-interactive");
 		update_ref_status = !!run_add_p(the_repository, ADD_P_RESET,
-						&add_p_opt, rev, &pathspec);
+						&interactive_opts, rev, &pathspec);
 		goto cleanup;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
diff --git a/builtin/stash.c b/builtin/stash.c
index 1977e50df2..5070b8a88f 100644
--- a/builtin/stash.c
+++ b/builtin/stash.c
@@ -1302,7 +1302,7 @@ static int stash_staged(struct stash_info *info, struct strbuf *out_patch,
 
 static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 		       struct strbuf *out_patch, int quiet,
-		       struct add_p_opt *add_p_opt)
+		       struct interactive_options *interactive_opts)
 {
 	int ret = 0;
 	struct child_process cp_read_tree = CHILD_PROCESS_INIT;
@@ -1327,7 +1327,7 @@ static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 	old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 	setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-	ret = !!run_add_p(the_repository, ADD_P_STASH, add_p_opt, NULL, ps);
+	ret = !!run_add_p(the_repository, ADD_P_STASH, interactive_opts, NULL, ps);
 
 	the_repository->index_file = old_repo_index_file;
 	if (old_index_env && *old_index_env)
@@ -1422,7 +1422,8 @@ static int stash_working_tree(struct stash_info *info, const struct pathspec *ps
 }
 
 static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_buf,
-			   int include_untracked, int patch_mode, struct add_p_opt *add_p_opt,
+			   int include_untracked, int patch_mode,
+			   struct interactive_options *interactive_opts,
 			   int only_staged, struct stash_info *info, struct strbuf *patch,
 			   int quiet)
 {
@@ -1504,7 +1505,7 @@ static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_b
 		untracked_commit_option = 1;
 	}
 	if (patch_mode) {
-		ret = stash_patch(info, ps, patch, quiet, add_p_opt);
+		ret = stash_patch(info, ps, patch, quiet, interactive_opts);
 		if (ret < 0) {
 			if (!quiet)
 				fprintf_ln(stderr, _("Cannot save the current "
@@ -1590,7 +1591,8 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED,
 }
 
 static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int quiet,
-			 int keep_index, int patch_mode, struct add_p_opt *add_p_opt,
+			 int keep_index, int patch_mode,
+			 struct interactive_options *interactive_opts,
 			 int include_untracked, int only_staged)
 {
 	int ret = 0;
@@ -1662,7 +1664,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q
 	if (stash_msg)
 		strbuf_addstr(&stash_msg_buf, stash_msg);
 	if (do_create_stash(ps, &stash_msg_buf, include_untracked, patch_mode,
-			    add_p_opt, only_staged, &info, &patch, quiet)) {
+			    interactive_opts, only_staged, &info, &patch, quiet)) {
 		ret = -1;
 		goto done;
 	}
@@ -1835,7 +1837,7 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	char *pathspec_from_file = NULL;
 	struct pathspec ps;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1843,8 +1845,8 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1901,19 +1903,19 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	}
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index, patch_mode,
-			    &add_p_opt, include_untracked, only_staged);
+			    &interactive_opts, include_untracked, only_staged);
 
 	clear_pathspec(&ps);
 	free(pathspec_from_file);
@@ -1938,7 +1940,7 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	struct pathspec ps;
 	struct strbuf stash_msg_buf = STRBUF_INIT;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1946,8 +1948,8 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1967,20 +1969,20 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 
 	memset(&ps, 0, sizeof(ps));
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index,
-			    patch_mode, &add_p_opt, include_untracked,
+			    patch_mode, &interactive_opts, include_untracked,
 			    only_staged);
 
 	strbuf_release(&stash_msg_buf);
diff --git a/commit.h b/commit.h
index 1d6e0c7518..7b6e59d6c1 100644
--- a/commit.h
+++ b/commit.h
@@ -258,7 +258,7 @@ int for_each_commit_graft(each_commit_graft_fn, void *);
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt);
+		    int patch, struct interactive_options *opts);
 
 struct commit_extra_header {
 	struct commit_extra_header *next;
-- 
2.51.0.261.g7ce5a0a67e.dirty
Patrick Steinhardt· Aug 19, 2025, 10:56 UTC · re: Patrick Steinhardt · lore

[PATCH RFC 09/11] add-patch: remove dependency on "add-interactive" subsystem

With the preceding commit we have split out interactive configuration that is used by both "git add -p" and "git add -i". But we still initialize that configuration in the "add -p" subsystem by calling `init_add_i_state()`, even though we only do so to initialize the interactive configuration as well as a repository pointer.

Stop doing so and instead store and initialize the interactive configuration in `struct add_p_state` directly.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 68 ++++++++++++++++++++++++++++++++-----------------------------
 1 file changed, 36 insertions(+), 32 deletions(-)
Show changes to add-patch.c +36 −32
diff --git a/add-patch.c b/add-patch.c
index 45bc254e0c..1bcbc91de9 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -2,7 +2,6 @@
 #define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
-#include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
 #include "config.h"
@@ -263,7 +262,8 @@ struct hunk {
 };
 
 struct add_p_state {
-	struct add_i_state s;
+	struct repository *r;
+	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
 	/* parsed diff */
@@ -385,7 +385,7 @@ static void add_p_state_clear(struct add_p_state *s)
 	for (i = 0; i < s->file_diff_nr; i++)
 		free(s->file_diff[i].hunk);
 	free(s->file_diff);
-	clear_add_i_state(&s->s);
+	interactive_config_clear(&s->cfg);
 }
 
 __attribute__((format (printf, 2, 3)))
@@ -394,9 +394,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.cfg.error_color, stdout);
+	fputs(s->cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.cfg.reset_color);
+	puts(s->cfg.reset_color);
 	va_end(args);
 }
 
@@ -414,7 +414,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->s.r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
 }
 
 static int parse_range(const char **p,
@@ -519,12 +519,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.cfg.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
-	if (s->s.cfg.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
-	if (s->s.cfg.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
+	if (s->cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->cfg.context);
+	if (s->cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
+	if (s->cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -555,7 +555,7 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 
 	if (want_color_fd(1, -1)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.cfg.interactive_diff_filter;
+		const char *diff_filter = s->cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -788,7 +788,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.cfg.fraginfo_color);
+			strbuf_addstr(out, s->cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -810,7 +810,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.cfg.reset_color);
+			strbuf_addf(out, "%s\n", s->cfg.reset_color);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1198,12 +1198,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.cfg.file_old_color :
+			      s->cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.cfg.file_new_color :
-			      s->s.cfg.context_color);
+			      s->cfg.file_new_color :
+			      s->cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.cfg.reset_color);
+		strbuf_addstr(&s->colored, s->cfg.reset_color);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1322,7 +1322,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.cfg.use_single_key) {
+	if (s->cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1336,7 +1336,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1617,15 +1617,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.cfg.reset_color)
-			fputs(s->s.cfg.reset_color, stdout);
+		if (*s->cfg.reset_color)
+			fputs(s->cfg.reset_color, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			break;
@@ -1782,7 +1782,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.cfg.header_color,
+				color_fprintf_ln(stdout, s->cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1800,7 +1800,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.cfg.help_color, "%s",
+			color_fprintf(stdout, s->cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1818,7 +1818,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.cfg.help_color,
+				color_fprintf_ln(stdout, s->cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1838,7 +1838,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->s.r->index);
+		discard_index(s->r->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1849,8 +1849,8 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->s.r) >= 0)
-			repo_refresh_and_write_index(s->s.r, REFRESH_QUIET, 0,
+		if (repo_read_index(s->r) >= 0)
+			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
 	}
 
@@ -1863,11 +1863,15 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
-		{ r }, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT
+		.r = r,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, opts);
+	interactive_config_init(&s.cfg, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
-- 
2.51.0.261.g7ce5a0a67e.dirty
Patrick Steinhardt· Aug 19, 2025, 10:56 UTC · re: Patrick Steinhardt · lore

[PATCH RFC 10/11] add-patch: add support for in-memory index patching

With `run_add_p()` callers have the ability to apply changes from a specific revision to a repository's index. This infra supports several different modes, like for example applying changes to the index, worktree or both.

One feature that is missing though is the ability to apply changes to an in-memory index different from the repository's index. Add a new function `run_add_p_index()` to plug this gap.

This new function will be used in a subsequent commit.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 add-patch.h |   8 +++++
 2 files changed, 116 insertions(+), 3 deletions(-)
Show changes to 2 files +116 −3

add-patch.c, add-patch.h

diff --git a/add-patch.c b/add-patch.c
index 1bcbc91de9..adef20c02b 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -4,11 +4,13 @@
 #include "git-compat-util.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "commit.h"
 #include "config.h"
 #include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "object-name.h"
 #include "pager.h"
 #include "read-cache-ll.h"
@@ -263,6 +265,8 @@ struct hunk {
 
 struct add_p_state {
 	struct repository *r;
+	struct index_state *index;
+	const char *index_file;
 	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
@@ -414,7 +418,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->index_file);
 }
 
 static int parse_range(const char **p,
@@ -1838,7 +1842,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->r->index);
+		discard_index(s->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1849,9 +1853,12 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->r) >= 0)
+		read_index_from(s->index, s->index_file, s->r->gitdir);
+		if (read_index_from(s->index, s->index_file, s->r->gitdir) >= 0 &&
+		    s->index == s->r->index) {
 			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
+		}
 	}
 
 	putchar('\n');
@@ -1864,6 +1871,8 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 {
 	struct add_p_state s = {
 		.r = r,
+		.index = r->index,
+		.index_file = r->index_file,
 		.answer = STRBUF_INIT,
 		.buf = STRBUF_INIT,
 		.plain = STRBUF_INIT,
@@ -1922,3 +1931,99 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	add_p_state_clear(&s);
 	return 0;
 }
+
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps)
+{
+	struct patch_mode mode = {
+		.apply_args = { "--cached", NULL },
+		.apply_check_args = { "--cached", NULL },
+		.prompt_mode = {
+			N_("Stage mode change [y,n,q,a,d%s,?]? "),
+			N_("Stage deletion [y,n,q,a,d%s,?]? "),
+			N_("Stage addition [y,n,q,a,d%s,?]? "),
+			N_("Stage this hunk [y,n,q,a,d%s,?]? ")
+		},
+		.edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
+				     "will immediately be marked for staging."),
+		.help_patch_text =
+			N_("y - stage this hunk\n"
+			   "n - do not stage this hunk\n"
+			   "q - quit; do not stage this hunk or any of the remaining "
+				"ones\n"
+			   "a - stage this hunk and all later hunks in the file\n"
+			   "d - do not stage this hunk or any of the later hunks in "
+				"the file\n"),
+		.index_only = 1,
+	};
+	struct add_p_state s = {
+		.r = r,
+		.index = index,
+		.index_file = index_file,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
+		.mode = &mode,
+		.revision = revision,
+	};
+	struct strbuf parent_revision = STRBUF_INIT;
+	char parent_tree_oid[GIT_MAX_HEXSZ + 1];
+	size_t binary_count = 0;
+	struct commit *commit;
+	int ret;
+
+	commit = lookup_commit_reference_by_name(revision);
+	if (!commit) {
+		err(&s, _("Revision does not refer to a commit"));
+		ret = -1;
+		goto out;
+	}
+
+	if (commit->parents)
+		oid_to_hex_r(parent_tree_oid, get_commit_tree_oid(commit->parents->item));
+	else
+		oid_to_hex_r(parent_tree_oid, r->hash_algo->empty_tree);
+
+	strbuf_addf(&parent_revision, "%s~", revision);
+	mode.diff_cmd[0] = "diff-tree";
+	mode.diff_cmd[1] = "-r";
+	mode.diff_cmd[2] = parent_tree_oid;
+
+	interactive_config_init(&s.cfg, r, opts);
+
+	if (parse_diff(&s, ps) < 0) {
+		ret = -1;
+		goto out;
+	}
+
+	for (size_t i = 0; i < s.file_diff_nr; i++) {
+		if (s.file_diff[i].binary && !s.file_diff[i].hunk_nr)
+			binary_count++;
+		else if (patch_update_file(&s, s.file_diff + i))
+			break;
+	}
+
+	if (s.file_diff_nr == 0) {
+		err(&s, _("No changes."));
+		ret = -1;
+		goto out;
+	}
+
+	if (binary_count == s.file_diff_nr) {
+		err(&s, _("Only binary files changed."));
+		ret = -1;
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	strbuf_release(&parent_revision);
+	add_p_state_clear(&s);
+	return ret;
+}
diff --git a/add-patch.h b/add-patch.h
index 51c0d7bce9..d0edfec936 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -3,6 +3,7 @@
 
 #include "color.h"
 
+struct index_state;
 struct pathspec;
 struct repository;
 
@@ -50,4 +51,11 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps);
+
 #endif
-- 
2.51.0.261.g7ce5a0a67e.dirty
D. Ben Knoble· Aug 20, 2025, 21:15 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 10/11] add-patch: add support for in-memory index patching

On Wed, Aug 20, 2025 at 4:17 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 34 quoted lines
>
> With `run_add_p()` callers have the ability to apply changes from a
> specific revision to a repository's index. This infra supports several
> different modes, like for example applying changes to the index,
> worktree or both.
>
> One feature that is missing though is the ability to apply changes to an
> in-memory index different from the repository's index. Add a new
> function `run_add_p_index()` to plug this gap.
>
> This new function will be used in a subsequent commit.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  add-patch.c | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
>  add-patch.h |   8 +++++
>  2 files changed, 116 insertions(+), 3 deletions(-)
>
> diff --git a/add-patch.c b/add-patch.c
> index 1bcbc91de9..adef20c02b 100644
> --- a/add-patch.c
> +++ b/add-patch.c
> @@ -1849,9 +1853,12 @@ static int patch_update_file(struct add_p_state *s,
>                                          NULL, 0, NULL, 0))
>                                 error(_("'git apply' failed"));
>                 }
> -               if (repo_read_index(s->r) >= 0)
> +               read_index_from(s->index, s->index_file, s->r->gitdir);
> +               if (read_index_from(s->index, s->index_file, s->r->gitdir) >= 0 &&
> +                   s->index == s->r->index) {
>                         repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
>                                                      1, NULL, NULL, NULL);
> +               }
>         }

Is this call to read_index_from duplicated? I don't see anything that indicates that would be desirable here.

Show 81 quoted lines
>
>         putchar('\n');
> @@ -1864,6 +1871,8 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
>  {
>         struct add_p_state s = {
>                 .r = r,
> +               .index = r->index,
> +               .index_file = r->index_file,
>                 .answer = STRBUF_INIT,
>                 .buf = STRBUF_INIT,
>                 .plain = STRBUF_INIT,
> @@ -1922,3 +1931,99 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
>         add_p_state_clear(&s);
>         return 0;
>  }
> +
> +int run_add_p_index(struct repository *r,
> +                   struct index_state *index,
> +                   const char *index_file,
> +                   struct interactive_options *opts,
> +                   const char *revision,
> +                   const struct pathspec *ps)
> +{
> +       struct patch_mode mode = {
> +               .apply_args = { "--cached", NULL },
> +               .apply_check_args = { "--cached", NULL },
> +               .prompt_mode = {
> +                       N_("Stage mode change [y,n,q,a,d%s,?]? "),
> +                       N_("Stage deletion [y,n,q,a,d%s,?]? "),
> +                       N_("Stage addition [y,n,q,a,d%s,?]? "),
> +                       N_("Stage this hunk [y,n,q,a,d%s,?]? ")
> +               },
> +               .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
> +                                    "will immediately be marked for staging."),
> +               .help_patch_text =
> +                       N_("y - stage this hunk\n"
> +                          "n - do not stage this hunk\n"
> +                          "q - quit; do not stage this hunk or any of the remaining "
> +                               "ones\n"
> +                          "a - stage this hunk and all later hunks in the file\n"
> +                          "d - do not stage this hunk or any of the later hunks in "
> +                               "the file\n"),
> +               .index_only = 1,
> +       };
> +       struct add_p_state s = {
> +               .r = r,
> +               .index = index,
> +               .index_file = index_file,
> +               .answer = STRBUF_INIT,
> +               .buf = STRBUF_INIT,
> +               .plain = STRBUF_INIT,
> +               .colored = STRBUF_INIT,
> +               .mode = &mode,
> +               .revision = revision,
> +       };
> +       struct strbuf parent_revision = STRBUF_INIT;
> +       char parent_tree_oid[GIT_MAX_HEXSZ + 1];
> +       size_t binary_count = 0;
> +       struct commit *commit;
> +       int ret;
> +
> +       commit = lookup_commit_reference_by_name(revision);
> +       if (!commit) {
> +               err(&s, _("Revision does not refer to a commit"));
> +               ret = -1;
> +               goto out;
> +       }
> +
> +       if (commit->parents)
> +               oid_to_hex_r(parent_tree_oid, get_commit_tree_oid(commit->parents->item));
> +       else
> +               oid_to_hex_r(parent_tree_oid, r->hash_algo->empty_tree);
> +
> +       strbuf_addf(&parent_revision, "%s~", revision);
> +       mode.diff_cmd[0] = "diff-tree";
> +       mode.diff_cmd[1] = "-r";
> +       mode.diff_cmd[2] = parent_tree_oid;
> +
> +       interactive_config_init(&s.cfg, r, opts);
> +
> +       if (parse_diff(&s, ps) < 0) {

I noticed run_add_p() calls discard_index() right before parse_diff() [but it also reads/refreshes the index there]. Sounds like that's not something we need for in-memory indices?

Show 30 quoted lines
> +               ret = -1;
> +               goto out;
> +       }
> +
> +       for (size_t i = 0; i < s.file_diff_nr; i++) {
> +               if (s.file_diff[i].binary && !s.file_diff[i].hunk_nr)
> +                       binary_count++;
> +               else if (patch_update_file(&s, s.file_diff + i))
> +                       break;
> +       }
> +
> +       if (s.file_diff_nr == 0) {
> +               err(&s, _("No changes."));
> +               ret = -1;
> +               goto out;
> +       }
> +
> +       if (binary_count == s.file_diff_nr) {
> +               err(&s, _("Only binary files changed."));
> +               ret = -1;
> +               goto out;
> +       }
> +
> +       ret = 0;
> +
> +out:
> +       strbuf_release(&parent_revision);
> +       add_p_state_clear(&s);
> +       return ret;
> +}

This single call to add_p_state_clear() is probably easier to follow than the original in run_add_p(). Nice.

-- 
D. Ben Knoble
Patrick Steinhardt· Aug 22, 2025, 12:21 UTC · re: D. Ben Knoble · lore

Re: [PATCH RFC 10/11] add-patch: add support for in-memory index patching

On Wed, Aug 20, 2025 at 05:15:03PM -0400, D. Ben Knoble wrote:
Show 20 quoted lines
> On Wed, Aug 20, 2025 at 4:17 AM Patrick Steinhardt <ps@pks.im> wrote:
> > diff --git a/add-patch.c b/add-patch.c
> > index 1bcbc91de9..adef20c02b 100644
> > --- a/add-patch.c
> > +++ b/add-patch.c
> > @@ -1849,9 +1853,12 @@ static int patch_update_file(struct add_p_state *s,
> >                                          NULL, 0, NULL, 0))
> >                                 error(_("'git apply' failed"));
> >                 }
> > -               if (repo_read_index(s->r) >= 0)
> > +               read_index_from(s->index, s->index_file, s->r->gitdir);
> > +               if (read_index_from(s->index, s->index_file, s->r->gitdir) >= 0 &&
> > +                   s->index == s->r->index) {
> >                         repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
> >                                                      1, NULL, NULL, NULL);
> > +               }
> >         }
> 
> Is this call to read_index_from duplicated? I don't see anything that
> indicates that would be desirable here.
Indeed, good catch!
Show 74 quoted lines
> > @@ -1922,3 +1931,99 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
> >         add_p_state_clear(&s);
> >         return 0;
> >  }
> > +
> > +int run_add_p_index(struct repository *r,
> > +                   struct index_state *index,
> > +                   const char *index_file,
> > +                   struct interactive_options *opts,
> > +                   const char *revision,
> > +                   const struct pathspec *ps)
> > +{
> > +       struct patch_mode mode = {
> > +               .apply_args = { "--cached", NULL },
> > +               .apply_check_args = { "--cached", NULL },
> > +               .prompt_mode = {
> > +                       N_("Stage mode change [y,n,q,a,d%s,?]? "),
> > +                       N_("Stage deletion [y,n,q,a,d%s,?]? "),
> > +                       N_("Stage addition [y,n,q,a,d%s,?]? "),
> > +                       N_("Stage this hunk [y,n,q,a,d%s,?]? ")
> > +               },
> > +               .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
> > +                                    "will immediately be marked for staging."),
> > +               .help_patch_text =
> > +                       N_("y - stage this hunk\n"
> > +                          "n - do not stage this hunk\n"
> > +                          "q - quit; do not stage this hunk or any of the remaining "
> > +                               "ones\n"
> > +                          "a - stage this hunk and all later hunks in the file\n"
> > +                          "d - do not stage this hunk or any of the later hunks in "
> > +                               "the file\n"),
> > +               .index_only = 1,
> > +       };
> > +       struct add_p_state s = {
> > +               .r = r,
> > +               .index = index,
> > +               .index_file = index_file,
> > +               .answer = STRBUF_INIT,
> > +               .buf = STRBUF_INIT,
> > +               .plain = STRBUF_INIT,
> > +               .colored = STRBUF_INIT,
> > +               .mode = &mode,
> > +               .revision = revision,
> > +       };
> > +       struct strbuf parent_revision = STRBUF_INIT;
> > +       char parent_tree_oid[GIT_MAX_HEXSZ + 1];
> > +       size_t binary_count = 0;
> > +       struct commit *commit;
> > +       int ret;
> > +
> > +       commit = lookup_commit_reference_by_name(revision);
> > +       if (!commit) {
> > +               err(&s, _("Revision does not refer to a commit"));
> > +               ret = -1;
> > +               goto out;
> > +       }
> > +
> > +       if (commit->parents)
> > +               oid_to_hex_r(parent_tree_oid, get_commit_tree_oid(commit->parents->item));
> > +       else
> > +               oid_to_hex_r(parent_tree_oid, r->hash_algo->empty_tree);
> > +
> > +       strbuf_addf(&parent_revision, "%s~", revision);
> > +       mode.diff_cmd[0] = "diff-tree";
> > +       mode.diff_cmd[1] = "-r";
> > +       mode.diff_cmd[2] = parent_tree_oid;
> > +
> > +       interactive_config_init(&s.cfg, r, opts);
> > +
> > +       if (parse_diff(&s, ps) < 0) {
> 
> I noticed run_add_p() calls discard_index() right before parse_diff()
> [but it also reads/refreshes the index there]. Sounds like that's not
> something we need for in-memory indices?

No. We don't want to discard contents, as it might even be that somebody has an index that we want to apply multiple revisions to. Discarding it is not sensible in that case.

Patrick
Patrick Steinhardt· Aug 19, 2025, 10:56 UTC · re: Patrick Steinhardt · lore

[PATCH RFC 11/11] builtin/history: implement "split" subcommand

It is quite a common use case that one wants to split up one commit into multiple commits by moving parts of the changes of the original commit out of it into a separate commit. This is quite an involved operation though:

  1. Identify the commit in question that is to be dropped.
  2. Perform an interactive rebase on top of that commit's parent.
  3. Modify the instruction sheet to "edit" the commit that is to be
     split up.
  4. Drop the commit via "git reset HEAD~".
  5. Stage changes that should go into the first commit and commit it.
  6. Stage changes that should go into the second commit and commit it.
  7. Finalize the rebase.

This is quite complex, and overall I would claim that most people who are not experts in Git would struggle with this flow.

Introduce a new "split" subcommand for git-history(1) to make this way easier. All the user needs to do is to say `git history split $COMMIT`. From hereon, Git asks the user which parts of the commit shall be moved out into a separate commit and, once done, asks the user for the commit message. Git then creates that split-out commit and applies the original commit on top of it.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  59 ++++++++
 builtin/history.c              | 245 +++++++++++++++++++++++++++++++++
 t/meson.build                  |   1 +
 t/t3452-history-split.sh       | 304 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 609 insertions(+)
Show changes to 4 files +607 −0

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3452-history-split.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 6e8b4e1326..f0f1f2a093 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -10,6 +10,7 @@ SYNOPSIS
 [synopsis]
 git history drop [<options>] <revision>
 git history reorder [<options>] <revision> --(before|after)=<revision>
+git history split [<options>] <revision> [--] [<pathspec>...]
 
 DESCRIPTION
 -----------
@@ -47,6 +48,26 @@ reorder <revision> (--before=<revision>|--after=<revision>)::
 	commit. The commits must be related to one another and must be
 	reachable from the current `HEAD` commit.
 
+split <revision> [--message=<message>] [--] [<pathspec>...]::
+	Interactively split up the commit into two commits by choosing
+	hunks introduced by it that will be moved into the new split-out
+	commit. These hunks will then be written into a new commit that
+	becomes the parent of the previous commit. The original commit
+	stays intact, except that its parent will be the newly split-out
+	commit.
++
+The commit message of the new commit will be asked for by launching the
+configured editor. Authorship of the commit will be the same as for the
+original commit.
++
+If passed, _<pathspec>_ can be used to limit which changes shall be split out
+of the original commit. Files not matching any of the pathspecs will remain
+part of the original commit. For more details about the _<pathspec>_ syntax,
+see the 'pathspec' entry.
++
+It is invalid to select either all or no hunks, as that would lead to
+one of the commits becoming empty.
+
 EXAMPLES
 --------
 
@@ -88,6 +109,44 @@ f44a46e third
 bf7438d first
 ----------
 
+* Split a commit.
++
+----------
+$ git log --stat --oneline
+3f81232 (HEAD -> main) original
+ bar | 1 +
+ foo | 1 +
+ 2 files changed, 2 insertions(+)
+
+$ git history split HEAD --message="split-out commit"
+diff --git a/bar b/bar
+new file mode 100644
+index 0000000..5716ca5
+--- /dev/null
++++ b/bar
+@@ -0,0 +1 @@
++bar
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? y
+
+diff --git a/foo b/foo
+new file mode 100644
+index 0000000..257cc56
+--- /dev/null
++++ b/foo
+@@ -0,0 +1 @@
++foo
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? n
+
+$ git log --stat --oneline
+7cebe64 (HEAD -> main) original
+ foo | 1 +
+ 1 file changed, 1 insertion(+)
+d1582f3 split-out commit
+ bar | 1 +
+ 1 file changed, 1 insertion(+)
+----------
+
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index de6073f557..b2dab826ac 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,16 +1,26 @@
+/* Required for `comment_line_str`. */
+#define USE_THE_REPOSITORY_VARIABLE
+
 #include "builtin.h"
+#include "cache-tree.h"
 #include "commit.h"
 #include "commit-reach.h"
 #include "config.h"
+#include "editor.h"
 #include "environment.h"
 #include "gettext.h"
 #include "hex.h"
 #include "object-name.h"
 #include "parse-options.h"
+#include "path.h"
+#include "pathspec.h"
+#include "read-cache-ll.h"
 #include "refs.h"
 #include "reset.h"
 #include "revision.h"
+#include "run-command.h"
 #include "sequencer.h"
+#include "sparse-index.h"
 
 static int collect_commits(struct repository *repo,
 			   struct commit *old_commit,
@@ -424,6 +434,239 @@ static int cmd_history_reorder(int argc,
 	return ret;
 }
 
+static int split_commit(struct repository *repo,
+			struct commit *original_commit,
+			struct pathspec *pathspec,
+			const char *commit_message,
+			struct object_id *out)
+{
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
+	struct strbuf index_file = STRBUF_INIT, split_message = STRBUF_INIT;
+	struct child_process read_tree_cmd = CHILD_PROCESS_INIT;
+	struct index_state index = INDEX_STATE_INIT(repo);
+	struct object_id original_commit_tree_oid, parent_tree_oid;
+	const char *original_message, *original_body, *ptr;
+	char original_commit_oid[GIT_MAX_HEXSZ + 1];
+	char *split_message_path = NULL, *original_author = NULL;
+	struct commit_list *parents = NULL;
+	struct commit *first_commit;
+	struct tree *split_tree;
+	size_t len;
+	int ret;
+
+	if (original_commit->parents)
+		parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
+	else
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
+
+	/*
+	 * Construct the first commit. This is done by taking the original
+	 * commit parent's tree and selectively patching changes from the diff
+	 * between that parent and its child.
+	 */
+	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
+
+	read_tree_cmd.git_cmd = 1;
+	strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
+	strvec_push(&read_tree_cmd.args, "read-tree");
+	strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
+	ret = run_command(&read_tree_cmd);
+	if (ret < 0)
+		goto out;
+
+	ret = read_index_from(&index, index_file.buf, repo->gitdir);
+	if (ret < 0) {
+		ret = error(_("failed reading temporary index"));
+		goto out;
+	}
+
+	oid_to_hex_r(original_commit_oid, &original_commit->object.oid);
+	ret = run_add_p_index(repo, &index, index_file.buf, &interactive_opts,
+			      original_commit_oid, pathspec);
+	if (ret < 0)
+		goto out;
+
+	split_tree = write_in_core_index_as_tree(repo, &index);
+	if (!split_tree) {
+		ret = error(_("failed split tree"));
+		goto out;
+	}
+
+	unlink(index_file.buf);
+
+	/*
+	 * We disallow the cases where either the split-out commit or the
+	 * original commit would become empty. Consequently, if we see that the
+	 * new tree ID matches either of those trees we abort.
+	 */
+	if (oideq(&split_tree->object.oid, &parent_tree_oid)) {
+		ret = error(_("split commit is empty"));
+		goto out;
+	} else if (oideq(&split_tree->object.oid, &original_commit_tree_oid)) {
+		ret = error(_("split commit tree matches original commit"));
+		goto out;
+	}
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+
+	/*
+	 * But we do ask the user for a new commit message. This is in contrast
+	 * to the second commit, where we'll retain the original commit
+	 * message.
+	 */
+	if (!commit_message) {
+		split_message_path = repo_git_path(repo, "SPLIT_MSG");
+		strbuf_addch(&split_message, '\n');
+		strbuf_commented_addf(&split_message, comment_line_str,
+				      _("Please enter a commit message for the split-out changes."));
+		write_file_buf(split_message_path, split_message.buf, split_message.len);
+
+		strbuf_reset(&split_message);
+		if (launch_editor(split_message_path, &split_message, NULL)) {
+			fprintf(stderr, _("Please supply the message using either -m or -F option.\n"));
+			ret = -1;
+			goto out;
+		}
+		strbuf_stripspace(&split_message, comment_line_str);
+	} else {
+		strbuf_addstr(&split_message, commit_message);
+	}
+	cleanup_message(&split_message, COMMIT_MSG_CLEANUP_ALL, 0);
+
+	ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
+			  original_commit->parents, &out[0], original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing split-out commit"));
+		goto out;
+	}
+
+	/*
+	 * The second commit is much simpler to construct, as we can simply use
+	 * the original commit details, except that we adjust its parent to be
+	 * the newly split-out commit.
+	 */
+	find_commit_subject(original_message, &original_body);
+	first_commit = lookup_commit_reference(repo, &out[0]);
+	commit_list_append(first_commit, &parents);
+
+	ret = commit_tree(original_body, strlen(original_body), &original_commit_tree_oid,
+			  parents, &out[1], original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing second commit"));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	if (index_file.len)
+		unlink(index_file.buf);
+	strbuf_release(&split_message);
+	strbuf_release(&index_file);
+	free_commit_list(parents);
+	free(split_message_path);
+	free(original_author);
+	release_index(&index);
+	return ret;
+}
+
+static int cmd_history_split(int argc,
+			     const char **argv,
+			     const char *prefix,
+			     struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history split [<options>] <revision>"),
+		NULL,
+	};
+	const char *commit_message = NULL;
+	struct option options[] = {
+		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
+		OPT_END(),
+	};
+	struct commit *original_commit, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct commit_list *list = NULL;
+	struct object_id split_commits[2];
+	struct pathspec pathspec = { 0 };
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	repo_config(repo, git_default_config, NULL);
+
+	original_commit = lookup_commit_reference_by_name(argv[0]);
+	if (!original_commit) {
+		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
+		goto out;
+	}
+
+	if (original_commit->parents && original_commit->parents->next) {
+		ret = error(_("commit to be split must not be a merge commit"));
+		goto out;
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	commit_list_append(original_commit, &list);
+	if (!repo_is_descendant_of(repo, original_commit, list)) {
+		ret = error (_("split commit must be reachable from current HEAD commit"));
+		goto out;
+	}
+
+	parse_pathspec(&pathspec, 0,
+		       PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
+		       prefix, argv + 1);
+
+	/*
+	 * Collect the list of commits that we'll have to reapply now already.
+	 * This ensures that we'll abort early on in case the range of commits
+	 * contains merges, which we do not yet handle.
+	 */
+	ret = collect_commits(repo, original_commit->parents ? original_commit->parents->item : NULL,
+			      head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/*
+	 * Then we split up the commit and replace the original commit with the
+	 * new new ones.
+	 */
+	ret = split_commit(repo, original_commit, &pathspec,
+			   commit_message, split_commits);
+	if (ret < 0)
+		goto out;
+
+	replace_commits(&commits, &original_commit->object.oid,
+			split_commits, ARRAY_SIZE(split_commits));
+
+	/*
+	 * And now we pick commits in the new order on top of either the root
+	 * commit or on top the old commit's parent.
+	 */
+	ret = apply_commits(repo, &commits, head,
+			    original_commit->parents ? original_commit->parents->item : NULL,
+			    "split");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	clear_pathspec(&pathspec);
+	strvec_clear(&commits);
+	free_commit_list(list);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -432,12 +675,14 @@ int cmd_history(int argc,
 	const char * const usage[] = {
 		N_("git history drop [<options>] <revision>"),
 		N_("git history reorder [<options>] <revision> --(before|after)=<revision>"),
+		N_("git history split [<options>] <revision> [--] [<pathspec>...]"),
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
 		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
 		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
+		OPT_SUBCOMMAND("split", &fn, cmd_history_split),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 8eded9ec1b..80beac8c1f 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -378,6 +378,7 @@ integration_tests = [
   't3438-rebase-broken-files.sh',
   't3450-history-drop.sh',
   't3451-history-reorder.sh',
+  't3452-history-split.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh
new file mode 100755
index 0000000000..bc965b15b2
--- /dev/null
+++ b/t/t3452-history-split.sh
@@ -0,0 +1,304 @@
+#!/bin/sh
+
+test_description='tests for git-history split subcommand'
+
+. ./test-lib.sh
+
+set_fake_editor () {
+	write_script fake-editor.sh <<-\EOF &&
+	echo "split-out commit" >"$1"
+	EOF
+	test_set_editor "$(pwd)"/fake-editor.sh
+}
+
+expect_log () {
+	git log --format="%s" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+expect_tree_entries () {
+	git ls-tree --name-only "$1" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history split HEAD 2>err &&
+		test_grep "commit to be split must not be a merge commit" err &&
+		test_must_fail git history split HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'can split up tip commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		set_fake_editor &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		initial
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m root &&
+		test_commit tip &&
+
+		set_fake_editor &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		root
+		split-out commit
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up in-between commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+		test_commit tip &&
+
+		set_fake_editor &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		split-me
+		split-out commit
+		initial
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can pick multiple hunks' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar baz foo qux &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "split-out commit" <<-EOF &&
+		y
+		n
+		y
+		n
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		baz
+		foo
+		qux
+		EOF
+	)
+'
+
+
+test_expect_success 'can use only last hunk' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "split-out commit" <<-EOF &&
+		n
+		y
+		EOF
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'can specify message via option' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "message option" <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF
+		split-me
+		message option
+		EOF
+	)
+'
+
+test_expect_success 'can use pathspec to limit what gets split' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "message option" -- foo <<-EOF &&
+		y
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'refuses to create empty split-out commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		n
+		n
+		EOF
+		test_grep "split commit is empty" err
+	)
+'
+
+test_expect_success 'refuses to create empty original commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		y
+		y
+		EOF
+		test_grep "split commit tree matches original commit" err
+	)
+'
+
+test_done
-- 
2.51.0.261.g7ce5a0a67e.dirty
D. Ben Knoble· Aug 20, 2025, 21:27 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 11/11] builtin/history: implement "split" subcommand

On Wed, Aug 20, 2025 at 5:05 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 24 quoted lines
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> index 6e8b4e1326..f0f1f2a093 100644
> --- a/Documentation/git-history.adoc
> +++ b/Documentation/git-history.adoc
> @@ -47,6 +48,26 @@ reorder <revision> (--before=<revision>|--after=<revision>)::
>         commit. The commits must be related to one another and must be
>         reachable from the current `HEAD` commit.
>
> +split <revision> [--message=<message>] [--] [<pathspec>...]::
> +       Interactively split up the commit into two commits by choosing
> +       hunks introduced by it that will be moved into the new split-out
> +       commit. These hunks will then be written into a new commit that
> +       becomes the parent of the previous commit. The original commit
> +       stays intact, except that its parent will be the newly split-out
> +       commit.
> ++
> +The commit message of the new commit will be asked for by launching the
> +configured editor. Authorship of the commit will be the same as for the
> +original commit.
> ++
> +If passed, _<pathspec>_ can be used to limit which changes shall be split out
> +of the original commit. Files not matching any of the pathspecs will remain
> +part of the original commit. For more details about the _<pathspec>_ syntax,
> +see the 'pathspec' entry.
Glossary entry?
Show 5 quoted lines
> +       /*
> +        * But we do ask the user for a new commit message. This is in contrast
> +        * to the second commit, where we'll retain the original commit
> +        * message.
> +        */

Interesting. I can see using the original as the template for _both_, or the first instead of the second. jj's split works a little differently (especially with their notion of descriptions), so I can't use them as a reference for the behavior.

I suppose this is one of those "everybody has their preference" things, but I think giving the message in both new commits as the template gives splitters the most information available when writing the message. (Of course, in my editor, I can presumably do something like ":Git show -s <split-commit-ish>" if I want.)

Show 6 quoted lines
> +       if (!commit_message) {
> +               split_message_path = repo_git_path(repo, "SPLIT_MSG");
> +               strbuf_addch(&split_message, '\n');
> +               strbuf_commented_addf(&split_message, comment_line_str,
> +                                     _("Please enter a commit message for the split-out changes."));
> +               write_file_buf(split_message_path, split_message.buf, split_message.len);

I also noticed the commented template differs substantially from the regular commit template, and my editor doesn't recognize "SPLIT_MSG" as a commit message file.

The latter can be fixed elsewhere, but for the former: perhaps it's worth using the usual template with the wording here prepended? Respecting commit.verbose / commit.status, too.

BTW, if I quit the editor with an error here, I'm left back where I started. So I'd have to re-stage changes if I wanted to split again, which is a bit different from how interactive rebase will leave me with the partially staged changes. Obviously that's harder to do with the in-memory index + automatic re-application of remaining patch when finished, so maybe a note in the docs about this being "all or nothing"?

Best, D. Ben Knoble

Patrick Steinhardt· Aug 22, 2025, 12:22 UTC · re: D. Ben Knoble · lore

Re: [PATCH RFC 11/11] builtin/history: implement "split" subcommand

On Wed, Aug 20, 2025 at 05:27:32PM -0400, D. Ben Knoble wrote:
Show 27 quoted lines
> On Wed, Aug 20, 2025 at 5:05 AM Patrick Steinhardt <ps@pks.im> wrote:
> > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > index 6e8b4e1326..f0f1f2a093 100644
> > --- a/Documentation/git-history.adoc
> > +++ b/Documentation/git-history.adoc
> > @@ -47,6 +48,26 @@ reorder <revision> (--before=<revision>|--after=<revision>)::
> >         commit. The commits must be related to one another and must be
> >         reachable from the current `HEAD` commit.
> >
> > +split <revision> [--message=<message>] [--] [<pathspec>...]::
> > +       Interactively split up the commit into two commits by choosing
> > +       hunks introduced by it that will be moved into the new split-out
> > +       commit. These hunks will then be written into a new commit that
> > +       becomes the parent of the previous commit. The original commit
> > +       stays intact, except that its parent will be the newly split-out
> > +       commit.
> > ++
> > +The commit message of the new commit will be asked for by launching the
> > +configured editor. Authorship of the commit will be the same as for the
> > +original commit.
> > ++
> > +If passed, _<pathspec>_ can be used to limit which changes shall be split out
> > +of the original commit. Files not matching any of the pathspecs will remain
> > +part of the original commit. For more details about the _<pathspec>_ syntax,
> > +see the 'pathspec' entry.
> 
> Glossary entry?
Yup.
Show 16 quoted lines
> > +       /*
> > +        * But we do ask the user for a new commit message. This is in contrast
> > +        * to the second commit, where we'll retain the original commit
> > +        * message.
> > +        */
> 
> Interesting. I can see using the original as the template for _both_,
> or the first instead of the second. jj's split works a little
> differently (especially with their notion of descriptions), so I can't
> use them as a reference for the behavior.
> 
> I suppose this is one of those "everybody has their preference"
> things, but I think giving the message in both new commits as the
> template gives splitters the most information available when writing
> the message. (Of course, in my editor, I can presumably do something
> like ":Git show -s <split-commit-ish>" if I want.)

I think giving only the split-out changes is a reasonable default, but I can totally see that we might eventually want to add a command line option to change the behaviour.

Show 14 quoted lines
> > +       if (!commit_message) {
> > +               split_message_path = repo_git_path(repo, "SPLIT_MSG");
> > +               strbuf_addch(&split_message, '\n');
> > +               strbuf_commented_addf(&split_message, comment_line_str,
> > +                                     _("Please enter a commit message for the split-out changes."));
> > +               write_file_buf(split_message_path, split_message.buf, split_message.len);
> 
> I also noticed the commented template differs substantially from the
> regular commit template, and my editor doesn't recognize "SPLIT_MSG"
> as a commit message file.
> 
> The latter can be fixed elsewhere, but for the former: perhaps it's
> worth using the usual template with the wording here prepended?
> Respecting commit.verbose / commit.status, too.

Yeah, that's something I wanted to get around to, but haven't yet. I also noticed that it's not exactly easy to figure out what you're currently editing without that lack of context.

I'll include that in v2.
Show 7 quoted lines
> BTW, if I quit the editor with an error here, I'm left back where I
> started. So I'd have to re-stage changes if I wanted to split again,
> which is a bit different from how interactive rebase will leave me
> with the partially staged changes. Obviously that's harder to do with
> the in-memory index + automatic re-application of remaining patch when
> finished, so maybe a note in the docs about this being "all or
> nothing"?

Yeah, fair. I guess adding a note for now is the best way to go about it, but this is certainly something we can and should iterate on in the future.

Patrick
Junio C Hamano· Aug 22, 2025, 18:08 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 11/11] builtin/history: implement "split" subcommand

Patrick Steinhardt <ps@pks.im> writes:
Show 10 quoted lines
>> Interesting. I can see using the original as the template for _both_,
>> or the first instead of the second. jj's split works a little
>> differently (especially with their notion of descriptions), so I can't
>> use them as a reference for the behavior.
>> 
>> I suppose this is one of those "everybody has their preference"
>> things, but I think giving the message in both new commits as the
>> template gives splitters the most information available when writing
>> the message. (Of course, in my editor, I can presumably do something
>> like ":Git show -s <split-commit-ish>" if I want.)

In other words, removing is easy, while remembering and retyping is harder.

When I split an existing commit, that is almost always because after doing too many things in a single commit and the time I realize it is when I am writing the commit message. So I would suggest to give the same original message to both, to avoid losing information.

Patrick Steinhardt· Aug 24, 2025, 16:03 UTC · re: Junio C Hamano · lore

Re: [PATCH RFC 11/11] builtin/history: implement "split" subcommand

On Fri, Aug 22, 2025 at 11:08:22AM -0700, Junio C Hamano wrote:
Show 20 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
> 
> >> Interesting. I can see using the original as the template for _both_,
> >> or the first instead of the second. jj's split works a little
> >> differently (especially with their notion of descriptions), so I can't
> >> use them as a reference for the behavior.
> >> 
> >> I suppose this is one of those "everybody has their preference"
> >> things, but I think giving the message in both new commits as the
> >> template gives splitters the most information available when writing
> >> the message. (Of course, in my editor, I can presumably do something
> >> like ":Git show -s <split-commit-ish>" if I want.)
> 
> In other words, removing is easy, while remembering and retyping is
> harder.
> 
> When I split an existing commit, that is almost always because after
> doing too many things in a single commit and the time I realize it
> is when I am writing the commit message.  So I would suggest to give
> the same original message to both, to avoid losing information.

For now I'll rework this a bit so that the editor will list all changes in the split-out commit, similar to how git-commit(1) does it. That at least makes it way easier to see what you're currently changing. I'll think about this some more though.

Patrick
Jean-Noël AVILA· Aug 23, 2025, 16:37 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 11/11] builtin/history: implement "split" subcommand

On Tuesday, 19 August 2025 12:56:07 CEST Patrick Steinhardt wrote:
Show 36 quoted lines
> It is quite a common use case that one wants to split up one commit into
> multiple commits by moving parts of the changes of the original commit
> out of it into a separate commit. This is quite an involved operation
> though:
> 
>   1. Identify the commit in question that is to be dropped.
> 
>   2. Perform an interactive rebase on top of that commit's parent.
> 
>   3. Modify the instruction sheet to "edit" the commit that is to be
>      split up.
> 
>   4. Drop the commit via "git reset HEAD~".
> 
>   5. Stage changes that should go into the first commit and commit it.
> 
>   6. Stage changes that should go into the second commit and commit it.
> 
>   7. Finalize the rebase.
> 
> This is quite complex, and overall I would claim that most people who
> are not experts in Git would struggle with this flow.
> 
> Introduce a new "split" subcommand for git-history(1) to make this way
> easier. All the user needs to do is to say `git history split $COMMIT`.
> From hereon, Git asks the user which parts of the commit shall be moved
> out into a separate commit and, once done, asks the user for the commit
> message. Git then creates that split-out commit and applies the original
> commit on top of it.
> 
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  Documentation/git-history.adoc |  59 ++++++++
>  builtin/history.c              | 245 +++++++++++++++++++++++++++++++++
>  t/meson.build                  |   1 +
>  t/t3452-history-split.sh       | 304 ++++++++++++++++++++++++++++++++++++++
+++
Show 11 quoted lines
>  4 files changed, 609 insertions(+)
> 
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> index 6e8b4e1326..f0f1f2a093 100644
> --- a/Documentation/git-history.adoc
> +++ b/Documentation/git-history.adoc
> @@ -10,6 +10,7 @@ SYNOPSIS
>  [synopsis]
>  git history drop [<options>] <revision>
>  git history reorder [<options>] <revision> --(before|after)=<revision>
> +git history split [<options>] <revision> [--] [<pathspec>...]

I just realized that there are no other <options> available for the first two commands! So maybe simplify the synopsis for the time being.

The --message is specific to split, so maybe also cite is specifically here. I

> 
>  DESCRIPTION
>  -----------
> @@ -47,6 +48,26 @@ reorder <revision> (--before=<revision>|--
after=<revision>)::
>  	commit. The commits must be related to one another and must be
>  	reachable from the current `HEAD` commit.
> 
> +split <revision> [--message=<message>] [--] [<pathspec>...]::

missing backticks. Also the order of options and <commit> are different from the general synopsis. Is this order allowed?

> +	Interactively split up the commit into two commits by choosing

You can use the placeholder in this sentence: "Interactively split up <commit>..."

Show 10 quoted lines
> +	hunks introduced by it that will be moved into the new split-out
> +	commit. These hunks will then be written into a new commit that
> +	becomes the parent of the previous commit. The original commit
> +	stays intact, except that its parent will be the newly split-out
> +	commit.
> ++
> +The commit message of the new commit will be asked for by launching the
> +configured editor. Authorship of the commit will be the same as for the
> +original commit.
> ++
I guess this only happens if --message is not passed?
> +If passed, _<pathspec>_ can be used to limit which changes shall be split 
out
> +of the original commit. Files not matching any of the pathspecs will remain
> +part of the original commit. For more details about the _<pathspec>_ 
syntax,
> +see the 'pathspec' entry.

You may have meant: + For more details, see the 'pathspec' entry in linkgit:gitglossary[7].

Show 51 quoted lines
> ++
> +It is invalid to select either all or no hunks, as that would lead to
> +one of the commits becoming empty.
> +
>  EXAMPLES
>  --------
> 
> @@ -88,6 +109,44 @@ f44a46e third
>  bf7438d first
>  ----------
> 
> +* Split a commit.
> ++
> +----------
> +$ git log --stat --oneline
> +3f81232 (HEAD -> main) original
> + bar | 1 +
> + foo | 1 +
> + 2 files changed, 2 insertions(+)
> +
> +$ git history split HEAD --message="split-out commit"
> +diff --git a/bar b/bar
> +new file mode 100644
> +index 0000000..5716ca5
> +--- /dev/null
> ++++ b/bar
> +@@ -0,0 +1 @@
> ++bar
> +(1/1) Stage addition [y,n,q,a,d,e,p,?]? y
> +
> +diff --git a/foo b/foo
> +new file mode 100644
> +index 0000000..257cc56
> +--- /dev/null
> ++++ b/foo
> +@@ -0,0 +1 @@
> ++foo
> +(1/1) Stage addition [y,n,q,a,d,e,p,?]? n
> +
> +$ git log --stat --oneline
> +7cebe64 (HEAD -> main) original
> + foo | 1 +
> + 1 file changed, 1 insertion(+)
> +d1582f3 split-out commit
> + bar | 1 +
> + 1 file changed, 1 insertion(+)
> +----------
> +
> +
>  CONFIGURATION
>  -------------
Patrick Steinhardt· Aug 24, 2025, 16:02 UTC · re: Jean-Noël AVILA · lore

Re: [PATCH RFC 11/11] builtin/history: implement "split" subcommand

On Sat, Aug 23, 2025 at 06:37:23PM +0200, Jean-Noël AVILA wrote:
Show 10 quoted lines
> On Tuesday, 19 August 2025 12:56:07 CEST Patrick Steinhardt wrote:
> > @@ -47,6 +48,26 @@ reorder <revision> (--before=<revision>|--
> after=<revision>)::
> >  	commit. The commits must be related to one another and must be
> >  	reachable from the current `HEAD` commit.
> > 
> > +split <revision> [--message=<message>] [--] [<pathspec>...]::
> 
> missing backticks. Also the order of options and <commit> are different from 
> the general synopsis. Is this order allowed?
It is, but this usage definitely wasn't intended. Will fix.
Patrick
D. Ben Knoble· Aug 19, 2025, 21:28 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Tue, Aug 19, 2025 at 6:57 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 7 quoted lines
>
> Hi,
>
> over recent months I've been playing around with Jujutsu quite
> frequently. While I still prefer using Git, there's been a couple
> features in it that I really like and that I'd like to have in Git, as
> well.
Excellent! I'm looking forward to reading this series and playing with it.

Unfortunately, patches 8–11 got dropped on their way to me (but I see the lore archive has them). Odd. (Not in spam or deleted messages, either.)

Patrick Steinhardt· Aug 20, 2025, 06:54 UTC · re: D. Ben Knoble · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Tue, Aug 19, 2025 at 05:28:48PM -0400, D. Ben Knoble wrote:
Show 14 quoted lines
> On Tue, Aug 19, 2025 at 6:57 AM Patrick Steinhardt <ps@pks.im> wrote:
> >
> > Hi,
> >
> > over recent months I've been playing around with Jujutsu quite
> > frequently. While I still prefer using Git, there's been a couple
> > features in it that I really like and that I'd like to have in Git, as
> > well.
> 
> Excellent! I'm looking forward to reading this series and playing with it.
> 
> Unfortunately, patches 8–11 got dropped on their way to me (but I see
> the lore archive has them). Odd. (Not in spam or deleted messages,
> either.)

It's a common issue with GMail that mails from the LKML get rate limited quite aggressively. Konstantin Ryabitsev (kernel.org admin) often gives the recommendation to not use GMail for mailing lists.

Patrick
Ben Knoble· Aug 20, 2025, 16:55 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

Show 23 quoted lines
> Le 20 août 2025 à 03:56, Patrick Steinhardt <ps@pks.im> a écrit :
> 
> On Tue, Aug 19, 2025 at 05:28:48PM -0400, D. Ben Knoble wrote:
>>> On Tue, Aug 19, 2025 at 6:57 AM Patrick Steinhardt <ps@pks.im> wrote:
>>> 
>>> Hi,
>>> 
>>> over recent months I've been playing around with Jujutsu quite
>>> frequently. While I still prefer using Git, there's been a couple
>>> features in it that I really like and that I'd like to have in Git, as
>>> well.
>> 
>> Excellent! I'm looking forward to reading this series and playing with it.
>> 
>> Unfortunately, patches 8–11 got dropped on their way to me (but I see
>> the lore archive has them). Odd. (Not in spam or deleted messages,
>> either.)
> 
> It's a common issue with GMail that mails from the LKML get rate limited
> quite aggressively. Konstantin Ryabitsev (kernel.org admin) often gives
> the recommendation to not use GMail for mailing lists.
> 
> Patrick
Indeed I just saw a mastodon thread on that topic today. I’ll get switched over eventually, but it’s not a top priority for me and hasn’t hindered my side so far. My apologies for any stress it puts on the ML infra, though. 
Junio C Hamano· Aug 20, 2025, 17:36 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

Patrick Steinhardt <ps@pks.im> writes:
Show 12 quoted lines
> In the end, I'd like us to learn from what people like about Jujutsu and
> apply those learnings to Git. We won't be able to apply all learnings
> from Jujutsu, as the workflow is quite different there due to the lack
> of the index. But other things we certainly can apply to Git directly.
>
> Note: This patch series currently builds on the cherry-pick infra.
> As such, when one hits a merge conflict one needs to `git cherry-pick
> --continue`, which is quite suboptimal. I didn't want to overpolish this
> series before getting some feedback, but it is something I'll fix in
> subsequent versions. Furthermore, the command for now bails out in the
> case where there's any merge commits in the history that is being
> rewritten. This is another restriction that can be lifted in the future.
Two comments.
 - You would want to honor notes.rewriteref yourself, as cherry-pick
   does not and that is deliberate [*].
 - It is a sensible design decision to limit it to linear single
   strand of pearls history.  "history reword <commit>" when
   <commit> can be reached from many branches along linear history
   that rewrites all these commits on these branches would be handy.
   There may need some way to say "these branches are protected, if
   'history reword <commit>' needs to touch commits on any of these,
   abort" and things like that.
[Footnote]
 * "history edit" (aka "rebase") is an operation that "edits" the
   history, once the edit finishes, the result is *the* history you
   want, and the previous one is to be discarded (except for in
   reflog).  "cherry-pick" on the other hand is "I have this good
   thing on this development track, I want an equivalent _copy_ of
   it on _another_ track"---it merely is an easier and quicker way
   than typing the same thing yourself on top of the other track,
   and does not duplicate notes.
Ben Knoble· Aug 20, 2025, 17:49 UTC · re: Junio C Hamano · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

Show 21 quoted lines
> Le 20 août 2025 à 13:39, Junio C Hamano <gitster@pobox.com> a écrit :
> 
> Patrick Steinhardt <ps@pks.im> writes:
> 
>> In the end, I'd like us to learn from what people like about Jujutsu and
>> apply those learnings to Git. We won't be able to apply all learnings
>> from Jujutsu, as the workflow is quite different there due to the lack
>> of the index. But other things we certainly can apply to Git directly.
>> 
>> Note: This patch series currently builds on the cherry-pick infra.
>> As such, when one hits a merge conflict one needs to `git cherry-pick
>> --continue`, which is quite suboptimal. I didn't want to overpolish this
>> series before getting some feedback, but it is something I'll fix in
>> subsequent versions. Furthermore, the command for now bails out in the
>> case where there's any merge commits in the history that is being
>> rewritten. This is another restriction that can be lifted in the future.
> 
> Two comments.
> 
> - You would want to honor notes.rewriteref yourself, as cherry-pick
>   does not and that is deliberate [*].
Seconded
Show 7 quoted lines
> - It is a sensible design decision to limit it to linear single
>   strand of pearls history.  "history reword <commit>" when
>   <commit> can be reached from many branches along linear history
>   that rewrites all these commits on these branches would be handy.
>   There may need some way to say "these branches are protected, if
>   'history reword <commit>' needs to touch commits on any of these,
>   abort" and things like that.
This reminds me of looking at rebase’s update-refs through a mirror, and I think is similar to what jj actually does. In particular, editing a commit that is reachable from multiple non-overlapping branches could update all of them.
A reasonable heuristic for safety is “pushed,” but I would also want to be able to edit something in @{u}.. even if it’s in @{push} so that I can force-push a new version. I suspect jj might also have heuristics we can borrow.
PS thanks all for being willing to borrow improvements! Reminds me of Neovim inspiring improvements in Vim, which I get to benefit from without switching :)
Patrick Steinhardt· Aug 22, 2025, 12:21 UTC · re: Ben Knoble · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Wed, Aug 20, 2025 at 01:49:40PM -0400, Ben Knoble wrote:
Show 24 quoted lines
> 
> > Le 20 août 2025 à 13:39, Junio C Hamano <gitster@pobox.com> a écrit :
> > 
> > Patrick Steinhardt <ps@pks.im> writes:
> > 
> >> In the end, I'd like us to learn from what people like about Jujutsu and
> >> apply those learnings to Git. We won't be able to apply all learnings
> >> from Jujutsu, as the workflow is quite different there due to the lack
> >> of the index. But other things we certainly can apply to Git directly.
> >> 
> >> Note: This patch series currently builds on the cherry-pick infra.
> >> As such, when one hits a merge conflict one needs to `git cherry-pick
> >> --continue`, which is quite suboptimal. I didn't want to overpolish this
> >> series before getting some feedback, but it is something I'll fix in
> >> subsequent versions. Furthermore, the command for now bails out in the
> >> case where there's any merge commits in the history that is being
> >> rewritten. This is another restriction that can be lifted in the future.
> > 
> > Two comments.
> > 
> > - You would want to honor notes.rewriteref yourself, as cherry-pick
> >   does not and that is deliberate [*].
> 
> Seconded

Okay, I'll have a look at that. I'll probably not do that in v2 yet, but will try to get it into v3.

Show 21 quoted lines
> > - It is a sensible design decision to limit it to linear single
> >   strand of pearls history.  "history reword <commit>" when
> >   <commit> can be reached from many branches along linear history
> >   that rewrites all these commits on these branches would be handy.
> >   There may need some way to say "these branches are protected, if
> >   'history reword <commit>' needs to touch commits on any of these,
> >   abort" and things like that.
> 
> This reminds me of looking at rebase’s update-refs through a mirror,
> and I think is similar to what jj actually does. In particular,
> editing a commit that is reachable from multiple non-overlapping
> branches could update all of them.
> 
> A reasonable heuristic for safety is “pushed,” but I would also want
> to be able to edit something in @{u}.. even if it’s in @{push} so that
> I can force-push a new version. I suspect jj might also have
> heuristics we can borrow.
> 
> PS thanks all for being willing to borrow improvements! Reminds me of
> Neovim inspiring improvements in Vim, which I get to benefit from
> without switching :)

At least initially I want to keep it simple, just to get this in at first. So I'm trying to be quite defensive overall and die in all kinds of situations that require more thought. That way it becomes way easier to eventually extend the different subcommands to maybe lift some of the current restrictions.

Patrick
Junio C Hamano· Aug 22, 2025, 17:58 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

Patrick Steinhardt <ps@pks.im> writes:
Show 5 quoted lines
> At least initially I want to keep it simple, just to get this in at
> first. So I'm trying to be quite defensive overall and die in all kinds
> of situations that require more thought. That way it becomes way easier
> to eventually extend the different subcommands to maybe lift some of the
> current restrictions.
I like that approach very much.  Thanks.
Sergey Organov· Aug 21, 2025, 16:26 UTC · re: Junio C Hamano · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

Junio C Hamano <gitster@pobox.com> writes:
Show 39 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
>
>> In the end, I'd like us to learn from what people like about Jujutsu and
>> apply those learnings to Git. We won't be able to apply all learnings
>> from Jujutsu, as the workflow is quite different there due to the lack
>> of the index. But other things we certainly can apply to Git directly.
>>
>> Note: This patch series currently builds on the cherry-pick infra.
>> As such, when one hits a merge conflict one needs to `git cherry-pick
>> --continue`, which is quite suboptimal. I didn't want to overpolish this
>> series before getting some feedback, but it is something I'll fix in
>> subsequent versions. Furthermore, the command for now bails out in the
>> case where there's any merge commits in the history that is being
>> rewritten. This is another restriction that can be lifted in the future.
>
> Two comments.
>
>  - You would want to honor notes.rewriteref yourself, as cherry-pick
>    does not and that is deliberate [*].
>
>  - It is a sensible design decision to limit it to linear single
>    strand of pearls history.  "history reword <commit>" when
>    <commit> can be reached from many branches along linear history
>    that rewrites all these commits on these branches would be handy.
>    There may need some way to say "these branches are protected, if
>    'history reword <commit>' needs to touch commits on any of these,
>    abort" and things like that.
>
>
> [Footnote]
>
>  * "history edit" (aka "rebase") is an operation that "edits" the
>    history, once the edit finishes, the result is *the* history you
>    want, and the previous one is to be discarded (except for in
>    reflog).  "cherry-pick" on the other hand is "I have this good
>    thing on this development track, I want an equivalent _copy_ of
>    it on _another_ track"---it merely is an easier and quicker way
>    than typing the same thing yourself on top of the other track,
>    and does not duplicate notes.

Unless I'm ignorant, "git rebase" (aka "history edit") lacks essential feature though: in addition to saying: get "this" history and rebase it "there", one should be able to say: get "that" history, and rebase it "here" (aka cherry-pick on steroids), that also would eliminate the need for 'git cherry-pick <range>' that (poorly) duplicates rebase functionality.

If not to implement this as, say, "git rebase --pick", I'd like to see this feature in the new "git history" command, but then it'd eventually become yet another "git rebase"? Or will Git then aim to eventually factor-out a lot of "git rebase" functionality into new "git history rebase <what> <where>", or something like this?

Thanks, Sergey Organov

Ben Knoble· Aug 21, 2025, 17:21 UTC · re: Sergey Organov · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

Show 50 quoted lines
> Le 21 août 2025 à 12:51, Sergey Organov <sorganov@gmail.com> a écrit :
> 
> Junio C Hamano <gitster@pobox.com> writes:
> 
>> Patrick Steinhardt <ps@pks.im> writes:
>> 
>>> In the end, I'd like us to learn from what people like about Jujutsu and
>>> apply those learnings to Git. We won't be able to apply all learnings
>>> from Jujutsu, as the workflow is quite different there due to the lack
>>> of the index. But other things we certainly can apply to Git directly.
>>> 
>>> Note: This patch series currently builds on the cherry-pick infra.
>>> As such, when one hits a merge conflict one needs to `git cherry-pick
>>> --continue`, which is quite suboptimal. I didn't want to overpolish this
>>> series before getting some feedback, but it is something I'll fix in
>>> subsequent versions. Furthermore, the command for now bails out in the
>>> case where there's any merge commits in the history that is being
>>> rewritten. This is another restriction that can be lifted in the future.
>> 
>> Two comments.
>> 
>> - You would want to honor notes.rewriteref yourself, as cherry-pick
>>   does not and that is deliberate [*].
>> 
>> - It is a sensible design decision to limit it to linear single
>>   strand of pearls history.  "history reword <commit>" when
>>   <commit> can be reached from many branches along linear history
>>   that rewrites all these commits on these branches would be handy.
>>   There may need some way to say "these branches are protected, if
>>   'history reword <commit>' needs to touch commits on any of these,
>>   abort" and things like that.
>> 
>> 
>> [Footnote]
>> 
>> * "history edit" (aka "rebase") is an operation that "edits" the
>>   history, once the edit finishes, the result is *the* history you
>>   want, and the previous one is to be discarded (except for in
>>   reflog).  "cherry-pick" on the other hand is "I have this good
>>   thing on this development track, I want an equivalent _copy_ of
>>   it on _another_ track"---it merely is an easier and quicker way
>>   than typing the same thing yourself on top of the other track,
>>   and does not duplicate notes.
> 
> Unless I'm ignorant, "git rebase" (aka "history edit") lacks essential
> feature though: in addition to saying: get "this" history and rebase it
> "there", one should be able to say: get "that" history, and rebase it
> "here" (aka cherry-pick on steroids), that also would eliminate the need
> for 'git cherry-pick <range>' that (poorly) duplicates rebase
> functionality.
But isn’t that
    git rebase --onto=<here> <that-upstream> <that>
? And why is that cherry-picking a range is a poor substitute [it is rather rebase that duplicates cherry-pick ;)]?
Sergey Organov· Aug 21, 2025, 18:15 UTC · re: Ben Knoble · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

Ben Knoble <ben.knoble@gmail.com> writes:
Show 54 quoted lines
>> Le 21 août 2025 à 12:51, Sergey Organov <sorganov@gmail.com> a écrit :
>> 
>> Junio C Hamano <gitster@pobox.com> writes:
>> 
>>> Patrick Steinhardt <ps@pks.im> writes:
>>> 
>>>> In the end, I'd like us to learn from what people like about Jujutsu and
>>>> apply those learnings to Git. We won't be able to apply all learnings
>>>> from Jujutsu, as the workflow is quite different there due to the lack
>>>> of the index. But other things we certainly can apply to Git directly.
>>>> 
>>>> Note: This patch series currently builds on the cherry-pick infra.
>>>> As such, when one hits a merge conflict one needs to `git cherry-pick
>>>> --continue`, which is quite suboptimal. I didn't want to overpolish this
>>>> series before getting some feedback, but it is something I'll fix in
>>>> subsequent versions. Furthermore, the command for now bails out in the
>>>> case where there's any merge commits in the history that is being
>>>> rewritten. This is another restriction that can be lifted in the future.
>>> 
>>> Two comments.
>>> 
>>> - You would want to honor notes.rewriteref yourself, as cherry-pick
>>>   does not and that is deliberate [*].
>>> 
>>> - It is a sensible design decision to limit it to linear single
>>>   strand of pearls history.  "history reword <commit>" when
>>>   <commit> can be reached from many branches along linear history
>>>   that rewrites all these commits on these branches would be handy.
>>>   There may need some way to say "these branches are protected, if
>>>   'history reword <commit>' needs to touch commits on any of these,
>>>   abort" and things like that.
>>> 
>>> 
>>> [Footnote]
>>> 
>>> * "history edit" (aka "rebase") is an operation that "edits" the
>>>   history, once the edit finishes, the result is *the* history you
>>>   want, and the previous one is to be discarded (except for in
>>>   reflog).  "cherry-pick" on the other hand is "I have this good
>>>   thing on this development track, I want an equivalent _copy_ of
>>>   it on _another_ track"---it merely is an easier and quicker way
>>>   than typing the same thing yourself on top of the other track,
>>>   and does not duplicate notes.
>> 
>> Unless I'm ignorant, "git rebase" (aka "history edit") lacks essential
>> feature though: in addition to saying: get "this" history and rebase it
>> "there", one should be able to say: get "that" history, and rebase it
>> "here" (aka cherry-pick on steroids), that also would eliminate the need
>> for 'git cherry-pick <range>' that (poorly) duplicates rebase
>> functionality.
>
> But isn’t that
>
>     git rebase --onto=<here> <that-upstream> <that>

Cause we end-up being on modified <that> (unless my memory fails me) rather than on modified <here> when intent is to keep <that> intact.

> ? And why is that cherry-picking a range is a poor substitute [it is
> rather rebase that duplicates cherry-pick ;)]?

Because rebase has a lot of useful features compared to cherry-pick, such as --interactive, --exec, merges handling, etc. In fact, IMHO, "cherry-pick" should better be Git plumbing, and every-day-user-needs should be handled by "rebase".

Historically, when ranges support was added to "cherry-pick", it was probably a mistake, as "rebase" appears to be a better place for needed functionality, and what was needed is rather adding "--pick" support to "rebase".

-- 
Sergey Organov
Martin von Zweigbergk· Aug 24, 2025, 01:25 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Tue, Aug 19, 2025 at 3:57 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 12 quoted lines
>
> Hi,
>
> over recent months I've been playing around with Jujutsu quite
> frequently. While I still prefer using Git, there's been a couple
> features in it that I really like and that I'd like to have in Git, as
> well.
>
> A copule of these features relate to history editing. Most importantly,
> I really dig the following commands:
>
>   - jj-abandon(1) to drop a specific commit from your history.

It also rebases all descendants on top of the parent(s) of the abandoned commit(s). Branches pointing to the rebased commits are also repointed. So is the working copy if it points to a rebased commit (the closest equivalent in Git would be HEAD). Do you plan to make all the `git history` commands behave that way too?

Show 22 quoted lines
>
>   - jj-absorb(1) to take some changes and automatically apply them to
>     commits in your history that last modified the respective hunks.
>
>   - jj-split(1) to split a commit into two.
>
>   - jj-new(1) to insert a new commit after or before a specific other
>     commit.
>
> Not all of these commands can be ported directly into Git. jj-new(1) for
> example doesn't really make a ton of sense for us, I'd claim. But some
> of these commands _do_ make sense.
>
> I thus had a look at implementing some of these commands in Git itself,
> where the result is this patch series. Specifically, the following
> commands are introduced by this patch series:
>
>   - `git history drop` to drop a specific commit. This is basically the
>     same as jj-abandon(1).
>
>   - `git history reorder` to reorder a specific commit before or after
>     another commit. This is inspired by jj-new(1).

It seems more similar to `jj rebase -r X -A/-B Y`, which rips X out of the graph and inserts it after/before Y. Just FYI; I'm not asking for any changes.

>
>   - `git history split` takes a commit and splits it into two. This is
>     basically the same as jj-split(1).

FYI, the default behavior of `jj split` is to split the commit into parent and child, but there's also `-A/-B X` to take the selected changes and insert them after/before X, or `-d X` to put them on top of X.

Show 9 quoted lines
>
> If this is something we want to have I think it'd be just a starting
> point. There's other commands that I think are quite common and that
> might make sense to introduce eventually:
>
>   - An equivalent to jj-absorb(1) would be awesome to have.
>
>   - `git history reword` to change only the commit message of a specific
>     commit.

FYI, `jj describe` can also change the commit message of multiple commits at once (e.g. `jj describe main..@` to edit your current chain of commits). It concatenates each description with some separators between in that case so you can update them all at once in your $EDITOR.

I'm letting you know these things in case it impacts planning for the CLI arguments.

Show 7 quoted lines
>
>   - `git history squash` to squash together multiple commits into one.
>
> In the end, I'd like us to learn from what people like about Jujutsu and
> apply those learnings to Git. We won't be able to apply all learnings
> from Jujutsu, as the workflow is quite different there due to the lack
> of the index. But other things we certainly can apply to Git directly.

Perhaps the simplest thing to copy is revsets (which we copied from Mercurial). See https://jj-vcs.github.io/jj/latest/revsets/. It's not at all simple to implement, but I think it should be relatively simple from a UX point of view because it can probably be done in a mostly backwards compatible way.

Show 61 quoted lines
>
> Note: This patch series currently builds on the cherry-pick infra.
> As such, when one hits a merge conflict one needs to `git cherry-pick
> --continue`, which is quite suboptimal. I didn't want to overpolish this
> series before getting some feedback, but it is something I'll fix in
> subsequent versions. Furthermore, the command for now bails out in the
> case where there's any merge commits in the history that is being
> rewritten. This is another restriction that can be lifted in the future.
>
> Thanks!
>
> Patrick
>
> ---
> Patrick Steinhardt (11):
>       sequencer: optionally skip printing commit summary
>       sequencer: add option to rewind HEAD after picking commits
>       cache-tree: allow writing in-memory index as tree
>       builtin: add new "history" command
>       builtin/history: implement "drop" subcommand
>       builtin/history: implement "reorder" subcommand
>       add-patch: split out header from "add-interactive.h"
>       add-patch: split out `struct interactive_options`
>       add-patch: remove dependency on "add-interactive" subsystem
>       add-patch: add support for in-memory index patching
>       builtin/history: implement "split" subcommand
>
>  .gitignore                     |   1 +
>  Documentation/git-history.adoc | 159 ++++++++++
>  Documentation/meson.build      |   1 +
>  Makefile                       |   1 +
>  add-interactive.c              | 151 +++------
>  add-interactive.h              |  43 +--
>  add-patch.c                    | 271 ++++++++++++++--
>  add-patch.h                    |  61 ++++
>  builtin.h                      |   1 +
>  builtin/add.c                  |  22 +-
>  builtin/checkout.c             |   7 +-
>  builtin/commit.c               |  16 +-
>  builtin/history.c              | 691 +++++++++++++++++++++++++++++++++++++++++
>  builtin/reset.c                |  16 +-
>  builtin/stash.c                |  46 +--
>  cache-tree.c                   |   5 +-
>  cache-tree.h                   |   3 +-
>  commit.h                       |   2 +-
>  git.c                          |   1 +
>  meson.build                    |   1 +
>  sequencer.c                    |  36 ++-
>  sequencer.h                    |   4 +
>  t/meson.build                  |   5 +-
>  t/t3450-history-drop.sh        | 127 ++++++++
>  t/t3451-history-reorder.sh     | 218 +++++++++++++
>  t/t3452-history-split.sh       | 304 ++++++++++++++++++
>  26 files changed, 1947 insertions(+), 246 deletions(-)
>
>
> ---
> base-commit: c44beea485f0f2feaf460e2ac87fdd5608d63cf0
> change-id: 20250819-b4-pks-history-builtin-83398f9a05f0
>
>
Patrick Steinhardt· Aug 24, 2025, 16:03 UTC · re: Martin von Zweigbergk · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Sat, Aug 23, 2025 at 06:25:06PM -0700, Martin von Zweigbergk wrote:
Show 19 quoted lines
> On Tue, Aug 19, 2025 at 3:57 AM Patrick Steinhardt <ps@pks.im> wrote:
> >
> > Hi,
> >
> > over recent months I've been playing around with Jujutsu quite
> > frequently. While I still prefer using Git, there's been a couple
> > features in it that I really like and that I'd like to have in Git, as
> > well.
> >
> > A copule of these features relate to history editing. Most importantly,
> > I really dig the following commands:
> >
> >   - jj-abandon(1) to drop a specific commit from your history.
> 
> It also rebases all descendants on top of the parent(s) of the
> abandoned commit(s). Branches pointing to the rebased commits are also
> repointed. So is the working copy if it points to a rebased commit
> (the closest equivalent in Git would be HEAD). Do you plan to make all
> the `git history` commands behave that way too?
Yup.
[snip]
Show 6 quoted lines
> >   - `git history reorder` to reorder a specific commit before or after
> >     another commit. This is inspired by jj-new(1).
> 
> It seems more similar to `jj rebase -r X -A/-B Y`, which rips X out of
> the graph and inserts it after/before Y. Just FYI; I'm not asking for
> any changes.

Fair enough. I think calling this `git history rebase` would be problematic though given that "rebase" is already a widely used term in Git that means something else.

Show 7 quoted lines
> >   - `git history split` takes a commit and splits it into two. This is
> >     basically the same as jj-split(1).
> 
> FYI, the default behavior of `jj split` is to split the commit into
> parent and child, but there's also `-A/-B X` to take the selected
> changes and insert them after/before X, or `-d X` to put them on top
> of X.

Oh, that's neat and I can totally see how that is useful. I'll not yet add such a change, but that's certainly something we can add at a later point in time rather easily.

Show 17 quoted lines
> > If this is something we want to have I think it'd be just a starting
> > point. There's other commands that I think are quite common and that
> > might make sense to introduce eventually:
> >
> >   - An equivalent to jj-absorb(1) would be awesome to have.
> >
> >   - `git history reword` to change only the commit message of a specific
> >     commit.
> 
> FYI, `jj describe` can also change the commit message of multiple
> commits at once (e.g. `jj describe main..@` to edit your current chain
> of commits). It concatenates each description with some separators
> between in that case so you can update them all at once in your
> $EDITOR.
> 
> I'm letting you know these things in case it impacts planning for the
> CLI arguments.

And I very much appreciate it :) A bunch of the functionality in Git will have different terminology so that it fits better into Git overall. There for example is already the "reword" action in the interactive rebase code, so it makes sense to keep on using it. Same for "drop", for example.

Show 12 quoted lines
> >   - `git history squash` to squash together multiple commits into one.
> >
> > In the end, I'd like us to learn from what people like about Jujutsu and
> > apply those learnings to Git. We won't be able to apply all learnings
> > from Jujutsu, as the workflow is quite different there due to the lack
> > of the index. But other things we certainly can apply to Git directly.
> 
> Perhaps the simplest thing to copy is revsets (which we copied from
> Mercurial). See https://jj-vcs.github.io/jj/latest/revsets/. It's not
> at all simple to implement, but I think it should be relatively simple
> from a UX point of view because it can probably be done in a mostly
> backwards compatible way.

True, I very much like the way revsets work in JJ. Whether it can be used in a backwards-compatible way... no idea. I'd have to think about this more closely, but for now I think there's other, lower-hanging fruit :)

Patrick
SZEDER Gábor· Sep 17, 2025, 20:12 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Sun, Aug 24, 2025 at 06:03:02PM +0200, Patrick Steinhardt wrote:
Show 22 quoted lines
> On Sat, Aug 23, 2025 at 06:25:06PM -0700, Martin von Zweigbergk wrote:
> > On Tue, Aug 19, 2025 at 3:57 AM Patrick Steinhardt <ps@pks.im> wrote:
> > >
> > > Hi,
> > >
> > > over recent months I've been playing around with Jujutsu quite
> > > frequently. While I still prefer using Git, there's been a couple
> > > features in it that I really like and that I'd like to have in Git, as
> > > well.
> > >
> > > A copule of these features relate to history editing. Most importantly,
> > > I really dig the following commands:
> > >
> > >   - jj-abandon(1) to drop a specific commit from your history.
> > 
> > It also rebases all descendants on top of the parent(s) of the
> > abandoned commit(s). Branches pointing to the rebased commits are also
> > repointed. So is the working copy if it points to a rebased commit
> > (the closest equivalent in Git would be HEAD). Do you plan to make all
> > the `git history` commands behave that way too?
> 
> Yup.

That sounds scary... What does "all descendants" mean in this context?

Let's suppose I have this piece of history, I'm on 'branch2', and I drop commit B. Which commits will be rewritten and which branches will be repointed?

   A---B---C---D   branch1
            \   \
             \   E---F   branch2
              \       \
               \       G---H---I   branch3
                \
                 J---K---L   branch4
Matthias Beyer· Dec 3, 2025, 18:18 UTC · re: SZEDER Gábor · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

Hi,
Am Wed, Sep 17, 2025 at 10:12:31PM +0200, schrieb SZEDER Gábor:
Show 39 quoted lines
> On Sun, Aug 24, 2025 at 06:03:02PM +0200, Patrick Steinhardt wrote:
> > On Sat, Aug 23, 2025 at 06:25:06PM -0700, Martin von Zweigbergk wrote:
> > > On Tue, Aug 19, 2025 at 3:57 AM Patrick Steinhardt <ps@pks.im> wrote:
> > > >
> > > > Hi,
> > > >
> > > > over recent months I've been playing around with Jujutsu quite
> > > > frequently. While I still prefer using Git, there's been a couple
> > > > features in it that I really like and that I'd like to have in Git, as
> > > > well.
> > > >
> > > > A copule of these features relate to history editing. Most importantly,
> > > > I really dig the following commands:
> > > >
> > > >   - jj-abandon(1) to drop a specific commit from your history.
> > > 
> > > It also rebases all descendants on top of the parent(s) of the
> > > abandoned commit(s). Branches pointing to the rebased commits are also
> > > repointed. So is the working copy if it points to a rebased commit
> > > (the closest equivalent in Git would be HEAD). Do you plan to make all
> > > the `git history` commands behave that way too?
> > 
> > Yup.
> 
> That sounds scary...  What does "all descendants" mean in this
> context?
> 
> Let's suppose I have this piece of history, I'm on 'branch2', and I
> drop commit B.  Which commits will be rewritten and which branches
> will be repointed?
> 
>    A---B---C---D   branch1
>             \   \
>              \   E---F   branch2
>               \       \
>                \       G---H---I   branch3
>                 \
>                  J---K---L   branch4
> 

Just speaking as a user here, but my expectation in this scenario would be that rewriting B would be denied by default here, as branch{1..4} would be rewritten although I am at branch2.

In the scenario at hand, I would expect that I can only rewrite G, H, I while on branch 3 and J, K, L while on branch4 (without passing some extra flags for "yes, please also rewrite the other branches").

Just my 2cts
Phillip Wood· Dec 10, 2025, 09:58 UTC · re: Matthias Beyer · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

Hi Matthias
On 03/12/2025 18:18, Matthias Beyer wrote:
Show 22 quoted lines
> Am Wed, Sep 17, 2025 at 10:12:31PM +0200, schrieb SZEDER Gábor:
> 
>> Let's suppose I have this piece of history, I'm on 'branch2', and I
>> drop commit B.  Which commits will be rewritten and which branches
>> will be repointed?
>>
>>     A---B---C---D   branch1
>>              \   \
>>               \   E---F   branch2
>>                \       \
>>                 \       G---H---I   branch3
>>                  \
>>                   J---K---L   branch4
>>
> 
> Just speaking as a user here, but my expectation in this scenario would
> be that rewriting B would be denied by default here, as branch{1..4}
> would be rewritten although I am at branch2.
> 
> In the scenario at hand, I would expect that I can only rewrite G, H, I
> while on branch 3 and J, K, L while on branch4 (without passing some
> extra flags for "yes, please also rewrite the other branches").

Is that because you have branches that you don't want to rewrite because they've been merged upstream or is there another reason? If we start rewriting multiple branches we should probably check that we're not rewriting something that has been merged upstream but if I rewrite a commits that's an ancestor of several branches it would be very helpful to rewrite them all at the same time to keep them in sync.

Thanks
Phillip
Matthias Beyer· Dec 10, 2025, 10:37 UTC · re: Phillip Wood · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

Am Wed, Dec 10, 2025 at 09:58:13AM +0000, schrieb Phillip Wood:
Show 32 quoted lines
> Hi Matthias
> 
> On 03/12/2025 18:18, Matthias Beyer wrote:
> > Am Wed, Sep 17, 2025 at 10:12:31PM +0200, schrieb SZEDER Gábor:
> > 
> > > Let's suppose I have this piece of history, I'm on 'branch2', and I
> > > drop commit B.  Which commits will be rewritten and which branches
> > > will be repointed?
> > > 
> > >     A---B---C---D   branch1
> > >              \   \
> > >               \   E---F   branch2
> > >                \       \
> > >                 \       G---H---I   branch3
> > >                  \
> > >                   J---K---L   branch4
> > > 
> > 
> > Just speaking as a user here, but my expectation in this scenario would
> > be that rewriting B would be denied by default here, as branch{1..4}
> > would be rewritten although I am at branch2.
> > 
> > In the scenario at hand, I would expect that I can only rewrite G, H, I
> > while on branch 3 and J, K, L while on branch4 (without passing some
> > extra flags for "yes, please also rewrite the other branches").
> 
> Is that because you have branches that you don't want to rewrite because
> they've been merged upstream or is there another reason? If we start
> rewriting multiple branches we should probably check that we're not
> rewriting something that has been merged upstream but if I rewrite a commits
> that's an ancestor of several branches it would be very helpful to rewrite
> them all at the same time to keep them in sync.

Its mostly because I don't like too much magic and because I think being explicit is always better than not.

So from my POV, I would expect "the simple case" to be "the simple CLI call" and if I want the tool to do magic and "rewrite all the things"^tm, that I would need to specify a flag for that.

Best, Matthias

Phillip Wood· Dec 10, 2025, 11:34 UTC · re: Matthias Beyer · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On 10/12/2025 10:37, Matthias Beyer wrote:
Show 38 quoted lines
> Am Wed, Dec 10, 2025 at 09:58:13AM +0000, schrieb Phillip Wood:
>> On 03/12/2025 18:18, Matthias Beyer wrote:
>>> Am Wed, Sep 17, 2025 at 10:12:31PM +0200, schrieb SZEDER Gábor:
>>>
>>>> Let's suppose I have this piece of history, I'm on 'branch2', and I
>>>> drop commit B.  Which commits will be rewritten and which branches
>>>> will be repointed?
>>>>
>>>>      A---B---C---D   branch1
>>>>               \   \
>>>>                \   E---F   branch2
>>>>                 \       \
>>>>                  \       G---H---I   branch3
>>>>                   \
>>>>                    J---K---L   branch4
>>>>
>>>
>>> Just speaking as a user here, but my expectation in this scenario would
>>> be that rewriting B would be denied by default here, as branch{1..4}
>>> would be rewritten although I am at branch2.
>>>
>>> In the scenario at hand, I would expect that I can only rewrite G, H, I
>>> while on branch 3 and J, K, L while on branch4 (without passing some
>>> extra flags for "yes, please also rewrite the other branches").
>>
>> Is that because you have branches that you don't want to rewrite because
>> they've been merged upstream or is there another reason? If we start
>> rewriting multiple branches we should probably check that we're not
>> rewriting something that has been merged upstream but if I rewrite a commits
>> that's an ancestor of several branches it would be very helpful to rewrite
>> them all at the same time to keep them in sync.
> 
> Its mostly because I don't like too much magic and because I think being
> explicit is always better than not.
> 
> So from my POV, I would expect "the simple case" to be "the simple CLI
> call" and if I want the tool to do magic and "rewrite all the
> things"^tm, that I would need to specify a flag for that.

Thanks, that's useful to know. I'd assumed rewriting all the branches descended from the rewritten commit was the natural thing do do but clearly not everyone thinks it is.

Phillip
Junio C Hamano· Dec 10, 2025, 14:18 UTC · re: Phillip Wood · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

Phillip Wood <phillip.wood123@gmail.com> writes:
Show 10 quoted lines
>> Its mostly because I don't like too much magic and because I think being
>> explicit is always better than not.
>> 
>> So from my POV, I would expect "the simple case" to be "the simple CLI
>> call" and if I want the tool to do magic and "rewrite all the
>> things"^tm, that I would need to specify a flag for that.
>
> Thanks, that's useful to know. I'd assumed rewriting all the branches 
> descended from the rewritten commit was the natural thing do do but 
> clearly not everyone thinks it is.

It probably depends on the way one looks at the tool, as a building block (in which case less magic may be preferrable) or a complete solution for one part of workflow. I probably fall into former camp more often than other people, but for this particular one, I tend to think it is less confusing if we moved all branch refs away from the commits that are obsoleted by rewriting/replaying.

Patrick Steinhardt· Dec 19, 2025, 12:22 UTC · re: Junio C Hamano · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Wed, Dec 10, 2025 at 11:18:29PM +0900, Junio C Hamano wrote:
Show 19 quoted lines
> Phillip Wood <phillip.wood123@gmail.com> writes:
> 
> >> Its mostly because I don't like too much magic and because I think being
> >> explicit is always better than not.
> >> 
> >> So from my POV, I would expect "the simple case" to be "the simple CLI
> >> call" and if I want the tool to do magic and "rewrite all the
> >> things"^tm, that I would need to specify a flag for that.
> >
> > Thanks, that's useful to know. I'd assumed rewriting all the branches 
> > descended from the rewritten commit was the natural thing do do but 
> > clearly not everyone thinks it is.
> 
> It probably depends on the way one looks at the tool, as a building
> block (in which case less magic may be preferrable) or a complete
> solution for one part of workflow.  I probably fall into former camp
> more often than other people, but for this particular one, I tend to
> think it is less confusing if we moved all branch refs away from the
> commits that are obsoleted by rewriting/replaying.

Okay, so the majority of folks here seem to favor rewriting all dependent branches, which is also the default that JJ uses here, and git-replay(1) does it, too.

There is one major difference between git-replay(1) and git-history(1) though: the former works with revision ranges, whereas the latter does not. By using revision ranges we avoid the problem I have mentioned in a different branch of this discussion, which is that we have no easy way to figure out which branches we'd have to touch in the first place. This is because we simply walk the revision range there and then look at which of our references point into that range. That's simple enough.

But in our case we're not working with ranges, we are working with a singular commit. In my head this meant that we'd have to basically do a revision walk that starts from all of our branches so that we can figure out which of them would eventually reach the commit that we are about to rewrite. And that of course doesn't scale.

Now we could of course also introduce ranges into git-history(1). That would indeed solve the issue, as we can reuse the same architecture as we already have in git-replay(1). But I don't really want to go there as it is leaking complexity to the user: they want to rewrite a single commit, why should they have to think about ranges?

But now that I've thought about the problem a bit I think we can avoid that issue by implicitly identifying the range: it's all the commits between the commit we're about to rewrite and HEAD. So, same as with git-replay(1), the set of branches that we'd need to rewrite is any one branch that points into that range. It keeps the UI simple as the user still only has to think about a singular commit, should be sufficiently fast to compute in most cases, and it allows mega-merge workflows like JJ supports.

Does that make sense to everyone? If so, I'll revise my stance and will adapt the current implementation to do exactly that.

Thanks for the discussion!
Patrick
SZEDER Gábor· Dec 19, 2025, 13:58 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Fri, Dec 19, 2025 at 01:22:03PM +0100, Patrick Steinhardt wrote:
Show 24 quoted lines
> On Wed, Dec 10, 2025 at 11:18:29PM +0900, Junio C Hamano wrote:
> > Phillip Wood <phillip.wood123@gmail.com> writes:
> > 
> > >> Its mostly because I don't like too much magic and because I think being
> > >> explicit is always better than not.
> > >> 
> > >> So from my POV, I would expect "the simple case" to be "the simple CLI
> > >> call" and if I want the tool to do magic and "rewrite all the
> > >> things"^tm, that I would need to specify a flag for that.
> > >
> > > Thanks, that's useful to know. I'd assumed rewriting all the branches 
> > > descended from the rewritten commit was the natural thing do do but 
> > > clearly not everyone thinks it is.
> > 
> > It probably depends on the way one looks at the tool, as a building
> > block (in which case less magic may be preferrable) or a complete
> > solution for one part of workflow.  I probably fall into former camp
> > more often than other people, but for this particular one, I tend to
> > think it is less confusing if we moved all branch refs away from the
> > commits that are obsoleted by rewriting/replaying.
> 
> Okay, so the majority of folks here seem to favor rewriting all
> dependent branches, which is also the default that JJ uses here, and
> git-replay(1) does it, too.

I can't find the word "conflict" in this subthread, so let me bring back that little history snippet from around the beginning of the subthread:

  Let's suppose I have this piece of history, I'm on 'branch2', and I
  drop commit B.  Which commits will be rewritten and which branches
  will be repointed?
  
     A---B---C---D   branch1
              \   \
               \   E---F   branch2
                \       \
                 \       G---H---I   branch3
                  \
                   J---K---L   branch4

If we were to rewrite all dependent branches after dropping commit B, then besides 'branch2' we would rewrite 'branch1', 'branch3' and 'branch4' as well, right?

Now, let's suppose that dropping B would cause conflicts when rewriting commits G, H, I, J, K and L.

When does the user have to resolve these conflicts?

If not right now, then how exactly will those dependent branches be rewritten? (I understand jj can store conflicts and they can be resolved later... But are we there yet?)

If right now, when 'git history' applies each of those commits, then rewriting all dependent branches seems to be a horrible idea.

> But now that I've thought about the problem a bit I think we can avoid
> that issue by implicitly identifying the range: it's all the commits
> between the commit we're about to rewrite and HEAD. So, same as with
> git-replay(1),
I think 'git rebase --update-refs' does the same as well.
Show 8 quoted lines
> the set of branches that we'd need to rewrite is any one
> branch that points into that range. It keeps the UI simple as the user
> still only has to think about a singular commit, should be sufficiently
> fast to compute in most cases, and it allows mega-merge workflows like
> JJ supports.
> 
> Does that make sense to everyone? If so, I'll revise my stance and will
> adapt the current implementation to do exactly that.

I would very much prefer that the tool would only rewrite branches that I explicitly allowed to be rewritten, with an '--all' option that would allow the rewrite of all dependent branches.

Or at the very least there must be an escape hatch.
Patrick Steinhardt· Dec 19, 2025, 14:09 UTC · re: SZEDER Gábor · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Fri, Dec 19, 2025 at 02:58:40PM +0100, SZEDER Gábor wrote:
Show 50 quoted lines
> On Fri, Dec 19, 2025 at 01:22:03PM +0100, Patrick Steinhardt wrote:
> > On Wed, Dec 10, 2025 at 11:18:29PM +0900, Junio C Hamano wrote:
> > > Phillip Wood <phillip.wood123@gmail.com> writes:
> > > 
> > > >> Its mostly because I don't like too much magic and because I think being
> > > >> explicit is always better than not.
> > > >> 
> > > >> So from my POV, I would expect "the simple case" to be "the simple CLI
> > > >> call" and if I want the tool to do magic and "rewrite all the
> > > >> things"^tm, that I would need to specify a flag for that.
> > > >
> > > > Thanks, that's useful to know. I'd assumed rewriting all the branches 
> > > > descended from the rewritten commit was the natural thing do do but 
> > > > clearly not everyone thinks it is.
> > > 
> > > It probably depends on the way one looks at the tool, as a building
> > > block (in which case less magic may be preferrable) or a complete
> > > solution for one part of workflow.  I probably fall into former camp
> > > more often than other people, but for this particular one, I tend to
> > > think it is less confusing if we moved all branch refs away from the
> > > commits that are obsoleted by rewriting/replaying.
> > 
> > Okay, so the majority of folks here seem to favor rewriting all
> > dependent branches, which is also the default that JJ uses here, and
> > git-replay(1) does it, too.
> 
> I can't find the word "conflict" in this subthread, so let me bring
> back that little history snippet from around the beginning of the
> subthread:
> 
>   Let's suppose I have this piece of history, I'm on 'branch2', and I
>   drop commit B.  Which commits will be rewritten and which branches
>   will be repointed?
>   
>      A---B---C---D   branch1
>               \   \
>                \   E---F   branch2
>                 \       \
>                  \       G---H---I   branch3
>                   \
>                    J---K---L   branch4
> 
> If we were to rewrite all dependent branches after dropping commit B,
> then besides 'branch2' we would rewrite 'branch1', 'branch3' and
> 'branch4' as well, right?
> 
> Now, let's suppose that dropping B would cause conflicts when
> rewriting commits G, H, I, J, K and L.
> 
> When does the user have to resolve these conflicts?

In the current proposed subcommands there cannot be any conflicts, as both reword and split do not change the resulting trees.

> If not right now, then how exactly will those dependent branches be
> rewritten?  (I understand jj can store conflicts and they can be
> resolved later...  But are we there yet?)

No, we aren't. I think Elijah wants to work on that, and I agree that for a subset of commands it might be a required feature.

Show 14 quoted lines
> > the set of branches that we'd need to rewrite is any one
> > branch that points into that range. It keeps the UI simple as the user
> > still only has to think about a singular commit, should be sufficiently
> > fast to compute in most cases, and it allows mega-merge workflows like
> > JJ supports.
> > 
> > Does that make sense to everyone? If so, I'll revise my stance and will
> > adapt the current implementation to do exactly that.
> 
> I would very much prefer that the tool would only rewrite branches
> that I explicitly allowed to be rewritten, with an '--all' option that
> would allow the rewrite of all dependent branches.
> 
> Or at the very least there must be an escape hatch.

I agree a 100% about there being an escape hatch. You really don't want to rewrite dependent branches in all cases. I'll include an option that allows users to pick what to either:

  - Rewrite all dependent branches.
  - Rewrite only the currently checked-out branch or reference.
  - Only give us what _would_ happen, so basically git-replay(1)'s
    "--ref-action=print" mode.
Thanks!
Patrick
Elijah Newren· Dec 19, 2025, 16:30 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Fri, Dec 19, 2025 at 4:22 AM Patrick Steinhardt <ps@pks.im> wrote:
>
[...]
Show 14 quoted lines
> Okay, so the majority of folks here seem to favor rewriting all
> dependent branches, which is also the default that JJ uses here, and
> git-replay(1) does it, too.
>
> There is one major difference between git-replay(1) and git-history(1)
> though: the former works with revision ranges, whereas the latter does
> not. By using revision ranges we avoid the problem I have mentioned in a
> different branch of this discussion, which is that we have no easy way
> to figure out which branches we'd have to touch in the first place. This
> is because we simply walk the revision range there and then look at
> which of our references point into that range. That's simple enough.
>
> But in our case we're not working with ranges, we are working with a
> singular commit.

I don't understand the distinction at all. `git replay edit` also took a single commit, and then implemented the obvious (and jj-like) behavior of rewriting all branches that descended from that commit.

> In my head this meant that we'd have to basically do a
> revision walk that starts from all of our branches so that we can figure
> out which of them would eventually reach the commit that we are about to
> rewrite.
Yes, and it's only a few lines of code, as I showed earlier.
> And that of course doesn't scale.

That's quite an assumption about scaling; I don't believe it. Under what conditions would this be slow enough for users to notice and be bothered? commit-graphs not enabled + weird local clone with thousands of local branches? Also, isn't jj specifically designed for large repositories and with scaling in mind, and yet this is their default behavior?

More importantly, this is being used to justify a large principle of least astonishment violation (disconnecting branches with shared history), so we'd not only need to show that walking all branches was slower enough for users to notice, but slower enough that the negative user performance experience offsets the negative user experience from the astonishing behavior. Typically, spending extra cycles to provide users with good warnings/errors is a good use of time, especially when it'll take them far longer to discover and recover from negative surprises.

> Now we could of course also introduce ranges into git-history(1). That
> would indeed solve the issue,

I actually don't follow; how would this help? I'm not even sure how it would make sense; am I missing something?

> as we can reuse the same architecture as
> we already have in git-replay(1). But I don't really want to go there as
> it is leaking complexity to the user: they want to rewrite a single
> commit, why should they have to think about ranges?

I totally agree that they shouldn't have to think about ranges. They rewrite a commit and every branch that descends from it is rewritten for them. If the commit the user tried to edit was part of an immutable branch (to be implemented later), by default you throw an error. For the special cases where users do want to disconnect connected histories, you can provide an option for users to specify that they only want the current branch (or only the branches which the current branch contains).

> But now that I've thought about the problem a bit I think we can avoid
> that issue by implicitly identifying the range

Yes! As I've been saying, anything that descends from the commit being rewritten.

> it's all the commits
> between the commit we're about to rewrite and HEAD.
Huh?
Show 9 quoted lines
> So, same as with
> git-replay(1), the set of branches that we'd need to rewrite is any one
> branch that points into that range. It keeps the UI simple as the user
> still only has to think about a singular commit, should be sufficiently
> fast to compute in most cases, and it allows mega-merge workflows like
> JJ supports.
>
> Does that make sense to everyone? If so, I'll revise my stance and will
> adapt the current implementation to do exactly that.

No, it doesn't make any sense to me at all. It'll avoid the principle of least astonishment violation in some cases, but leave it present for others (e.g. other branches which contain this one, or other branches which share the specified commit even if they diverge afterwards). I think we shouldn't have a principle of least astonishment violation. There are three ways to avoid such a violation:

  (1) rewrite all branches (refs/heads/*) that descend from the commit
(code for this already previously provided and is shorter than the
existing code in this series)
  (2) walk all branches that descend from the commit so we can give
the user a warning/error when multiple branches are affected
  (3) force the user to be explicit about what they want.  Provide a
set of mutually exclusive flags, and error if none are provided.  One
flag would be for rewriting all branches, one would be for rewriting
only the current branch, and we could add others (e.g. rewriting all
branches contained in the current branch).
I don't think retaining this POLA violation makes sense as a starting point.
Elijah Newren· Dec 20, 2025, 16:51 UTC · re: Elijah Newren · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Fri, Dec 19, 2025 at 8:30 AM Elijah Newren <newren@gmail.com> wrote:
Show 48 quoted lines
>
> On Fri, Dec 19, 2025 at 4:22 AM Patrick Steinhardt <ps@pks.im> wrote:
> >
> [...]
> > Okay, so the majority of folks here seem to favor rewriting all
> > dependent branches, which is also the default that JJ uses here, and
> > git-replay(1) does it, too.
> >
> > There is one major difference between git-replay(1) and git-history(1)
> > though: the former works with revision ranges, whereas the latter does
> > not. By using revision ranges we avoid the problem I have mentioned in a
> > different branch of this discussion, which is that we have no easy way
> > to figure out which branches we'd have to touch in the first place. This
> > is because we simply walk the revision range there and then look at
> > which of our references point into that range. That's simple enough.
> >
> > But in our case we're not working with ranges, we are working with a
> > singular commit.
>
> I don't understand the distinction at all.  `git replay edit` also
> took a single commit, and then implemented the obvious (and jj-like)
> behavior of rewriting all branches that descended from that commit.
>
> > In my head this meant that we'd have to basically do a
> > revision walk that starts from all of our branches so that we can figure
> > out which of them would eventually reach the commit that we are about to
> > rewrite.
>
> Yes, and it's only a few lines of code, as I showed earlier.
>
> > And that of course doesn't scale.
>
> That's quite an assumption about scaling; I don't believe it.  Under
> what conditions would this be slow enough for users to notice and be
> bothered?  commit-graphs not enabled + weird local clone with
> thousands of local branches?  Also, isn't jj specifically designed for
> large repositories and with scaling in mind, and yet this is their
> default behavior?
>
> More importantly, this is being used to justify a large principle of
> least astonishment violation (disconnecting branches with shared
> history), so we'd not only need to show that walking all branches was
> slower enough for users to notice, but slower enough that the negative
> user performance experience offsets the negative user experience from
> the astonishing behavior.  Typically, spending extra cycles to provide
> users with good warnings/errors is a good use of time, especially when
> it'll take them far longer to discover and recover from negative
> surprises.
A quick clarification in case I'm misunderstood above:
When I talk about rewriting branches descended from the commit, I am
specifically talking about refs/heads/*, not refs/remotes/origin/* or
refs/tags/* or anything else.  Because:
  * I suspect we'll soon implement an "immutable branches" concept, so
that e.g. a request to modify a commit in the history of "main" would
result in an error (by default).  This leads to the idea that we're
just rewriting the user's local stuff they have on top of the
immutable history, i.e. their local branches.
  * refs/tags/* are designed to be immutable, obviously.  In fact, we
might want to automatically include tags in the set of "immutable
branches" by default.
  * refs/remotes/* are designed to match what the corresponding remote
had, not to be independently rewritten.  We'd really mess people up if
we changed that.
  * The fact that users created local branches means they are marking
those parts of history as a relevant area of interest
  * While it could be that some of refs/remotes/origin/* have shared
history with whatever commit is being reworded/split/edited, so too
could branches that were never pushed.  I think focusing on the
branches of interest to the user (i.e. their local branches) makes
sense.
Patrick Steinhardt· Jan 6, 2026, 15:39 UTC · re: Elijah Newren · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Sat, Dec 20, 2025 at 08:51:23AM -0800, Elijah Newren wrote:
Show 73 quoted lines
> On Fri, Dec 19, 2025 at 8:30 AM Elijah Newren <newren@gmail.com> wrote:
> >
> > On Fri, Dec 19, 2025 at 4:22 AM Patrick Steinhardt <ps@pks.im> wrote:
> > >
> > [...]
> > > Okay, so the majority of folks here seem to favor rewriting all
> > > dependent branches, which is also the default that JJ uses here, and
> > > git-replay(1) does it, too.
> > >
> > > There is one major difference between git-replay(1) and git-history(1)
> > > though: the former works with revision ranges, whereas the latter does
> > > not. By using revision ranges we avoid the problem I have mentioned in a
> > > different branch of this discussion, which is that we have no easy way
> > > to figure out which branches we'd have to touch in the first place. This
> > > is because we simply walk the revision range there and then look at
> > > which of our references point into that range. That's simple enough.
> > >
> > > But in our case we're not working with ranges, we are working with a
> > > singular commit.
> >
> > I don't understand the distinction at all.  `git replay edit` also
> > took a single commit, and then implemented the obvious (and jj-like)
> > behavior of rewriting all branches that descended from that commit.
> >
> > > In my head this meant that we'd have to basically do a
> > > revision walk that starts from all of our branches so that we can figure
> > > out which of them would eventually reach the commit that we are about to
> > > rewrite.
> >
> > Yes, and it's only a few lines of code, as I showed earlier.
> >
> > > And that of course doesn't scale.
> >
> > That's quite an assumption about scaling; I don't believe it.  Under
> > what conditions would this be slow enough for users to notice and be
> > bothered?  commit-graphs not enabled + weird local clone with
> > thousands of local branches?  Also, isn't jj specifically designed for
> > large repositories and with scaling in mind, and yet this is their
> > default behavior?
> >
> > More importantly, this is being used to justify a large principle of
> > least astonishment violation (disconnecting branches with shared
> > history), so we'd not only need to show that walking all branches was
> > slower enough for users to notice, but slower enough that the negative
> > user performance experience offsets the negative user experience from
> > the astonishing behavior.  Typically, spending extra cycles to provide
> > users with good warnings/errors is a good use of time, especially when
> > it'll take them far longer to discover and recover from negative
> > surprises.
> 
> A quick clarification in case I'm misunderstood above:
> 
> When I talk about rewriting branches descended from the commit, I am
> specifically talking about refs/heads/*, not refs/remotes/origin/* or
> refs/tags/* or anything else.  Because:
>   * I suspect we'll soon implement an "immutable branches" concept, so
> that e.g. a request to modify a commit in the history of "main" would
> result in an error (by default).  This leads to the idea that we're
> just rewriting the user's local stuff they have on top of the
> immutable history, i.e. their local branches.
>   * refs/tags/* are designed to be immutable, obviously.  In fact, we
> might want to automatically include tags in the set of "immutable
> branches" by default.
>   * refs/remotes/* are designed to match what the corresponding remote
> had, not to be independently rewritten.  We'd really mess people up if
> we changed that.
>   * The fact that users created local branches means they are marking
> those parts of history as a relevant area of interest
>   * While it could be that some of refs/remotes/origin/* have shared
> history with whatever commit is being reworded/split/edited, so too
> could branches that were never pushed.  I think focusing on the
> branches of interest to the user (i.e. their local branches) makes
> sense.
Fair. I'll send a new revision tomorrow that does this. Thanks!
Patrick
Phillip Wood· Dec 22, 2025, 10:46 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On 19/12/2025 12:22, Patrick Steinhardt wrote:
Show 6 quoted lines
> 
> But in our case we're not working with ranges, we are working with a
> singular commit. In my head this meant that we'd have to basically do a
> revision walk that starts from all of our branches so that we can figure
> out which of them would eventually reach the commit that we are about to
> rewrite. And that of course doesn't scale.

I'm not so sure about that. In repositories with lots of refs most of them are likely to be tags or remote tracking branches rather than local branches so I'd hope that the number of refs we have to walk was manageable. I'd also expect the commit we're rewriting to be relatively recent so the revision walk should quickly prune any branches that point to commits older than the one we're rewriting which should further reduce the number of commits we need to walk.

Show 8 quoted lines
> But now that I've thought about the problem a bit I think we can avoid
> that issue by implicitly identifying the range: it's all the commits
> between the commit we're about to rewrite and HEAD. So, same as with
> git-replay(1), the set of branches that we'd need to rewrite is any one
> branch that points into that range. It keeps the UI simple as the user
> still only has to think about a singular commit, should be sufficiently
> fast to compute in most cases, and it allows mega-merge workflows like
> JJ supports.

I agree that users should not have to think about commit ranges, but using an implicit range between the commit we're about to rewrite and HEAD will not rewrite all the branches descended from that commit, instead it will behave like "git rebase --update-refs".

> Does that make sense to everyone? If so, I'll revise my stance and will
> adapt the current implementation to do exactly that.

I'd much rather rewrite all the branches descended from the commit we're about the rewrite rather than those that happen to point into the revision range between that commit and HEAD as I think that ends up being confusing.

Thanks
Phillip
Patrick Steinhardt· Jan 6, 2026, 15:40 UTC · re: Phillip Wood · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Mon, Dec 22, 2025 at 10:46:12AM +0000, Phillip Wood wrote:
Show 36 quoted lines
> On 19/12/2025 12:22, Patrick Steinhardt wrote:
> > 
> > But in our case we're not working with ranges, we are working with a
> > singular commit. In my head this meant that we'd have to basically do a
> > revision walk that starts from all of our branches so that we can figure
> > out which of them would eventually reach the commit that we are about to
> > rewrite. And that of course doesn't scale.
> 
> I'm not so sure about that. In repositories with lots of refs most of them
> are likely to be tags or remote tracking branches rather than local branches
> so I'd hope that the number of refs we have to walk was manageable. I'd also
> expect the commit we're rewriting to be relatively recent so the revision
> walk should quickly prune any branches that point to commits older than the
> one we're rewriting which should further reduce the number of commits we
> need to walk.
> 
> > But now that I've thought about the problem a bit I think we can avoid
> > that issue by implicitly identifying the range: it's all the commits
> > between the commit we're about to rewrite and HEAD. So, same as with
> > git-replay(1), the set of branches that we'd need to rewrite is any one
> > branch that points into that range. It keeps the UI simple as the user
> > still only has to think about a singular commit, should be sufficiently
> > fast to compute in most cases, and it allows mega-merge workflows like
> > JJ supports.
> 
> I agree that users should not have to think about commit ranges, but using
> an implicit range between the commit we're about to rewrite and HEAD will
> not rewrite all the branches descended from that commit, instead it will
> behave like "git rebase --update-refs".
> 
> > Does that make sense to everyone? If so, I'll revise my stance and will
> > adapt the current implementation to do exactly that.
> 
> I'd much rather rewrite all the branches descended from the commit we're
> about the rewrite rather than those that happen to point into the revision
> range between that commit and HEAD as I think that ends up being confusing.

Yeah, my next version of this patch series will do that now. I'll probably send it out tomorrow.

Thanks for your thoughts!
Patrick
D. Ben Knoble· Dec 22, 2025, 13:47 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

[resend] I originally wrote this before some of the fruitful conversation replying to this message, so grain of salt. I think my questions have been answered in terms of how --update-refs behaves, etc.

I still don't think we lose anything by not deviating from other commands now, but I also agree (perhaps to come later, actually using the experimental status to break things?) that I don't want to have to remember to rebase descendants myself.

On Fri, Dec 19, 2025 at 7:48 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 62 quoted lines
>
> On Wed, Dec 10, 2025 at 11:18:29PM +0900, Junio C Hamano wrote:
> > Phillip Wood <phillip.wood123@gmail.com> writes:
> >
> > >> Its mostly because I don't like too much magic and because I think being
> > >> explicit is always better than not.
> > >>
> > >> So from my POV, I would expect "the simple case" to be "the simple CLI
> > >> call" and if I want the tool to do magic and "rewrite all the
> > >> things"^tm, that I would need to specify a flag for that.
> > >
> > > Thanks, that's useful to know. I'd assumed rewriting all the branches
> > > descended from the rewritten commit was the natural thing do do but
> > > clearly not everyone thinks it is.
> >
> > It probably depends on the way one looks at the tool, as a building
> > block (in which case less magic may be preferrable) or a complete
> > solution for one part of workflow.  I probably fall into former camp
> > more often than other people, but for this particular one, I tend to
> > think it is less confusing if we moved all branch refs away from the
> > commits that are obsoleted by rewriting/replaying.
>
> Okay, so the majority of folks here seem to favor rewriting all
> dependent branches, which is also the default that JJ uses here, and
> git-replay(1) does it, too.
>
> There is one major difference between git-replay(1) and git-history(1)
> though: the former works with revision ranges, whereas the latter does
> not. By using revision ranges we avoid the problem I have mentioned in a
> different branch of this discussion, which is that we have no easy way
> to figure out which branches we'd have to touch in the first place. This
> is because we simply walk the revision range there and then look at
> which of our references point into that range. That's simple enough.
>
> But in our case we're not working with ranges, we are working with a
> singular commit. In my head this meant that we'd have to basically do a
> revision walk that starts from all of our branches so that we can figure
> out which of them would eventually reach the commit that we are about to
> rewrite. And that of course doesn't scale.
>
> Now we could of course also introduce ranges into git-history(1). That
> would indeed solve the issue, as we can reuse the same architecture as
> we already have in git-replay(1). But I don't really want to go there as
> it is leaking complexity to the user: they want to rewrite a single
> commit, why should they have to think about ranges?
>
> But now that I've thought about the problem a bit I think we can avoid
> that issue by implicitly identifying the range: it's all the commits
> between the commit we're about to rewrite and HEAD. So, same as with
> git-replay(1), the set of branches that we'd need to rewrite is any one
> branch that points into that range. It keeps the UI simple as the user
> still only has to think about a singular commit, should be sufficiently
> fast to compute in most cases, and it allows mega-merge workflows like
> JJ supports.
>
> Does that make sense to everyone? If so, I'll revise my stance and will
> adapt the current implementation to do exactly that.
>
> Thanks for the discussion!
>
> Patrick
>
Makes sense to me, and is easily explainable.

One thing that I think JJ handles and which it sounds like replay, history do not (I’m not sure about rebase with update-refs): stacked branches that point to a chain of commits reaching into the range, but whose reference is still outside it. For example:

A <- B <- C

If branchB points at B and similar for branchC, and branchB is HEAD, then “replay <stuff> A..B“ and “history <cmd> <A|B>” sound like they would leave branchC alone? That means I have to remember to do something like “rebase --onto=branchB branchB@{1} branchC”, and if I forget I usually have to later replace branchB@{1} with branchC~<n>.

OTOH, it means branchC serves as an additional backup of the original branchB pre-rewrite :)

Anyway. If the other commands don’t support it yet, I don’t think we lose anything by not rewriting descendants. But something to consider in terms of workflow.

-- 
D. Ben Knoble
Martin von Zweigbergk· Dec 10, 2025, 16:49 UTC · re: Matthias Beyer · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Wed, Dec 10, 2025 at 2:38 AM Matthias Beyer <mail@beyermatthias.de> wrote:
Show 30 quoted lines
>
> Am Wed, Dec 10, 2025 at 09:58:13AM +0000, schrieb Phillip Wood:
> > Hi Matthias
> >
> > On 03/12/2025 18:18, Matthias Beyer wrote:
> > > Am Wed, Sep 17, 2025 at 10:12:31PM +0200, schrieb SZEDER Gábor:
> > >
> > > > Let's suppose I have this piece of history, I'm on 'branch2', and I
> > > > drop commit B.  Which commits will be rewritten and which branches
> > > > will be repointed?
> > > >
> > > >     A---B---C---D   branch1
> > > >              \   \
> > > >               \   E---F   branch2
> > > >                \       \
> > > >                 \       G---H---I   branch3
> > > >                  \
> > > >                   J---K---L   branch4
> > > >
> > >
> > > Just speaking as a user here, but my expectation in this scenario would
> > > be that rewriting B would be denied by default here, as branch{1..4}
> > > would be rewritten although I am at branch2.
> > >
> > > In the scenario at hand, I would expect that I can only rewrite G, H, I
> > > while on branch 3 and J, K, L while on branch4 (without passing some
> > > extra flags for "yes, please also rewrite the other branches").
> >
> > Is that because you have branches that you don't want to rewrite because
> > they've been merged upstream or is there another reason?

I think that's a common reason even if it's not Matthias's reason. Perhaps one way of doing it would be to have a configurable set of ref patterns that are considered immutable. That's similar to what jj does, though we use a more general language for selecting revisions for it (https://docs.jj-vcs.dev/latest/config/#set-of-immutable-commits). I think that has been well received. As you might expect, the set of immutable revisions are respected by all commands.

Elijah Newren· Dec 10, 2025, 18:27 UTC · re: Martin von Zweigbergk · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Wed, Dec 10, 2025 at 8:52 AM Martin von Zweigbergk <martinvonz@gmail.com> wrote:

Show 40 quoted lines
>
> On Wed, Dec 10, 2025 at 2:38 AM Matthias Beyer <mail@beyermatthias.de> wrote:
> >
> > Am Wed, Dec 10, 2025 at 09:58:13AM +0000, schrieb Phillip Wood:
> > > Hi Matthias
> > >
> > > On 03/12/2025 18:18, Matthias Beyer wrote:
> > > > Am Wed, Sep 17, 2025 at 10:12:31PM +0200, schrieb SZEDER Gábor:
> > > >
> > > > > Let's suppose I have this piece of history, I'm on 'branch2', and I
> > > > > drop commit B.  Which commits will be rewritten and which branches
> > > > > will be repointed?
> > > > >
> > > > >     A---B---C---D   branch1
> > > > >              \   \
> > > > >               \   E---F   branch2
> > > > >                \       \
> > > > >                 \       G---H---I   branch3
> > > > >                  \
> > > > >                   J---K---L   branch4
> > > > >
> > > >
> > > > Just speaking as a user here, but my expectation in this scenario would
> > > > be that rewriting B would be denied by default here, as branch{1..4}
> > > > would be rewritten although I am at branch2.
> > > >
> > > > In the scenario at hand, I would expect that I can only rewrite G, H, I
> > > > while on branch 3 and J, K, L while on branch4 (without passing some
> > > > extra flags for "yes, please also rewrite the other branches").
> > >
> > > Is that because you have branches that you don't want to rewrite because
> > > they've been merged upstream or is there another reason?
>
> I think that's a common reason even if it's not Matthias's reason.
> Perhaps one way of doing it would be to have a configurable set of ref
> patterns that are considered immutable. That's similar to what jj
> does, though we use a more general language for selecting revisions
> for it (https://docs.jj-vcs.dev/latest/config/#set-of-immutable-commits).
> I think that has been well received. As you might expect, the set of
> immutable revisions are respected by all commands.

I like the idea of a set of immutable revisions...but wouldn't that result in the request to drop commit B in the graph above being met with an error rather than with a single branch being rewritten?

Martin von Zweigbergk· Dec 10, 2025, 18:45 UTC · re: Elijah Newren · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Wed, Dec 10, 2025 at 10:27 AM Elijah Newren <newren@gmail.com> wrote:
Show 47 quoted lines
>
> On Wed, Dec 10, 2025 at 8:52 AM Martin von Zweigbergk
> <martinvonz@gmail.com> wrote:
> >
> > On Wed, Dec 10, 2025 at 2:38 AM Matthias Beyer <mail@beyermatthias.de> wrote:
> > >
> > > Am Wed, Dec 10, 2025 at 09:58:13AM +0000, schrieb Phillip Wood:
> > > > Hi Matthias
> > > >
> > > > On 03/12/2025 18:18, Matthias Beyer wrote:
> > > > > Am Wed, Sep 17, 2025 at 10:12:31PM +0200, schrieb SZEDER Gábor:
> > > > >
> > > > > > Let's suppose I have this piece of history, I'm on 'branch2', and I
> > > > > > drop commit B.  Which commits will be rewritten and which branches
> > > > > > will be repointed?
> > > > > >
> > > > > >     A---B---C---D   branch1
> > > > > >              \   \
> > > > > >               \   E---F   branch2
> > > > > >                \       \
> > > > > >                 \       G---H---I   branch3
> > > > > >                  \
> > > > > >                   J---K---L   branch4
> > > > > >
> > > > >
> > > > > Just speaking as a user here, but my expectation in this scenario would
> > > > > be that rewriting B would be denied by default here, as branch{1..4}
> > > > > would be rewritten although I am at branch2.
> > > > >
> > > > > In the scenario at hand, I would expect that I can only rewrite G, H, I
> > > > > while on branch 3 and J, K, L while on branch4 (without passing some
> > > > > extra flags for "yes, please also rewrite the other branches").
> > > >
> > > > Is that because you have branches that you don't want to rewrite because
> > > > they've been merged upstream or is there another reason?
> >
> > I think that's a common reason even if it's not Matthias's reason.
> > Perhaps one way of doing it would be to have a configurable set of ref
> > patterns that are considered immutable. That's similar to what jj
> > does, though we use a more general language for selecting revisions
> > for it (https://docs.jj-vcs.dev/latest/config/#set-of-immutable-commits).
> > I think that has been well received. As you might expect, the set of
> > immutable revisions are respected by all commands.
>
> I like the idea of a set of immutable revisions...but wouldn't that
> result in the request to drop commit B in the graph above being met
> with an error rather than with a single branch being rewritten?

If branch1 (or any of those branches, really) is configured as immutable, then yes. But it's desirable to prevent rewriting or dropping B in that case, right?

Elijah Newren· Dec 10, 2025, 19:55 UTC · re: Martin von Zweigbergk · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Wed, Dec 10, 2025 at 10:45 AM Martin von Zweigbergk <martinvonz@gmail.com> wrote:

Show 53 quoted lines
>
> On Wed, Dec 10, 2025 at 10:27 AM Elijah Newren <newren@gmail.com> wrote:
> >
> > On Wed, Dec 10, 2025 at 8:52 AM Martin von Zweigbergk
> > <martinvonz@gmail.com> wrote:
> > >
> > > On Wed, Dec 10, 2025 at 2:38 AM Matthias Beyer <mail@beyermatthias.de> wrote:
> > > >
> > > > Am Wed, Dec 10, 2025 at 09:58:13AM +0000, schrieb Phillip Wood:
> > > > > Hi Matthias
> > > > >
> > > > > On 03/12/2025 18:18, Matthias Beyer wrote:
> > > > > > Am Wed, Sep 17, 2025 at 10:12:31PM +0200, schrieb SZEDER Gábor:
> > > > > >
> > > > > > > Let's suppose I have this piece of history, I'm on 'branch2', and I
> > > > > > > drop commit B.  Which commits will be rewritten and which branches
> > > > > > > will be repointed?
> > > > > > >
> > > > > > >     A---B---C---D   branch1
> > > > > > >              \   \
> > > > > > >               \   E---F   branch2
> > > > > > >                \       \
> > > > > > >                 \       G---H---I   branch3
> > > > > > >                  \
> > > > > > >                   J---K---L   branch4
> > > > > > >
> > > > > >
> > > > > > Just speaking as a user here, but my expectation in this scenario would
> > > > > > be that rewriting B would be denied by default here, as branch{1..4}
> > > > > > would be rewritten although I am at branch2.
> > > > > >
> > > > > > In the scenario at hand, I would expect that I can only rewrite G, H, I
> > > > > > while on branch 3 and J, K, L while on branch4 (without passing some
> > > > > > extra flags for "yes, please also rewrite the other branches").
> > > > >
> > > > > Is that because you have branches that you don't want to rewrite because
> > > > > they've been merged upstream or is there another reason?
> > >
> > > I think that's a common reason even if it's not Matthias's reason.
> > > Perhaps one way of doing it would be to have a configurable set of ref
> > > patterns that are considered immutable. That's similar to what jj
> > > does, though we use a more general language for selecting revisions
> > > for it (https://docs.jj-vcs.dev/latest/config/#set-of-immutable-commits).
> > > I think that has been well received. As you might expect, the set of
> > > immutable revisions are respected by all commands.
> >
> > I like the idea of a set of immutable revisions...but wouldn't that
> > result in the request to drop commit B in the graph above being met
> > with an error rather than with a single branch being rewritten?
>
> If branch1 (or any of those branches, really) is configured as
> immutable, then yes. But it's desirable to prevent rewriting or
> dropping B in that case, right?

Yes, I definitely think so. I was mixing up in my head the different requests and thought that Matthias had asked to rewrite just one branch, which sounded like it wasn't something you'd get through immutable revisions and had me confused why you were suggesting that. But, it was all just me mis-remembering and not re-reading. Sorry for the mix-up.

Kristoffer Haugsbakk· Dec 15, 2025, 23:50 UTC · re: Matthias Beyer · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Wed, Dec 10, 2025, at 11:37, Matthias Beyer wrote:
Show 11 quoted lines
>>[snip]
>>
>> Is that because you have branches that you don't want to rewrite because
>> they've been merged upstream or is there another reason? If we start
>> rewriting multiple branches we should probably check that we're not
>> rewriting something that has been merged upstream but if I rewrite a commits
>> that's an ancestor of several branches it would be very helpful to rewrite
>> them all at the same time to keep them in sync.
>
> Its mostly because I don't like too much magic and because I think being
> explicit is always better than not.

That the first thing here is not magic but the other thing is seems arbitrary:

1. Change these commits, i.e. make new commits and all their descendants
2. Update all branches that point at the commits that have now been
   replaced

These two feel conceptually similar to me in terms of complexity, and neither of them are magical.

>
> So from my POV, I would expect "the simple case" to be "the simple CLI
> call" and if I want the tool to do magic and "rewrite all the
> things"^tm, that I would need to specify a flag for that.
Kristoffer Haugsbakk· Aug 24, 2025, 17:31 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Tue, Aug 19, 2025, at 12:55, Patrick Steinhardt wrote:
Show 36 quoted lines
> Hi,
>
> over recent months I've been playing around with Jujutsu quite
> frequently. While I still prefer using Git, there's been a couple
> features in it that I really like and that I'd like to have in Git, as
> well.
>
> A copule of these features relate to history editing. Most importantly,
> I really dig the following commands:
>
>   - jj-abandon(1) to drop a specific commit from your history.
>
>   - jj-absorb(1) to take some changes and automatically apply them to
>     commits in your history that last modified the respective hunks.
>
>   - jj-split(1) to split a commit into two.
>
>   - jj-new(1) to insert a new commit after or before a specific other
>     commit.
>
> Not all of these commands can be ported directly into Git. jj-new(1) for
> example doesn't really make a ton of sense for us, I'd claim. But some
> of these commands _do_ make sense.
>
> I thus had a look at implementing some of these commands in Git itself,
> where the result is this patch series. Specifically, the following
> commands are introduced by this patch series:
>
>   - `git history drop` to drop a specific commit. This is basically the
>     same as jj-abandon(1).
>
>   - `git history reorder` to reorder a specific commit before or after
>     another commit. This is inspired by jj-new(1).
>
>   - `git history split` takes a commit and splits it into two. This is
>     basically the same as jj-split(1).

I think it would be nice if git-history(1) called the `post-rewrite` hook. In particular for Split; then all the possible rewrite modes are covered (one-to-one, many-to-one (squash), and one-to-many).

Patrick Steinhardt· Aug 24, 2025, 17:38 UTC · re: Kristoffer Haugsbakk · lore

Re: [PATCH RFC 00/11] Introduce git-history(1) command for easy history editing

On Sun, Aug 24, 2025 at 07:31:45PM +0200, Kristoffer Haugsbakk wrote:
Show 41 quoted lines
> On Tue, Aug 19, 2025, at 12:55, Patrick Steinhardt wrote:
> > Hi,
> >
> > over recent months I've been playing around with Jujutsu quite
> > frequently. While I still prefer using Git, there's been a couple
> > features in it that I really like and that I'd like to have in Git, as
> > well.
> >
> > A copule of these features relate to history editing. Most importantly,
> > I really dig the following commands:
> >
> >   - jj-abandon(1) to drop a specific commit from your history.
> >
> >   - jj-absorb(1) to take some changes and automatically apply them to
> >     commits in your history that last modified the respective hunks.
> >
> >   - jj-split(1) to split a commit into two.
> >
> >   - jj-new(1) to insert a new commit after or before a specific other
> >     commit.
> >
> > Not all of these commands can be ported directly into Git. jj-new(1) for
> > example doesn't really make a ton of sense for us, I'd claim. But some
> > of these commands _do_ make sense.
> >
> > I thus had a look at implementing some of these commands in Git itself,
> > where the result is this patch series. Specifically, the following
> > commands are introduced by this patch series:
> >
> >   - `git history drop` to drop a specific commit. This is basically the
> >     same as jj-abandon(1).
> >
> >   - `git history reorder` to reorder a specific commit before or after
> >     another commit. This is inspired by jj-new(1).
> >
> >   - `git history split` takes a commit and splits it into two. This is
> >     basically the same as jj-split(1).
> 
> I think it would be nice if git-history(1) called the `post-rewrite` hook.
> In particular for Split; then all the possible rewrite modes are covered
> (one-to-one, many-to-one (squash), and one-to-many).
That's a sensible thing indeed. I'll add this to my todo list for v3.
Patrick
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 00/16] Introduce git-history(1) command for easy history editing

Hi,

over recent months I've been playing around with Jujutsu quite frequently. While I still prefer using Git, there's been a couple features in it that I really like and that I'd like to have in Git, as well.

A copule of these features relate to history editing. Most importantly, I really dig the following commands:

  - jj-abandon(1) to drop a specific commit from your history.
  - jj-absorb(1) to take some changes and automatically apply them to
    commits in your history that last modified the respective hunks.
  - jj-split(1) to split a commit into two.
  - jj-new(1) to insert a new commit after or before a specific other
    commit.

Not all of these commands can be ported directly into Git. jj-new(1) for example doesn't really make a ton of sense for us, I'd claim. But some of these commands _do_ make sense.

I thus had a look at implementing some of these commands in Git itself, where the result is this patch series. Specifically, the following commands are introduced by this patch series:

  - `git history drop` to drop a specific commit. This is basically the
    same as jj-abandon(1).
  - `git history reorder` to reorder a specific commit before or after
    another commit. This is inspired by jj-new(1).
  - `git history split` takes a commit and splits it into two. This is
    basically the same as jj-split(1).

If this is something we want to have I think it'd be just a starting point. There's other commands that I think are quite common and that might make sense to introduce eventually:

  - An equivalent to jj-absorb(1) would be awesome to have.
  - `git history reword` to change only the commit message of a specific
    commit.
  - `git history squash` to squash together multiple commits into one.

In the end, I'd like us to learn from what people like about Jujutsu and apply those learnings to Git. We won't be able to apply all learnings from Jujutsu, as the workflow is quite different there due to the lack of the index. But other things we certainly can apply to Git directly.

Note: This patch series currently builds on the cherry-pick infra.
As such, when one hits a merge conflict one needs to `git cherry-pick
--continue`, which is quite suboptimal. I didn't want to overpolish this
series before getting some feedback, but it is something I'll fix in
subsequent versions. Furthermore, the command for now bails out in the
case where there's any merge commits in the history that is being
rewritten. This is another restriction that can be lifted in the future.
Changes in v2:
  - Add a new "reword" subcommand.
  - List git-history(1) in "command-list.txt".
  - Add some missing error handling.
  - Simplify calling convention of `apply_commits()` to handle root
    commits internally instead of requiring every caller to do so.
  - Add tests to verify that git-history(1) refuses to work with changes
    in the worktree or index.
  - Mark git-history(1) as experimental.
  - Introduce commands to manage interrupted history edits.
  - A bunch of improvements to the manpage.
  - Link to v1: https://lore.kernel.org/r/20250819-b4-pks-history-builtin-v1-0-9b77c32688fe@pks.im
Thanks!
Patrick
---
Patrick Steinhardt (16):
      sequencer: optionally skip printing commit summary
      sequencer: add option to rewind HEAD after picking commits
      sequencer: introduce new history editing mode
      sequencer: stop using `the_repository` in `sequencer_remove_state()`
      cache-tree: allow writing in-memory index as tree
      builtin: add new "history" command
      builtin/history: introduce subcommands to manage interrupted rewrites
      builtin/history: implement "drop" subcommand
      builtin/history: implement "reorder" subcommand
      add-patch: split out header from "add-interactive.h"
      add-patch: split out `struct interactive_options`
      add-patch: remove dependency on "add-interactive" subsystem
      add-patch: add support for in-memory index patching
      wt-status: provide function to expose status for trees
      builtin/history: implement "split" subcommand
      builtin/history: implement "reword" subcommand
 .gitignore                     |   1 +
 Documentation/git-history.adoc | 188 +++++++++
 Documentation/meson.build      |   1 +
 Makefile                       |   1 +
 add-interactive.c              | 151 ++-----
 add-interactive.h              |  43 +-
 add-patch.c                    | 270 ++++++++++--
 add-patch.h                    |  61 +++
 builtin.h                      |   1 +
 builtin/add.c                  |  22 +-
 builtin/checkout.c             |   7 +-
 builtin/commit.c               |  16 +-
 builtin/history.c              | 932 +++++++++++++++++++++++++++++++++++++++++
 builtin/rebase.c               |   4 +-
 builtin/reset.c                |  16 +-
 builtin/revert.c               |   2 +-
 builtin/stash.c                |  46 +-
 cache-tree.c                   |   5 +-
 cache-tree.h                   |   3 +-
 command-list.txt               |   1 +
 commit.h                       |   2 +-
 git.c                          |   1 +
 meson.build                    |   1 +
 sequencer.c                    | 187 +++++++--
 sequencer.h                    |   9 +-
 t/meson.build                  |   7 +-
 t/t3450-history.sh             |  42 ++
 t/t3451-history-drop.sh        | 174 ++++++++
 t/t3452-history-reorder.sh     | 234 +++++++++++
 t/t3453-history-split.sh       | 387 +++++++++++++++++
 t/t3454-history-reword.sh      | 158 +++++++
 wt-status.c                    |  24 ++
 wt-status.h                    |   3 +
 33 files changed, 2714 insertions(+), 286 deletions(-)
Range-diff versus v1:
 1:  c1ce1b2e20 =  1:  6348d1ff69 sequencer: optionally skip printing commit summary
 2:  969d896da1 !  2:  d70cb727bc sequencer: add option to rewind HEAD after picking commits
    @@ sequencer.c: static int pick_commits(struct repository *r,
      	/*
      	 * Sequence of picks finished successfully; cleanup by
      	 * removing the .git/sequencer directory
    +@@ sequencer.c: int sequencer_pick_revisions(struct repository *r,
    + 	if (opts->revs->cmdline.nr == 1 &&
    + 	    opts->revs->cmdline.rev->whence == REV_CMD_REV &&
    + 	    opts->revs->no_walk &&
    +-	    !opts->revs->cmdline.rev->flags) {
    ++	    !opts->revs->cmdline.rev->flags &&
    ++	    !opts->restore_head_target) {
    + 		struct commit *cmit;
    + 
    + 		if (prepare_revision_walk(opts->revs)) {
     
      ## sequencer.h ##
     @@ sequencer.h: struct replay_opts {
 -:  ---------- >  3:  9717385fba sequencer: introduce new history editing mode
 -:  ---------- >  4:  360e5cf08f sequencer: stop using `the_repository` in `sequencer_remove_state()`
 3:  658cff279e =  5:  74be65b8f6 cache-tree: allow writing in-memory index as tree
 4:  886ea1e088 !  6:  a84728f097 builtin: add new "history" command
    @@ Documentation/git-history.adoc (new)
     +
     +NAME
     +----
    -+git-history - Rewrite history of the current branch
    ++git-history - EXPERIMENTAL: Rewrite history of the current branch
     +
     +SYNOPSIS
     +--------
    @@ Documentation/git-history.adoc (new)
     +merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
     +flag instead.
     +
    ++THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
    ++
     +COMMANDS
     +--------
     +
     +This command requires a subcommand. Several subcommands are available to
    -+rewrite history in different ways.
    ++rewrite history in different ways:
     +
     +CONFIGURATION
     +-------------
    @@ builtin/history.c (new)
     +	return 0;
     +}
     
    + ## command-list.txt ##
    +@@ command-list.txt: git-grep                                mainporcelain           info
    + git-gui                                 mainporcelain
    + git-hash-object                         plumbingmanipulators
    + git-help                                ancillaryinterrogators          complete
    ++git-history                             mainporcelain           history
    + git-hook                                purehelpers
    + git-http-backend                        synchingrepositories
    + git-http-fetch                          synchelpers
    +
      ## git.c ##
     @@ git.c: static struct cmd_struct commands[] = {
      	{ "grep", cmd_grep, RUN_SETUP_GENTLY },
 -:  ---------- >  7:  234ad69878 builtin/history: introduce subcommands to manage interrupted rewrites
 5:  084312482f !  8:  a81669a4d1 builtin/history: implement "drop" subcommand
    @@ Commit message
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
     
      ## Documentation/git-history.adoc ##
    -@@ Documentation/git-history.adoc: git-history - Rewrite history of the current branch
    - SYNOPSIS
    - --------
    - [synopsis]
    --git history [<options>]
    -+git history drop [<options>] <revision>
    +@@ Documentation/git-history.adoc: SYNOPSIS
    + git history abort
    + git history continue
    + git history quit
    ++git history drop <commit>
      
      DESCRIPTION
      -----------
     @@ Documentation/git-history.adoc: COMMANDS
      This command requires a subcommand. Several subcommands are available to
    - rewrite history in different ways.
    + rewrite history in different ways:
      
    -+drop <revision>::
    ++`drop <commit>`::
     +	Drop a commit from the history and reapply all children of that
     +	commit on top of the commit's parent. The commit that is to be
     +	dropped must be reachable from the current `HEAD` commit.
    @@ Documentation/git-history.adoc: COMMANDS
     +root commit. It is invalid to drop a root commit that does not have any
     +child commits, as that would lead to an empty branch.
     +
    + The following commands are used to manage an interrupted history-rewriting
    + operation:
    + 
    +@@ Documentation/git-history.adoc: operation:
    + 	the original branch. The index and working tree are also left unchanged
    + 	as a result.
    + 
     +EXAMPLES
     +--------
     +
    -+* Drop a commit from history.
    -++
    ++Drop a commit from history
    ++~~~~~~~~~~~~~~~~~~~~~~~~~~
    ++
     +----------
     +$ git log --oneline
     +2d4cd6d third
    @@ Documentation/git-history.adoc: COMMANDS
      ## builtin/history.c ##
     @@
      #include "builtin.h"
    + #include "branch.h"
     +#include "commit.h"
     +#include "commit-reach.h"
     +#include "config.h"
    @@ builtin/history.c
     +#include "refs.h"
     +#include "reset.h"
     +#include "revision.h"
    -+#include "sequencer.h"
    -+
    + #include "sequencer.h"
    + 
    + static int cmd_history_abort(int argc,
    +@@ builtin/history.c: static int cmd_history_quit(int argc,
    + 	return ret;
    + }
    + 
     +static int collect_commits(struct repository *repo,
     +			   struct commit *old_commit,
     +			   struct commit *new_commit,
    @@ builtin/history.c
     +	int ret;
     +
     +	/*
    -+	 * Check that the old actually is an ancestor of HEAD. If not
    ++	 * Check that the old commit actually is an ancestor of HEAD. If not
     +	 * the whole request becomes nonsensical.
     +	*/
     +	if (old_commit) {
    @@ builtin/history.c
     +
     +	/*
     +	 * Revisions are in newest-order-first. We have to reverse the
    -+	 * array though so that we pick the oldest commits first. Note
    -+	 * that we keep the first string untouched, as it is the
    -+	 * equivalent of `argv[0]` to `setup_revisions()`.
    ++	 * array though so that we pick the oldest commits first.
     +	 */
     +	for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
     +		SWAP(out->v[i], out->v[j]);
    @@ builtin/history.c
     +	 * We have performed all safety checks, so we now prepare
     +	 * replaying the commits.
     +	*/
    -+	replay_opts.action = REPLAY_PICK;
    ++	replay_opts.action = REPLAY_HISTORY_EDIT;
     +	sequencer_init_config(&replay_opts);
     +	replay_opts.quiet = 1;
     +	replay_opts.skip_commit_summary = 1;
    @@ builtin/history.c
     +	 * squash that root commit with the first commit we're picking
     +	 * onto it.
     +	 */
    -+	if (!base) {
    ++	if (!base->parents) {
     +		if (commit_tree("", 0, repo->hash_algo->empty_tree, NULL,
     +				&root_commit, NULL, NULL) < 0) {
     +			ret = error(_("Could not create new root commit"));
    @@ builtin/history.c
     +		replay_opts.have_squash_onto = 1;
     +		reset_opts.oid = &root_commit;
     +	} else {
    -+		reset_opts.oid = &base->object.oid;
    ++		reset_opts.oid = &base->parents->item->object.oid;
     +	}
     +
     +	replay_opts.restore_head_target =
    @@ builtin/history.c
     +			    struct repository *repo)
     +{
     +	const char * const usage[] = {
    -+		N_("git history drop [<options>] <revision>"),
    ++		N_("git history drop <commit>"),
     +		NULL,
     +	};
     +	struct option options[] = {
    @@ builtin/history.c
     +		if (ret < 0)
     +			goto out;
     +
    -+		ret = apply_commits(repo, &commits, head, commit_to_drop->parents ?
    -+				    commit_to_drop->parents->item : NULL, "drop");
    ++		ret = apply_commits(repo, &commits, head, commit_to_drop, "drop");
     +		if (ret < 0)
     +			goto out;
     +	}
    @@ builtin/history.c
     +	strbuf_release(&buf);
     +	return ret;
     +}
    - 
    ++
      int cmd_history(int argc,
      		const char **argv,
      		const char *prefix,
    --		struct repository *repo UNUSED)
    -+		struct repository *repo)
    - {
    - 	const char * const usage[] = {
    --		N_("git history [<options>]"),
    -+		N_("git history drop [<options>] <revision>"),
    +@@ builtin/history.c: int cmd_history(int argc,
    + 		N_("git history abort"),
    + 		N_("git history continue"),
    + 		N_("git history quit"),
    ++		N_("git history drop <commit>"),
      		NULL,
      	};
    -+	parse_opt_subcommand_fn *fn = NULL;
    - 	struct option options[] = {
    + 	parse_opt_subcommand_fn *fn = NULL;
    +@@ builtin/history.c: int cmd_history(int argc,
    + 		OPT_SUBCOMMAND("abort", &fn, cmd_history_abort),
    + 		OPT_SUBCOMMAND("continue", &fn, cmd_history_continue),
    + 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
     +		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
      		OPT_END(),
      	};
      
    - 	argc = parse_options(argc, argv, prefix, options, usage, 0);
    --	return 0;
    -+	return fn(argc, argv, prefix, repo);
    - }
     
      ## t/meson.build ##
     @@ t/meson.build: integration_tests = [
    -   't3436-rebase-more-options.sh',
        't3437-rebase-fixup-options.sh',
        't3438-rebase-broken-files.sh',
    -+  't3450-history-drop.sh',
    +   't3450-history.sh',
    ++  't3451-history-drop.sh',
        't3500-cherry.sh',
        't3501-revert-cherry-pick.sh',
        't3502-cherry-pick-merge.sh',
    -@@ t/meson.build: if perl.found() and time.found()
    -       timeout: 0,
    -     )
    -   endforeach
    --endif
    - \ No newline at end of file
    -+endif
     
    - ## t/t3450-history-drop.sh (new) ##
    + ## t/t3451-history-drop.sh (new) ##
     @@
     +#!/bin/sh
     +
    @@ t/t3450-history-drop.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'refuses to work with changes in the worktree or index' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		test_commit initial &&
    ++		test_commit file file &&
    ++		echo foo >file &&
    ++		test_must_fail git history drop HEAD 2>err &&
    ++		test_grep "Your local changes to the following files would be overwritten" err &&
    ++		git add file &&
    ++		test_must_fail git history drop HEAD 2>err &&
    ++		test_grep "Your local changes to the following files would be overwritten" err
    ++	)
    ++'
    ++
     +test_expect_success 'can drop tip of a branch' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3450-history-drop.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'conflicts are detected' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		test_commit base &&
    ++		echo original >file &&
    ++		git add . &&
    ++		git commit -m original &&
    ++		echo modified >file &&
    ++		git commit -am modified &&
    ++
    ++		test_must_fail git history drop HEAD~ >err 2>&1 &&
    ++		test_grep CONFLICT err &&
    ++		test_grep "git history continue" err &&
    ++		echo resolved >file &&
    ++		git add file &&
    ++		git history continue &&
    ++
    ++		cat >expect <<-EOF &&
    ++		modified
    ++		base
    ++		EOF
    ++		git log --format=%s >actual &&
    ++		test_cmp expect actual &&
    ++		echo resolved >expect &&
    ++		git cat-file -p HEAD:file >actual &&
    ++		test_cmp expect actual
    ++	)
    ++'
    ++
     +test_done
 6:  5ba28ca5e5 !  9:  95ce67205d builtin/history: implement "reorder" subcommand
    @@ Commit message
         builtin/history: implement "reorder" subcommand
     
         When working in projects where having nice commits matters it's quite
    -    common that developers end up reordering commits a lot. Tihs is
    +    common that developers end up reordering commits a lot. This is
         typically done via interactive rebases, where they can then rearrange
         commits in the instruction sheet.
     
         Still, this operation is a frequent-enough operation to provide a more
    -    direct of doing this imperatively. As such, introduce a new "reorder"
    -    subcommand where users can reorder a commit A to come after or before
    -    another commit B:
    +    direct way of doing this imperatively. As such, introduce a new
    +    "reorder" subcommand where users can reorder a commit A to come after or
    +    before another commit B:
     
             $ git log --oneline
             a978f73 fifth
    @@ Commit message
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
     
      ## Documentation/git-history.adoc ##
    -@@ Documentation/git-history.adoc: SYNOPSIS
    - --------
    - [synopsis]
    - git history drop [<options>] <revision>
    -+git history reorder [<options>] <revision> --(before|after)=<revision>
    +@@ Documentation/git-history.adoc: git history abort
    + git history continue
    + git history quit
    + git history drop <commit>
    ++git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
      
      DESCRIPTION
      -----------
    -@@ Documentation/git-history.adoc: Dropping the root commit converts the child of that commit into the new
    +@@ Documentation/git-history.adoc: rewrite history in different ways:
    + `drop <commit>`::
    + 	Drop a commit from the history and reapply all children of that
    + 	commit on top of the commit's parent. The commit that is to be
    +-	dropped must be reachable from the current `HEAD` commit.
    ++	dropped must be reachable from the currently checked-out commit.
    + +
    + Dropping the root commit converts the child of that commit into the new
      root commit. It is invalid to drop a root commit that does not have any
      child commits, as that would lead to an empty branch.
      
    -+reorder <revision> (--before=<revision>|--after=<revision>)::
    -+	Reorder the commit so that it becomes either the parent
    -+	(`--before=`) or child (`--after=`) of the other specified
    -+	commit. The commits must be related to one another and must be
    -+	reachable from the current `HEAD` commit.
    -+
    - EXAMPLES
    - --------
    ++`reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)`::
    ++	Move the commit so that it becomes either the parent of
    ++	<following-commit> or the child of <preceding-commit>. The commits must
    ++	be related to one another and must be reachable from the current `HEAD`
    ++	commit.
    ++
    + The following commands are used to manage an interrupted history-rewriting
    + operation:
      
     @@ Documentation/git-history.adoc: b1bc1bd third
      e098c27 first
      ----------
      
    -+* Reorder a commit.
    -++
    ++Reorder a commit
    ++~~~~~~~~~~~~~~~~
    ++
     +----------
     +$ git log --oneline
     +a978f73 fifth
    @@ builtin/history.c: static int cmd_history_drop(int argc,
     +			       struct repository *repo)
     +{
     +	const char * const usage[] = {
    -+		N_("git history reorder [<options>] <revision> (--before=<commit>|--after=<commit>)"),
    ++		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
     +		NULL,
     +	};
     +	const char *before = NULL, *after = NULL;
    @@ builtin/history.c: static int cmd_history_drop(int argc,
     +	replace_commits(&commits, &commit_to_reorder->object.oid, NULL, 0);
     +	replace_commits(&commits, &anchor->object.oid, replacement, ARRAY_SIZE(replacement));
     +
    -+	/*
    -+	 * And now we pick commits in the new order on top of either the root
    -+	 * commit or on top the old commit's parent.
    -+	 */
    -+	ret = apply_commits(repo, &commits, head,
    -+			    old->parents ? old->parents->item : NULL, "reorder");
    ++	ret = apply_commits(repo, &commits, head, old, "reorder");
     +	if (ret < 0)
     +		goto out;
     +
    @@ builtin/history.c: static int cmd_history_drop(int argc,
      		const char **argv,
      		const char *prefix,
     @@ builtin/history.c: int cmd_history(int argc,
    - {
    - 	const char * const usage[] = {
    - 		N_("git history drop [<options>] <revision>"),
    -+		N_("git history reorder [<options>] <revision> --(before|after)=<revision>"),
    + 		N_("git history continue"),
    + 		N_("git history quit"),
    + 		N_("git history drop <commit>"),
    ++		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
      		NULL,
      	};
      	parse_opt_subcommand_fn *fn = NULL;
    - 	struct option options[] = {
    +@@ builtin/history.c: int cmd_history(int argc,
    + 		OPT_SUBCOMMAND("continue", &fn, cmd_history_continue),
    + 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
      		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
     +		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
      		OPT_END(),
    @@ builtin/history.c: int cmd_history(int argc,
     
      ## t/meson.build ##
     @@ t/meson.build: integration_tests = [
    -   't3437-rebase-fixup-options.sh',
        't3438-rebase-broken-files.sh',
    -   't3450-history-drop.sh',
    -+  't3451-history-reorder.sh',
    +   't3450-history.sh',
    +   't3451-history-drop.sh',
    ++  't3452-history-reorder.sh',
        't3500-cherry.sh',
        't3501-revert-cherry-pick.sh',
        't3502-cherry-pick-merge.sh',
     
    - ## t/t3451-history-reorder.sh (new) ##
    + ## t/t3452-history-reorder.sh (new) ##
     @@
     +#!/bin/sh
     +
    @@ t/t3451-history-reorder.sh (new)
     +
     +. ./test-lib.sh
     +
    -+test_expect_success 'reorder refuses to work with merge commits' '
    ++test_expect_success 'refuses to work with merge commits' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
     +	(
    @@ t/t3451-history-reorder.sh (new)
     +	)
     +'
     +
    -+test_expect_success 'reorder requires exactly one of --before or --after' '
    ++test_expect_success 'refuses to work with changes in the worktree or index' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		test_commit initial &&
    ++		test_commit file file &&
    ++		echo foo >file &&
    ++		test_must_fail git history reorder HEAD --before=HEAD~ 2>err &&
    ++		test_grep "Your local changes to the following files would be overwritten" err &&
    ++		git add file &&
    ++		test_must_fail git history reorder HEAD --before=HEAD~ 2>err &&
    ++		test_grep "Your local changes to the following files would be overwritten" err
    ++	)
    ++'
    ++
    ++test_expect_success 'requires exactly one of --before or --after' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
     +	(
    @@ t/t3451-history-reorder.sh (new)
     +	)
     +'
     +
    -+test_expect_success 'reorder refuses to reorder commit with itself' '
    ++test_expect_success 'refuses to reorder commit with itself' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
     +	(
    @@ t/t3451-history-reorder.sh (new)
     +		test_must_fail git symbolic-ref HEAD &&
     +		echo "second edit" >file &&
     +		git add file &&
    -+		test_must_fail git cherry-pick --continue &&
    ++		test_must_fail git history continue &&
     +		echo "first edit" >file &&
     +		git add file &&
    -+		git cherry-pick --continue &&
    ++		git history continue &&
     +
     +		cat >expect <<-EOF &&
     +		first edit
 7:  91221a3883 = 10:  1bc1d4f06c add-patch: split out header from "add-interactive.h"
 8:  018d7bd8ea = 11:  fed38713fa add-patch: split out `struct interactive_options`
 9:  915376b78a = 12:  d8ba71c015 add-patch: remove dependency on "add-interactive" subsystem
10:  3fa285e5b8 ! 13:  b56722b519 add-patch: add support for in-memory index patching
    @@ add-patch.c: static int patch_update_file(struct add_p_state *s,
      				error(_("'git apply' failed"));
      		}
     -		if (repo_read_index(s->r) >= 0)
    -+		read_index_from(s->index, s->index_file, s->r->gitdir);
     +		if (read_index_from(s->index, s->index_file, s->r->gitdir) >= 0 &&
     +		    s->index == s->r->index) {
      			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 -:  ---------- > 14:  5a6b18855e wt-status: provide function to expose status for trees
11:  81e16d3c01 ! 15:  4701b4dff2 builtin/history: implement "split" subcommand
    @@ Commit message
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
     
      ## Documentation/git-history.adoc ##
    -@@ Documentation/git-history.adoc: SYNOPSIS
    - [synopsis]
    - git history drop [<options>] <revision>
    - git history reorder [<options>] <revision> --(before|after)=<revision>
    -+git history split [<options>] <revision> [--] [<pathspec>...]
    +@@ Documentation/git-history.adoc: git history continue
    + git history quit
    + git history drop <commit>
    + git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
    ++git history split [<options>] <commit> [--] [<pathspec>...]
      
      DESCRIPTION
      -----------
    -@@ Documentation/git-history.adoc: reorder <revision> (--before=<revision>|--after=<revision>)::
    - 	commit. The commits must be related to one another and must be
    - 	reachable from the current `HEAD` commit.
    +@@ Documentation/git-history.adoc: child commits, as that would lead to an empty branch.
    + 	be related to one another and must be reachable from the current `HEAD`
    + 	commit.
      
    -+split <revision> [--message=<message>] [--] [<pathspec>...]::
    -+	Interactively split up the commit into two commits by choosing
    ++`split [--message=<message>] <commit> [--] [<pathspec>...]`::
    ++	Interactively split up <commit> into two commits by choosing
     +	hunks introduced by it that will be moved into the new split-out
     +	commit. These hunks will then be written into a new commit that
     +	becomes the parent of the previous commit. The original commit
    @@ Documentation/git-history.adoc: reorder <revision> (--before=<revision>|--after=
     +	commit.
     ++
     +The commit message of the new commit will be asked for by launching the
    -+configured editor. Authorship of the commit will be the same as for the
    -+original commit.
    ++configured editor, unless it has been specified with the `-m` option.
    ++Authorship of the commit will be the same as for the original commit.
     ++
     +If passed, _<pathspec>_ can be used to limit which changes shall be split out
     +of the original commit. Files not matching any of the pathspecs will remain
    -+part of the original commit. For more details about the _<pathspec>_ syntax,
    -+see the 'pathspec' entry.
    ++part of the original commit. For more details, see the 'pathspec' entry in
    ++linkgit:gitglossary[7].
     ++
     +It is invalid to select either all or no hunks, as that would lead to
     +one of the commits becoming empty.
     +
    - EXAMPLES
    - --------
    + The following commands are used to manage an interrupted history-rewriting
    + operation:
      
     @@ Documentation/git-history.adoc: f44a46e third
      bf7438d first
      ----------
      
    -+* Split a commit.
    -++
    ++Split a commit
    ++~~~~~~~~~~~~~~
    ++
     +----------
     +$ git log --stat --oneline
     +3f81232 (HEAD -> main) original
    @@ builtin/history.c
     +#define USE_THE_REPOSITORY_VARIABLE
     +
      #include "builtin.h"
    + #include "branch.h"
     +#include "cache-tree.h"
      #include "commit.h"
      #include "commit-reach.h"
    @@ builtin/history.c
      #include "sequencer.h"
     +#include "sparse-index.h"
      
    - static int collect_commits(struct repository *repo,
    - 			   struct commit *old_commit,
    + static int cmd_history_abort(int argc,
    + 			     const char **argv,
     @@ builtin/history.c: static int cmd_history_reorder(int argc,
      	return ret;
      }
      
    ++static void change_data_free(void *util, const char *str UNUSED)
    ++{
    ++	struct wt_status_change_data *d = util;
    ++	free(d->rename_source);
    ++	free(d);
    ++}
    ++
    ++static int fill_commit_message(struct repository *repo,
    ++			       const struct object_id *old_tree,
    ++			       const struct object_id *new_tree,
    ++			       const char *default_message,
    ++			       const char *provided_message,
    ++			       const char *action,
    ++			       struct strbuf *out)
    ++{
    ++	if (!provided_message) {
    ++		struct wt_status s;
    ++		const char *path = git_path_commit_editmsg();
    ++		const char *hint =
    ++			_("Please enter the commit message for the %s changes. Lines starting\n"
    ++			  "with '%s' will be kept; you may remove them yourself if you want to.\n");
    ++
    ++		strbuf_addstr(out, default_message);
    ++		strbuf_addch(out, '\n');
    ++		strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
    ++		write_file_buf(path, out->buf, out->len);
    ++
    ++		wt_status_prepare(repo, &s);
    ++		FREE_AND_NULL(s.branch);
    ++		s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
    ++		s.commit_template = 1;
    ++		s.colopts = 0;
    ++		s.display_comment_prefix = 1;
    ++		s.hints = 0;
    ++		s.use_color = 0;
    ++		s.whence = FROM_COMMIT;
    ++		s.committable = 1;
    ++
    ++		s.fp = fopen(git_path_commit_editmsg(), "a");
    ++		if (!s.fp)
    ++			return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
    ++
    ++		wt_status_collect_changes_trees(&s, old_tree, new_tree);
    ++		wt_status_print(&s);
    ++		wt_status_collect_free_buffers(&s);
    ++		string_list_clear_func(&s.change, change_data_free);
    ++
    ++		strbuf_reset(out);
    ++		if (launch_editor(path, out, NULL)) {
    ++			fprintf(stderr, _("Please supply the message using either -m or -F option.\n"));
    ++			return -1;
    ++		}
    ++		strbuf_stripspace(out, comment_line_str);
    ++
    ++	} else {
    ++		strbuf_addstr(out, provided_message);
    ++	}
    ++
    ++	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
    ++
    ++	if (!out->len) {
    ++		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
    ++		return -1;
    ++	}
    ++
    ++	return 0;
    ++}
    ++
     +static int split_commit(struct repository *repo,
     +			struct commit *original_commit,
     +			struct pathspec *pathspec,
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +	struct object_id original_commit_tree_oid, parent_tree_oid;
     +	const char *original_message, *original_body, *ptr;
     +	char original_commit_oid[GIT_MAX_HEXSZ + 1];
    -+	char *split_message_path = NULL, *original_author = NULL;
    ++	char *original_author = NULL;
     +	struct commit_list *parents = NULL;
     +	struct commit *first_commit;
     +	struct tree *split_tree;
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +	if (ptr)
     +		original_author = xmemdupz(ptr, len);
     +
    -+	/*
    -+	 * But we do ask the user for a new commit message. This is in contrast
    -+	 * to the second commit, where we'll retain the original commit
    -+	 * message.
    -+	 */
    -+	if (!commit_message) {
    -+		split_message_path = repo_git_path(repo, "SPLIT_MSG");
    -+		strbuf_addch(&split_message, '\n');
    -+		strbuf_commented_addf(&split_message, comment_line_str,
    -+				      _("Please enter a commit message for the split-out changes."));
    -+		write_file_buf(split_message_path, split_message.buf, split_message.len);
    -+
    -+		strbuf_reset(&split_message);
    -+		if (launch_editor(split_message_path, &split_message, NULL)) {
    -+			fprintf(stderr, _("Please supply the message using either -m or -F option.\n"));
    -+			ret = -1;
    -+			goto out;
    -+		}
    -+		strbuf_stripspace(&split_message, comment_line_str);
    -+	} else {
    -+		strbuf_addstr(&split_message, commit_message);
    -+	}
    -+	cleanup_message(&split_message, COMMIT_MSG_CLEANUP_ALL, 0);
    ++	ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
    ++				  "", commit_message, "split-out", &split_message);
    ++	if (ret < 0)
    ++		goto out;
     +
     +	ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
     +			  original_commit->parents, &out[0], original_author, NULL);
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +	strbuf_release(&split_message);
     +	strbuf_release(&index_file);
     +	free_commit_list(parents);
    -+	free(split_message_path);
     +	free(original_author);
     +	release_index(&index);
     +	return ret;
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +			     struct repository *repo)
     +{
     +	const char * const usage[] = {
    -+		N_("git history split [<options>] <revision>"),
    ++		N_("git history split [<options>] <commit>"),
     +		NULL,
     +	};
     +	const char *commit_message = NULL;
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +	int ret;
     +
     +	argc = parse_options(argc, argv, prefix, options, usage, 0);
    ++	if (argc < 1) {
    ++		ret = error(_("command expects a revision"));
    ++		goto out;
    ++	}
     +	repo_config(repo, git_default_config, NULL);
     +
     +	original_commit = lookup_commit_reference_by_name(argv[0]);
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +	replace_commits(&commits, &original_commit->object.oid,
     +			split_commits, ARRAY_SIZE(split_commits));
     +
    -+	/*
    -+	 * And now we pick commits in the new order on top of either the root
    -+	 * commit or on top the old commit's parent.
    -+	 */
    -+	ret = apply_commits(repo, &commits, head,
    -+			    original_commit->parents ? original_commit->parents->item : NULL,
    -+			    "split");
    ++	ret = apply_commits(repo, &commits, head, original_commit, "split");
     +	if (ret < 0)
     +		goto out;
     +
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
      		const char **argv,
      		const char *prefix,
     @@ builtin/history.c: int cmd_history(int argc,
    - 	const char * const usage[] = {
    - 		N_("git history drop [<options>] <revision>"),
    - 		N_("git history reorder [<options>] <revision> --(before|after)=<revision>"),
    -+		N_("git history split [<options>] <revision> [--] [<pathspec>...]"),
    + 		N_("git history quit"),
    + 		N_("git history drop <commit>"),
    + 		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
    ++		N_("git history split [<options>] <commit> [--] [<pathspec>...]"),
      		NULL,
      	};
      	parse_opt_subcommand_fn *fn = NULL;
    - 	struct option options[] = {
    +@@ builtin/history.c: int cmd_history(int argc,
    + 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
      		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
      		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
     +		OPT_SUBCOMMAND("split", &fn, cmd_history_split),
    @@ builtin/history.c: int cmd_history(int argc,
     
      ## t/meson.build ##
     @@ t/meson.build: integration_tests = [
    -   't3438-rebase-broken-files.sh',
    -   't3450-history-drop.sh',
    -   't3451-history-reorder.sh',
    -+  't3452-history-split.sh',
    +   't3450-history.sh',
    +   't3451-history-drop.sh',
    +   't3452-history-reorder.sh',
    ++  't3453-history-split.sh',
        't3500-cherry.sh',
        't3501-revert-cherry-pick.sh',
        't3502-cherry-pick-merge.sh',
     
    - ## t/t3452-history-split.sh (new) ##
    + ## t/t3453-history-split.sh (new) ##
     @@
     +#!/bin/sh
     +
    @@ t/t3452-history-split.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'refuses to work with changes in the worktree or index' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		test_commit initial &&
    ++		touch bar foo &&
    ++		git add . &&
    ++		git commit -m split-me &&
    ++
    ++		echo changed >bar &&
    ++		test_must_fail git history split -m message HEAD 2>err <<-EOF &&
    ++		y
    ++		n
    ++		EOF
    ++		test_grep "Your local changes to the following files would be overwritten" err &&
    ++
    ++		git add bar &&
    ++		test_must_fail git history split -m message HEAD 2>err <<-EOF &&
    ++		y
    ++		n
    ++		EOF
    ++		test_grep "Your local changes to the following files would be overwritten" err
    ++	)
    ++'
    ++
     +test_expect_success 'can split up tip commit' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3452-history-split.sh (new)
     +		git add . &&
     +		git commit -m split-me &&
     +
    ++		git symbolic-ref HEAD >expect &&
     +		set_fake_editor &&
     +		git history split HEAD <<-EOF &&
     +		y
     +		n
     +		EOF
    ++		git symbolic-ref HEAD >actual &&
    ++		test_cmp expect actual &&
     +
     +		expect_log <<-EOF &&
     +		split-me
    @@ t/t3452-history-split.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'aborts with empty commit message' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		touch bar foo &&
    ++		git add . &&
    ++		git commit -m split-me &&
    ++
    ++		test_must_fail git history split HEAD -m "" <<-EOF 2>err &&
    ++		y
    ++		n
    ++		EOF
    ++		test_grep "Aborting commit due to empty commit message." err
    ++	)
    ++'
    ++
     +test_expect_success 'can specify message via option' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3452-history-split.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'commit message editor sees split-out changes' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		touch bar foo &&
    ++		git add . &&
    ++		git commit -m split-me &&
    ++
    ++		write_script fake-editor.sh <<-\EOF &&
    ++		cp "$1" . &&
    ++		echo "some commit message" >>"$1"
    ++		EOF
    ++		test_set_editor "$(pwd)"/fake-editor.sh &&
    ++
    ++		git history split HEAD <<-EOF &&
    ++		y
    ++		n
    ++		EOF
    ++
    ++		cat >expect <<-EOF &&
    ++
    ++		# Please enter the commit message for the split-out changes. Lines starting
    ++		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
    ++		# Changes to be committed:
    ++		#	new file:   bar
    ++		#
    ++		EOF
    ++		test_cmp expect COMMIT_EDITMSG &&
    ++
    ++		expect_log <<-EOF
    ++		split-me
    ++		some commit message
    ++		EOF
    ++	)
    ++'
    ++
     +test_expect_success 'can use pathspec to limit what gets split' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
 -:  ---------- > 16:  3e3587d375 builtin/history: implement "reword" subcommand

--- base-commit: c44beea485f0f2feaf460e2ac87fdd5608d63cf0 change-id: 20250819-b4-pks-history-builtin-83398f9a05f0

Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 01/16] sequencer: optionally skip printing commit summary

When picking commits by using for example git-cherry-pick(1) we end up printing a commit summary that gives the reader information around what exactly we have been picking:

```
$ git cherry-pick main
[other 76c8456] bar
 Date: Tue Aug 19 08:07:26 2025 +0200
 1 file changed, 1 insertion(+)
 create mode 100644 bar
```

While useful for some commands, we're about to introduce a new command where this output will be less so. But right now there is no way to disable printing this commit summary.

Introduce a new `skip_commit_summary` replay option that does so. Persist the option into the sequencer configuration so that it persists across different processes, e.g. when we need to stop due to a merge conflict.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 sequencer.c | 12 +++++++++---
 sequencer.h |  1 +
 2 files changed, 10 insertions(+), 3 deletions(-)
Show changes to 2 files +10 −3

sequencer.c, sequencer.h

diff --git a/sequencer.c b/sequencer.c
index aaf2e4df64..7066cdc939 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -1742,7 +1742,7 @@ static int do_commit(struct repository *r,
 			refs_delete_ref(get_main_ref_store(r), "",
 					"CHERRY_PICK_HEAD", NULL, REF_NO_DEREF);
 			unlink(git_path_merge_msg(r));
-			if (!is_rebase_i(opts))
+			if (!is_rebase_i(opts) && !opts->skip_commit_summary)
 				print_commit_summary(r, NULL, &oid,
 						SUMMARY_SHOW_AUTHOR_DATE);
 			return res;
@@ -3139,8 +3139,12 @@ static int populate_opts_cb(const char *key, const char *value,
 	else if (!strcmp(key, "options.default-msg-cleanup")) {
 		opts->explicit_cleanup = 1;
 		opts->default_msg_cleanup = get_cleanup_mode(value, 1);
-	} else
+	} else if (!strcmp(key, "options.skip-commit-summary")) {
+		opts->skip_commit_summary =
+			git_config_bool_or_int(key, value, ctx->kvi, &error_flag);
+	} else {
 		return error(_("invalid key: %s"), key);
+	}
 
 	if (!error_flag)
 		return error(_("invalid value for '%s': '%s'"), key, value);
@@ -3698,11 +3702,13 @@ static int save_opts(struct replay_opts *opts)
 				"options.allow-rerere-auto", NULL,
 				opts->allow_rerere_auto == RERERE_AUTOUPDATE ?
 				"true" : "false");
-
 	if (opts->explicit_cleanup)
 		res |= repo_config_set_in_file_gently(the_repository, opts_file,
 				"options.default-msg-cleanup", NULL,
 				describe_cleanup_mode(opts->default_msg_cleanup));
+	if (opts->skip_commit_summary)
+		res |= repo_config_set_in_file_gently(the_repository, opts_file,
+					"options.skip-commit-summary", NULL, "true");
 	return res;
 }
 
diff --git a/sequencer.h b/sequencer.h
index 304ba4b4d3..1767fd737e 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -52,6 +52,7 @@ struct replay_opts {
 	int keep_redundant_commits;
 	int verbose;
 	int quiet;
+	int skip_commit_summary;
 	int reschedule_failed_exec;
 	int committer_date_is_author_date;
 	int ignore_date;
-- 
2.51.0.308.g032396e0da.dirty
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 02/16] sequencer: add option to rewind HEAD after picking commits

While the sequencer infrastructure knows to rewind "HEAD" to whatever it was pointing to before a rebase, it doesn't do the same for non-rebase operations like cherry-picks. This is because the expectation is that the user directly picks commits on top of whatever "HEAD" points to, and we advance the reference pointed to by "HEAD" instead of updating it directly.

We're about to introduce a new command though that needs to detach "HEAD" while being more similar to git-cherry-pick(1) rathen than to git-rebase(1). As such, we'll want to restore "HEAD" to point to the branch that we started on while not using the more heavy-weight rebase machinery.

Introduce a new option `restore_head_target` to do so. Persist the option into the sequencer configuration so that it persists across different processes, e.g. when we need to stop due to a merge conflict.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 sequencer.c | 27 ++++++++++++++++++++++++++-
 sequencer.h |  3 +++
 2 files changed, 29 insertions(+), 1 deletion(-)
Show changes to 2 files +29 −1

sequencer.c, sequencer.h

diff --git a/sequencer.c b/sequencer.c
index 7066cdc939..bff181df76 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -413,6 +413,7 @@ void replay_opts_release(struct replay_opts *opts)
 	struct replay_ctx *ctx = opts->ctx;
 
 	free(opts->gpg_sign);
+	free(opts->restore_head_target);
 	free(opts->reflog_action);
 	free(opts->default_strategy);
 	free(opts->strategy);
@@ -3142,6 +3143,8 @@ static int populate_opts_cb(const char *key, const char *value,
 	} else if (!strcmp(key, "options.skip-commit-summary")) {
 		opts->skip_commit_summary =
 			git_config_bool_or_int(key, value, ctx->kvi, &error_flag);
+	} else if (!strcmp(key, "options.restore-head-target")) {
+		git_config_string_dup(&opts->restore_head_target, key, value);
 	} else {
 		return error(_("invalid key: %s"), key);
 	}
@@ -3709,6 +3712,10 @@ static int save_opts(struct replay_opts *opts)
 	if (opts->skip_commit_summary)
 		res |= repo_config_set_in_file_gently(the_repository, opts_file,
 					"options.skip-commit-summary", NULL, "true");
+	if (opts->restore_head_target)
+		res |= repo_config_set_in_file_gently(the_repository, opts_file,
+				"options.restore-head-target", NULL, opts->restore_head_target);
+
 	return res;
 }
 
@@ -5177,6 +5184,23 @@ static int pick_commits(struct repository *r,
 			return -1;
 	}
 
+	if (opts->restore_head_target) {
+		struct reset_head_opts reset_opts = { 0 };
+		const char *msg;
+
+		msg = reflog_message(opts, "finish", "returning to %s", opts->restore_head_target);
+
+		reset_opts.branch = opts->restore_head_target;
+		reset_opts.flags = RESET_HEAD_REFS_ONLY;
+		reset_opts.branch_msg = msg;
+		reset_opts.head_msg = msg;
+
+		if (reset_head(r, &reset_opts)) {
+			error(_("could not switch HEAD back to %s"), opts->restore_head_target);
+			return -1;
+		}
+	}
+
 	/*
 	 * Sequence of picks finished successfully; cleanup by
 	 * removing the .git/sequencer directory
@@ -5533,7 +5557,8 @@ int sequencer_pick_revisions(struct repository *r,
 	if (opts->revs->cmdline.nr == 1 &&
 	    opts->revs->cmdline.rev->whence == REV_CMD_REV &&
 	    opts->revs->no_walk &&
-	    !opts->revs->cmdline.rev->flags) {
+	    !opts->revs->cmdline.rev->flags &&
+	    !opts->restore_head_target) {
 		struct commit *cmit;
 
 		if (prepare_revision_walk(opts->revs)) {
diff --git a/sequencer.h b/sequencer.h
index 1767fd737e..a905f6afc7 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -72,6 +72,9 @@ struct replay_opts {
 	/* Reflog */
 	char *reflog_action;
 
+	/* Reference to which HEAD shall be reset to after the operation. */
+	char *restore_head_target;
+
 	/* placeholder commit for -i --root */
 	struct object_id squash_onto;
 	int have_squash_onto;
-- 
2.51.0.308.g032396e0da.dirty
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 03/16] sequencer: introduce new history editing mode

Introduce a new history editing mode into our sequencer machinery. This mode is basically the same as `REBASE_CHERRY`, but will be used by the new git-history(1) command that is to be introduced in a subsequent commit.

Note that the advice already points towards the git-history(1) command. This advice is bogus right now, but we'll introduce the relevant infra in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 sequencer.c        | 130 ++++++++++++++++++++++++++++++++++++++++++-----------
 sequencer.h        |   3 +-
 t/t3450-history.sh |  12 +++++
 3 files changed, 119 insertions(+), 26 deletions(-)
Show changes to 3 files +119 −26

sequencer.c, sequencer.h, t/t3450-history.sh

diff --git a/sequencer.c b/sequencer.c
index bff181df76..898ac1a2a8 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -465,6 +465,8 @@ static const char *action_name(const struct replay_opts *opts)
 		return N_("cherry-pick");
 	case REPLAY_INTERACTIVE_REBASE:
 		return N_("rebase");
+	case REPLAY_HISTORY_EDIT:
+		return N_("history edit");
 	}
 	die(_("unknown action: %d"), opts->action);
 }
@@ -557,6 +559,13 @@ static void print_advice(struct repository *r, int show_hint,
 					    "You can instead skip this commit with \"git revert --skip\".\n"
 					    "To abort and get back to the state before \"git revert\",\n"
 					    "run \"git revert --abort\"."));
+		else if (opts->action == REPLAY_HISTORY_EDIT)
+			advise_if_enabled(ADVICE_MERGE_CONFLICT,
+					  _("After resolving the conflicts, mark them with\n"
+					    "\"git add/rm <pathspec>\", then run\n"
+					    "\"git history continue\".\n"
+					    "To abort and get back to the state before \"git history\",\n"
+					    "run \"git history abort\"."));
 		else
 			BUG("unexpected pick action in print_advice()");
 	}
@@ -1742,6 +1751,8 @@ static int do_commit(struct repository *r,
 		if (!res) {
 			refs_delete_ref(get_main_ref_store(r), "",
 					"CHERRY_PICK_HEAD", NULL, REF_NO_DEREF);
+			refs_delete_ref(get_main_ref_store(r), "",
+					"HISTORY_EDIT_HEAD", NULL, REF_NO_DEREF);
 			unlink(git_path_merge_msg(r));
 			if (!is_rebase_i(opts) && !opts->skip_commit_summary)
 				print_commit_summary(r, NULL, &oid,
@@ -2491,16 +2502,24 @@ static int do_pick_commit(struct repository *r,
 	 * However, if the merge did not even start, then we don't want to
 	 * write it at all.
 	 */
-	if ((command == TODO_PICK || command == TODO_REWORD ||
-	     command == TODO_EDIT) && !opts->no_commit &&
-	    (res == 0 || res == 1) &&
-	    refs_update_ref(get_main_ref_store(the_repository), NULL, "CHERRY_PICK_HEAD", &commit->object.oid, NULL,
-			    REF_NO_DEREF, UPDATE_REFS_MSG_ON_ERR))
-		res = -1;
-	if (command == TODO_REVERT && ((opts->no_commit && res == 0) || res == 1) &&
-	    refs_update_ref(get_main_ref_store(the_repository), NULL, "REVERT_HEAD", &commit->object.oid, NULL,
-			    REF_NO_DEREF, UPDATE_REFS_MSG_ON_ERR))
-		res = -1;
+	if (opts->action == REPLAY_HISTORY_EDIT && command == TODO_PICK &&
+	    !opts->no_commit && (res == 0 || res == 1)) {
+		if (refs_update_ref(get_main_ref_store(the_repository), NULL,
+				    "HISTORY_EDIT_HEAD", &commit->object.oid, NULL,
+				    REF_NO_DEREF, UPDATE_REFS_MSG_ON_ERR))
+			res = -1;
+	} else if ((command == TODO_PICK || command == TODO_REWORD ||
+		   command == TODO_EDIT) && !opts->no_commit && (res == 0 || res == 1)) {
+		if (refs_update_ref(get_main_ref_store(the_repository), NULL,
+				    "CHERRY_PICK_HEAD", &commit->object.oid, NULL,
+				    REF_NO_DEREF, UPDATE_REFS_MSG_ON_ERR))
+			res = -1;
+	} else if (command == TODO_REVERT && ((opts->no_commit && res == 0) || res == 1)) {
+		if (refs_update_ref(get_main_ref_store(the_repository), NULL,
+				    "REVERT_HEAD", &commit->object.oid, NULL,
+				    REF_NO_DEREF, UPDATE_REFS_MSG_ON_ERR))
+			res = -1;
+	}
 
 	if (res) {
 		error(command == TODO_REVERT
@@ -2526,6 +2545,8 @@ static int do_pick_commit(struct repository *r,
 		unlink(git_path_merge_msg(r));
 		refs_delete_ref(get_main_ref_store(r), "", "AUTO_MERGE",
 				NULL, REF_NO_DEREF);
+		refs_delete_ref(get_main_ref_store(r), "", "HISTORY_EDIT_HEAD",
+				NULL, REF_NO_DEREF);
 		fprintf(stderr,
 			_("dropping %s %s -- patch contents already upstream\n"),
 			oid_to_hex(&commit->object.oid), msg.subject);
@@ -2843,12 +2864,17 @@ static int parse_insn_line(struct repository *r, struct replay_opts *opts,
 	return 0;
 }
 
-int sequencer_get_last_command(struct repository *r UNUSED, enum replay_action *action)
+int sequencer_get_last_command(struct repository *r, enum replay_action *action)
 {
 	const char *todo_file, *bol;
 	struct strbuf buf = STRBUF_INIT;
 	int ret = 0;
 
+	if (refs_ref_exists(get_main_ref_store(r), "HISTORY_EDIT_HEAD")) {
+		*action = REPLAY_HISTORY_EDIT;
+		return 0;
+	}
+
 	todo_file = git_path_todo_file();
 	if (strbuf_read_file(&buf, todo_file, 0) < 0) {
 		if (errno == ENOENT || errno == ENOTDIR)
@@ -2995,6 +3021,15 @@ void sequencer_post_commit_cleanup(struct repository *r, int verbose)
 		need_cleanup = 1;
 	}
 
+	if (refs_ref_exists(get_main_ref_store(r), "HISTORY_EDIT_HEAD")) {
+		if (!refs_delete_ref(get_main_ref_store(r), "",
+				     "HISTORY_EDIT_HEAD", NULL, REF_NO_DEREF) &&
+		    verbose)
+			warning(_("cancelling a history edit in progress"));
+		opts.action = REPLAY_HISTORY_EDIT;
+		need_cleanup = 1;
+	}
+
 	if (refs_ref_exists(get_main_ref_store(r), "REVERT_HEAD")) {
 		if (!refs_delete_ref(get_main_ref_store(r), "", "REVERT_HEAD",
 				     NULL, REF_NO_DEREF) &&
@@ -3052,17 +3087,29 @@ static int read_populate_todo(struct repository *r,
 		return error(_("no commits parsed."));
 
 	if (!is_rebase_i(opts)) {
-		enum todo_command valid =
-			opts->action == REPLAY_PICK ? TODO_PICK : TODO_REVERT;
+		enum todo_command valid;
 		int i;
 
-		for (i = 0; i < todo_list->nr; i++)
+		switch (opts->action) {
+		case REPLAY_PICK:
+		case REPLAY_HISTORY_EDIT:
+			valid = TODO_PICK;
+			break;
+		default:
+			valid = TODO_REVERT;
+			break;
+		}
+
+		for (i = 0; i < todo_list->nr; i++) {
 			if (valid == todo_list->items[i].command)
 				continue;
 			else if (valid == TODO_PICK)
-				return error(_("cannot cherry-pick during a revert."));
+				return error(_("cannot cherry-pick during a %s."),
+					     action_name(opts));
 			else
-				return error(_("cannot revert during a cherry-pick."));
+				return error(_("cannot revert during a %s."),
+					     action_name(opts));
+		}
 	}
 
 	if (is_rebase_i(opts)) {
@@ -3353,15 +3400,25 @@ int write_basic_state(struct replay_opts *opts, const char *head_name,
 static int walk_revs_populate_todo(struct todo_list *todo_list,
 				struct replay_opts *opts)
 {
-	enum todo_command command = opts->action == REPLAY_PICK ?
-		TODO_PICK : TODO_REVERT;
-	const char *command_string = todo_command_info[command].str;
+	enum todo_command command;
+	const char *command_string;
 	const char *encoding;
 	struct commit *commit;
 
 	if (prepare_revs(opts))
 		return -1;
 
+	switch (opts->action) {
+	case REPLAY_PICK:
+	case REPLAY_HISTORY_EDIT:
+		command = TODO_PICK;
+		break;
+	default:
+		command = TODO_REVERT;
+		break;
+	}
+
+	command_string = todo_command_info[command].str;
 	encoding = get_log_output_encoding();
 
 	while ((commit = get_revision(opts->revs))) {
@@ -3412,6 +3469,11 @@ static int create_seq_dir(struct repository *r)
 			in_progress_advice =
 			_("try \"git cherry-pick (--continue | %s--abort | --quit)\"");
 			break;
+		case REPLAY_HISTORY_EDIT:
+			in_progress_error = _("history edit is already in progress");
+			in_progress_advice =
+			_("try \"git history (continue | abort | quit)\"");
+			break;
 		default:
 			BUG("unexpected action in create_seq_dir");
 		}
@@ -3472,13 +3534,14 @@ static int reset_merge(const struct object_id *oid)
 	return run_command(&cmd);
 }
 
-static int rollback_single_pick(struct repository *r)
+static int rollback_single_pick(struct repository *r, struct replay_opts *opts)
 {
 	struct object_id head_oid;
 
 	if (!refs_ref_exists(get_main_ref_store(r), "CHERRY_PICK_HEAD") &&
+	    !refs_ref_exists(get_main_ref_store(r), "HISTORY_EDIT_HEAD") &&
 	    !refs_ref_exists(get_main_ref_store(r), "REVERT_HEAD"))
-		return error(_("no cherry-pick or revert in progress"));
+		return error(_("no %s in progress"), action_name(opts));
 	if (refs_read_ref_full(get_main_ref_store(the_repository), "HEAD", 0, &head_oid, NULL))
 		return error(_("cannot resolve HEAD"));
 	if (is_null_oid(&head_oid))
@@ -3509,7 +3572,7 @@ int sequencer_rollback(struct repository *r, struct replay_opts *opts)
 		 * If CHERRY_PICK_HEAD or REVERT_HEAD indicates
 		 * a single-cherry-pick in progress, abort that.
 		 */
-		return rollback_single_pick(r);
+		return rollback_single_pick(r, opts);
 	}
 	if (!f)
 		return error_errno(_("cannot open '%s'"), git_path_head_file());
@@ -5213,8 +5276,9 @@ static int continue_single_pick(struct repository *r, struct replay_opts *opts)
 	struct child_process cmd = CHILD_PROCESS_INIT;
 
 	if (!refs_ref_exists(get_main_ref_store(r), "CHERRY_PICK_HEAD") &&
+	    !refs_ref_exists(get_main_ref_store(r), "HISTORY_EDIT_HEAD") &&
 	    !refs_ref_exists(get_main_ref_store(r), "REVERT_HEAD"))
-		return error(_("no cherry-pick or revert in progress"));
+		return error(_("no %s in progress"), action_name(opts));
 
 	cmd.git_cmd = 1;
 	strvec_push(&cmd.args, "commit");
@@ -5393,6 +5457,14 @@ static int commit_staged_changes(struct repository *r,
 			goto out;
 		}
 
+		if (refs_ref_exists(get_main_ref_store(r),
+				    "HISTORY_EDIT_HEAD") &&
+		    refs_delete_ref(get_main_ref_store(r), "",
+				    "HISTORY_EDIT_HEAD", NULL, REF_NO_DEREF)) {
+			ret = error(_("could not remove HISTORY_EDIT_HEAD"));
+			goto out;
+		}
+
 		if (unlink(git_path_merge_msg(r)) && errno != ENOENT) {
 			ret = error_errno(_("could not remove '%s'"),
 					  git_path_merge_msg(r));
@@ -5471,6 +5543,7 @@ int sequencer_continue(struct repository *r, struct replay_opts *opts)
 		/* Verify that the conflict has been resolved */
 		if (refs_ref_exists(get_main_ref_store(r),
 				    "CHERRY_PICK_HEAD") ||
+		    refs_ref_exists(get_main_ref_store(r), "HISTORY_EDIT_HEAD") ||
 		    refs_ref_exists(get_main_ref_store(r), "REVERT_HEAD")) {
 			res = continue_single_pick(r, opts);
 			if (res)
@@ -5505,8 +5578,15 @@ static int single_pick(struct repository *r,
 	int check_todo;
 	struct todo_item item;
 
-	item.command = opts->action == REPLAY_PICK ?
-			TODO_PICK : TODO_REVERT;
+	switch (opts->action) {
+	case REPLAY_PICK:
+	case REPLAY_HISTORY_EDIT:
+		item.command = TODO_PICK;
+		break;
+	default:
+		item.command = TODO_REVERT;
+		break;
+	}
 	item.commit = cmit;
 
 	return do_pick_commit(r, &item, opts, 0, &check_todo);
diff --git a/sequencer.h b/sequencer.h
index a905f6afc7..082fbe3e35 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -21,7 +21,8 @@ extern const char *rebase_resolvemsg;
 enum replay_action {
 	REPLAY_REVERT,
 	REPLAY_PICK,
-	REPLAY_INTERACTIVE_REBASE
+	REPLAY_INTERACTIVE_REBASE,
+	REPLAY_HISTORY_EDIT,
 };
 
 enum commit_msg_cleanup_mode {
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
new file mode 100755
index 0000000000..9eb1ed6749
--- /dev/null
+++ b/t/t3450-history.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+
+test_description='tests for git-history command'
+
+. ./test-lib.sh
+
+test_expect_success 'refuses to do anything without subcommand' '
+	test_must_fail git history 2>err &&
+	test_grep foo err
+'
+
+test_done
-- 
2.51.0.308.g032396e0da.dirty
D. Ben Knoble· Aug 26, 2025, 12:55 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v2 03/16] sequencer: introduce new history editing mode

On Sun, Aug 24, 2025 at 1:42 PM Patrick Steinhardt <ps@pks.im> wrote:
Show 23 quoted lines
> diff --git a/sequencer.c b/sequencer.c
> index bff181df76..898ac1a2a8 100644
> --- a/sequencer.c
> +++ b/sequencer.c
> @@ -3052,17 +3087,29 @@ static int read_populate_todo(struct repository *r,
>                 return error(_("no commits parsed."));
>
>         if (!is_rebase_i(opts)) {
> -               enum todo_command valid =
> -                       opts->action == REPLAY_PICK ? TODO_PICK : TODO_REVERT;
> +               enum todo_command valid;
>                 int i;
>
> -               for (i = 0; i < todo_list->nr; i++)
> +               switch (opts->action) {
> +               case REPLAY_PICK:
> +               case REPLAY_HISTORY_EDIT:
> +                       valid = TODO_PICK;
> +                       break;
> +               default:
> +                       valid = TODO_REVERT;
> +                       break;
> +               }

I think I see this hunk repeated in a few places—maybe some leftoverbits for a refactor?

-- 
D. Ben Knoble
Patrick Steinhardt· Sep 3, 2025, 12:19 UTC · re: D. Ben Knoble · lore

Re: [PATCH RFC v2 03/16] sequencer: introduce new history editing mode

On Tue, Aug 26, 2025 at 08:55:13AM -0400, D. Ben Knoble wrote:
Show 27 quoted lines
> On Sun, Aug 24, 2025 at 1:42 PM Patrick Steinhardt <ps@pks.im> wrote:
> > diff --git a/sequencer.c b/sequencer.c
> > index bff181df76..898ac1a2a8 100644
> > --- a/sequencer.c
> > +++ b/sequencer.c
> > @@ -3052,17 +3087,29 @@ static int read_populate_todo(struct repository *r,
> >                 return error(_("no commits parsed."));
> >
> >         if (!is_rebase_i(opts)) {
> > -               enum todo_command valid =
> > -                       opts->action == REPLAY_PICK ? TODO_PICK : TODO_REVERT;
> > +               enum todo_command valid;
> >                 int i;
> >
> > -               for (i = 0; i < todo_list->nr; i++)
> > +               switch (opts->action) {
> > +               case REPLAY_PICK:
> > +               case REPLAY_HISTORY_EDIT:
> > +                       valid = TODO_PICK;
> > +                       break;
> > +               default:
> > +                       valid = TODO_REVERT;
> > +                       break;
> > +               }
> 
> I think I see this hunk repeated in a few places—maybe some
> leftoverbits for a refactor?

Fair enough. I don't really see a strong reason why we shouldn't fix this in the same patch though. We can for example do something like the below patch.

Patrick
-- >8 --
Show changes to sequencer.c +16 −30
diff --git a/sequencer.c b/sequencer.c
index 898ac1a2a8..9a66e7d128 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -3063,6 +3063,19 @@ static void todo_list_write_total_nr(struct todo_list *todo_list)
 	}
 }
 
+static enum todo_command action_to_command(enum replay_action action)
+{
+	switch (action) {
+	case REPLAY_PICK:
+	case REPLAY_HISTORY_EDIT:
+		return TODO_PICK;
+	case REPLAY_REVERT:
+		return TODO_REVERT;
+	default:
+		BUG("unsupported action %d", action);
+	}
+}
+
 static int read_populate_todo(struct repository *r,
 			      struct todo_list *todo_list,
 			      struct replay_opts *opts)
@@ -3087,19 +3100,9 @@ static int read_populate_todo(struct repository *r,
 		return error(_("no commits parsed."));
 
 	if (!is_rebase_i(opts)) {
-		enum todo_command valid;
+		enum todo_command valid = action_to_command(opts->action);
 		int i;
 
-		switch (opts->action) {
-		case REPLAY_PICK:
-		case REPLAY_HISTORY_EDIT:
-			valid = TODO_PICK;
-			break;
-		default:
-			valid = TODO_REVERT;
-			break;
-		}
-
 		for (i = 0; i < todo_list->nr; i++) {
 			if (valid == todo_list->items[i].command)
 				continue;
@@ -3408,16 +3411,7 @@ static int walk_revs_populate_todo(struct todo_list *todo_list,
 	if (prepare_revs(opts))
 		return -1;
 
-	switch (opts->action) {
-	case REPLAY_PICK:
-	case REPLAY_HISTORY_EDIT:
-		command = TODO_PICK;
-		break;
-	default:
-		command = TODO_REVERT;
-		break;
-	}
-
+	command = action_to_command(opts->action);
 	command_string = todo_command_info[command].str;
 	encoding = get_log_output_encoding();
 
@@ -5578,15 +5572,7 @@ static int single_pick(struct repository *r,
 	int check_todo;
 	struct todo_item item;
 
-	switch (opts->action) {
-	case REPLAY_PICK:
-	case REPLAY_HISTORY_EDIT:
-		item.command = TODO_PICK;
-		break;
-	default:
-		item.command = TODO_REVERT;
-		break;
-	}
+	item.command = action_to_command(opts->action);
 	item.commit = cmit;
 
 	return do_pick_commit(r, &item, opts, 0, &check_todo);
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 04/16] sequencer: stop using `the_repository` in `sequencer_remove_state()`

Refactor `sequencer_remove_state()` to stop using `the_repository` in favor of a passed-in repository.

A lot of the other code in our sequencer infrastructure still uses `the_repository`, but this bigger refactoring is left for another day.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/rebase.c |  4 ++--
 builtin/revert.c |  2 +-
 sequencer.c      | 18 +++++++++---------
 sequencer.h      |  2 +-
 4 files changed, 13 insertions(+), 13 deletions(-)
Show changes to 4 files +13 −13

builtin/rebase.c, builtin/revert.c, sequencer.c, sequencer.h

diff --git a/builtin/rebase.c b/builtin/rebase.c
index 3c85768d29..66824ae136 100644
--- a/builtin/rebase.c
+++ b/builtin/rebase.c
@@ -568,7 +568,7 @@ static int finish_rebase(struct rebase_options *opts)
 		struct replay_opts replay = REPLAY_OPTS_INIT;
 
 		replay.action = REPLAY_INTERACTIVE_REBASE;
-		ret = sequencer_remove_state(&replay);
+		ret = sequencer_remove_state(the_repository, &replay);
 		replay_opts_release(&replay);
 	} else {
 		strbuf_addstr(&dir, opts->state_dir);
@@ -1405,7 +1405,7 @@ int cmd_rebase(int argc,
 			struct replay_opts replay = REPLAY_OPTS_INIT;
 
 			replay.action = REPLAY_INTERACTIVE_REBASE;
-			ret = sequencer_remove_state(&replay);
+			ret = sequencer_remove_state(the_repository, &replay);
 			replay_opts_release(&replay);
 		} else {
 			strbuf_reset(&buf);
diff --git a/builtin/revert.c b/builtin/revert.c
index c3f92b585d..6456cf2171 100644
--- a/builtin/revert.c
+++ b/builtin/revert.c
@@ -263,7 +263,7 @@ static int run_sequencer(int argc, const char **argv, const char *prefix,
 	free(options);
 
 	if (cmd == 'q') {
-		int ret = sequencer_remove_state(opts);
+		int ret = sequencer_remove_state(the_repository, opts);
 		if (!ret)
 			remove_branch_state(the_repository, 0);
 		return ret;
diff --git a/sequencer.c b/sequencer.c
index 898ac1a2a8..749e30c2e6 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -425,7 +425,7 @@ void replay_opts_release(struct replay_opts *opts)
 	free(opts->ctx);
 }
 
-int sequencer_remove_state(struct replay_opts *opts)
+int sequencer_remove_state(struct repository *repo, struct replay_opts *opts)
 {
 	struct strbuf buf = STRBUF_INIT;
 	int ret = 0;
@@ -437,7 +437,7 @@ int sequencer_remove_state(struct replay_opts *opts)
 			char *eol = strchr(p, '\n');
 			if (eol)
 				*eol = '\0';
-			if (refs_delete_ref(get_main_ref_store(the_repository), "(rebase) cleanup", p, NULL, 0) < 0) {
+			if (refs_delete_ref(get_main_ref_store(repo), "(rebase) cleanup", p, NULL, 0) < 0) {
 				warning(_("could not delete '%s'"), p);
 				ret = -1;
 			}
@@ -3048,7 +3048,7 @@ void sequencer_post_commit_cleanup(struct repository *r, int verbose)
 	if (!have_finished_the_last_pick())
 		goto out;
 
-	sequencer_remove_state(&opts);
+	sequencer_remove_state(the_repository, &opts);
 out:
 	replay_opts_release(&opts);
 }
@@ -3601,7 +3601,7 @@ int sequencer_rollback(struct repository *r, struct replay_opts *opts)
 	if (reset_merge(&oid))
 		goto fail;
 	strbuf_release(&buf);
-	return sequencer_remove_state(opts);
+	return sequencer_remove_state(the_repository, opts);
 fail:
 	strbuf_release(&buf);
 	return -1;
@@ -4903,7 +4903,7 @@ static int checkout_onto(struct repository *r, struct replay_opts *opts,
 	};
 	if (reset_head(r, &ropts)) {
 		apply_autostash(rebase_path_autostash());
-		sequencer_remove_state(opts);
+		sequencer_remove_state(the_repository, opts);
 		return error(_("could not detach HEAD"));
 	}
 
@@ -5268,7 +5268,7 @@ static int pick_commits(struct repository *r,
 	 * Sequence of picks finished successfully; cleanup by
 	 * removing the .git/sequencer directory
 	 */
-	return sequencer_remove_state(opts);
+	return sequencer_remove_state(the_repository, opts);
 }
 
 static int continue_single_pick(struct repository *r, struct replay_opts *opts)
@@ -6607,7 +6607,7 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
 
 	if (count_commands(todo_list) == 0) {
 		apply_autostash(rebase_path_autostash());
-		sequencer_remove_state(opts);
+		sequencer_remove_state(the_repository, opts);
 
 		return error(_("nothing to do"));
 	}
@@ -6618,12 +6618,12 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
 		return -1;
 	else if (res == -2) {
 		apply_autostash(rebase_path_autostash());
-		sequencer_remove_state(opts);
+		sequencer_remove_state(the_repository, opts);
 
 		return -1;
 	} else if (res == -3) {
 		apply_autostash(rebase_path_autostash());
-		sequencer_remove_state(opts);
+		sequencer_remove_state(the_repository, opts);
 		todo_list_release(&new_todo);
 
 		return error(_("nothing to do"));
diff --git a/sequencer.h b/sequencer.h
index 082fbe3e35..0e0e7301b8 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -170,7 +170,7 @@ int sequencer_continue(struct repository *repo, struct replay_opts *opts);
 int sequencer_rollback(struct repository *repo, struct replay_opts *opts);
 int sequencer_skip(struct repository *repo, struct replay_opts *opts);
 void replay_opts_release(struct replay_opts *opts);
-int sequencer_remove_state(struct replay_opts *opts);
+int sequencer_remove_state(struct repository *repo, struct replay_opts *opts);
 
 #define TODO_LIST_KEEP_EMPTY (1U << 0)
 #define TODO_LIST_SHORTEN_IDS (1U << 1)
-- 
2.51.0.308.g032396e0da.dirty
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 05/16] cache-tree: allow writing in-memory index as tree

The function `write_in_core_index_as_tree()` takes a repository and writes its index into a tree object. What this function cannot do though is to take an _arbitrary_ in-memory index.

Introduce a new `struct index_state` parameter so that the caller can pass a different index than the one belonging to the repository. This will be used in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/checkout.c | 3 ++-
 cache-tree.c       | 5 ++---
 cache-tree.h       | 3 ++-
 3 files changed, 6 insertions(+), 5 deletions(-)
Show changes to 3 files +6 −5

builtin/checkout.c, cache-tree.c, cache-tree.h

diff --git a/builtin/checkout.c b/builtin/checkout.c
index f9453473fe2..43583c8d1be 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -902,7 +902,8 @@ static int merge_working_tree(const struct checkout_opts *opts,
 					   0);
 			init_ui_merge_options(&o, the_repository);
 			o.verbosity = 0;
-			work = write_in_core_index_as_tree(the_repository);
+			work = write_in_core_index_as_tree(the_repository,
+							   the_repository->index);
 
 			ret = reset_tree(new_tree,
 					 opts, 1,
diff --git a/cache-tree.c b/cache-tree.c
index 66ef2becbe0..029ec933abe 100644
--- a/cache-tree.c
+++ b/cache-tree.c
@@ -699,11 +699,11 @@ static int write_index_as_tree_internal(struct object_id *oid,
 	return 0;
 }
 
-struct tree* write_in_core_index_as_tree(struct repository *repo) {
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state) {
 	struct object_id o;
 	int was_valid, ret;
 
-	struct index_state *index_state	= repo->index;
 	was_valid = index_state->cache_tree &&
 		    cache_tree_fully_valid(index_state->cache_tree);
 
@@ -723,7 +723,6 @@ struct tree* write_in_core_index_as_tree(struct repository *repo) {
 	return lookup_tree(repo, &index_state->cache_tree->oid);
 }
 
-
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix)
 {
 	int entries, was_valid;
diff --git a/cache-tree.h b/cache-tree.h
index b82c4963e7c..f8bddae5235 100644
--- a/cache-tree.h
+++ b/cache-tree.h
@@ -47,7 +47,8 @@ int cache_tree_verify(struct repository *, struct index_state *);
 #define WRITE_TREE_UNMERGED_INDEX (-2)
 #define WRITE_TREE_PREFIX_ERROR (-3)
 
-struct tree* write_in_core_index_as_tree(struct repository *repo);
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state);
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix);
 void prime_cache_tree(struct repository *, struct index_state *, struct tree *);
 
-- 
2.51.0.308.g032396e0da.dirty
Junio C Hamano· Aug 25, 2025, 16:38 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v2 05/16] cache-tree: allow writing in-memory index as tree

Patrick Steinhardt <ps@pks.im> writes:
Show 7 quoted lines
> The function `write_in_core_index_as_tree()` takes a repository and
> writes its index into a tree object. What this function cannot do though
> is to take an _arbitrary_ in-memory index.
>
> Introduce a new `struct index_state` parameter so that the caller can
> pass a different index than the one belonging to the repository. This
> will be used in a subsequent commit.
Nice.  

I wonder if this would also allow us to simplify the code paths for "git commit -o <pathspec>", where we use a separate temporary index that gets populated afresh from HEAD, grab the new snapshot for the paths that match the pathspec, and write it out as a tree to be wrapped in the new commit (and then the real index is also updated at these same paths).

I guess the code paths need to expose what is in the temporary index to hooks, which means the index file needs to be written out to an actual on-disk file, so the picture would be a bit different?

Patrick Steinhardt· Sep 3, 2025, 12:19 UTC · re: Junio C Hamano · lore

Re: [PATCH RFC v2 05/16] cache-tree: allow writing in-memory index as tree

On Mon, Aug 25, 2025 at 09:38:07AM -0700, Junio C Hamano wrote:
Show 22 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
> 
> > The function `write_in_core_index_as_tree()` takes a repository and
> > writes its index into a tree object. What this function cannot do though
> > is to take an _arbitrary_ in-memory index.
> >
> > Introduce a new `struct index_state` parameter so that the caller can
> > pass a different index than the one belonging to the repository. This
> > will be used in a subsequent commit.
> 
> Nice.  
> 
> I wonder if this would also allow us to simplify the code paths for
> "git commit -o <pathspec>", where we use a separate temporary index
> that gets populated afresh from HEAD, grab the new snapshot for the
> paths that match the pathspec, and write it out as a tree to be
> wrapped in the new commit (and then the real index is also updated
> at these same paths).
> 
> I guess the code paths need to expose what is in the temporary index
> to hooks, which means the index file needs to be written out to an
> actual on-disk file, so the picture would be a bit different?

Well, the sequencer itself is also writing out the temporary index to disk. Took me quite a while to figure out why the index I wrote always turned out to only contain a subset of the changes I wanted. So I don't really see a reason why we couldn't use the infra for other commands, but cannot say whether or not it would end up improving the status quo.

Patrick
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 06/16] builtin: add new "history" command

When rewriting history via git-rebase(1) there are a couple of very common use cases:

  - The ordering of two commits should be reversed.
  - A commit should be split up into two commits.
  - A commit should be dropped from the history completely.
  - Multiple commits should be squashed into one.

While these operations are all doable, it often feels needlessly cludgy to do so by doing an interactive rebase, using the editor to say what one wants, and then perform the actions. Furthermore, some operations like splitting up a commit into two are way more involved than that and require a whole series of commands.

Add a new "history" command to plug this gap. This command will have several different subcommands to imperatively rewrite history for common use cases like the above. These commands will be implemented in subsequent commits.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 .gitignore                     |  1 +
 Documentation/git-history.adoc | 45 ++++++++++++++++++++++++++++++++++++++++++
 Documentation/meson.build      |  1 +
 Makefile                       |  1 +
 builtin.h                      |  1 +
 builtin/history.c              | 20 +++++++++++++++++++
 command-list.txt               |  1 +
 git.c                          |  1 +
 meson.build                    |  1 +
 9 files changed, 72 insertions(+)
Show changes to 9 files +72 −0

.gitignore, Documentation/git-history.adoc, Documentation/meson.build, Makefile, builtin.h, builtin/history.c, command-list.txt, git.c, meson.build

diff --git a/.gitignore b/.gitignore
index 04c444404e..3932d4d618 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,6 +77,7 @@
 /git-grep
 /git-hash-object
 /git-help
+/git-history
 /git-hook
 /git-http-backend
 /git-http-fetch
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
new file mode 100644
index 0000000000..1537960374
--- /dev/null
+++ b/Documentation/git-history.adoc
@@ -0,0 +1,45 @@
+git-history(1)
+==============
+
+NAME
+----
+git-history - EXPERIMENTAL: Rewrite history of the current branch
+
+SYNOPSIS
+--------
+[synopsis]
+git history [<options>]
+
+DESCRIPTION
+-----------
+
+Rewrite history by rearranging or modifying specific commits in the
+history.
+
+This command is similar to linkgit:git-rebase[1] and uses the same
+underlying machinery. You should use rebases if you either want to
+reapply a range of commits onto a different base, or interactive rebases
+if you want to edit a range of commits.
+
+Note that this command does not (yet) work with histories that contain
+merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
+flag instead.
+
+THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
+
+COMMANDS
+--------
+
+This command requires a subcommand. Several subcommands are available to
+rewrite history in different ways:
+
+CONFIGURATION
+-------------
+
+include::includes/cmd-config-section-all.adoc[]
+
+include::config/sequencer.adoc[]
+
+GIT
+---
+Part of the linkgit:git[1] suite
diff --git a/Documentation/meson.build b/Documentation/meson.build
index 4404c623f0..a30b5307fd 100644
--- a/Documentation/meson.build
+++ b/Documentation/meson.build
@@ -64,6 +64,7 @@ manpages = {
   'git-gui.adoc' : 1,
   'git-hash-object.adoc' : 1,
   'git-help.adoc' : 1,
+  'git-history.adoc' : 1,
   'git-hook.adoc' : 1,
   'git-http-backend.adoc' : 1,
   'git-http-fetch.adoc' : 1,
diff --git a/Makefile b/Makefile
index e11340c1ae..bed6eda5e6 100644
--- a/Makefile
+++ b/Makefile
@@ -1261,6 +1261,7 @@ BUILTIN_OBJS += builtin/get-tar-commit-id.o
 BUILTIN_OBJS += builtin/grep.o
 BUILTIN_OBJS += builtin/hash-object.o
 BUILTIN_OBJS += builtin/help.o
+BUILTIN_OBJS += builtin/history.o
 BUILTIN_OBJS += builtin/hook.o
 BUILTIN_OBJS += builtin/index-pack.o
 BUILTIN_OBJS += builtin/init-db.o
diff --git a/builtin.h b/builtin.h
index bff13e3069..2934f4479a 100644
--- a/builtin.h
+++ b/builtin.h
@@ -172,6 +172,7 @@ int cmd_get_tar_commit_id(int argc, const char **argv, const char *prefix, struc
 int cmd_grep(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hash_object(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_help(int argc, const char **argv, const char *prefix, struct repository *repo);
+int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hook(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);
diff --git a/builtin/history.c b/builtin/history.c
new file mode 100644
index 0000000000..d1a40368e0
--- /dev/null
+++ b/builtin/history.c
@@ -0,0 +1,20 @@
+#include "builtin.h"
+#include "gettext.h"
+#include "parse-options.h"
+
+int cmd_history(int argc,
+		const char **argv,
+		const char *prefix,
+		struct repository *repo UNUSED)
+{
+	const char * const usage[] = {
+		N_("git history [<options>]"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	return 0;
+}
diff --git a/command-list.txt b/command-list.txt
index b7ade3ab9f..f95f0ce926 100644
--- a/command-list.txt
+++ b/command-list.txt
@@ -115,6 +115,7 @@ git-grep                                mainporcelain           info
 git-gui                                 mainporcelain
 git-hash-object                         plumbingmanipulators
 git-help                                ancillaryinterrogators          complete
+git-history                             mainporcelain           history
 git-hook                                purehelpers
 git-http-backend                        synchingrepositories
 git-http-fetch                          synchelpers
diff --git a/git.c b/git.c
index 83eac0aeab..9d2cba2906 100644
--- a/git.c
+++ b/git.c
@@ -560,6 +560,7 @@ static struct cmd_struct commands[] = {
 	{ "grep", cmd_grep, RUN_SETUP_GENTLY },
 	{ "hash-object", cmd_hash_object },
 	{ "help", cmd_help },
+	{ "history", cmd_history, RUN_SETUP },
 	{ "hook", cmd_hook, RUN_SETUP },
 	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
 	{ "init", cmd_init_db },
diff --git a/meson.build b/meson.build
index 5dd299b496..0e40778a23 100644
--- a/meson.build
+++ b/meson.build
@@ -603,6 +603,7 @@ builtin_sources = [
   'builtin/grep.c',
   'builtin/hash-object.c',
   'builtin/help.c',
+  'builtin/history.c',
   'builtin/hook.c',
   'builtin/index-pack.c',
   'builtin/init-db.c',
-- 
2.51.0.308.g032396e0da.dirty
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 07/16] builtin/history: introduce subcommands to manage interrupted rewrites

Introduce subcommands to manage the sequencer state for git-history(1). These aren't really useful yet, but will become useful in subsequent commits where we will introduce git-history(1) subcommands that actually edit history.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  20 +++++++-
 builtin/history.c              | 114 +++++++++++++++++++++++++++++++++++++++--
 t/meson.build                  |   3 +-
 t/t3450-history.sh             |  32 +++++++++++-
 4 files changed, 163 insertions(+), 6 deletions(-)
Show changes to 4 files +163 −6

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3450-history.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 1537960374..3e9a789b83 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -8,7 +8,9 @@ git-history - EXPERIMENTAL: Rewrite history of the current branch
 SYNOPSIS
 --------
 [synopsis]
-git history [<options>]
+git history abort
+git history continue
+git history quit
 
 DESCRIPTION
 -----------
@@ -33,6 +35,22 @@ COMMANDS
 This command requires a subcommand. Several subcommands are available to
 rewrite history in different ways:
 
+The following commands are used to manage an interrupted history-rewriting
+operation:
+
+`abort`::
+	Abort the history-rewriting operation and reset HEAD to the original
+	branch.
+
+`continue`::
+	Restart the history-rewriting process after having resolved a merge
+	conflict.
+
+`quit`::
+	Abort the history-rewriting operation but `HEAD` is not reset back to
+	the original branch. The index and working tree are also left unchanged
+	as a result.
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index d1a40368e0..0ad45dbfef 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,20 +1,128 @@
 #include "builtin.h"
+#include "branch.h"
 #include "gettext.h"
 #include "parse-options.h"
+#include "sequencer.h"
+
+static int cmd_history_abort(int argc,
+			     const char **argv,
+			     const char *prefix,
+			     struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history abort"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct replay_opts opts = REPLAY_OPTS_INIT;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc) {
+		ret = error(_("command does not take arguments"));
+		goto out;
+	}
+
+	opts.action = REPLAY_HISTORY_EDIT;
+	ret = sequencer_rollback(repo, &opts);
+	if (ret)
+		goto out;
+
+	ret = 0;
+
+out:
+	replay_opts_release(&opts);
+	return ret;
+}
+
+static int cmd_history_continue(int argc,
+				const char **argv,
+				const char *prefix,
+				struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history continue"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct replay_opts opts = REPLAY_OPTS_INIT;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc) {
+		ret = error(_("command does not take arguments"));
+		goto out;
+	}
+
+	opts.action = REPLAY_HISTORY_EDIT;
+	ret = sequencer_continue(repo, &opts);
+	if (ret)
+		goto out;
+
+	ret = 0;
+
+out:
+	replay_opts_release(&opts);
+	return ret;
+}
+
+static int cmd_history_quit(int argc,
+			    const char **argv,
+			    const char *prefix,
+			    struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history quit"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct replay_opts opts = REPLAY_OPTS_INIT;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc) {
+		ret = error(_("command does not take arguments"));
+		goto out;
+	}
+
+	opts.action = REPLAY_HISTORY_EDIT;
+	ret = sequencer_remove_state(repo, &opts);
+	if (ret)
+		goto out;
+	remove_branch_state(repo, 0);
+
+	ret = 0;
+
+out:
+	replay_opts_release(&opts);
+	return ret;
+}
 
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
-		struct repository *repo UNUSED)
+		struct repository *repo)
 {
 	const char * const usage[] = {
-		N_("git history [<options>]"),
+		N_("git history abort"),
+		N_("git history continue"),
+		N_("git history quit"),
 		NULL,
 	};
+	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
+		OPT_SUBCOMMAND("abort", &fn, cmd_history_abort),
+		OPT_SUBCOMMAND("continue", &fn, cmd_history_continue),
+		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
 		OPT_END(),
 	};
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
-	return 0;
+	return fn(argc, argv, prefix, repo);
 }
diff --git a/t/meson.build b/t/meson.build
index bbeba1a8d5..966d7c14f4 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -376,6 +376,7 @@ integration_tests = [
   't3436-rebase-more-options.sh',
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
+  't3450-history.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
@@ -1214,4 +1215,4 @@ if perl.found() and time.found()
       timeout: 0,
     )
   endforeach
-endif
\ No newline at end of file
+endif
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
index 9eb1ed6749..aa9d44c03b 100755
--- a/t/t3450-history.sh
+++ b/t/t3450-history.sh
@@ -6,7 +6,37 @@ test_description='tests for git-history command'
 
 test_expect_success 'refuses to do anything without subcommand' '
 	test_must_fail git history 2>err &&
-	test_grep foo err
+	test_grep "need a subcommand" err
+'
+
+test_expect_success 'abort complains about arguments' '
+	test_must_fail git history abort foo 2>err &&
+	test_grep "command does not take arguments" err
+'
+
+test_expect_success 'abort complains when no history edit is active' '
+	test_must_fail git history abort 2>err &&
+	test_grep "no history edit in progress" err
+'
+
+test_expect_success 'continue complains about arguments' '
+	test_must_fail git history continue foo 2>err &&
+	test_grep "command does not take arguments" err
+'
+
+test_expect_success 'continue complains when no history edit is active' '
+	test_must_fail git history continue 2>err &&
+	test_grep "no history edit in progress" err
+'
+
+test_expect_success 'quit complains about arguments' '
+	test_must_fail git history quit foo 2>err &&
+	test_grep "command does not take arguments" err
+'
+
+test_expect_success 'quit does not complain when no history edit is active' '
+	git history quit 2>err &&
+	test_must_be_empty err
 '
 
 test_done
-- 
2.51.0.308.g032396e0da.dirty
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 08/16] builtin/history: implement "drop" subcommand

It is a fairly common operation to perform an interactive rebase so that one of the commits can be dropped from history. Doing this is not very hard in general, but still requires the user to perform multiple steps:

  1. Identify the commit in question that is to be dropped.
  2. Perform an interactive rebase on top of that commit's parent.
  3. Edit the instruction sheet to drop that commit.

This is needlessly complex for such a supposedly-trivial operation. Furthermore, the second step doesn't account for certain edge cases like for example dropping the root commit.

Introduce a new "drop" subcommand to make this use case significantly simpler: all the user needs to do is to say `git history drop $COMMIT` and they're done.

Note that for now, this command only allows users to drop a single commit at once. It should be easy enough though to expand the command at a later point in time to support dropping whole commit ranges.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  27 ++++
 builtin/history.c              | 287 +++++++++++++++++++++++++++++++++++++++++
 t/meson.build                  |   1 +
 t/t3451-history-drop.sh        | 174 +++++++++++++++++++++++++
 4 files changed, 489 insertions(+)
Show changes to 4 files +489 −0

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3451-history-drop.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 3e9a789b83..db5b292994 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -11,6 +11,7 @@ SYNOPSIS
 git history abort
 git history continue
 git history quit
+git history drop <commit>
 
 DESCRIPTION
 -----------
@@ -35,6 +36,15 @@ COMMANDS
 This command requires a subcommand. Several subcommands are available to
 rewrite history in different ways:
 
+`drop <commit>`::
+	Drop a commit from the history and reapply all children of that
+	commit on top of the commit's parent. The commit that is to be
+	dropped must be reachable from the current `HEAD` commit.
++
+Dropping the root commit converts the child of that commit into the new
+root commit. It is invalid to drop a root commit that does not have any
+child commits, as that would lead to an empty branch.
+
 The following commands are used to manage an interrupted history-rewriting
 operation:
 
@@ -51,6 +61,23 @@ operation:
 	the original branch. The index and working tree are also left unchanged
 	as a result.
 
+EXAMPLES
+--------
+
+Drop a commit from history
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+----------
+$ git log --oneline
+2d4cd6d third
+125a0f3 second
+e098c27 first
+$ git history drop HEAD~
+$ git log
+b1bc1bd third
+e098c27 first
+----------
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index 0ad45dbfef..2132b6a441 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,7 +1,16 @@
 #include "builtin.h"
 #include "branch.h"
+#include "commit.h"
+#include "commit-reach.h"
+#include "config.h"
+#include "environment.h"
 #include "gettext.h"
+#include "hex.h"
+#include "object-name.h"
 #include "parse-options.h"
+#include "refs.h"
+#include "reset.h"
+#include "revision.h"
 #include "sequencer.h"
 
 static int cmd_history_abort(int argc,
@@ -104,6 +113,282 @@ static int cmd_history_quit(int argc,
 	return ret;
 }
 
+static int collect_commits(struct repository *repo,
+			   struct commit *old_commit,
+			   struct commit *new_commit,
+			   struct strvec *out)
+{
+	struct setup_revision_opt revision_opts = {
+		.assume_dashdash = 1,
+	};
+	struct strvec revisions = STRVEC_INIT;
+	struct commit_list *from_list = NULL;
+	struct commit *child;
+	struct rev_info rev = { 0 };
+	int ret;
+
+	/*
+	 * Check that the old commit actually is an ancestor of HEAD. If not
+	 * the whole request becomes nonsensical.
+	*/
+	if (old_commit) {
+		commit_list_insert(old_commit, &from_list);
+		if (!repo_is_descendant_of(repo, new_commit, from_list)) {
+			ret = error(_("commit must be reachable from current HEAD commit"));
+			goto out;
+		}
+	}
+
+	repo_init_revisions(repo, &rev, NULL);
+	strvec_push(&revisions, "");
+	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
+	if (old_commit)
+		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
+	if (setup_revisions(revisions.nr, revisions.v, &rev, &revision_opts) != 1 ||
+	    prepare_revision_walk(&rev)) {
+		ret = error(_("revision walk setup failed"));
+		goto out;
+	}
+
+	while ((child = get_revision(&rev))) {
+		if (old_commit && !child->parents)
+			BUG("revision walk did not find child commit");
+		if (child->parents && child->parents->next) {
+			ret = error(_("cannot rearrange commit history with merges"));
+			goto out;
+		}
+
+		strvec_push(out, oid_to_hex(&child->object.oid));
+
+		if (child->parents && old_commit &&
+		    commit_list_contains(old_commit, child->parents))
+			break;
+	}
+
+	/*
+	 * Revisions are in newest-order-first. We have to reverse the
+	 * array though so that we pick the oldest commits first.
+	 */
+	for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
+		SWAP(out->v[i], out->v[j]);
+
+	ret = 0;
+
+out:
+	free_commit_list(from_list);
+	strvec_clear(&revisions);
+	release_revisions(&rev);
+	reset_revision_walk();
+	return ret;
+}
+
+static int apply_commits(struct repository *repo,
+			 const struct strvec *commits,
+			 struct commit *head,
+			 struct commit *base,
+			 const char *action)
+{
+	struct setup_revision_opt revision_opts = {
+		.assume_dashdash = 1,
+	};
+	struct replay_opts replay_opts = REPLAY_OPTS_INIT;
+	struct reset_head_opts reset_opts = { 0 };
+	struct object_id root_commit;
+	struct strvec args = STRVEC_INIT;
+	struct strbuf buf = STRBUF_INIT;
+	char hex[GIT_MAX_HEXSZ + 1];
+	int ref_flags, ret;
+
+	/*
+	 * We have performed all safety checks, so we now prepare
+	 * replaying the commits.
+	*/
+	replay_opts.action = REPLAY_HISTORY_EDIT;
+	sequencer_init_config(&replay_opts);
+	replay_opts.quiet = 1;
+	replay_opts.skip_commit_summary = 1;
+	if (!replay_opts.strategy && replay_opts.default_strategy) {
+		replay_opts.strategy = replay_opts.default_strategy;
+		replay_opts.default_strategy = NULL;
+	}
+
+	strvec_push(&args, "");
+	strvec_pushv(&args, commits->v);
+
+	replay_opts.revs = xmalloc(sizeof(*replay_opts.revs));
+	repo_init_revisions(repo, replay_opts.revs, NULL);
+	replay_opts.revs->no_walk = 1;
+	replay_opts.revs->unsorted_input = 1;
+	if (setup_revisions(args.nr, args.v, replay_opts.revs,
+			    &revision_opts) != 1) {
+		ret = error(_("setting up revisions failed"));
+		goto out;
+	}
+
+	/*
+	 * If we're dropping the root commit we first need to create
+	 * a new empty root. We then instruct the seqencer machinery to
+	 * squash that root commit with the first commit we're picking
+	 * onto it.
+	 */
+	if (!base->parents) {
+		if (commit_tree("", 0, repo->hash_algo->empty_tree, NULL,
+				&root_commit, NULL, NULL) < 0) {
+			ret = error(_("Could not create new root commit"));
+			goto out;
+		}
+
+		replay_opts.squash_onto = root_commit;
+		replay_opts.have_squash_onto = 1;
+		reset_opts.oid = &root_commit;
+	} else {
+		reset_opts.oid = &base->parents->item->object.oid;
+	}
+
+	replay_opts.restore_head_target =
+		xstrdup_or_null(refs_resolve_ref_unsafe(get_main_ref_store(repo),
+							"HEAD", 0, NULL, &ref_flags));
+	if (!(ref_flags & REF_ISSYMREF))
+		FREE_AND_NULL(replay_opts.restore_head_target);
+
+	/*
+	 * Perform a hard-reset to the parent of our commit that is to
+	 * be dropped. This is the new base onto which we'll pick all
+	 * the descendants.
+	 */
+	strbuf_addf(&buf, "%s (start): checkout %s", action,
+		    oid_to_hex_r(hex, reset_opts.oid));
+	reset_opts.orig_head = &head->object.oid;
+	reset_opts.flags = RESET_HEAD_DETACH | RESET_ORIG_HEAD;
+	reset_opts.head_msg = buf.buf;
+	reset_opts.default_reflog_action = action;
+	if (reset_head(repo, &reset_opts) < 0) {
+		ret = error(_("could not switch to %s"), oid_to_hex(reset_opts.oid));
+		goto out;
+	}
+
+	ret = sequencer_pick_revisions(repo, &replay_opts);
+	if (ret < 0) {
+		ret = error(_("could not pick commits"));
+		goto out;
+	} else if (ret > 0) {
+		/*
+		 * A positive return value indicates we've got a merge
+		 * conflict. Bail out, but don't print a message as
+		 * `sequencer_pick_revisions()` already printed enough
+		 * information.
+		 */
+		ret = -1;
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	replay_opts_release(&replay_opts);
+	strbuf_release(&buf);
+	strvec_clear(&args);
+	return ret;
+}
+
+static int cmd_history_drop(int argc,
+			    const char **argv,
+			    const char *prefix,
+			    struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history drop <commit>"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct commit *commit_to_drop, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct strbuf buf = STRBUF_INIT;
+	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);
+
+	commit_to_drop = lookup_commit_reference_by_name(argv[0]);
+	if (!commit_to_drop) {
+		ret = error(_("commit to be dropped cannot be found: %s"), argv[0]);
+		goto out;
+	}
+	if (commit_to_drop->parents && commit_to_drop->parents->next) {
+		ret = error(_("commit to be dropped must not be a merge commit"));
+		goto out;
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	if (oideq(&commit_to_drop->object.oid, &head->object.oid)) {
+		/*
+		 * If we want to drop the tip of the current branch we don't
+		 * have to perform any rebase at all. Instead, we simply
+		 * perform a hard reset to the parent commit.
+		 */
+		struct reset_head_opts reset_opts = {
+			.orig_head = &head->object.oid,
+			.flags = RESET_ORIG_HEAD,
+			.default_reflog_action = "drop",
+		};
+		char hex[GIT_MAX_HEXSZ + 1];
+
+		if (!commit_to_drop->parents) {
+			ret = error(_("cannot drop the only commit on this branch"));
+			goto out;
+		}
+
+		oid_to_hex_r(hex, &commit_to_drop->parents->item->object.oid);
+		strbuf_addf(&buf, "drop (start): checkout %s", hex);
+		reset_opts.oid = &commit_to_drop->parents->item->object.oid;
+		reset_opts.head_msg = buf.buf;
+
+		if (reset_head(repo, &reset_opts) < 0) {
+			ret = error(_("could not switch to %s"), hex);
+			goto out;
+		}
+	} else {
+		/*
+		 * Prepare a revision walk from old commit to the commit that is
+		 * about to be dropped. This serves three purposes:
+		 *
+		 *   - We verify that the history doesn't contain any merges.
+		 *     For now, merges aren't yet handled by us.
+		 *
+		 *   - We need to find the child of the commit-to-be-dropped.
+		 *     This child is what will be adopted by the parent of the
+		 *     commit that we are about to drop.
+		 *
+		 *   - We compute the list of commits-to-be-picked.
+		 */
+		ret = collect_commits(repo, commit_to_drop, head, &commits);
+		if (ret < 0)
+			goto out;
+
+		ret = apply_commits(repo, &commits, head, commit_to_drop, "drop");
+		if (ret < 0)
+			goto out;
+	}
+
+	ret = 0;
+
+out:
+	strvec_clear(&commits);
+	strbuf_release(&buf);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -113,6 +398,7 @@ int cmd_history(int argc,
 		N_("git history abort"),
 		N_("git history continue"),
 		N_("git history quit"),
+		N_("git history drop <commit>"),
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -120,6 +406,7 @@ int cmd_history(int argc,
 		OPT_SUBCOMMAND("abort", &fn, cmd_history_abort),
 		OPT_SUBCOMMAND("continue", &fn, cmd_history_continue),
 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
+		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 966d7c14f4..8189c6c561 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -377,6 +377,7 @@ integration_tests = [
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
+  't3451-history-drop.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3451-history-drop.sh b/t/t3451-history-drop.sh
new file mode 100755
index 0000000000..325d353d73
--- /dev/null
+++ b/t/t3451-history-drop.sh
@@ -0,0 +1,174 @@
+#!/bin/sh
+
+test_description='tests for git-history drop subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history drop HEAD 2>err &&
+		test_grep "commit to be dropped must not be a merge commit" err &&
+		test_must_fail git history drop HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work when history becomes empty' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		test_must_fail git history drop HEAD 2>err &&
+		test_grep "cannot drop the only commit on this branch" err
+	)
+'
+
+test_expect_success 'refuses to work with changes in the worktree or index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		test_commit file file &&
+		echo foo >file &&
+		test_must_fail git history drop HEAD 2>err &&
+		test_grep "Your local changes to the following files would be overwritten" err &&
+		git add file &&
+		test_must_fail git history drop HEAD 2>err &&
+		test_grep "Your local changes to the following files would be overwritten" err
+	)
+'
+
+test_expect_success 'can drop tip of a branch' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		git history drop HEAD &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can drop commit in the middle' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+
+		git symbolic-ref HEAD >expect &&
+		git history drop HEAD~2 &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		fifth
+		fourth
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'correct order is retained' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history drop HEAD~3 &&
+		cat >expect <<-EOF &&
+		fifth
+		fourth
+		third
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can drop root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history drop HEAD~2 &&
+		cat >expect <<-EOF &&
+		third
+		second
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'conflicts are detected' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		echo original >file &&
+		git add . &&
+		git commit -m original &&
+		echo modified >file &&
+		git commit -am modified &&
+
+		test_must_fail git history drop HEAD~ >err 2>&1 &&
+		test_grep CONFLICT err &&
+		test_grep "git history continue" err &&
+		echo resolved >file &&
+		git add file &&
+		git history continue &&
+
+		cat >expect <<-EOF &&
+		modified
+		base
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+		echo resolved >expect &&
+		git cat-file -p HEAD:file >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.51.0.308.g032396e0da.dirty
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 09/16] builtin/history: implement "reorder" subcommand

When working in projects where having nice commits matters it's quite common that developers end up reordering commits a lot. This is typically done via interactive rebases, where they can then rearrange commits in the instruction sheet.

Still, this operation is a frequent-enough operation to provide a more direct way of doing this imperatively. As such, introduce a new "reorder" subcommand where users can reorder a commit A to come after or before another commit B:

    $ git log --oneline
    a978f73 fifth
    57594ee fourth
    04eb1c4 third
    d535e30 second
    bf7438d first
    $ git history reorder :/fourth --before=:/second
    $ git log --oneline
    1610fe0 fifth
    444f97d third
    2f90797 second
    b0ae659 fourth
    bf7438d first
    $ git history reorder :/fourth --after=:/second
    $ git log --oneline
    c48729d fifth
    f44a46e third
    26693b8 fourth
    8cb4171 second
    bf7438d first
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  35 +++++-
 builtin/history.c              | 130 +++++++++++++++++++++++
 t/meson.build                  |   1 +
 t/t3452-history-reorder.sh     | 234 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 399 insertions(+), 1 deletion(-)
Show changes to 4 files +399 −1

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3452-history-reorder.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index db5b292994..b36cd925dd 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -12,6 +12,7 @@ git history abort
 git history continue
 git history quit
 git history drop <commit>
+git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
 
 DESCRIPTION
 -----------
@@ -39,12 +40,18 @@ rewrite history in different ways:
 `drop <commit>`::
 	Drop a commit from the history and reapply all children of that
 	commit on top of the commit's parent. The commit that is to be
-	dropped must be reachable from the current `HEAD` commit.
+	dropped must be reachable from the currently checked-out commit.
 +
 Dropping the root commit converts the child of that commit into the new
 root commit. It is invalid to drop a root commit that does not have any
 child commits, as that would lead to an empty branch.
 
+`reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)`::
+	Move the commit so that it becomes either the parent of
+	<following-commit> or the child of <preceding-commit>. The commits must
+	be related to one another and must be reachable from the current `HEAD`
+	commit.
+
 The following commands are used to manage an interrupted history-rewriting
 operation:
 
@@ -78,6 +85,32 @@ b1bc1bd third
 e098c27 first
 ----------
 
+Reorder a commit
+~~~~~~~~~~~~~~~~
+
+----------
+$ git log --oneline
+a978f73 fifth
+57594ee fourth
+04eb1c4 third
+d535e30 second
+bf7438d first
+$ git history reorder :/fourth --before=:/second
+$ git log --oneline
+1610fe0 fifth
+444f97d third
+2f90797 second
+b0ae659 fourth
+bf7438d first
+$ git history reorder :/fourth --after=:/second
+$ git log --oneline
+c48729d fifth
+f44a46e third
+26693b8 fourth
+8cb4171 second
+bf7438d first
+----------
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index 2132b6a441..16b516856e 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -182,6 +182,33 @@ static int collect_commits(struct repository *repo,
 	return ret;
 }
 
+static void replace_commits(struct strvec *commits,
+			    const struct object_id *commit_to_replace,
+			    const struct object_id *replacements,
+			    size_t replacements_nr)
+{
+	char commit_to_replace_oid[GIT_MAX_HEXSZ + 1];
+	struct strvec replacement_oids = STRVEC_INIT;
+	bool found = false;
+	size_t i;
+
+	oid_to_hex_r(commit_to_replace_oid, commit_to_replace);
+	for (i = 0; i < replacements_nr; i++)
+		strvec_push(&replacement_oids, oid_to_hex(&replacements[i]));
+
+	for (i = 0; i < commits->nr; i++) {
+		if (strcmp(commits->v[i], commit_to_replace_oid))
+			continue;
+		strvec_splice(commits, i, 1, replacement_oids.v, replacement_oids.nr);
+		found = true;
+		break;
+	}
+	if (!found)
+		BUG("could not find commit to replace");
+
+	strvec_clear(&replacement_oids);
+}
+
 static int apply_commits(struct repository *repo,
 			 const struct strvec *commits,
 			 struct commit *head,
@@ -389,6 +416,107 @@ static int cmd_history_drop(int argc,
 	return ret;
 }
 
+static int cmd_history_reorder(int argc,
+			       const char **argv,
+			       const char *prefix,
+			       struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
+		NULL,
+	};
+	const char *before = NULL, *after = NULL;
+	struct option options[] = {
+		OPT_STRING(0, "before", &before, N_("commit"), N_("reorder before this commit")),
+		OPT_STRING(0, "after", &after, N_("commit"), N_("reorder after this commit")),
+		OPT_END(),
+	};
+	struct commit *commit_to_reorder, *head, *anchor, *old;
+	struct strvec commits = STRVEC_INIT;
+	struct object_id replacement[2];
+	struct commit_list *list = NULL;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc != 1)
+		die(_("command expects a single revision"));
+	if (!before && !after)
+		die(_("exactly one option of 'before' or 'after' must be given"));
+	die_for_incompatible_opt2(!!before, "before", !!after, "after");
+
+	repo_config(repo, git_default_config, NULL);
+
+	commit_to_reorder = lookup_commit_reference_by_name(argv[0]);
+	if (!commit_to_reorder)
+		die(_("commit to be reordered cannot be found: %s"), argv[0]);
+	if (commit_to_reorder->parents && commit_to_reorder->parents->next)
+		die(_("commit to be reordered must not be a merge commit"));
+
+	anchor = lookup_commit_reference_by_name(before ? before : after);
+	if (!commit_to_reorder)
+		die(_("anchor commit cannot be found: %s"), before ? before : after);
+
+	if (oideq(&commit_to_reorder->object.oid, &anchor->object.oid))
+		die(_("commit to reorder and anchor must not be the same"));
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head)
+		die(_("could not resolve HEAD to a commit"));
+
+	commit_list_append(commit_to_reorder, &list);
+	if (!repo_is_descendant_of(repo, commit_to_reorder, list))
+		die(_("reordered commit must be reachable from current HEAD commit"));
+
+	/*
+	 * There is no requirement for the user to have either one of the
+	 * provided commits be the parent or child. We thus have to figure out
+	 * ourselves which one is which.
+	*/
+	if (repo_is_descendant_of(repo, anchor, list))
+		old = commit_to_reorder;
+	else
+		old = anchor;
+
+	/*
+	 * Select the whole range of commits, including the boundary commit
+	 * itself. In case the old commit is the root commit we simply pass no
+	 * boundary.
+	*/
+	ret = collect_commits(repo, old->parents ? old->parents->item : NULL,
+			      head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/*
+	 * Perform the reordering of commits in the strvec. This is done by:
+	 *
+	 *   - Deleting the to-be-reordered commit from the range of commits.
+	 *
+	 *   - Replacing the anchor commit with the anchor commit plus the
+	 *     to-be-reordered commit.
+	 */
+	if (before) {
+		replacement[0] = commit_to_reorder->object.oid;
+		replacement[1] = anchor->object.oid;
+	} else {
+		replacement[0] = anchor->object.oid;
+		replacement[1] = commit_to_reorder->object.oid;
+	}
+	replace_commits(&commits, &commit_to_reorder->object.oid, NULL, 0);
+	replace_commits(&commits, &anchor->object.oid, replacement, ARRAY_SIZE(replacement));
+
+	ret = apply_commits(repo, &commits, head, old, "reorder");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	free_commit_list(list);
+	strvec_clear(&commits);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -399,6 +527,7 @@ int cmd_history(int argc,
 		N_("git history continue"),
 		N_("git history quit"),
 		N_("git history drop <commit>"),
+		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -407,6 +536,7 @@ int cmd_history(int argc,
 		OPT_SUBCOMMAND("continue", &fn, cmd_history_continue),
 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
 		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
+		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 8189c6c561..2bf7bcab5a 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -378,6 +378,7 @@ integration_tests = [
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
   't3451-history-drop.sh',
+  't3452-history-reorder.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3452-history-reorder.sh b/t/t3452-history-reorder.sh
new file mode 100755
index 0000000000..49a0784c29
--- /dev/null
+++ b/t/t3452-history-reorder.sh
@@ -0,0 +1,234 @@
+#!/bin/sh
+
+test_description='tests for git-history reorder subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history reorder HEAD --before=HEAD~ 2>err &&
+		test_grep "commit to be reordered must not be a merge commit" err &&
+		test_must_fail git history reorder HEAD~ --after=HEAD 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work with changes in the worktree or index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		test_commit file file &&
+		echo foo >file &&
+		test_must_fail git history reorder HEAD --before=HEAD~ 2>err &&
+		test_grep "Your local changes to the following files would be overwritten" err &&
+		git add file &&
+		test_must_fail git history reorder HEAD --before=HEAD~ 2>err &&
+		test_grep "Your local changes to the following files would be overwritten" err
+	)
+'
+
+test_expect_success 'requires exactly one of --before or --after' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_must_fail git history reorder HEAD 2>err &&
+		test_grep "exactly one option of ${SQ}before${SQ} or ${SQ}after${SQ} must be given" err &&
+		test_must_fail git history reorder HEAD --before=a --after=b 2>err &&
+		test_grep "options ${SQ}before${SQ} and ${SQ}after${SQ} cannot be used together" err
+	)
+'
+
+test_expect_success 'refuses to reorder commit with itself' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_must_fail git history reorder HEAD --after=HEAD 2>err &&
+		test_grep "commit to reorder and anchor must not be the same" err
+	)
+'
+
+test_expect_success '--before can move commit back in history' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history reorder :/fourth --before=:/second &&
+		cat >expect <<-EOF &&
+		fifth
+		third
+		second
+		fourth
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--before can move commit forward in history' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history reorder :/second --before=:/fourth &&
+		cat >expect <<-EOF &&
+		fifth
+		fourth
+		second
+		third
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--before can make a commit a root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history reorder :/third --before=:/first &&
+		cat >expect <<-EOF &&
+		second
+		first
+		third
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--after can move commit back in history' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history reorder :/fourth --after=:/second &&
+		cat >expect <<-EOF &&
+		fifth
+		third
+		fourth
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--after can move commit forward in history' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history reorder :/second --after=:/fourth &&
+		cat >expect <<-EOF &&
+		fifth
+		second
+		fourth
+		third
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--after can make commit the tip' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history reorder :/first --after=:/third &&
+		cat >expect <<-EOF &&
+		first
+		third
+		second
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'conflicts are detected' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		echo base >file &&
+		git add file &&
+		git commit -m base &&
+		echo "first edit" >file &&
+		git commit -am "first edit" &&
+		echo "second edit" >file &&
+		git commit -am "second edit" &&
+
+		git symbolic-ref HEAD >expect-head &&
+		test_must_fail git history reorder HEAD --before=HEAD~ &&
+		test_must_fail git symbolic-ref HEAD &&
+		echo "second edit" >file &&
+		git add file &&
+		test_must_fail git history continue &&
+		echo "first edit" >file &&
+		git add file &&
+		git history continue &&
+
+		cat >expect <<-EOF &&
+		first edit
+		second edit
+		base
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+
+		git symbolic-ref HEAD >actual-head &&
+		test_cmp expect-head actual-head
+	)
+'
+
+test_done
-- 
2.51.0.308.g032396e0da.dirty
D. Ben Knoble· Aug 26, 2025, 13:03 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v2 09/16] builtin/history: implement "reorder" subcommand

On Sun, Aug 24, 2025 at 1:43 PM Patrick Steinhardt <ps@pks.im> wrote:
Show 22 quoted lines
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> index db5b292994..b36cd925dd 100644
> --- a/Documentation/git-history.adoc
> +++ b/Documentation/git-history.adoc
> @@ -12,6 +12,7 @@ git history abort
>  git history continue
>  git history quit
>  git history drop <commit>
> +git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
>
>  DESCRIPTION
>  -----------
> @@ -39,12 +40,18 @@ rewrite history in different ways:
>  `drop <commit>`::
>         Drop a commit from the history and reapply all children of that
>         commit on top of the commit's parent. The commit that is to be
> -       dropped must be reachable from the current `HEAD` commit.
> +       dropped must be reachable from the currently checked-out commit.
>  +
>  Dropping the root commit converts the child of that commit into the new
>  root commit. It is invalid to drop a root commit that does not have any
>  child commits, as that would lead to an empty branch.
Fixup in the wrong commit, maybe?
Show 9 quoted lines
> +`reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)`::
> +       Move the commit so that it becomes either the parent of
> +       <following-commit> or the child of <preceding-commit>. The commits must
> +       be related to one another and must be reachable from the current `HEAD`
> +       commit.
> +
>  The following commands are used to manage an interrupted history-rewriting
>  operation:
>
-- 
D. Ben Knoble
Patrick Steinhardt· Sep 3, 2025, 12:19 UTC · re: D. Ben Knoble · lore

Re: [PATCH RFC v2 09/16] builtin/history: implement "reorder" subcommand

On Tue, Aug 26, 2025 at 09:03:18AM -0400, D. Ben Knoble wrote:
Show 17 quoted lines
> On Sun, Aug 24, 2025 at 1:43 PM Patrick Steinhardt <ps@pks.im> wrote:
> > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > index db5b292994..b36cd925dd 100644
> > --- a/Documentation/git-history.adoc
> > +++ b/Documentation/git-history.adoc
> > @@ -39,12 +40,18 @@ rewrite history in different ways:
> >  `drop <commit>`::
> >         Drop a commit from the history and reapply all children of that
> >         commit on top of the commit's parent. The commit that is to be
> > -       dropped must be reachable from the current `HEAD` commit.
> > +       dropped must be reachable from the currently checked-out commit.
> >  +
> >  Dropping the root commit converts the child of that commit into the new
> >  root commit. It is invalid to drop a root commit that does not have any
> >  child commits, as that would lead to an empty branch.
> 
> Fixup in the wrong commit, maybe?
Indeed, fixed now.
Patrick
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 10/16] add-patch: split out header from "add-interactive.h"

While we have a "add-patch.c" code file, its declarations are part of "add-interactive.h". This makes it somewhat harder than necessary to find relevant code and to identify clear boundaries between the two subsystems.

Split up concerns and move declarations that relate to "add-patch.c" into a new "add-patch.h" header.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.h | 23 +++--------------------
 add-patch.c       |  1 +
 add-patch.h       | 26 ++++++++++++++++++++++++++
 3 files changed, 30 insertions(+), 20 deletions(-)
Show changes to 3 files +30 −20

add-interactive.h, add-patch.c, add-patch.h

diff --git a/add-interactive.h b/add-interactive.h
index 4213dcd67b..fb95b6ee05 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -1,14 +1,11 @@
 #ifndef ADD_INTERACTIVE_H
 #define ADD_INTERACTIVE_H
 
+#include "add-patch.h"
 #include "color.h"
 
-struct add_p_opt {
-	int context;
-	int interhunkcontext;
-};
-
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+struct pathspec;
+struct repository;
 
 struct add_i_state {
 	struct repository *r;
@@ -32,21 +29,7 @@ void init_add_i_state(struct add_i_state *s, struct repository *r,
 		      struct add_p_opt *add_p_opt);
 void clear_add_i_state(struct add_i_state *s);
 
-struct repository;
-struct pathspec;
 int run_add_i(struct repository *r, const struct pathspec *ps,
 	      struct add_p_opt *add_p_opt);
 
-enum add_p_mode {
-	ADD_P_ADD,
-	ADD_P_STASH,
-	ADD_P_RESET,
-	ADD_P_CHECKOUT,
-	ADD_P_WORKTREE,
-};
-
-int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
-	      const struct pathspec *ps);
-
 #endif
diff --git a/add-patch.c b/add-patch.c
index 302e6ba7d9..e2b002fa73 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -3,6 +3,7 @@
 
 #include "git-compat-util.h"
 #include "add-interactive.h"
+#include "add-patch.h"
 #include "advice.h"
 #include "editor.h"
 #include "environment.h"
diff --git a/add-patch.h b/add-patch.h
new file mode 100644
index 0000000000..4394c74107
--- /dev/null
+++ b/add-patch.h
@@ -0,0 +1,26 @@
+#ifndef ADD_PATCH_H
+#define ADD_PATCH_H
+
+struct pathspec;
+struct repository;
+
+struct add_p_opt {
+	int context;
+	int interhunkcontext;
+};
+
+#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+
+enum add_p_mode {
+	ADD_P_ADD,
+	ADD_P_STASH,
+	ADD_P_RESET,
+	ADD_P_CHECKOUT,
+	ADD_P_WORKTREE,
+};
+
+int run_add_p(struct repository *r, enum add_p_mode mode,
+	      struct add_p_opt *o, const char *revision,
+	      const struct pathspec *ps);
+
+#endif
-- 
2.51.0.308.g032396e0da.dirty
Junio C Hamano· Aug 25, 2025, 16:41 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v2 10/16] add-patch: split out header from "add-interactive.h"

Patrick Steinhardt <ps@pks.im> writes:
Show 14 quoted lines
> While we have a "add-patch.c" code file, its declarations are part of
> "add-interactive.h". This makes it somewhat harder than necessary to
> find relevant code and to identify clear boundaries between the two
> subsystems.
>
> Split up concerns and move declarations that relate to "add-patch.c"
> into a new "add-patch.h" header.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  add-interactive.h | 23 +++--------------------
>  add-patch.c       |  1 +
>  add-patch.h       | 26 ++++++++++++++++++++++++++
>  3 files changed, 30 insertions(+), 20 deletions(-)

What is left in the interactive side is the things "add -i" can do other than "add -p" (aka "add -i" plus "5: patch"), which makes sense.

It is surprising that this step does not touch any of the clients of the "-p" machinery (like "git reset -p"), though. They surely do not need the rest of "add-interactive.h", do they?

Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 11/16] add-patch: split out `struct interactive_options`

The `struct add_p_opt` is reused both by our the infra for "git add -p" and "git add -i". Users of `run_add_i()` for example are expected to pass `struct add_p_opt`. This is somewhat confusing and raises the question which options apply to what part of the stack.

But things are even more confusing than that: while callers are expected to pass in `struct add_p_opt`, these options ultimately get used to initialize a `struct add_i_state` that is used by both subsystems. So we are basically going full circle here.

Refactor the code and split out a new `struct interactive_options` that hosts common options used by both. These options are then applied to a `struct interactive_config` that hosts common configuration.

This refactoring doesn't yet fully detangle the two subsystems from one another, as we still end up calling `init_add_i_state()` in the "git add -p" subsystem. This will be fixed in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.c  | 151 +++++++++++++----------------------------------------
 add-interactive.h  |  20 ++-----
 add-patch.c        | 145 +++++++++++++++++++++++++++++++++++++++++---------
 add-patch.h        |  33 ++++++++++--
 builtin/add.c      |  22 ++++----
 builtin/checkout.c |   4 +-
 builtin/commit.c   |  16 +++---
 builtin/reset.c    |  16 +++---
 builtin/stash.c    |  46 ++++++++--------
 commit.h           |   2 +-
 10 files changed, 243 insertions(+), 212 deletions(-)
Show changes to 10 files +243 −212

add-interactive.c, add-interactive.h, add-patch.c, add-patch.h, builtin/add.c, builtin/checkout.c, builtin/commit.c, builtin/reset.c, builtin/stash.c, commit.h

diff --git a/add-interactive.c b/add-interactive.c
index 3e692b47ec..3babc3e013 100644
--- a/add-interactive.c
+++ b/add-interactive.c
@@ -3,7 +3,6 @@
 #include "git-compat-util.h"
 #include "add-interactive.h"
 #include "color.h"
-#include "config.h"
 #include "diffcore.h"
 #include "gettext.h"
 #include "hash.h"
@@ -20,96 +19,18 @@
 #include "prompt.h"
 #include "tree.h"
 
-static void init_color(struct repository *r, struct add_i_state *s,
-		       const char *section_and_slot, char *dst,
-		       const char *default_color)
-{
-	char *key = xstrfmt("color.%s", section_and_slot);
-	const char *value;
-
-	if (!s->use_color)
-		dst[0] = '\0';
-	else if (repo_config_get_value(r, key, &value) ||
-		 color_parse(value, dst))
-		strlcpy(dst, default_color, COLOR_MAXLEN);
-
-	free(key);
-}
-
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt)
+		      struct interactive_options *opts)
 {
-	const char *value;
-
 	s->r = r;
-	s->context = -1;
-	s->interhunkcontext = -1;
-
-	if (repo_config_get_value(r, "color.interactive", &value))
-		s->use_color = -1;
-	else
-		s->use_color =
-			git_config_colorbool("color.interactive", value);
-	s->use_color = want_color(s->use_color);
-
-	init_color(r, s, "interactive.header", s->header_color, GIT_COLOR_BOLD);
-	init_color(r, s, "interactive.help", s->help_color, GIT_COLOR_BOLD_RED);
-	init_color(r, s, "interactive.prompt", s->prompt_color,
-		   GIT_COLOR_BOLD_BLUE);
-	init_color(r, s, "interactive.error", s->error_color,
-		   GIT_COLOR_BOLD_RED);
-
-	init_color(r, s, "diff.frag", s->fraginfo_color,
-		   diff_get_color(s->use_color, DIFF_FRAGINFO));
-	init_color(r, s, "diff.context", s->context_color, "fall back");
-	if (!strcmp(s->context_color, "fall back"))
-		init_color(r, s, "diff.plain", s->context_color,
-			   diff_get_color(s->use_color, DIFF_CONTEXT));
-	init_color(r, s, "diff.old", s->file_old_color,
-		diff_get_color(s->use_color, DIFF_FILE_OLD));
-	init_color(r, s, "diff.new", s->file_new_color,
-		diff_get_color(s->use_color, DIFF_FILE_NEW));
-
-	strlcpy(s->reset_color,
-		s->use_color ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
-
-	FREE_AND_NULL(s->interactive_diff_filter);
-	repo_config_get_string(r, "interactive.difffilter",
-			       &s->interactive_diff_filter);
-
-	FREE_AND_NULL(s->interactive_diff_algorithm);
-	repo_config_get_string(r, "diff.algorithm",
-			       &s->interactive_diff_algorithm);
-
-	if (!repo_config_get_int(r, "diff.context", &s->context))
-		if (s->context < 0)
-			die(_("%s cannot be negative"), "diff.context");
-	if (!repo_config_get_int(r, "diff.interHunkContext", &s->interhunkcontext))
-		if (s->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "diff.interHunkContext");
-
-	repo_config_get_bool(r, "interactive.singlekey", &s->use_single_key);
-	if (s->use_single_key)
-		setbuf(stdin, NULL);
-
-	if (add_p_opt->context != -1) {
-		if (add_p_opt->context < 0)
-			die(_("%s cannot be negative"), "--unified");
-		s->context = add_p_opt->context;
-	}
-	if (add_p_opt->interhunkcontext != -1) {
-		if (add_p_opt->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "--inter-hunk-context");
-		s->interhunkcontext = add_p_opt->interhunkcontext;
-	}
+	interactive_config_init(&s->cfg, r, opts);
 }
 
 void clear_add_i_state(struct add_i_state *s)
 {
-	FREE_AND_NULL(s->interactive_diff_filter);
-	FREE_AND_NULL(s->interactive_diff_algorithm);
+	interactive_config_clear(&s->cfg);
 	memset(s, 0, sizeof(*s));
-	s->use_color = -1;
+	interactive_config_clear(&s->cfg);
 }
 
 /*
@@ -262,7 +183,7 @@ static void list(struct add_i_state *s, struct string_list *list, int *selected,
 		return;
 
 	if (opts->header)
-		color_fprintf_ln(stdout, s->header_color,
+		color_fprintf_ln(stdout, s->cfg.header_color,
 				 "%s", opts->header);
 
 	for (i = 0; i < list->nr; i++) {
@@ -330,7 +251,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 		list(s, &items->items, items->selected, &opts->list_opts);
 
-		color_fprintf(stdout, s->prompt_color, "%s", opts->prompt);
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", opts->prompt);
 		fputs(singleton ? "> " : ">> ", stdout);
 		fflush(stdout);
 
@@ -408,7 +329,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 			if (from < 0 || from >= items->items.nr ||
 			    (singleton && from + 1 != to)) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("Huh (%s)?"), p);
 				break;
 			} else if (singleton) {
@@ -968,7 +889,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 				free(files->items.items[i].string);
 			} else if (item->index.unmerged ||
 				 item->worktree.unmerged) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("ignoring unmerged: %s"),
 						 files->items.items[i].string);
 				free(item);
@@ -990,9 +911,9 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 	opts->prompt = N_("Patch update");
 	count = list_and_choose(s, files, opts);
 	if (count > 0) {
-		struct add_p_opt add_p_opt = {
-			.context = s->context,
-			.interhunkcontext = s->interhunkcontext,
+		struct interactive_options opts = {
+			.context = s->cfg.context,
+			.interhunkcontext = s->cfg.interhunkcontext,
 		};
 		struct strvec args = STRVEC_INIT;
 		struct pathspec ps_selected = { 0 };
@@ -1004,7 +925,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 		parse_pathspec(&ps_selected,
 			       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,
 			       PATHSPEC_LITERAL_PATH, "", args.v);
-		res = run_add_p(s->r, ADD_P_ADD, &add_p_opt, NULL, &ps_selected);
+		res = run_add_p(s->r, ADD_P_ADD, &opts, NULL, &ps_selected);
 		strvec_clear(&args);
 		clear_pathspec(&ps_selected);
 	}
@@ -1040,10 +961,10 @@ static int run_diff(struct add_i_state *s, const struct pathspec *ps,
 		struct child_process cmd = CHILD_PROCESS_INIT;
 
 		strvec_pushl(&cmd.args, "git", "diff", "-p", "--cached", NULL);
-		if (s->context != -1)
-			strvec_pushf(&cmd.args, "--unified=%i", s->context);
-		if (s->interhunkcontext != -1)
-			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->interhunkcontext);
+		if (s->cfg.context != -1)
+			strvec_pushf(&cmd.args, "--unified=%i", s->cfg.context);
+		if (s->cfg.interhunkcontext != -1)
+			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
 		strvec_pushl(&cmd.args, oid_to_hex(!is_initial ? &oid :
 			     s->r->hash_algo->empty_tree), "--", NULL);
 		for (i = 0; i < files->items.nr; i++)
@@ -1061,17 +982,17 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 		    struct prefix_item_list *files UNUSED,
 		    struct list_and_choose_options *opts UNUSED)
 {
-	color_fprintf_ln(stdout, s->help_color, "status        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "status        - %s",
 			 _("show paths with changes"));
-	color_fprintf_ln(stdout, s->help_color, "update        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "update        - %s",
 			 _("add working tree state to the staged set of changes"));
-	color_fprintf_ln(stdout, s->help_color, "revert        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "revert        - %s",
 			 _("revert staged set of changes back to the HEAD version"));
-	color_fprintf_ln(stdout, s->help_color, "patch         - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "patch         - %s",
 			 _("pick hunks and update selectively"));
-	color_fprintf_ln(stdout, s->help_color, "diff          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "diff          - %s",
 			 _("view diff between HEAD and index"));
-	color_fprintf_ln(stdout, s->help_color, "add untracked - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "add untracked - %s",
 			 _("add contents of untracked files to the staged set of changes"));
 
 	return 0;
@@ -1079,21 +1000,21 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 
 static void choose_prompt_help(struct add_i_state *s)
 {
-	color_fprintf_ln(stdout, s->help_color, "%s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "%s",
 			 _("Prompt help:"));
-	color_fprintf_ln(stdout, s->help_color, "1          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "1          - %s",
 			 _("select a single item"));
-	color_fprintf_ln(stdout, s->help_color, "3-5        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "3-5        - %s",
 			 _("select a range of items"));
-	color_fprintf_ln(stdout, s->help_color, "2-3,6-9    - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "2-3,6-9    - %s",
 			 _("select multiple ranges"));
-	color_fprintf_ln(stdout, s->help_color, "foo        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "foo        - %s",
 			 _("select item based on unique prefix"));
-	color_fprintf_ln(stdout, s->help_color, "-...       - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "-...       - %s",
 			 _("unselect specified items"));
-	color_fprintf_ln(stdout, s->help_color, "*          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "*          - %s",
 			 _("choose all items"));
-	color_fprintf_ln(stdout, s->help_color, "           - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "           - %s",
 			 _("(empty) finish selecting"));
 }
 
@@ -1128,7 +1049,7 @@ static void print_command_item(int i, int selected UNUSED,
 
 static void command_prompt_help(struct add_i_state *s)
 {
-	const char *help_color = s->help_color;
+	const char *help_color = s->cfg.help_color;
 	color_fprintf_ln(stdout, help_color, "%s", _("Prompt help:"));
 	color_fprintf_ln(stdout, help_color, "1          - %s",
 			 _("select a numbered item"));
@@ -1139,7 +1060,7 @@ static void command_prompt_help(struct add_i_state *s)
 }
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt)
+	      struct interactive_options *interactive_opts)
 {
 	struct add_i_state s = { NULL };
 	struct print_command_item_data data = { "[", "]" };
@@ -1182,15 +1103,15 @@ int run_add_i(struct repository *r, const struct pathspec *ps,
 			->util = util;
 	}
 
-	init_add_i_state(&s, r, add_p_opt);
+	init_add_i_state(&s, r, interactive_opts);
 
 	/*
 	 * When color was asked for, use the prompt color for
 	 * highlighting, otherwise use square brackets.
 	 */
-	if (s.use_color) {
-		data.color = s.prompt_color;
-		data.reset = s.reset_color;
+	if (s.cfg.use_color) {
+		data.color = s.cfg.prompt_color;
+		data.reset = s.cfg.reset_color;
 	}
 	print_file_item_data.color = data.color;
 	print_file_item_data.reset = data.reset;
diff --git a/add-interactive.h b/add-interactive.h
index fb95b6ee05..eefa2edc7c 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -2,34 +2,20 @@
 #define ADD_INTERACTIVE_H
 
 #include "add-patch.h"
-#include "color.h"
 
 struct pathspec;
 struct repository;
 
 struct add_i_state {
 	struct repository *r;
-	int use_color;
-	char header_color[COLOR_MAXLEN];
-	char help_color[COLOR_MAXLEN];
-	char prompt_color[COLOR_MAXLEN];
-	char error_color[COLOR_MAXLEN];
-	char reset_color[COLOR_MAXLEN];
-	char fraginfo_color[COLOR_MAXLEN];
-	char context_color[COLOR_MAXLEN];
-	char file_old_color[COLOR_MAXLEN];
-	char file_new_color[COLOR_MAXLEN];
-
-	int use_single_key;
-	char *interactive_diff_filter, *interactive_diff_algorithm;
-	int context, interhunkcontext;
+	struct interactive_config cfg;
 };
 
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt);
+		      struct interactive_options *opts);
 void clear_add_i_state(struct add_i_state *s);
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt);
+	      struct interactive_options *opts);
 
 #endif
diff --git a/add-patch.c b/add-patch.c
index e2b002fa73..45bc254e0c 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -5,6 +5,8 @@
 #include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "config.h"
+#include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
@@ -279,6 +281,99 @@ struct add_p_state {
 	const char *revision;
 };
 
+static void init_color(struct repository *r,
+		       struct interactive_config *cfg,
+		       const char *section_and_slot, char *dst,
+		       const char *default_color)
+{
+	char *key = xstrfmt("color.%s", section_and_slot);
+	const char *value;
+
+	if (!cfg->use_color)
+		dst[0] = '\0';
+	else if (repo_config_get_value(r, key, &value) ||
+		 color_parse(value, dst))
+		strlcpy(dst, default_color, COLOR_MAXLEN);
+
+	free(key);
+}
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts)
+{
+	const char *value;
+
+	cfg->context = -1;
+	cfg->interhunkcontext = -1;
+
+	if (repo_config_get_value(r, "color.interactive", &value))
+		cfg->use_color = -1;
+	else
+		cfg->use_color =
+			git_config_colorbool("color.interactive", value);
+	cfg->use_color = want_color(cfg->use_color);
+
+	init_color(r, cfg, "interactive.header", cfg->header_color, GIT_COLOR_BOLD);
+	init_color(r, cfg, "interactive.help", cfg->help_color, GIT_COLOR_BOLD_RED);
+	init_color(r, cfg, "interactive.prompt", cfg->prompt_color,
+		   GIT_COLOR_BOLD_BLUE);
+	init_color(r, cfg, "interactive.error", cfg->error_color,
+		   GIT_COLOR_BOLD_RED);
+
+	init_color(r, cfg, "diff.frag", cfg->fraginfo_color,
+		   diff_get_color(cfg->use_color, DIFF_FRAGINFO));
+	init_color(r, cfg, "diff.context", cfg->context_color, "fall back");
+	if (!strcmp(cfg->context_color, "fall back"))
+		init_color(r, cfg, "diff.plain", cfg->context_color,
+			   diff_get_color(cfg->use_color, DIFF_CONTEXT));
+	init_color(r, cfg, "diff.old", cfg->file_old_color,
+		diff_get_color(cfg->use_color, DIFF_FILE_OLD));
+	init_color(r, cfg, "diff.new", cfg->file_new_color,
+		diff_get_color(cfg->use_color, DIFF_FILE_NEW));
+
+	strlcpy(cfg->reset_color,
+		cfg->use_color ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
+
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	repo_config_get_string(r, "interactive.difffilter",
+			       &cfg->interactive_diff_filter);
+
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	repo_config_get_string(r, "diff.algorithm",
+			       &cfg->interactive_diff_algorithm);
+
+	if (!repo_config_get_int(r, "diff.context", &cfg->context))
+		if (cfg->context < 0)
+			die(_("%s cannot be negative"), "diff.context");
+	if (!repo_config_get_int(r, "diff.interHunkContext", &cfg->interhunkcontext))
+		if (cfg->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "diff.interHunkContext");
+
+	repo_config_get_bool(r, "interactive.singlekey", &cfg->use_single_key);
+	if (cfg->use_single_key)
+		setbuf(stdin, NULL);
+
+	if (opts->context != -1) {
+		if (opts->context < 0)
+			die(_("%s cannot be negative"), "--unified");
+		cfg->context = opts->context;
+	}
+	if (opts->interhunkcontext != -1) {
+		if (opts->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "--inter-hunk-context");
+		cfg->interhunkcontext = opts->interhunkcontext;
+	}
+}
+
+void interactive_config_clear(struct interactive_config *cfg)
+{
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	memset(cfg, 0, sizeof(*cfg));
+	cfg->use_color = -1;
+}
+
 static void add_p_state_clear(struct add_p_state *s)
 {
 	size_t i;
@@ -299,9 +394,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.error_color, stdout);
+	fputs(s->s.cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.reset_color);
+	puts(s->s.cfg.reset_color);
 	va_end(args);
 }
 
@@ -424,12 +519,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.context);
-	if (s->s.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.interhunkcontext);
-	if (s->s.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.interactive_diff_algorithm);
+	if (s->s.cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
+	if (s->s.cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
+	if (s->s.cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -460,7 +555,7 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 
 	if (want_color_fd(1, -1)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.interactive_diff_filter;
+		const char *diff_filter = s->s.cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -693,7 +788,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.fraginfo_color);
+			strbuf_addstr(out, s->s.cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -715,7 +810,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.reset_color);
+			strbuf_addf(out, "%s\n", s->s.cfg.reset_color);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1103,12 +1198,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.file_old_color :
+			      s->s.cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.file_new_color :
-			      s->s.context_color);
+			      s->s.cfg.file_new_color :
+			      s->s.cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.reset_color);
+		strbuf_addstr(&s->colored, s->s.cfg.reset_color);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1227,7 +1322,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.use_single_key) {
+	if (s->s.cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1241,7 +1336,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1522,15 +1617,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.reset_color)
-			fputs(s->s.reset_color, stdout);
+		if (*s->s.cfg.reset_color)
+			fputs(s->s.cfg.reset_color, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			break;
@@ -1687,7 +1782,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.header_color,
+				color_fprintf_ln(stdout, s->s.cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1705,7 +1800,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.help_color, "%s",
+			color_fprintf(stdout, s->s.cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1723,7 +1818,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.help_color,
+				color_fprintf_ln(stdout, s->s.cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1764,7 +1859,7 @@ static int patch_update_file(struct add_p_state *s,
 }
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
@@ -1772,7 +1867,7 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, o);
+	init_add_i_state(&s.s, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
diff --git a/add-patch.h b/add-patch.h
index 4394c74107..51c0d7bce9 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -1,15 +1,42 @@
 #ifndef ADD_PATCH_H
 #define ADD_PATCH_H
 
+#include "color.h"
+
 struct pathspec;
 struct repository;
 
-struct add_p_opt {
+struct interactive_options {
 	int context;
 	int interhunkcontext;
 };
 
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+#define INTERACTIVE_OPTIONS_INIT { \
+	.context = -1, \
+	.interhunkcontext = -1, \
+}
+
+struct interactive_config {
+	int use_color;
+	char header_color[COLOR_MAXLEN];
+	char help_color[COLOR_MAXLEN];
+	char prompt_color[COLOR_MAXLEN];
+	char error_color[COLOR_MAXLEN];
+	char reset_color[COLOR_MAXLEN];
+	char fraginfo_color[COLOR_MAXLEN];
+	char context_color[COLOR_MAXLEN];
+	char file_old_color[COLOR_MAXLEN];
+	char file_new_color[COLOR_MAXLEN];
+
+	int use_single_key;
+	char *interactive_diff_filter, *interactive_diff_algorithm;
+	int context, interhunkcontext;
+};
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts);
+void interactive_config_clear(struct interactive_config *cfg);
 
 enum add_p_mode {
 	ADD_P_ADD,
@@ -20,7 +47,7 @@ enum add_p_mode {
 };
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
 #endif
diff --git a/builtin/add.c b/builtin/add.c
index 0235854f80..a94c826c14 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -30,7 +30,7 @@ static const char * const builtin_add_usage[] = {
 	NULL
 };
 static int patch_interactive, add_interactive, edit_interactive;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int take_worktree_changes;
 static int add_renormalize;
 static int pathspec_file_nul;
@@ -159,7 +159,7 @@ static int refresh(struct repository *repo, int verbose, const struct pathspec *
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt)
+		    int patch, struct interactive_options *interactive_opts)
 {
 	struct pathspec pathspec;
 	int ret;
@@ -171,9 +171,9 @@ int interactive_add(struct repository *repo,
 		       prefix, argv);
 
 	if (patch)
-		ret = !!run_add_p(repo, ADD_P_ADD, add_p_opt, NULL, &pathspec);
+		ret = !!run_add_p(repo, ADD_P_ADD, interactive_opts, NULL, &pathspec);
 	else
-		ret = !!run_add_i(repo, &pathspec, add_p_opt);
+		ret = !!run_add_i(repo, &pathspec, interactive_opts);
 
 	clear_pathspec(&pathspec);
 	return ret;
@@ -255,8 +255,8 @@ static struct option builtin_add_options[] = {
 	OPT_GROUP(""),
 	OPT_BOOL('i', "interactive", &add_interactive, N_("interactive picking")),
 	OPT_BOOL('p', "patch", &patch_interactive, N_("select hunks interactively")),
-	OPT_DIFF_UNIFIED(&add_p_opt.context),
-	OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+	OPT_DIFF_UNIFIED(&interactive_opts.context),
+	OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 	OPT_BOOL('e', "edit", &edit_interactive, N_("edit current diff and apply")),
 	OPT__FORCE(&ignored_too, N_("allow adding otherwise ignored files"), 0),
 	OPT_BOOL('u', "update", &take_worktree_changes, N_("update tracked files")),
@@ -398,9 +398,9 @@ int cmd_add(int argc,
 	prepare_repo_settings(repo);
 	repo->settings.command_requires_full_index = 0;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (patch_interactive)
@@ -410,11 +410,11 @@ int cmd_add(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--dry-run", "--interactive/--patch");
 		if (pathspec_from_file)
 			die(_("options '%s' and '%s' cannot be used together"), "--pathspec-from-file", "--interactive/--patch");
-		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &add_p_opt));
+		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &interactive_opts));
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
diff --git a/builtin/checkout.c b/builtin/checkout.c
index 43583c8d1b..0b90f398fe 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -546,7 +546,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 
 	if (opts->patch_mode) {
 		enum add_p_mode patch_mode;
-		struct add_p_opt add_p_opt = {
+		struct interactive_options interactive_opts = {
 			.context = opts->patch_context,
 			.interhunkcontext = opts->patch_interhunk_context,
 		};
@@ -575,7 +575,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 		else
 			BUG("either flag must have been set, worktree=%d, index=%d",
 			    opts->checkout_worktree, opts->checkout_index);
-		return !!run_add_p(the_repository, patch_mode, &add_p_opt,
+		return !!run_add_p(the_repository, patch_mode, &interactive_opts,
 				   rev, &opts->pathspec);
 	}
 
diff --git a/builtin/commit.c b/builtin/commit.c
index b5b9608813..767351fd87 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -123,7 +123,7 @@ static const char *edit_message, *use_message;
 static char *fixup_message, *fixup_commit, *squash_message;
 static const char *fixup_prefix;
 static int all, also, interactive, patch_interactive, only, amend, signoff;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int edit_flag = -1; /* unspecified */
 static int quiet, verbose, no_verify, allow_empty, dry_run, renew_authorship;
 static int config_commit_verbose = -1; /* unspecified */
@@ -356,9 +356,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 	const char *ret;
 	char *path = NULL;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (is_status)
@@ -407,7 +407,7 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 		setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-		if (interactive_add(the_repository, argv, prefix, patch_interactive, &add_p_opt) != 0)
+		if (interactive_add(the_repository, argv, prefix, patch_interactive, &interactive_opts) != 0)
 			die(_("interactive add failed"));
 
 		the_repository->index_file = old_repo_index_file;
@@ -432,9 +432,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		ret = get_lock_file_path(&index_lock);
 		goto out;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
@@ -1738,8 +1738,8 @@ int cmd_commit(int argc,
 		OPT_BOOL('i', "include", &also, N_("add specified files to index for commit")),
 		OPT_BOOL(0, "interactive", &interactive, N_("interactively add files")),
 		OPT_BOOL('p', "patch", &patch_interactive, N_("interactively add changes")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('o', "only", &only, N_("commit only specified files")),
 		OPT_BOOL('n', "no-verify", &no_verify, N_("bypass pre-commit and commit-msg hooks")),
 		OPT_BOOL(0, "dry-run", &dry_run, N_("show what would be committed")),
diff --git a/builtin/reset.c b/builtin/reset.c
index ed35802af1..088449e120 100644
--- a/builtin/reset.c
+++ b/builtin/reset.c
@@ -346,7 +346,7 @@ int cmd_reset(int argc,
 	struct object_id oid;
 	struct pathspec pathspec;
 	int intent_to_add = 0;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	const struct option options[] = {
 		OPT__QUIET(&quiet, N_("be quiet, only report errors")),
 		OPT_BOOL(0, "no-refresh", &no_refresh,
@@ -371,8 +371,8 @@ int cmd_reset(int argc,
 			       PARSE_OPT_OPTARG,
 			       option_parse_recurse_submodules_worktree_updater),
 		OPT_BOOL('p', "patch", &patch_mode, N_("select hunks interactively")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('N', "intent-to-add", &intent_to_add,
 				N_("record only the fact that removed paths will be added later")),
 		OPT_PATHSPEC_FROM_FILE(&pathspec_from_file),
@@ -423,9 +423,9 @@ int cmd_reset(int argc,
 		oidcpy(&oid, &tree->object.oid);
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	prepare_repo_settings(the_repository);
@@ -436,12 +436,12 @@ int cmd_reset(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--patch", "--{hard,mixed,soft}");
 		trace2_cmd_mode("patch-interactive");
 		update_ref_status = !!run_add_p(the_repository, ADD_P_RESET,
-						&add_p_opt, rev, &pathspec);
+						&interactive_opts, rev, &pathspec);
 		goto cleanup;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
diff --git a/builtin/stash.c b/builtin/stash.c
index 1977e50df2..5070b8a88f 100644
--- a/builtin/stash.c
+++ b/builtin/stash.c
@@ -1302,7 +1302,7 @@ static int stash_staged(struct stash_info *info, struct strbuf *out_patch,
 
 static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 		       struct strbuf *out_patch, int quiet,
-		       struct add_p_opt *add_p_opt)
+		       struct interactive_options *interactive_opts)
 {
 	int ret = 0;
 	struct child_process cp_read_tree = CHILD_PROCESS_INIT;
@@ -1327,7 +1327,7 @@ static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 	old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 	setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-	ret = !!run_add_p(the_repository, ADD_P_STASH, add_p_opt, NULL, ps);
+	ret = !!run_add_p(the_repository, ADD_P_STASH, interactive_opts, NULL, ps);
 
 	the_repository->index_file = old_repo_index_file;
 	if (old_index_env && *old_index_env)
@@ -1422,7 +1422,8 @@ static int stash_working_tree(struct stash_info *info, const struct pathspec *ps
 }
 
 static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_buf,
-			   int include_untracked, int patch_mode, struct add_p_opt *add_p_opt,
+			   int include_untracked, int patch_mode,
+			   struct interactive_options *interactive_opts,
 			   int only_staged, struct stash_info *info, struct strbuf *patch,
 			   int quiet)
 {
@@ -1504,7 +1505,7 @@ static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_b
 		untracked_commit_option = 1;
 	}
 	if (patch_mode) {
-		ret = stash_patch(info, ps, patch, quiet, add_p_opt);
+		ret = stash_patch(info, ps, patch, quiet, interactive_opts);
 		if (ret < 0) {
 			if (!quiet)
 				fprintf_ln(stderr, _("Cannot save the current "
@@ -1590,7 +1591,8 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED,
 }
 
 static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int quiet,
-			 int keep_index, int patch_mode, struct add_p_opt *add_p_opt,
+			 int keep_index, int patch_mode,
+			 struct interactive_options *interactive_opts,
 			 int include_untracked, int only_staged)
 {
 	int ret = 0;
@@ -1662,7 +1664,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q
 	if (stash_msg)
 		strbuf_addstr(&stash_msg_buf, stash_msg);
 	if (do_create_stash(ps, &stash_msg_buf, include_untracked, patch_mode,
-			    add_p_opt, only_staged, &info, &patch, quiet)) {
+			    interactive_opts, only_staged, &info, &patch, quiet)) {
 		ret = -1;
 		goto done;
 	}
@@ -1835,7 +1837,7 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	char *pathspec_from_file = NULL;
 	struct pathspec ps;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1843,8 +1845,8 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1901,19 +1903,19 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	}
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index, patch_mode,
-			    &add_p_opt, include_untracked, only_staged);
+			    &interactive_opts, include_untracked, only_staged);
 
 	clear_pathspec(&ps);
 	free(pathspec_from_file);
@@ -1938,7 +1940,7 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	struct pathspec ps;
 	struct strbuf stash_msg_buf = STRBUF_INIT;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1946,8 +1948,8 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1967,20 +1969,20 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 
 	memset(&ps, 0, sizeof(ps));
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index,
-			    patch_mode, &add_p_opt, include_untracked,
+			    patch_mode, &interactive_opts, include_untracked,
 			    only_staged);
 
 	strbuf_release(&stash_msg_buf);
diff --git a/commit.h b/commit.h
index 1d6e0c7518..7b6e59d6c1 100644
--- a/commit.h
+++ b/commit.h
@@ -258,7 +258,7 @@ int for_each_commit_graft(each_commit_graft_fn, void *);
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt);
+		    int patch, struct interactive_options *opts);
 
 struct commit_extra_header {
 	struct commit_extra_header *next;
-- 
2.51.0.308.g032396e0da.dirty
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 12/16] add-patch: remove dependency on "add-interactive" subsystem

With the preceding commit we have split out interactive configuration that is used by both "git add -p" and "git add -i". But we still initialize that configuration in the "add -p" subsystem by calling `init_add_i_state()`, even though we only do so to initialize the interactive configuration as well as a repository pointer.

Stop doing so and instead store and initialize the interactive configuration in `struct add_p_state` directly.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 68 ++++++++++++++++++++++++++++++++-----------------------------
 1 file changed, 36 insertions(+), 32 deletions(-)
Show changes to add-patch.c +36 −32
diff --git a/add-patch.c b/add-patch.c
index 45bc254e0c..1bcbc91de9 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -2,7 +2,6 @@
 #define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
-#include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
 #include "config.h"
@@ -263,7 +262,8 @@ struct hunk {
 };
 
 struct add_p_state {
-	struct add_i_state s;
+	struct repository *r;
+	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
 	/* parsed diff */
@@ -385,7 +385,7 @@ static void add_p_state_clear(struct add_p_state *s)
 	for (i = 0; i < s->file_diff_nr; i++)
 		free(s->file_diff[i].hunk);
 	free(s->file_diff);
-	clear_add_i_state(&s->s);
+	interactive_config_clear(&s->cfg);
 }
 
 __attribute__((format (printf, 2, 3)))
@@ -394,9 +394,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.cfg.error_color, stdout);
+	fputs(s->cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.cfg.reset_color);
+	puts(s->cfg.reset_color);
 	va_end(args);
 }
 
@@ -414,7 +414,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->s.r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
 }
 
 static int parse_range(const char **p,
@@ -519,12 +519,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.cfg.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
-	if (s->s.cfg.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
-	if (s->s.cfg.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
+	if (s->cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->cfg.context);
+	if (s->cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
+	if (s->cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -555,7 +555,7 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 
 	if (want_color_fd(1, -1)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.cfg.interactive_diff_filter;
+		const char *diff_filter = s->cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -788,7 +788,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.cfg.fraginfo_color);
+			strbuf_addstr(out, s->cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -810,7 +810,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.cfg.reset_color);
+			strbuf_addf(out, "%s\n", s->cfg.reset_color);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1198,12 +1198,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.cfg.file_old_color :
+			      s->cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.cfg.file_new_color :
-			      s->s.cfg.context_color);
+			      s->cfg.file_new_color :
+			      s->cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.cfg.reset_color);
+		strbuf_addstr(&s->colored, s->cfg.reset_color);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1322,7 +1322,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.cfg.use_single_key) {
+	if (s->cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1336,7 +1336,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1617,15 +1617,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.cfg.reset_color)
-			fputs(s->s.cfg.reset_color, stdout);
+		if (*s->cfg.reset_color)
+			fputs(s->cfg.reset_color, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			break;
@@ -1782,7 +1782,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.cfg.header_color,
+				color_fprintf_ln(stdout, s->cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1800,7 +1800,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.cfg.help_color, "%s",
+			color_fprintf(stdout, s->cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1818,7 +1818,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.cfg.help_color,
+				color_fprintf_ln(stdout, s->cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1838,7 +1838,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->s.r->index);
+		discard_index(s->r->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1849,8 +1849,8 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->s.r) >= 0)
-			repo_refresh_and_write_index(s->s.r, REFRESH_QUIET, 0,
+		if (repo_read_index(s->r) >= 0)
+			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
 	}
 
@@ -1863,11 +1863,15 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
-		{ r }, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT
+		.r = r,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, opts);
+	interactive_config_init(&s.cfg, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
-- 
2.51.0.308.g032396e0da.dirty
Junio C Hamano· Aug 25, 2025, 16:43 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v2 12/16] add-patch: remove dependency on "add-interactive" subsystem

Patrick Steinhardt <ps@pks.im> writes:
Show 13 quoted lines
> With the preceding commit we have split out interactive configuration
> that is used by both "git add -p" and "git add -i". But we still
> initialize that configuration in the "add -p" subsystem by calling
> `init_add_i_state()`, even though we only do so to initialize the
> interactive configuration as well as a repository pointer.
>
> Stop doing so and instead store and initialize the interactive
> configuration in `struct add_p_state` directly.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  add-patch.c | 68 ++++++++++++++++++++++++++++++++-----------------------------
>  1 file changed, 36 insertions(+), 32 deletions(-)

Ahh, with the two steps, this and the previous one, my question is answered. I like the shape of the code base at this step very much.

Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 13/16] add-patch: add support for in-memory index patching

With `run_add_p()` callers have the ability to apply changes from a specific revision to a repository's index. This infra supports several different modes, like for example applying changes to the index, worktree or both.

One feature that is missing though is the ability to apply changes to an in-memory index different from the repository's index. Add a new function `run_add_p_index()` to plug this gap.

This new function will be used in a subsequent commit.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 add-patch.h |   8 +++++
 2 files changed, 115 insertions(+), 3 deletions(-)
Show changes to 2 files +115 −3

add-patch.c, add-patch.h

diff --git a/add-patch.c b/add-patch.c
index 1bcbc91de9..2a72c7b931 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -4,11 +4,13 @@
 #include "git-compat-util.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "commit.h"
 #include "config.h"
 #include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "object-name.h"
 #include "pager.h"
 #include "read-cache-ll.h"
@@ -263,6 +265,8 @@ struct hunk {
 
 struct add_p_state {
 	struct repository *r;
+	struct index_state *index;
+	const char *index_file;
 	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
@@ -414,7 +418,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->index_file);
 }
 
 static int parse_range(const char **p,
@@ -1838,7 +1842,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->r->index);
+		discard_index(s->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1849,9 +1853,11 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->r) >= 0)
+		if (read_index_from(s->index, s->index_file, s->r->gitdir) >= 0 &&
+		    s->index == s->r->index) {
 			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
+		}
 	}
 
 	putchar('\n');
@@ -1864,6 +1870,8 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 {
 	struct add_p_state s = {
 		.r = r,
+		.index = r->index,
+		.index_file = r->index_file,
 		.answer = STRBUF_INIT,
 		.buf = STRBUF_INIT,
 		.plain = STRBUF_INIT,
@@ -1922,3 +1930,99 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	add_p_state_clear(&s);
 	return 0;
 }
+
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps)
+{
+	struct patch_mode mode = {
+		.apply_args = { "--cached", NULL },
+		.apply_check_args = { "--cached", NULL },
+		.prompt_mode = {
+			N_("Stage mode change [y,n,q,a,d%s,?]? "),
+			N_("Stage deletion [y,n,q,a,d%s,?]? "),
+			N_("Stage addition [y,n,q,a,d%s,?]? "),
+			N_("Stage this hunk [y,n,q,a,d%s,?]? ")
+		},
+		.edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
+				     "will immediately be marked for staging."),
+		.help_patch_text =
+			N_("y - stage this hunk\n"
+			   "n - do not stage this hunk\n"
+			   "q - quit; do not stage this hunk or any of the remaining "
+				"ones\n"
+			   "a - stage this hunk and all later hunks in the file\n"
+			   "d - do not stage this hunk or any of the later hunks in "
+				"the file\n"),
+		.index_only = 1,
+	};
+	struct add_p_state s = {
+		.r = r,
+		.index = index,
+		.index_file = index_file,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
+		.mode = &mode,
+		.revision = revision,
+	};
+	struct strbuf parent_revision = STRBUF_INIT;
+	char parent_tree_oid[GIT_MAX_HEXSZ + 1];
+	size_t binary_count = 0;
+	struct commit *commit;
+	int ret;
+
+	commit = lookup_commit_reference_by_name(revision);
+	if (!commit) {
+		err(&s, _("Revision does not refer to a commit"));
+		ret = -1;
+		goto out;
+	}
+
+	if (commit->parents)
+		oid_to_hex_r(parent_tree_oid, get_commit_tree_oid(commit->parents->item));
+	else
+		oid_to_hex_r(parent_tree_oid, r->hash_algo->empty_tree);
+
+	strbuf_addf(&parent_revision, "%s~", revision);
+	mode.diff_cmd[0] = "diff-tree";
+	mode.diff_cmd[1] = "-r";
+	mode.diff_cmd[2] = parent_tree_oid;
+
+	interactive_config_init(&s.cfg, r, opts);
+
+	if (parse_diff(&s, ps) < 0) {
+		ret = -1;
+		goto out;
+	}
+
+	for (size_t i = 0; i < s.file_diff_nr; i++) {
+		if (s.file_diff[i].binary && !s.file_diff[i].hunk_nr)
+			binary_count++;
+		else if (patch_update_file(&s, s.file_diff + i))
+			break;
+	}
+
+	if (s.file_diff_nr == 0) {
+		err(&s, _("No changes."));
+		ret = -1;
+		goto out;
+	}
+
+	if (binary_count == s.file_diff_nr) {
+		err(&s, _("Only binary files changed."));
+		ret = -1;
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	strbuf_release(&parent_revision);
+	add_p_state_clear(&s);
+	return ret;
+}
diff --git a/add-patch.h b/add-patch.h
index 51c0d7bce9..d0edfec936 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -3,6 +3,7 @@
 
 #include "color.h"
 
+struct index_state;
 struct pathspec;
 struct repository;
 
@@ -50,4 +51,11 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps);
+
 #endif
-- 
2.51.0.308.g032396e0da.dirty
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 14/16] wt-status: provide function to expose status for trees

The "wt-status" subsystem is responsible for printing status information around the current state of the working tree. This most importantly includes information around whether the working tree or the index have any changes.

We're about to introduce a new command though where the changes in neither of them are actually relevant to us. Instead, what we want is to format the changes between two different trees. While it is a little bit of a stretch to add this as functionality to _working tree_ status, it doesn't make any sense to open-code this functionality, either.

Implement a new function `wt_status_collect_changes_trees()` that diffs two trees and formats the status accordingly. This function is not yet used, but will be in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 wt-status.c | 24 ++++++++++++++++++++++++
 wt-status.h |  3 +++
 2 files changed, 27 insertions(+)
Show changes to 2 files +27 −0

wt-status.c, wt-status.h

diff --git a/wt-status.c b/wt-status.c
index 454601afa15..f09309d12e3 100644
--- a/wt-status.c
+++ b/wt-status.c
@@ -612,6 +612,30 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q,
 	}
 }
 
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish)
+{
+	struct diff_options opts = { 0 };
+
+	repo_diff_setup(s->repo, &opts);
+	opts.output_format = DIFF_FORMAT_CALLBACK;
+	opts.format_callback = wt_status_collect_updated_cb;
+	opts.format_callback_data = s;
+	opts.detect_rename = s->detect_rename >= 0 ? s->detect_rename : opts.detect_rename;
+	opts.rename_limit = s->rename_limit >= 0 ? s->rename_limit : opts.rename_limit;
+	opts.rename_score = s->rename_score >= 0 ? s->rename_score : opts.rename_score;
+	opts.flags.recursive = 1;
+	diff_setup_done(&opts);
+
+	diff_tree_oid(old_treeish, new_treeish, "", &opts);
+	diffcore_std(&opts);
+	diff_flush(&opts);
+	wt_status_get_state(s->repo, &s->state, 0);
+
+	diff_free(&opts);
+}
+
 static void wt_status_collect_changes_worktree(struct wt_status *s)
 {
 	struct rev_info rev;
diff --git a/wt-status.h b/wt-status.h
index 4e377ce62b8..b262e345f79 100644
--- a/wt-status.h
+++ b/wt-status.h
@@ -153,6 +153,9 @@ void wt_status_add_cut_line(struct wt_status *s);
 void wt_status_prepare(struct repository *r, struct wt_status *s);
 void wt_status_print(struct wt_status *s);
 void wt_status_collect(struct wt_status *s);
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish);
 /*
  * Frees the buffers allocated by wt_status_collect.
  */
-- 
2.51.0.308.g032396e0da.dirty
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 15/16] builtin/history: implement "split" subcommand

It is quite a common use case that one wants to split up one commit into multiple commits by moving parts of the changes of the original commit out of it into a separate commit. This is quite an involved operation though:

  1. Identify the commit in question that is to be dropped.
  2. Perform an interactive rebase on top of that commit's parent.
  3. Modify the instruction sheet to "edit" the commit that is to be
     split up.
  4. Drop the commit via "git reset HEAD~".
  5. Stage changes that should go into the first commit and commit it.
  6. Stage changes that should go into the second commit and commit it.
  7. Finalize the rebase.

This is quite complex, and overall I would claim that most people who are not experts in Git would struggle with this flow.

Introduce a new "split" subcommand for git-history(1) to make this way easier. All the user needs to do is to say `git history split $COMMIT`. From hereon, Git asks the user which parts of the commit shall be moved out into a separate commit and, once done, asks the user for the commit message. Git then creates that split-out commit and applies the original commit on top of it.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  60 +++++++
 builtin/history.c              | 291 +++++++++++++++++++++++++++++++
 t/meson.build                  |   1 +
 t/t3453-history-split.sh       | 387 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 739 insertions(+)
Show changes to 4 files +737 −0

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3453-history-split.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index b36cd925dd..6f0c64b90e 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -13,6 +13,7 @@ git history continue
 git history quit
 git history drop <commit>
 git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
+git history split [<options>] <commit> [--] [<pathspec>...]
 
 DESCRIPTION
 -----------
@@ -52,6 +53,26 @@ child commits, as that would lead to an empty branch.
 	be related to one another and must be reachable from the current `HEAD`
 	commit.
 
+`split [--message=<message>] <commit> [--] [<pathspec>...]`::
+	Interactively split up <commit> into two commits by choosing
+	hunks introduced by it that will be moved into the new split-out
+	commit. These hunks will then be written into a new commit that
+	becomes the parent of the previous commit. The original commit
+	stays intact, except that its parent will be the newly split-out
+	commit.
++
+The commit message of the new commit will be asked for by launching the
+configured editor, unless it has been specified with the `-m` option.
+Authorship of the commit will be the same as for the original commit.
++
+If passed, _<pathspec>_ can be used to limit which changes shall be split out
+of the original commit. Files not matching any of the pathspecs will remain
+part of the original commit. For more details, see the 'pathspec' entry in
+linkgit:gitglossary[7].
++
+It is invalid to select either all or no hunks, as that would lead to
+one of the commits becoming empty.
+
 The following commands are used to manage an interrupted history-rewriting
 operation:
 
@@ -111,6 +132,45 @@ f44a46e third
 bf7438d first
 ----------
 
+Split a commit
+~~~~~~~~~~~~~~
+
+----------
+$ git log --stat --oneline
+3f81232 (HEAD -> main) original
+ bar | 1 +
+ foo | 1 +
+ 2 files changed, 2 insertions(+)
+
+$ git history split HEAD --message="split-out commit"
+diff --git a/bar b/bar
+new file mode 100644
+index 0000000..5716ca5
+--- /dev/null
++++ b/bar
+@@ -0,0 +1 @@
++bar
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? y
+
+diff --git a/foo b/foo
+new file mode 100644
+index 0000000..257cc56
+--- /dev/null
++++ b/foo
+@@ -0,0 +1 @@
++foo
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? n
+
+$ git log --stat --oneline
+7cebe64 (HEAD -> main) original
+ foo | 1 +
+ 1 file changed, 1 insertion(+)
+d1582f3 split-out commit
+ bar | 1 +
+ 1 file changed, 1 insertion(+)
+----------
+
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index 16b516856e..6d3f44152c 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,17 +1,27 @@
+/* Required for `comment_line_str`. */
+#define USE_THE_REPOSITORY_VARIABLE
+
 #include "builtin.h"
 #include "branch.h"
+#include "cache-tree.h"
 #include "commit.h"
 #include "commit-reach.h"
 #include "config.h"
+#include "editor.h"
 #include "environment.h"
 #include "gettext.h"
 #include "hex.h"
 #include "object-name.h"
 #include "parse-options.h"
+#include "path.h"
+#include "pathspec.h"
+#include "read-cache-ll.h"
 #include "refs.h"
 #include "reset.h"
 #include "revision.h"
+#include "run-command.h"
 #include "sequencer.h"
+#include "sparse-index.h"
 
 static int cmd_history_abort(int argc,
 			     const char **argv,
@@ -517,6 +527,285 @@ static int cmd_history_reorder(int argc,
 	return ret;
 }
 
+static void change_data_free(void *util, const char *str UNUSED)
+{
+	struct wt_status_change_data *d = util;
+	free(d->rename_source);
+	free(d);
+}
+
+static int fill_commit_message(struct repository *repo,
+			       const struct object_id *old_tree,
+			       const struct object_id *new_tree,
+			       const char *default_message,
+			       const char *provided_message,
+			       const char *action,
+			       struct strbuf *out)
+{
+	if (!provided_message) {
+		struct wt_status s;
+		const char *path = git_path_commit_editmsg();
+		const char *hint =
+			_("Please enter the commit message for the %s changes. Lines starting\n"
+			  "with '%s' will be kept; you may remove them yourself if you want to.\n");
+
+		strbuf_addstr(out, default_message);
+		strbuf_addch(out, '\n');
+		strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
+		write_file_buf(path, out->buf, out->len);
+
+		wt_status_prepare(repo, &s);
+		FREE_AND_NULL(s.branch);
+		s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
+		s.commit_template = 1;
+		s.colopts = 0;
+		s.display_comment_prefix = 1;
+		s.hints = 0;
+		s.use_color = 0;
+		s.whence = FROM_COMMIT;
+		s.committable = 1;
+
+		s.fp = fopen(git_path_commit_editmsg(), "a");
+		if (!s.fp)
+			return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
+
+		wt_status_collect_changes_trees(&s, old_tree, new_tree);
+		wt_status_print(&s);
+		wt_status_collect_free_buffers(&s);
+		string_list_clear_func(&s.change, change_data_free);
+
+		strbuf_reset(out);
+		if (launch_editor(path, out, NULL)) {
+			fprintf(stderr, _("Please supply the message using either -m or -F option.\n"));
+			return -1;
+		}
+		strbuf_stripspace(out, comment_line_str);
+
+	} else {
+		strbuf_addstr(out, provided_message);
+	}
+
+	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
+
+	if (!out->len) {
+		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
+		return -1;
+	}
+
+	return 0;
+}
+
+static int split_commit(struct repository *repo,
+			struct commit *original_commit,
+			struct pathspec *pathspec,
+			const char *commit_message,
+			struct object_id *out)
+{
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
+	struct strbuf index_file = STRBUF_INIT, split_message = STRBUF_INIT;
+	struct child_process read_tree_cmd = CHILD_PROCESS_INIT;
+	struct index_state index = INDEX_STATE_INIT(repo);
+	struct object_id original_commit_tree_oid, parent_tree_oid;
+	const char *original_message, *original_body, *ptr;
+	char original_commit_oid[GIT_MAX_HEXSZ + 1];
+	char *original_author = NULL;
+	struct commit_list *parents = NULL;
+	struct commit *first_commit;
+	struct tree *split_tree;
+	size_t len;
+	int ret;
+
+	if (original_commit->parents)
+		parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
+	else
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
+
+	/*
+	 * Construct the first commit. This is done by taking the original
+	 * commit parent's tree and selectively patching changes from the diff
+	 * between that parent and its child.
+	 */
+	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
+
+	read_tree_cmd.git_cmd = 1;
+	strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
+	strvec_push(&read_tree_cmd.args, "read-tree");
+	strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
+	ret = run_command(&read_tree_cmd);
+	if (ret < 0)
+		goto out;
+
+	ret = read_index_from(&index, index_file.buf, repo->gitdir);
+	if (ret < 0) {
+		ret = error(_("failed reading temporary index"));
+		goto out;
+	}
+
+	oid_to_hex_r(original_commit_oid, &original_commit->object.oid);
+	ret = run_add_p_index(repo, &index, index_file.buf, &interactive_opts,
+			      original_commit_oid, pathspec);
+	if (ret < 0)
+		goto out;
+
+	split_tree = write_in_core_index_as_tree(repo, &index);
+	if (!split_tree) {
+		ret = error(_("failed split tree"));
+		goto out;
+	}
+
+	unlink(index_file.buf);
+
+	/*
+	 * We disallow the cases where either the split-out commit or the
+	 * original commit would become empty. Consequently, if we see that the
+	 * new tree ID matches either of those trees we abort.
+	 */
+	if (oideq(&split_tree->object.oid, &parent_tree_oid)) {
+		ret = error(_("split commit is empty"));
+		goto out;
+	} else if (oideq(&split_tree->object.oid, &original_commit_tree_oid)) {
+		ret = error(_("split commit tree matches original commit"));
+		goto out;
+	}
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+
+	ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
+				  "", commit_message, "split-out", &split_message);
+	if (ret < 0)
+		goto out;
+
+	ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
+			  original_commit->parents, &out[0], original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing split-out commit"));
+		goto out;
+	}
+
+	/*
+	 * The second commit is much simpler to construct, as we can simply use
+	 * the original commit details, except that we adjust its parent to be
+	 * the newly split-out commit.
+	 */
+	find_commit_subject(original_message, &original_body);
+	first_commit = lookup_commit_reference(repo, &out[0]);
+	commit_list_append(first_commit, &parents);
+
+	ret = commit_tree(original_body, strlen(original_body), &original_commit_tree_oid,
+			  parents, &out[1], original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing second commit"));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	if (index_file.len)
+		unlink(index_file.buf);
+	strbuf_release(&split_message);
+	strbuf_release(&index_file);
+	free_commit_list(parents);
+	free(original_author);
+	release_index(&index);
+	return ret;
+}
+
+static int cmd_history_split(int argc,
+			     const char **argv,
+			     const char *prefix,
+			     struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history split [<options>] <commit>"),
+		NULL,
+	};
+	const char *commit_message = NULL;
+	struct option options[] = {
+		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
+		OPT_END(),
+	};
+	struct commit *original_commit, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct commit_list *list = NULL;
+	struct object_id split_commits[2];
+	struct pathspec pathspec = { 0 };
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc < 1) {
+		ret = error(_("command expects a revision"));
+		goto out;
+	}
+	repo_config(repo, git_default_config, NULL);
+
+	original_commit = lookup_commit_reference_by_name(argv[0]);
+	if (!original_commit) {
+		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
+		goto out;
+	}
+
+	if (original_commit->parents && original_commit->parents->next) {
+		ret = error(_("commit to be split must not be a merge commit"));
+		goto out;
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	commit_list_append(original_commit, &list);
+	if (!repo_is_descendant_of(repo, original_commit, list)) {
+		ret = error (_("split commit must be reachable from current HEAD commit"));
+		goto out;
+	}
+
+	parse_pathspec(&pathspec, 0,
+		       PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
+		       prefix, argv + 1);
+
+	/*
+	 * Collect the list of commits that we'll have to reapply now already.
+	 * This ensures that we'll abort early on in case the range of commits
+	 * contains merges, which we do not yet handle.
+	 */
+	ret = collect_commits(repo, original_commit->parents ? original_commit->parents->item : NULL,
+			      head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/*
+	 * Then we split up the commit and replace the original commit with the
+	 * new new ones.
+	 */
+	ret = split_commit(repo, original_commit, &pathspec,
+			   commit_message, split_commits);
+	if (ret < 0)
+		goto out;
+
+	replace_commits(&commits, &original_commit->object.oid,
+			split_commits, ARRAY_SIZE(split_commits));
+
+	ret = apply_commits(repo, &commits, head, original_commit, "split");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	clear_pathspec(&pathspec);
+	strvec_clear(&commits);
+	free_commit_list(list);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -528,6 +817,7 @@ int cmd_history(int argc,
 		N_("git history quit"),
 		N_("git history drop <commit>"),
 		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
+		N_("git history split [<options>] <commit> [--] [<pathspec>...]"),
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -537,6 +827,7 @@ int cmd_history(int argc,
 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
 		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
 		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
+		OPT_SUBCOMMAND("split", &fn, cmd_history_split),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 2bf7bcab5a..b3d33c8588 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -379,6 +379,7 @@ integration_tests = [
   't3450-history.sh',
   't3451-history-drop.sh',
   't3452-history-reorder.sh',
+  't3453-history-split.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3453-history-split.sh b/t/t3453-history-split.sh
new file mode 100755
index 0000000000..b053fc2f29
--- /dev/null
+++ b/t/t3453-history-split.sh
@@ -0,0 +1,387 @@
+#!/bin/sh
+
+test_description='tests for git-history split subcommand'
+
+. ./test-lib.sh
+
+set_fake_editor () {
+	write_script fake-editor.sh <<-\EOF &&
+	echo "split-out commit" >"$1"
+	EOF
+	test_set_editor "$(pwd)"/fake-editor.sh
+}
+
+expect_log () {
+	git log --format="%s" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+expect_tree_entries () {
+	git ls-tree --name-only "$1" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history split HEAD 2>err &&
+		test_grep "commit to be split must not be a merge commit" err &&
+		test_must_fail git history split HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work with changes in the worktree or index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		echo changed >bar &&
+		test_must_fail git history split -m message HEAD 2>err <<-EOF &&
+		y
+		n
+		EOF
+		test_grep "Your local changes to the following files would be overwritten" err &&
+
+		git add bar &&
+		test_must_fail git history split -m message HEAD 2>err <<-EOF &&
+		y
+		n
+		EOF
+		test_grep "Your local changes to the following files would be overwritten" err
+	)
+'
+
+test_expect_success 'can split up tip commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git symbolic-ref HEAD >expect &&
+		set_fake_editor &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		initial
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m root &&
+		test_commit tip &&
+
+		set_fake_editor &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		root
+		split-out commit
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up in-between commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+		test_commit tip &&
+
+		set_fake_editor &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		split-me
+		split-out commit
+		initial
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can pick multiple hunks' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar baz foo qux &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "split-out commit" <<-EOF &&
+		y
+		n
+		y
+		n
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		baz
+		foo
+		qux
+		EOF
+	)
+'
+
+
+test_expect_success 'can use only last hunk' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "split-out commit" <<-EOF &&
+		n
+		y
+		EOF
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD -m "" <<-EOF 2>err &&
+		y
+		n
+		EOF
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_expect_success 'can specify message via option' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "message option" <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF
+		split-me
+		message option
+		EOF
+	)
+'
+
+test_expect_success 'commit message editor sees split-out changes' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		echo "some commit message" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		cat >expect <<-EOF &&
+
+		# Please enter the commit message for the split-out changes. Lines starting
+		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
+		# Changes to be committed:
+		#	new file:   bar
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		expect_log <<-EOF
+		split-me
+		some commit message
+		EOF
+	)
+'
+
+test_expect_success 'can use pathspec to limit what gets split' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "message option" -- foo <<-EOF &&
+		y
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'refuses to create empty split-out commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		n
+		n
+		EOF
+		test_grep "split commit is empty" err
+	)
+'
+
+test_expect_success 'refuses to create empty original commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		y
+		y
+		EOF
+		test_grep "split commit tree matches original commit" err
+	)
+'
+
+test_done
-- 
2.51.0.308.g032396e0da.dirty
Kristoffer Haugsbakk· Aug 24, 2025, 18:03 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v2 15/16] builtin/history: implement "split" subcommand

On Sun, Aug 24, 2025, at 19:42, Patrick Steinhardt wrote:
> It is quite a common use case that one wants to split up one commit into
> multiple commits by moving parts of the changes of the original commit
> out of it into a separate commit. This is quite an involved operation
s/out of it into/out into/
Show 16 quoted lines
> though:
>
>   1. Identify the commit in question that is to be dropped.
>
>   2. Perform an interactive rebase on top of that commit's parent.
>
>   3. Modify the instruction sheet to "edit" the commit that is to be
>      split up.
>
>   4. Drop the commit via "git reset HEAD~".
>
>   5. Stage changes that should go into the first commit and commit it.
>
>   6. Stage changes that should go into the second commit and commit it.
>
>   7. Finalize the rebase.

Exactly right and this fills a conspicuous hole (in the rewriting parts of git(1)).

Show 10 quoted lines
>
> This is quite complex, and overall I would claim that most people who
> are not experts in Git would struggle with this flow.
>
> Introduce a new "split" subcommand for git-history(1) to make this way
> easier. All the user needs to do is to say `git history split $COMMIT`.
> From hereon, Git asks the user which parts of the commit shall be moved
> out into a separate commit and, once done, asks the user for the commit
> message. Git then creates that split-out commit and applies the original
> commit on top of it.
The interactive mode here seems just-right.
• Split in two, give the commit message for the new one
• I can use `git history split :/'The second batch'` if I want to split
  a single commit multiple times
• I can use `git history reword :/'The second batch'` if I want
  to change the original commit message as well

But it’s interactive-only, correct? Would it make sense for a stateful split session so that other tools could be used to split the patch?

One nice thing about “the staging area” is that people can use whatever tools they want for things like selectively including changes in a commit. Likewise for a rebase session.

Patrick Steinhardt· Sep 3, 2025, 12:20 UTC · re: Kristoffer Haugsbakk · lore

Re: [PATCH RFC v2 15/16] builtin/history: implement "split" subcommand

On Sun, Aug 24, 2025 at 08:03:18PM +0200, Kristoffer Haugsbakk wrote:
Show 21 quoted lines
> On Sun, Aug 24, 2025, at 19:42, Patrick Steinhardt wrote:
> > This is quite complex, and overall I would claim that most people who
> > are not experts in Git would struggle with this flow.
> >
> > Introduce a new "split" subcommand for git-history(1) to make this way
> > easier. All the user needs to do is to say `git history split $COMMIT`.
> > From hereon, Git asks the user which parts of the commit shall be moved
> > out into a separate commit and, once done, asks the user for the commit
> > message. Git then creates that split-out commit and applies the original
> > commit on top of it.
> 
> The interactive mode here seems just-right.
> 
> • Split in two, give the commit message for the new one
> • I can use `git history split :/'The second batch'` if I want to split
>   a single commit multiple times
> • I can use `git history reword :/'The second batch'` if I want
>   to change the original commit message as well
> 
> But it’s interactive-only, correct?  Would it make sense for a stateful
> split session so that other tools could be used to split the patch?

Yeah, it's interactive-only. I see git-history(1) as part of porcelain rather than plumbing, so this is kind of intentional. I also wouldn't quite know how this would look like to have a split session.

But if anyone has an idea how such a stateful split session might look like I don't see a strong reason to not add this in the future. For now though my focus is to get a baseline going that helps the user to perform common tasks easier as opposed to before.

Patrick
D. Ben Knoble· Aug 26, 2025, 13:14 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v2 15/16] builtin/history: implement "split" subcommand

On Sun, Aug 24, 2025 at 1:44 PM Patrick Steinhardt <ps@pks.im> wrote:
Show 54 quoted lines
> diff --git a/builtin/history.c b/builtin/history.c
> index 16b516856e..6d3f44152c 100644
> --- a/builtin/history.c
> +++ b/builtin/history.c
> @@ -517,6 +527,285 @@ static int cmd_history_reorder(int argc,
>         return ret;
>  }
>
> +static void change_data_free(void *util, const char *str UNUSED)
> +{
> +       struct wt_status_change_data *d = util;
> +       free(d->rename_source);
> +       free(d);
> +}
> +
> +static int fill_commit_message(struct repository *repo,
> +                              const struct object_id *old_tree,
> +                              const struct object_id *new_tree,
> +                              const char *default_message,
> +                              const char *provided_message,
> +                              const char *action,
> +                              struct strbuf *out)
> +{
> +       if (!provided_message) {
> +               struct wt_status s;
> +               const char *path = git_path_commit_editmsg();
> +               const char *hint =
> +                       _("Please enter the commit message for the %s changes. Lines starting\n"
> +                         "with '%s' will be kept; you may remove them yourself if you want to.\n");
> +
> +               strbuf_addstr(out, default_message);
> +               strbuf_addch(out, '\n');
> +               strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
> +               write_file_buf(path, out->buf, out->len);
> +
> +               wt_status_prepare(repo, &s);
> +               FREE_AND_NULL(s.branch);
> +               s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
> +               s.commit_template = 1;
> +               s.colopts = 0;
> +               s.display_comment_prefix = 1;
> +               s.hints = 0;
> +               s.use_color = 0;
> +               s.whence = FROM_COMMIT;
> +               s.committable = 1;
> +
> +               s.fp = fopen(git_path_commit_editmsg(), "a");
> +               if (!s.fp)
> +                       return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
> +
> +               wt_status_collect_changes_trees(&s, old_tree, new_tree);
> +               wt_status_print(&s);
> +               wt_status_collect_free_buffers(&s);
> +               string_list_clear_func(&s.change, change_data_free);

I think I'm supposed to see the changes between the old and new trees, right? Does this only happen if I use the interactive machinery to edit a hunk? When I try accepting some changes and leaving others for the next commit I get no diff in the template.

I did try to add new diff lines to a hunk, and nothing showed up… maybe I'm holding it wrong? I'm pretty sure I compiled this version.

It doesn't look like it's triggered only on commit.verbose config, either.
> +
> +               strbuf_reset(out);
> +               if (launch_editor(path, out, NULL)) {
> +                       fprintf(stderr, _("Please supply the message using either -m or -F option.\n"));
According to the usage, git history split only supports -m, not -F ;)
Show 12 quoted lines
> +                       return -1;
> +               }
> +               strbuf_stripspace(out, comment_line_str);
> +
> +       } else {
> +               strbuf_addstr(out, provided_message);
> +       }
> +
> +       cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
> +
> +       if (!out->len) {
> +               fprintf(stderr, _("Aborting commit due to empty commit message.\n"));

It _would_ be nice if this and similar errors left me able to "try again" without losing staged changes—I think I mentioned this before, though. And with the in-memory indices vs. actual working state, presenting a UI here could be very difficult. So it's an understandable choice.

-- 
D. Ben Knoble
Patrick Steinhardt· Sep 3, 2025, 12:20 UTC · re: D. Ben Knoble · lore

Re: [PATCH RFC v2 15/16] builtin/history: implement "split" subcommand

On Tue, Aug 26, 2025 at 09:14:49AM -0400, D. Ben Knoble wrote:
Show 6 quoted lines
> On Sun, Aug 24, 2025 at 1:44 PM Patrick Steinhardt <ps@pks.im> wrote:
> > diff --git a/builtin/history.c b/builtin/history.c
> > index 16b516856e..6d3f44152c 100644
> > --- a/builtin/history.c
> > +++ b/builtin/history.c
> > @@ -517,6 +527,285 @@ static int cmd_history_reorder(int argc,
[snip]
Show 9 quoted lines
> > +               wt_status_collect_changes_trees(&s, old_tree, new_tree);
> > +               wt_status_print(&s);
> > +               wt_status_collect_free_buffers(&s);
> > +               string_list_clear_func(&s.change, change_data_free);
> 
> I think I'm supposed to see the changes between the old and new trees,
> right? Does this only happen if I use the interactive machinery to
> edit a hunk? When I try accepting some changes and leaving others for
> the next commit I get no diff in the template.

Yeah, it's supposed to show the diff between old and new tree indeed. So in theory you should see something.

> I did try to add new diff lines to a hunk, and nothing showed up…
> maybe I'm holding it wrong? I'm pretty sure I compiled this version.

Do you maybe have a reproducer for this? It seems to work alright for me, but I wouldn't be surprised if there was a bug here. The wt-status interfaces are quite something and I was tearing my hair while trying to figure them out.

> It doesn't look like it's triggered only on commit.verbose config, either.
Fixed now.
Show 6 quoted lines
> > +
> > +               strbuf_reset(out);
> > +               if (launch_editor(path, out, NULL)) {
> > +                       fprintf(stderr, _("Please supply the message using either -m or -F option.\n"));
> 
> According to the usage, git history split only supports -m, not -F ;)

True. I didn't want to add too many options right from the start to keep the series somewhat simple. We should eventually add it though.

Show 18 quoted lines
> > +                       return -1;
> > +               }
> > +               strbuf_stripspace(out, comment_line_str);
> > +
> > +       } else {
> > +               strbuf_addstr(out, provided_message);
> > +       }
> > +
> > +       cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
> > +
> > +       if (!out->len) {
> > +               fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
> 
> It _would_ be nice if this and similar errors left me able to "try
> again" without losing staged changes—I think I mentioned this before,
> though. And with the in-memory indices vs. actual working state,
> presenting a UI here could be very difficult. So it's an
> understandable choice.

Yeah, I don't dare touching this yet, but certainly see that this might be a worthwhile addition as we iterate on this command.

Patrick
D. Ben Knoble· Sep 3, 2025, 21:55 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v2 15/16] builtin/history: implement "split" subcommand

On Wed, Sep 3, 2025 at 8:20 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 29 quoted lines
>
> On Tue, Aug 26, 2025 at 09:14:49AM -0400, D. Ben Knoble wrote:
> > On Sun, Aug 24, 2025 at 1:44 PM Patrick Steinhardt <ps@pks.im> wrote:
> > > diff --git a/builtin/history.c b/builtin/history.c
> > > index 16b516856e..6d3f44152c 100644
> > > --- a/builtin/history.c
> > > +++ b/builtin/history.c
> > > @@ -517,6 +527,285 @@ static int cmd_history_reorder(int argc,
> [snip]
> > > +               wt_status_collect_changes_trees(&s, old_tree, new_tree);
> > > +               wt_status_print(&s);
> > > +               wt_status_collect_free_buffers(&s);
> > > +               string_list_clear_func(&s.change, change_data_free);
> >
> > I think I'm supposed to see the changes between the old and new trees,
> > right? Does this only happen if I use the interactive machinery to
> > edit a hunk? When I try accepting some changes and leaving others for
> > the next commit I get no diff in the template.
>
> Yeah, it's supposed to show the diff between old and new tree indeed. So
> in theory you should see something.
>
> > I did try to add new diff lines to a hunk, and nothing showed up…
> > maybe I'm holding it wrong? I'm pretty sure I compiled this version.
>
> Do you maybe have a reproducer for this? It seems to work alright for
> me, but I wouldn't be surprised if there was a bug here. The wt-status
> interfaces are quite something and I was tearing my hair while trying to
> figure them out.

Hm. I have a copy of these patches at https://github.com/benknoble/git/tree/ps-jj. After "make DEVELOPER=1 -j $(nproc)" on that branch, I did

    bin-wrappers/git history split @~3
    <input y,q> # once I even used "e" and added new diffs to the patch
    <type commit message> # no status info
    <exit editor>

Then it looks like the 2nd commit gets created automatically. Maybe I'm just missing how this should work? Thanks for looking at it.

-- 
D. Ben Knoble
Patrick Steinhardt· Sep 4, 2025, 12:57 UTC · re: D. Ben Knoble · lore

Re: [PATCH RFC v2 15/16] builtin/history: implement "split" subcommand

On Wed, Sep 03, 2025 at 05:55:28PM -0400, D. Ben Knoble wrote:
Show 42 quoted lines
> On Wed, Sep 3, 2025 at 8:20 AM Patrick Steinhardt <ps@pks.im> wrote:
> >
> > On Tue, Aug 26, 2025 at 09:14:49AM -0400, D. Ben Knoble wrote:
> > > On Sun, Aug 24, 2025 at 1:44 PM Patrick Steinhardt <ps@pks.im> wrote:
> > > > diff --git a/builtin/history.c b/builtin/history.c
> > > > index 16b516856e..6d3f44152c 100644
> > > > --- a/builtin/history.c
> > > > +++ b/builtin/history.c
> > > > @@ -517,6 +527,285 @@ static int cmd_history_reorder(int argc,
> > [snip]
> > > > +               wt_status_collect_changes_trees(&s, old_tree, new_tree);
> > > > +               wt_status_print(&s);
> > > > +               wt_status_collect_free_buffers(&s);
> > > > +               string_list_clear_func(&s.change, change_data_free);
> > >
> > > I think I'm supposed to see the changes between the old and new trees,
> > > right? Does this only happen if I use the interactive machinery to
> > > edit a hunk? When I try accepting some changes and leaving others for
> > > the next commit I get no diff in the template.
> >
> > Yeah, it's supposed to show the diff between old and new tree indeed. So
> > in theory you should see something.
> >
> > > I did try to add new diff lines to a hunk, and nothing showed up…
> > > maybe I'm holding it wrong? I'm pretty sure I compiled this version.
> >
> > Do you maybe have a reproducer for this? It seems to work alright for
> > me, but I wouldn't be surprised if there was a bug here. The wt-status
> > interfaces are quite something and I was tearing my hair while trying to
> > figure them out.
> 
> Hm. I have a copy of these patches at
> https://github.com/benknoble/git/tree/ps-jj. After "make DEVELOPER=1
> -j $(nproc)" on that branch, I did
> 
>     bin-wrappers/git history split @~3
>     <input y,q> # once I even used "e" and added new diffs to the patch
>     <type commit message> # no status info
>     <exit editor>
> 
> Then it looks like the 2nd commit gets created automatically. Maybe
> I'm just missing how this should work? Thanks for looking at it.

Weird. I used the exact same branch, command and input and did have status information in my editor. Note that for now the editor only asks for the commit message of the first commit. The second commit is basically retained from the original: both tree and commit message are the exact same, only difference is its parent.

Patrick
D. Ben Knoble· Sep 12, 2025, 18:26 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v2 15/16] builtin/history: implement "split" subcommand

On Thu, Sep 4, 2025 at 8:57 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 47 quoted lines
>
> On Wed, Sep 03, 2025 at 05:55:28PM -0400, D. Ben Knoble wrote:
> > On Wed, Sep 3, 2025 at 8:20 AM Patrick Steinhardt <ps@pks.im> wrote:
> > >
> > > On Tue, Aug 26, 2025 at 09:14:49AM -0400, D. Ben Knoble wrote:
> > > > On Sun, Aug 24, 2025 at 1:44 PM Patrick Steinhardt <ps@pks.im> wrote:
> > > > > diff --git a/builtin/history.c b/builtin/history.c
> > > > > index 16b516856e..6d3f44152c 100644
> > > > > --- a/builtin/history.c
> > > > > +++ b/builtin/history.c
> > > > > @@ -517,6 +527,285 @@ static int cmd_history_reorder(int argc,
> > > [snip]
> > > > > +               wt_status_collect_changes_trees(&s, old_tree, new_tree);
> > > > > +               wt_status_print(&s);
> > > > > +               wt_status_collect_free_buffers(&s);
> > > > > +               string_list_clear_func(&s.change, change_data_free);
> > > >
> > > > I think I'm supposed to see the changes between the old and new trees,
> > > > right? Does this only happen if I use the interactive machinery to
> > > > edit a hunk? When I try accepting some changes and leaving others for
> > > > the next commit I get no diff in the template.
> > >
> > > Yeah, it's supposed to show the diff between old and new tree indeed. So
> > > in theory you should see something.
> > >
> > > > I did try to add new diff lines to a hunk, and nothing showed up…
> > > > maybe I'm holding it wrong? I'm pretty sure I compiled this version.
> > >
> > > Do you maybe have a reproducer for this? It seems to work alright for
> > > me, but I wouldn't be surprised if there was a bug here. The wt-status
> > > interfaces are quite something and I was tearing my hair while trying to
> > > figure them out.
> >
> > Hm. I have a copy of these patches at
> > https://github.com/benknoble/git/tree/ps-jj. After "make DEVELOPER=1
> > -j $(nproc)" on that branch, I did
> >
> >     bin-wrappers/git history split @~3
> >     <input y,q> # once I even used "e" and added new diffs to the patch
> >     <type commit message> # no status info
> >     <exit editor>
> >
> > Then it looks like the 2nd commit gets created automatically. Maybe
> > I'm just missing how this should work? Thanks for looking at it.
>
> Weird. I used the exact same branch, command and input and did have
> status information in my editor.

Hm. I've pulled down v3, built it, and pushed to the same branch. The tip is e91e23546b (builtin/history: implement "reword" subcommand, 2025-09-04).

Now, a heavier-handed recipe:
    GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null bin-wrappers/git
-c commit.verbose=true history split @~5
    <input y,q> # once I even used "e" and added new diffs to the patch
I see the usual instructions:

# Please enter the commit message for the split-out changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # Modifications qui seront validées : # modifié : add-patch.c #

And finally I might know what happened, ha… if the "modified files" is the status information, then it has been there all along! Meanwhile, I was expecting a _diff_.

I'm actually _still_ expecting a diff with v3 and commit.verbose set, but I apologize if I've led you down a wild goose chase for the rest :)

> Note that for now the editor only asks
> for the commit message of the first commit. The second commit is
> basically retained from the original: both tree and commit message are
> the exact same, only difference is its parent.

Sensible, though I might agree that the 2nd message should be edited (mentioned elsewhere).

-- 
D. Ben Knoble
Patrick Steinhardt· Sep 15, 2025, 09:32 UTC · re: D. Ben Knoble · lore

Re: [PATCH RFC v2 15/16] builtin/history: implement "split" subcommand

On Fri, Sep 12, 2025 at 02:26:04PM -0400, D. Ben Knoble wrote:
Show 25 quoted lines
> Hm. I've pulled down v3, built it, and pushed to the same branch. The
> tip is e91e23546b (builtin/history: implement "reword" subcommand,
> 2025-09-04).
> 
> Now, a heavier-handed recipe:
> 
>     GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null bin-wrappers/git
> -c commit.verbose=true history split @~5
>     <input y,q> # once I even used "e" and added new diffs to the patch
> 
> I see the usual instructions:
> 
> # Please enter the commit message for the split-out changes. Lines starting
> # with '#' will be kept; you may remove them yourself if you want to.
> # Modifications qui seront validées :
> # modifié :         add-patch.c
> #
> 
> And finally I might know what happened, ha… if the "modified files" is
> the status information, then it has been there all along! Meanwhile, I
> was expecting a _diff_.
> 
> I'm actually _still_ expecting a diff with v3 and commit.verbose set,
> but I apologize if I've led you down a wild goose chase for the rest
> :)

Oh! I didn't even know this was a thing Git supports, I've never seen it before. For the sake of simplicity I'd propose to keep this as-is for now, but to amend that mode once the initial couple of patches have landed.

Would that be fine with you?
Patrick
Ben Knoble· Sep 15, 2025, 13:04 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v2 15/16] builtin/history: implement "split" subcommand

Show 35 quoted lines
> Le 15 sept. 2025 à 05:32, Patrick Steinhardt <ps@pks.im> a écrit :
> 
> On Fri, Sep 12, 2025 at 02:26:04PM -0400, D. Ben Knoble wrote:
>> Hm. I've pulled down v3, built it, and pushed to the same branch. The
>> tip is e91e23546b (builtin/history: implement "reword" subcommand,
>> 2025-09-04).
>> 
>> Now, a heavier-handed recipe:
>> 
>>    GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null bin-wrappers/git
>> -c commit.verbose=true history split @~5
>>    <input y,q> # once I even used "e" and added new diffs to the patch
>> 
>> I see the usual instructions:
>> 
>> # Please enter the commit message for the split-out changes. Lines starting
>> # with '#' will be kept; you may remove them yourself if you want to.
>> # Modifications qui seront validées :
>> # modifié :         add-patch.c
>> #
>> 
>> And finally I might know what happened, ha… if the "modified files" is
>> the status information, then it has been there all along! Meanwhile, I
>> was expecting a _diff_.
>> 
>> I'm actually _still_ expecting a diff with v3 and commit.verbose set,
>> but I apologize if I've led you down a wild goose chase for the rest
>> :)
> 
> Oh! I didn't even know this was a thing Git supports, I've never seen it
> before. For the sake of simplicity I'd propose to keep this as-is for
> now, but to amend that mode once the initial couple of patches have
> landed.
> 
> Would that be fine with you?
Sure! My apologies again for the « commit.verbose » red herring. 
Patrick Steinhardt· Aug 24, 2025, 17:42 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v2 16/16] builtin/history: implement "reword" subcommand

Implement a new "reword" subcommand for git-history(1). This subcommand is essentially the same as if a user performed an interactive rebase with a single commit changed to use the "reword" verb.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |   5 ++
 builtin/history.c              |  96 +++++++++++++++++++++++++
 t/meson.build                  |   1 +
 t/t3454-history-reword.sh      | 158 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 260 insertions(+)
Show changes to 4 files +260 −0

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3454-history-reword.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 6f0c64b90e..cbbcef3582 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -13,6 +13,7 @@ git history continue
 git history quit
 git history drop <commit>
 git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
+git history reword [<options>] <commit>
 git history split [<options>] <commit> [--] [<pathspec>...]
 
 DESCRIPTION
@@ -53,6 +54,10 @@ child commits, as that would lead to an empty branch.
 	be related to one another and must be reachable from the current `HEAD`
 	commit.
 
+`reword <commit> [--message=<message>]`::
+	Rewrite the commit message of the specified commit. All the other
+	details of this commit remain unchanged.
+
 `split [--message=<message>] <commit> [--] [<pathspec>...]`::
 	Interactively split up <commit> into two commits by choosing
 	hunks introduced by it that will be moved into the new split-out
diff --git a/builtin/history.c b/builtin/history.c
index 6d3f44152c..cdc93a1cbd 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -716,6 +716,100 @@ static int split_commit(struct repository *repo,
 	return ret;
 }
 
+static int cmd_history_reword(int argc,
+			      const char **argv,
+			      const char *prefix,
+			      struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history reword [<options>] <commit>"),
+		NULL,
+	};
+	const char *commit_message = NULL;
+	struct option options[] = {
+		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
+		OPT_END(),
+	};
+	struct strbuf final_message = STRBUF_INIT;
+	struct commit *original_commit, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct object_id parent_tree_oid, original_commit_tree_oid;
+	struct object_id rewritten_commit;
+	const char *original_message, *original_body, *ptr;
+	char *original_author = NULL;
+	size_t len;
+	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);
+
+	original_commit = lookup_commit_reference_by_name(argv[0]);
+	if (!original_commit) {
+		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
+		goto out;
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	/*
+	 * Collect the list of commits that we'll have to reapply now already.
+	 * This ensures that we'll abort early on in case the range of commits
+	 * contains merges, which we do not yet handle.
+	 */
+	ret = collect_commits(repo, original_commit->parents ? original_commit->parents->item : NULL,
+			      head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+	find_commit_subject(original_message, &original_body);
+
+	if (original_commit->parents)
+		parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
+	else
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
+
+	ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
+				  original_body, commit_message, "reworded", &final_message);
+	if (ret < 0)
+		goto out;
+
+	ret = commit_tree(final_message.buf, final_message.len,
+			  &repo_get_commit_tree(repo, original_commit)->object.oid,
+			  original_commit->parents, &rewritten_commit, original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing reworded commit"));
+		goto out;
+	}
+
+	replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
+
+	ret = apply_commits(repo, &commits, head, original_commit, "reword");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	strbuf_release(&final_message);
+	strvec_clear(&commits);
+	free(original_author);
+	return ret;
+}
+
 static int cmd_history_split(int argc,
 			     const char **argv,
 			     const char *prefix,
@@ -817,6 +911,7 @@ int cmd_history(int argc,
 		N_("git history quit"),
 		N_("git history drop <commit>"),
 		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
+		N_("git history reword [<options>] <commit>"),
 		N_("git history split [<options>] <commit> [--] [<pathspec>...]"),
 		NULL,
 	};
@@ -827,6 +922,7 @@ int cmd_history(int argc,
 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
 		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
 		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
+		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 b3d33c8588..948223f453 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -380,6 +380,7 @@ integration_tests = [
   't3451-history-drop.sh',
   't3452-history-reorder.sh',
   't3453-history-split.sh',
+  't3454-history-reword.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3454-history-reword.sh b/t/t3454-history-reword.sh
new file mode 100755
index 0000000000..9822c0336a
--- /dev/null
+++ b/t/t3454-history-reword.sh
@@ -0,0 +1,158 @@
+#!/bin/sh
+
+test_description='tests for git-history reword subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history reword HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err &&
+		test_must_fail git history reword HEAD 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work with changes in the worktree or index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base file &&
+		echo foo >file &&
+		test_must_fail git history reword HEAD 2>err &&
+		test_grep "Your local changes to the following files would be overwritten" err &&
+		git add file &&
+		test_must_fail git history reword HEAD 2>err &&
+		test_grep "Your local changes to the following files would be overwritten" err
+	)
+'
+
+test_expect_success 'can reword tip of a branch' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		git history reword -m "third reworded" HEAD &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		third reworded
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can reword commit in the middle' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		git history reword -m "second reworded" HEAD~ &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can reword root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history reword -m "first reworded" HEAD~2 &&
+
+		cat >expect <<-EOF &&
+		third
+		second
+		first reworded
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can use editor to rewrite commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		printf "\namend a comment\n" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+		git history reword HEAD &&
+
+		cat >expect <<-EOF &&
+		first
+
+		# Please enter the commit message for the reworded changes. Lines starting
+		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
+		# Changes to be committed:
+		#	new file:   first.t
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		cat >expect <<-EOF &&
+		first
+
+		amend a comment
+
+		EOF
+		git log --format=%B >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		test_must_fail git history reword -m "" HEAD 2>err &&
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+
+test_done
-- 
2.51.0.308.g032396e0da.dirty
Kristoffer Haugsbakk· Aug 24, 2025, 18:08 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v2 16/16] builtin/history: implement "reword" subcommand

On Sun, Aug 24, 2025, at 19:42, Patrick Steinhardt wrote:
Show 5 quoted lines
> Implement a new "reword" subcommand for git-history(1). This subcommand
> is essentially the same as if a user performed an interactive rebase
> with a single commit changed to use the "reword" verb.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
I get a “split” error when I typo the commit to reword:
    $ ./git history reword ./s2
    error: commit to be split cannot be found: ./s2
Patrick Steinhardt· Sep 3, 2025, 12:20 UTC · re: Kristoffer Haugsbakk · lore

Re: [PATCH RFC v2 16/16] builtin/history: implement "reword" subcommand

On Sun, Aug 24, 2025 at 08:08:07PM +0200, Kristoffer Haugsbakk wrote:
Show 11 quoted lines
> On Sun, Aug 24, 2025, at 19:42, Patrick Steinhardt wrote:
> > Implement a new "reword" subcommand for git-history(1). This subcommand
> > is essentially the same as if a user performed an interactive rebase
> > with a single commit changed to use the "reword" verb.
> >
> > Signed-off-by: Patrick Steinhardt <ps@pks.im>
> 
> I get a “split” error when I typo the commit to reword:
> 
>     $ ./git history reword ./s2
>     error: commit to be split cannot be found: ./s2
Oh, indeed, this is a boring copy-paste error:
Show changes to builtin/history.c +1 −1
diff --git a/builtin/history.c b/builtin/history.c
index cdc93a1cbd..f03272bddd 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -749,7 +749,7 @@ static int cmd_history_reword(int argc,
 
 	original_commit = lookup_commit_reference_by_name(argv[0]);
 	if (!original_commit) {
-		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
+		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
 		goto out;
 	}

Thanks for noticing!

Patrick
D. Ben Knoble· Sep 3, 2025, 23:39 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v2 00/16] Introduce git-history(1) command for easy history editing

On Sun, Aug 24, 2025 at 1:42 PM Patrick Steinhardt <ps@pks.im> wrote:
Show 12 quoted lines
> Changes in v2:
>   - Add a new "reword" subcommand.
>   - List git-history(1) in "command-list.txt".
>   - Add some missing error handling.
>   - Simplify calling convention of `apply_commits()` to handle root
>     commits internally instead of requiring every caller to do so.
>   - Add tests to verify that git-history(1) refuses to work with changes
>     in the worktree or index.
>   - Mark git-history(1) as experimental.
>   - Introduce commands to manage interrupted history edits.
>   - A bunch of improvements to the manpage.
>   - Link to v1: https://lore.kernel.org/r/20250819-b4-pks-history-builtin-v1-0-9b77c32688fe@pks.im

Having test-driven this a bit, I wanted to mention a feature from my editor that I use frequently and that "reword" reminded me of [*]. I wonder if this would be a good fit for git-history, but certainly wouldn't be _necessary_ for a v1.

With fugitive.vim [1], I can get a list of {staged,unstaged} changes and commits. It looks something like this

Head: ps-jj
Rebase: origin/master
Push: benknoble/ps-jj
Help: g?

Unstaged (1) M README.md

Unpushed to origin/master (16) 5c65c405e6 builtin/history: implement "reword" subcommand b215e38d49 builtin/history: implement "split" subcommand 93977ef8d2 wt-status: provide function to expose status for trees b09b4b9d48 add-patch: add support for in-memory index patching […]

If I stage changes, then the following mappings are available when the cursor is on any commit line:

cF                      Create a `fixup!` commit for the commit under the
                        cursor and immediately rebase it.
cS                      Create a `squash!` commit for the commit under the
                        cursor and immediately rebase it.
What they do is populate my command line with something like

:Git commit --fixup=<commit>|Git -c sequence.editor=true rebase --interactive --autosquash <commit>^

(The pipe is Vim's command separator, a bit like ";" or "&&" in shell.)

If git-history had a squash or fixup mode, I imagine it would function similarly (and could be used as the backend for fugitive's cF/cS with new enough Git).

[*] The reason I thought of this from "reword" is the obvious tie-in to interactive rebase commands. Fugitive does have "cw" to reword the last commit, "ca" to amend, and "cW" to "amend!" an arbitrary commit, but no mappings that "amend!" + rebase immediately. It doesn't need them because "rw" starts an interactive rebase with the commit under cursor set to "reword" ;) likewise "rd" for drop.

[1]: https://github.com/tpope/vim-fugitive
-- 
D. Ben Knoble
Patrick Steinhardt· Sep 4, 2025, 13:05 UTC · re: D. Ben Knoble · lore

Re: [PATCH RFC v2 00/16] Introduce git-history(1) command for easy history editing

On Wed, Sep 03, 2025 at 07:39:58PM -0400, D. Ben Knoble wrote:
> If git-history had a squash or fixup mode, I imagine it would function
> similarly (and could be used as the backend for fugitive's cF/cS with
> new enough Git).
Yup! I definitely do want to introduce a squash command eventually.
Patrick
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 00/18] Introduce git-history(1) command for easy history editing

Hi,

over recent months I've been playing around with Jujutsu quite frequently. While I still prefer using Git, there's been a couple features in it that I really like and that I'd like to have in Git, as well.

A copule of these features relate to history editing. Most importantly, I really dig the following commands:

  - jj-abandon(1) to drop a specific commit from your history.
  - jj-absorb(1) to take some changes and automatically apply them to
    commits in your history that last modified the respective hunks.
  - jj-split(1) to split a commit into two.
  - jj-new(1) to insert a new commit after or before a specific other
    commit.

Not all of these commands can be ported directly into Git. jj-new(1) for example doesn't really make a ton of sense for us, I'd claim. But some of these commands _do_ make sense.

I thus had a look at implementing some of these commands in Git itself, where the result is this patch series. Specifically, the following commands are introduced by this patch series:

  - `git history drop` to drop a specific commit. This is basically the
    same as jj-abandon(1).
  - `git history reorder` to reorder a specific commit before or after
    another commit. This is inspired by jj-new(1).
  - `git history split` takes a commit and splits it into two. This is
    basically the same as jj-split(1).

If this is something we want to have I think it'd be just a starting point. There's other commands that I think are quite common and that might make sense to introduce eventually:

  - An equivalent to jj-absorb(1) would be awesome to have.
  - `git history reword` to change only the commit message of a specific
    commit.
  - `git history squash` to squash together multiple commits into one.

In the end, I'd like us to learn from what people like about Jujutsu and apply those learnings to Git. We won't be able to apply all learnings from Jujutsu, as the workflow is quite different there due to the lack of the index. But other things we certainly can apply to Git directly.

Changes in v2:
  - Add a new "reword" subcommand.
  - List git-history(1) in "command-list.txt".
  - Add some missing error handling.
  - Simplify calling convention of `apply_commits()` to handle root
    commits internally instead of requiring every caller to do so.
  - Add tests to verify that git-history(1) refuses to work with changes
    in the worktree or index.
  - Mark git-history(1) as experimental.
  - Introduce commands to manage interrupted history edits.
  - A bunch of improvements to the manpage.
  - Link to v1: https://lore.kernel.org/r/20250819-b4-pks-history-builtin-v1-0-9b77c32688fe@pks.im
Changes in v3:
  - Add logic to drive the "post-rewrite" hook and add tests to verify
    that all hooks are executed as expected.
  - Deduplicate logic to turn a replay action into a todo command.
  - Move the addition of tests for the top-level git-history(1) command
    to the correct commit.
  - Some smaller commit message fixes.
  - Honor "commit.verbose".
  - Fix copy-paste error with an error message.
  - Link to v2: https://lore.kernel.org/r/20250824-b4-pks-history-builtin-v2-0-964ac12f65bd@pks.im
Note: this patch series is growing quite large overall. I'll send one
last version of the complete series with the RFC tag, but after that
I'll probably split the series into two and stop after introducing the
"reorder" command.
Thanks!
Patrick
---
Patrick Steinhardt (18):
      sequencer: optionally skip printing commit summary
      sequencer: add option to rewind HEAD after picking commits
      sequencer: introduce new history editing mode
      sequencer: stop using `the_repository` in `sequencer_remove_state()`
      sequencer: wire up "rewritten-hook" for REPLAY_HISTORY_EDIT
      cache-tree: allow writing in-memory index as tree
      builtin: add new "history" command
      builtin/history: introduce subcommands to manage interrupted rewrites
      builtin/history: implement "drop" subcommand
      builtin/history: implement "reorder" subcommand
      add-patch: split out header from "add-interactive.h"
      add-patch: split out `struct interactive_options`
      add-patch: remove dependency on "add-interactive" subsystem
      add-patch: add support for in-memory index patching
      wt-status: provide function to expose status for trees
      sequencer: allow callers to provide mappings for the old commit
      builtin/history: implement "split" subcommand
      builtin/history: implement "reword" subcommand
 .gitignore                     |   1 +
 Documentation/git-history.adoc | 188 ++++++++
 Documentation/meson.build      |   1 +
 Makefile                       |   1 +
 add-interactive.c              | 151 ++-----
 add-interactive.h              |  43 +-
 add-patch.c                    | 270 ++++++++++--
 add-patch.h                    |  61 +++
 builtin.h                      |   1 +
 builtin/add.c                  |  22 +-
 builtin/checkout.c             |   7 +-
 builtin/commit.c               |  16 +-
 builtin/history.c              | 958 +++++++++++++++++++++++++++++++++++++++++
 builtin/rebase.c               |   4 +-
 builtin/reset.c                |  16 +-
 builtin/revert.c               |   2 +-
 builtin/stash.c                |  46 +-
 cache-tree.c                   |   5 +-
 cache-tree.h                   |   3 +-
 command-list.txt               |   1 +
 commit.h                       |   2 +-
 git.c                          |   1 +
 meson.build                    |   1 +
 sequencer.c                    | 259 ++++++++---
 sequencer.h                    |  23 +-
 t/meson.build                  |   7 +-
 t/t3450-history.sh             |  42 ++
 t/t3451-history-drop.sh        | 207 +++++++++
 t/t3452-history-reorder.sh     | 278 ++++++++++++
 t/t3453-history-split.sh       | 468 ++++++++++++++++++++
 t/t3454-history-reword.sh      | 202 +++++++++
 wt-status.c                    |  24 ++
 wt-status.h                    |   3 +
 33 files changed, 3005 insertions(+), 309 deletions(-)
Range-diff versus v2:
 1:  ac3e60d11f =  1:  d397c84460 sequencer: optionally skip printing commit summary
 2:  411212b581 =  2:  9f6444ab79 sequencer: add option to rewind HEAD after picking commits
 3:  670a1879ea !  3:  6918f3fc1b sequencer: introduce new history editing mode
    @@ sequencer.c: void sequencer_post_commit_cleanup(struct repository *r, int verbos
      	if (refs_ref_exists(get_main_ref_store(r), "REVERT_HEAD")) {
      		if (!refs_delete_ref(get_main_ref_store(r), "", "REVERT_HEAD",
      				     NULL, REF_NO_DEREF) &&
    +@@ sequencer.c: static void todo_list_write_total_nr(struct todo_list *todo_list)
    + 	}
    + }
    + 
    ++static enum todo_command action_to_command(enum replay_action action)
    ++{
    ++	switch (action) {
    ++	case REPLAY_PICK:
    ++	case REPLAY_HISTORY_EDIT:
    ++		return TODO_PICK;
    ++	case REPLAY_REVERT:
    ++		return TODO_REVERT;
    ++	default:
    ++		BUG("unsupported action %d", action);
    ++	}
    ++}
    ++
    + static int read_populate_todo(struct repository *r,
    + 			      struct todo_list *todo_list,
    + 			      struct replay_opts *opts)
     @@ sequencer.c: static int read_populate_todo(struct repository *r,
      		return error(_("no commits parsed."));
      
      	if (!is_rebase_i(opts)) {
     -		enum todo_command valid =
     -			opts->action == REPLAY_PICK ? TODO_PICK : TODO_REVERT;
    -+		enum todo_command valid;
    ++		enum todo_command valid = action_to_command(opts->action);
      		int i;
      
     -		for (i = 0; i < todo_list->nr; i++)
    -+		switch (opts->action) {
    -+		case REPLAY_PICK:
    -+		case REPLAY_HISTORY_EDIT:
    -+			valid = TODO_PICK;
    -+			break;
    -+		default:
    -+			valid = TODO_REVERT;
    -+			break;
    -+		}
    -+
     +		for (i = 0; i < todo_list->nr; i++) {
      			if (valid == todo_list->items[i].command)
      				continue;
    @@ sequencer.c: int write_basic_state(struct replay_opts *opts, const char *head_na
      	if (prepare_revs(opts))
      		return -1;
      
    -+	switch (opts->action) {
    -+	case REPLAY_PICK:
    -+	case REPLAY_HISTORY_EDIT:
    -+		command = TODO_PICK;
    -+		break;
    -+	default:
    -+		command = TODO_REVERT;
    -+		break;
    -+	}
    -+
    ++	command = action_to_command(opts->action);
     +	command_string = todo_command_info[command].str;
      	encoding = get_log_output_encoding();
      
    @@ sequencer.c: static int single_pick(struct repository *r,
      
     -	item.command = opts->action == REPLAY_PICK ?
     -			TODO_PICK : TODO_REVERT;
    -+	switch (opts->action) {
    -+	case REPLAY_PICK:
    -+	case REPLAY_HISTORY_EDIT:
    -+		item.command = TODO_PICK;
    -+		break;
    -+	default:
    -+		item.command = TODO_REVERT;
    -+		break;
    -+	}
    ++	item.command = action_to_command(opts->action);
      	item.commit = cmit;
      
      	return do_pick_commit(r, &item, opts, 0, &check_todo);
    @@ sequencer.h: extern const char *rebase_resolvemsg;
      };
      
      enum commit_msg_cleanup_mode {
    -
    - ## t/t3450-history.sh (new) ##
    -@@
    -+#!/bin/sh
    -+
    -+test_description='tests for git-history command'
    -+
    -+. ./test-lib.sh
    -+
    -+test_expect_success 'refuses to do anything without subcommand' '
    -+	test_must_fail git history 2>err &&
    -+	test_grep foo err
    -+'
    -+
    -+test_done
 4:  6f2605f63d =  4:  e5317d44a3 sequencer: stop using `the_repository` in `sequencer_remove_state()`
 -:  ---------- >  5:  cc5108af10 sequencer: wire up "rewritten-hook" for REPLAY_HISTORY_EDIT
 5:  727ec4955d =  6:  457042c591 cache-tree: allow writing in-memory index as tree
 6:  eec4f74fd8 !  7:  1acf4de28a builtin: add new "history" command
    @@ meson.build: builtin_sources = [
        'builtin/hook.c',
        'builtin/index-pack.c',
        'builtin/init-db.c',
    +
    + ## t/meson.build ##
    +@@ t/meson.build: integration_tests = [
    +   't3436-rebase-more-options.sh',
    +   't3437-rebase-fixup-options.sh',
    +   't3438-rebase-broken-files.sh',
    ++  't3450-history.sh',
    +   't3500-cherry.sh',
    +   't3501-revert-cherry-pick.sh',
    +   't3502-cherry-pick-merge.sh',
    +@@ t/meson.build: if perl.found() and time.found()
    +       timeout: 0,
    +     )
    +   endforeach
    +-endif
    + \ No newline at end of file
    ++endif
    +
    + ## t/t3450-history.sh (new) ##
    +@@
    ++#!/bin/sh
    ++
    ++test_description='tests for git-history command'
    ++
    ++. ./test-lib.sh
    ++
    ++test_expect_success 'refuses to do anything without subcommand' '
    ++	test_must_fail git history 2>err &&
    ++	test_grep foo err
    ++'
    ++
    ++test_done
 7:  2820521f46 !  8:  07fff4c157 builtin/history: introduce subcommands to manage interrupted rewrites
    @@ builtin/history.c
     +	return fn(argc, argv, prefix, repo);
      }
     
    - ## t/meson.build ##
    -@@ t/meson.build: integration_tests = [
    -   't3436-rebase-more-options.sh',
    -   't3437-rebase-fixup-options.sh',
    -   't3438-rebase-broken-files.sh',
    -+  't3450-history.sh',
    -   't3500-cherry.sh',
    -   't3501-revert-cherry-pick.sh',
    -   't3502-cherry-pick-merge.sh',
    -@@ t/meson.build: if perl.found() and time.found()
    -       timeout: 0,
    -     )
    -   endforeach
    --endif
    - \ No newline at end of file
    -+endif
    -
      ## t/t3450-history.sh ##
     @@ t/t3450-history.sh: test_description='tests for git-history command'
      
 8:  3438f23f1b !  9:  a68cd72864 builtin/history: implement "drop" subcommand
    @@ Documentation/git-history.adoc: COMMANDS
     +`drop <commit>`::
     +	Drop a commit from the history and reapply all children of that
     +	commit on top of the commit's parent. The commit that is to be
    -+	dropped must be reachable from the current `HEAD` commit.
    ++	dropped must be reachable from the currently checked-out commit.
     ++
     +Dropping the root commit converts the child of that commit into the new
     +root commit. It is invalid to drop a root commit that does not have any
    @@ t/t3451-history-drop.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'hooks are executed for rewritten commits' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		test_commit first &&
    ++		test_commit second &&
    ++		test_commit third &&
    ++
    ++		write_script .git/hooks/prepare-commit-msg <<-EOF &&
    ++		echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
    ++		EOF
    ++		write_script .git/hooks/post-commit <<-EOF &&
    ++		echo "post-commit" >>"$(pwd)/hooks.log"
    ++		EOF
    ++		write_script .git/hooks/post-rewrite <<-EOF &&
    ++		{
    ++			echo "post-rewrite: \$@"
    ++			cat
    ++		} >>"$(pwd)/hooks.log"
    ++		EOF
    ++
    ++		git history drop HEAD~ &&
    ++		cat >expect <<-EOF &&
    ++		prepare-commit-msg: .git/COMMIT_EDITMSG message
    ++		post-commit
    ++		post-rewrite: history
    ++		$(git rev-parse third) $(git rev-parse HEAD)
    ++		EOF
    ++		test_cmp expect hooks.log
    ++	)
    ++'
    ++
     +test_expect_success 'can drop root commit' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
 9:  1af60a15fe ! 10:  3caf8f8e23 builtin/history: implement "reorder" subcommand
    @@ Documentation/git-history.adoc: git history abort
      
      DESCRIPTION
      -----------
    -@@ Documentation/git-history.adoc: rewrite history in different ways:
    - `drop <commit>`::
    - 	Drop a commit from the history and reapply all children of that
    - 	commit on top of the commit's parent. The commit that is to be
    --	dropped must be reachable from the current `HEAD` commit.
    -+	dropped must be reachable from the currently checked-out commit.
    - +
    - Dropping the root commit converts the child of that commit into the new
    +@@ Documentation/git-history.adoc: Dropping the root commit converts the child of that commit into the new
      root commit. It is invalid to drop a root commit that does not have any
      child commits, as that would lead to an empty branch.
      
    @@ t/t3452-history-reorder.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'hooks are executed for rewritten commits' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		test_commit first &&
    ++		test_commit second &&
    ++		test_commit third &&
    ++
    ++		write_script .git/hooks/prepare-commit-msg <<-EOF &&
    ++		echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
    ++		EOF
    ++		write_script .git/hooks/post-commit <<-EOF &&
    ++		echo "post-commit" >>"$(pwd)/hooks.log"
    ++		EOF
    ++		write_script .git/hooks/post-rewrite <<-EOF &&
    ++		{
    ++			echo "post-rewrite: \$@"
    ++			cat
    ++		} >>"$(pwd)/hooks.log"
    ++		EOF
    ++
    ++		git history reorder :/third --before=:/second &&
    ++		cat >expect <<-EOF &&
    ++		second
    ++		third
    ++		first
    ++		EOF
    ++		git log --format=%s >actual &&
    ++		test_cmp expect actual &&
    ++
    ++		cat >expect <<-EOF &&
    ++		prepare-commit-msg: .git/COMMIT_EDITMSG message
    ++		post-commit
    ++		prepare-commit-msg: .git/COMMIT_EDITMSG message
    ++		post-commit
    ++		post-rewrite: history
    ++		$(git rev-parse third) $(git rev-parse HEAD~)
    ++		$(git rev-parse second) $(git rev-parse HEAD)
    ++		EOF
    ++		test_cmp expect hooks.log
    ++	)
    ++'
    ++
     +test_expect_success 'conflicts are detected' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
10:  a5e90d5b2f = 11:  5f509b2edc add-patch: split out header from "add-interactive.h"
11:  927f2d226b = 12:  cba60462f5 add-patch: split out `struct interactive_options`
12:  1b232881f2 = 13:  316b830d2c add-patch: remove dependency on "add-interactive" subsystem
13:  4f1fcc0f85 = 14:  4e964cdcf8 add-patch: add support for in-memory index patching
14:  b88ac830f1 = 15:  9b33852290 wt-status: provide function to expose status for trees
 -:  ---------- > 16:  2a7fc74da1 sequencer: allow callers to provide mappings for the old commit
15:  163e8d4d6e ! 17:  9b68e75de2 builtin/history: implement "split" subcommand
    @@ Commit message
     
         It is quite a common use case that one wants to split up one commit into
         multiple commits by moving parts of the changes of the original commit
    -    out of it into a separate commit. This is quite an involved operation
    -    though:
    +    out into a separate commit. This is quite an involved operation though:
     
           1. Identify the commit in question that is to be dropped.
     
    @@ builtin/history.c
      
      static int cmd_history_abort(int argc,
      			     const char **argv,
    +@@ builtin/history.c: static int apply_commits(struct repository *repo,
    + 			 const struct strvec *commits,
    + 			 struct commit *head,
    + 			 struct commit *base,
    ++			 const struct oidmap *rewritten_commits,
    + 			 const char *action)
    + {
    + 	struct setup_revision_opt revision_opts = {
    +@@ builtin/history.c: static int apply_commits(struct repository *repo,
    + 		replay_opts.strategy = replay_opts.default_strategy;
    + 		replay_opts.default_strategy = NULL;
    + 	}
    ++	replay_opts.old_oid_mappings = rewritten_commits;
    + 
    + 	strvec_push(&args, "");
    + 	strvec_pushv(&args, commits->v);
    +@@ builtin/history.c: static int cmd_history_drop(int argc,
    + 		if (ret < 0)
    + 			goto out;
    + 
    +-		ret = apply_commits(repo, &commits, head, commit_to_drop, "drop");
    ++		ret = apply_commits(repo, &commits, head, commit_to_drop,
    ++				    NULL, "drop");
    + 		if (ret < 0)
    + 			goto out;
    + 	}
    +@@ builtin/history.c: static int cmd_history_reorder(int argc,
    + 	replace_commits(&commits, &commit_to_reorder->object.oid, NULL, 0);
    + 	replace_commits(&commits, &anchor->object.oid, replacement, ARRAY_SIZE(replacement));
    + 
    +-	ret = apply_commits(repo, &commits, head, old, "reorder");
    ++	ret = apply_commits(repo, &commits, head, old, NULL, "reorder");
    + 	if (ret < 0)
    + 		goto out;
    + 
     @@ builtin/history.c: static int cmd_history_reorder(int argc,
      	return ret;
      }
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +			       struct strbuf *out)
     +{
     +	if (!provided_message) {
    -+		struct wt_status s;
     +		const char *path = git_path_commit_editmsg();
     +		const char *hint =
     +			_("Please enter the commit message for the %s changes. Lines starting\n"
     +			  "with '%s' will be kept; you may remove them yourself if you want to.\n");
    ++		int verbose = 1;
     +
     +		strbuf_addstr(out, default_message);
     +		strbuf_addch(out, '\n');
     +		strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
     +		write_file_buf(path, out->buf, out->len);
     +
    -+		wt_status_prepare(repo, &s);
    -+		FREE_AND_NULL(s.branch);
    -+		s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
    -+		s.commit_template = 1;
    -+		s.colopts = 0;
    -+		s.display_comment_prefix = 1;
    -+		s.hints = 0;
    -+		s.use_color = 0;
    -+		s.whence = FROM_COMMIT;
    -+		s.committable = 1;
    -+
    -+		s.fp = fopen(git_path_commit_editmsg(), "a");
    -+		if (!s.fp)
    -+			return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
    -+
    -+		wt_status_collect_changes_trees(&s, old_tree, new_tree);
    -+		wt_status_print(&s);
    -+		wt_status_collect_free_buffers(&s);
    -+		string_list_clear_func(&s.change, change_data_free);
    ++		repo_config_get_bool(repo, "commit.verbose", &verbose);
    ++		if (verbose) {
    ++			struct wt_status s;
    ++
    ++			wt_status_prepare(repo, &s);
    ++			FREE_AND_NULL(s.branch);
    ++			s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
    ++			s.commit_template = 1;
    ++			s.colopts = 0;
    ++			s.display_comment_prefix = 1;
    ++			s.hints = 0;
    ++			s.use_color = 0;
    ++			s.whence = FROM_COMMIT;
    ++			s.committable = 1;
    ++
    ++			s.fp = fopen(git_path_commit_editmsg(), "a");
    ++			if (!s.fp)
    ++				return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
    ++
    ++			wt_status_collect_changes_trees(&s, old_tree, new_tree);
    ++			wt_status_print(&s);
    ++			wt_status_collect_free_buffers(&s);
    ++			string_list_clear_func(&s.change, change_data_free);
    ++		}
     +
     +		strbuf_reset(out);
     +		if (launch_editor(path, out, NULL)) {
    -+			fprintf(stderr, _("Please supply the message using either -m or -F option.\n"));
    ++			fprintf(stderr, _("Please supply the message using the -m option.\n"));
     +			return -1;
     +		}
     +		strbuf_stripspace(out, comment_line_str);
    -+
     +	} else {
     +		strbuf_addstr(out, provided_message);
     +	}
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
     +		OPT_END(),
     +	};
    ++	struct oidmap rewritten_commits = OIDMAP_INIT;
     +	struct commit *original_commit, *head;
     +	struct strvec commits = STRVEC_INIT;
     +	struct commit_list *list = NULL;
     +	struct object_id split_commits[2];
    ++	struct replay_oid_mapping mapping[2] = { 0 };
     +	struct pathspec pathspec = { 0 };
     +	int ret;
     +
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +	if (ret < 0)
     +		goto out;
     +
    ++	mapping[0].entry.oid = split_commits[0];
    ++	mapping[0].rewritten_oid = original_commit->object.oid;
    ++	oidmap_put(&rewritten_commits, &mapping[0]);
    ++	mapping[1].entry.oid = split_commits[1];
    ++	mapping[1].rewritten_oid = original_commit->object.oid;
    ++	oidmap_put(&rewritten_commits, &mapping[1]);
    ++
     +	replace_commits(&commits, &original_commit->object.oid,
     +			split_commits, ARRAY_SIZE(split_commits));
     +
    -+	ret = apply_commits(repo, &commits, head, original_commit, "split");
    ++	ret = apply_commits(repo, &commits, head, original_commit,
    ++			    &rewritten_commits, "split");
     +	if (ret < 0)
     +		goto out;
     +
     +	ret = 0;
     +
     +out:
    ++	oidmap_clear(&rewritten_commits, 0);
     +	clear_pathspec(&pathspec);
     +	strvec_clear(&commits);
     +	free_commit_list(list);
    @@ t/t3453-history-split.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'skips change summary with commit.verbose=false' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		touch bar foo &&
    ++		git add . &&
    ++		git commit -m split-me &&
    ++
    ++		write_script fake-editor.sh <<-\EOF &&
    ++		cp "$1" . &&
    ++		echo "some commit message" >>"$1"
    ++		EOF
    ++		test_set_editor "$(pwd)"/fake-editor.sh &&
    ++
    ++		git -c commit.verbose=false history split HEAD <<-EOF &&
    ++		y
    ++		n
    ++		EOF
    ++
    ++		cat >expect <<-EOF &&
    ++
    ++		# Please enter the commit message for the split-out changes. Lines starting
    ++		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
    ++		EOF
    ++		test_cmp expect COMMIT_EDITMSG &&
    ++
    ++		expect_log <<-EOF
    ++		split-me
    ++		some commit message
    ++		EOF
    ++	)
    ++'
    ++
     +test_expect_success 'can use pathspec to limit what gets split' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3453-history-split.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'hooks are executed for rewritten commits' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		touch bar foo &&
    ++		git add . &&
    ++		git commit -m split-me &&
    ++		old_head=$(git rev-parse HEAD) &&
    ++
    ++		write_script .git/hooks/prepare-commit-msg <<-EOF &&
    ++		echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
    ++		EOF
    ++		write_script .git/hooks/post-commit <<-EOF &&
    ++		echo "post-commit" >>"$(pwd)/hooks.log"
    ++		EOF
    ++		write_script .git/hooks/post-rewrite <<-EOF &&
    ++		{
    ++			echo "post-rewrite: \$@"
    ++			cat
    ++		} >>"$(pwd)/hooks.log"
    ++		EOF
    ++
    ++		set_fake_editor &&
    ++		git history split HEAD <<-EOF &&
    ++		y
    ++		n
    ++		EOF
    ++
    ++		expect_log <<-EOF &&
    ++		split-me
    ++		split-out commit
    ++		EOF
    ++
    ++		cat >expect <<-EOF &&
    ++		prepare-commit-msg: .git/COMMIT_EDITMSG message
    ++		post-commit
    ++		prepare-commit-msg: .git/COMMIT_EDITMSG message
    ++		post-commit
    ++		post-rewrite: history
    ++		$old_head $(git rev-parse HEAD~)
    ++		$old_head $(git rev-parse HEAD)
    ++		EOF
    ++		test_cmp expect hooks.log
    ++	)
    ++'
    ++
     +test_expect_success 'refuses to create empty original commit' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
16:  142e1e627b ! 18:  07eca0b266 builtin/history: implement "reword" subcommand
    @@ builtin/history.c: static int split_commit(struct repository *repo,
     +	struct object_id parent_tree_oid, original_commit_tree_oid;
     +	struct object_id rewritten_commit;
     +	const char *original_message, *original_body, *ptr;
    ++	struct oidmap rewritten_commits = OIDMAP_INIT;
    ++	struct replay_oid_mapping mapping = { 0 };
     +	char *original_author = NULL;
     +	size_t len;
     +	int ret;
    @@ builtin/history.c: static int split_commit(struct repository *repo,
     +
     +	original_commit = lookup_commit_reference_by_name(argv[0]);
     +	if (!original_commit) {
    -+		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
    ++		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
     +		goto out;
     +	}
     +
    @@ builtin/history.c: static int split_commit(struct repository *repo,
     +
     +	replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
     +
    -+	ret = apply_commits(repo, &commits, head, original_commit, "reword");
    ++	mapping.entry.oid = rewritten_commit;
    ++	mapping.rewritten_oid = original_commit->object.oid;
    ++	oidmap_put(&rewritten_commits, &mapping);
    ++
    ++	ret = apply_commits(repo, &commits, head, original_commit,
    ++			    &rewritten_commits, "reword");
     +	if (ret < 0)
     +		goto out;
     +
     +	ret = 0;
     +
     +out:
    ++	oidmap_clear(&rewritten_commits, 0);
     +	strbuf_release(&final_message);
     +	strvec_clear(&commits);
     +	free(original_author);
    @@ t/t3454-history-reword.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'hooks are executed for rewritten commits' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		test_commit first &&
    ++		test_commit second &&
    ++		test_commit third &&
    ++
    ++		write_script .git/hooks/prepare-commit-msg <<-EOF &&
    ++		echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
    ++		EOF
    ++		write_script .git/hooks/post-commit <<-EOF &&
    ++		echo "post-commit" >>"$(pwd)/hooks.log"
    ++		EOF
    ++		write_script .git/hooks/post-rewrite <<-EOF &&
    ++		{
    ++			echo "post-rewrite: \$@"
    ++			cat
    ++		} >>"$(pwd)/hooks.log"
    ++		EOF
    ++
    ++		git history reword -m "second reworded" HEAD~ &&
    ++
    ++		cat >expect <<-EOF &&
    ++		third
    ++		second reworded
    ++		first
    ++		EOF
    ++		git log --format=%s >actual &&
    ++		test_cmp expect actual &&
    ++
    ++		cat >expect <<-EOF &&
    ++		prepare-commit-msg: .git/COMMIT_EDITMSG message
    ++		post-commit
    ++		prepare-commit-msg: .git/COMMIT_EDITMSG message
    ++		post-commit
    ++		post-rewrite: history
    ++		$(git rev-parse second) $(git rev-parse HEAD~)
    ++		$(git rev-parse third) $(git rev-parse HEAD)
    ++		EOF
    ++		test_cmp expect hooks.log
    ++	)
    ++'
    ++
     +test_expect_success 'aborts with empty commit message' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3454-history-reword.sh (new)
     +	)
     +'
     +
    -+
     +test_done

--- base-commit: c44beea485f0f2feaf460e2ac87fdd5608d63cf0 change-id: 20250819-b4-pks-history-builtin-83398f9a05f0

Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 01/18] sequencer: optionally skip printing commit summary

When picking commits by using for example git-cherry-pick(1) we end up printing a commit summary that gives the reader information around what exactly we have been picking:

```
$ git cherry-pick main
[other 76c8456] bar
 Date: Tue Aug 19 08:07:26 2025 +0200
 1 file changed, 1 insertion(+)
 create mode 100644 bar
```

While useful for some commands, we're about to introduce a new command where this output will be less so. But right now there is no way to disable printing this commit summary.

Introduce a new `skip_commit_summary` replay option that does so. Persist the option into the sequencer configuration so that it persists across different processes, e.g. when we need to stop due to a merge conflict.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 sequencer.c | 12 +++++++++---
 sequencer.h |  1 +
 2 files changed, 10 insertions(+), 3 deletions(-)
Show changes to 2 files +10 −3

sequencer.c, sequencer.h

diff --git a/sequencer.c b/sequencer.c
index aaf2e4df64..7066cdc939 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -1742,7 +1742,7 @@ static int do_commit(struct repository *r,
 			refs_delete_ref(get_main_ref_store(r), "",
 					"CHERRY_PICK_HEAD", NULL, REF_NO_DEREF);
 			unlink(git_path_merge_msg(r));
-			if (!is_rebase_i(opts))
+			if (!is_rebase_i(opts) && !opts->skip_commit_summary)
 				print_commit_summary(r, NULL, &oid,
 						SUMMARY_SHOW_AUTHOR_DATE);
 			return res;
@@ -3139,8 +3139,12 @@ static int populate_opts_cb(const char *key, const char *value,
 	else if (!strcmp(key, "options.default-msg-cleanup")) {
 		opts->explicit_cleanup = 1;
 		opts->default_msg_cleanup = get_cleanup_mode(value, 1);
-	} else
+	} else if (!strcmp(key, "options.skip-commit-summary")) {
+		opts->skip_commit_summary =
+			git_config_bool_or_int(key, value, ctx->kvi, &error_flag);
+	} else {
 		return error(_("invalid key: %s"), key);
+	}
 
 	if (!error_flag)
 		return error(_("invalid value for '%s': '%s'"), key, value);
@@ -3698,11 +3702,13 @@ static int save_opts(struct replay_opts *opts)
 				"options.allow-rerere-auto", NULL,
 				opts->allow_rerere_auto == RERERE_AUTOUPDATE ?
 				"true" : "false");
-
 	if (opts->explicit_cleanup)
 		res |= repo_config_set_in_file_gently(the_repository, opts_file,
 				"options.default-msg-cleanup", NULL,
 				describe_cleanup_mode(opts->default_msg_cleanup));
+	if (opts->skip_commit_summary)
+		res |= repo_config_set_in_file_gently(the_repository, opts_file,
+					"options.skip-commit-summary", NULL, "true");
 	return res;
 }
 
diff --git a/sequencer.h b/sequencer.h
index 304ba4b4d3..1767fd737e 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -52,6 +52,7 @@ struct replay_opts {
 	int keep_redundant_commits;
 	int verbose;
 	int quiet;
+	int skip_commit_summary;
 	int reschedule_failed_exec;
 	int committer_date_is_author_date;
 	int ignore_date;
-- 
2.51.0.417.g1ba7204a04.dirty
Phillip Wood· Sep 10, 2025, 14:01 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v3 01/18] sequencer: optionally skip printing commit summary

Hi Patrick
On 04/09/2025 15:27, Patrick Steinhardt wrote:
Show 11 quoted lines
> 
> diff --git a/sequencer.c b/sequencer.c
> index aaf2e4df64..7066cdc939 100644
> --- a/sequencer.c
> +++ b/sequencer.c
> @@ -1742,7 +1742,7 @@ static int do_commit(struct repository *r,
>   			refs_delete_ref(get_main_ref_store(r), "",
>   					"CHERRY_PICK_HEAD", NULL, REF_NO_DEREF);
>   			unlink(git_path_merge_msg(r));
> -			if (!is_rebase_i(opts))
> +			if (!is_rebase_i(opts) && !opts->skip_commit_summary)

I think it would be cleaner to make rebase set the new option and remove !is_rebase_i(opts) here.

Thanks
Phillip
Show 45 quoted lines
>   				print_commit_summary(r, NULL, &oid,
>   						SUMMARY_SHOW_AUTHOR_DATE);
>   			return res;
> @@ -3139,8 +3139,12 @@ static int populate_opts_cb(const char *key, const char *value,
>   	else if (!strcmp(key, "options.default-msg-cleanup")) {
>   		opts->explicit_cleanup = 1;
>   		opts->default_msg_cleanup = get_cleanup_mode(value, 1);
> -	} else
> +	} else if (!strcmp(key, "options.skip-commit-summary")) {
> +		opts->skip_commit_summary =
> +			git_config_bool_or_int(key, value, ctx->kvi, &error_flag);
> +	} else {
>   		return error(_("invalid key: %s"), key);
> +	}
>   
>   	if (!error_flag)
>   		return error(_("invalid value for '%s': '%s'"), key, value);
> @@ -3698,11 +3702,13 @@ static int save_opts(struct replay_opts *opts)
>   				"options.allow-rerere-auto", NULL,
>   				opts->allow_rerere_auto == RERERE_AUTOUPDATE ?
>   				"true" : "false");
> -
>   	if (opts->explicit_cleanup)
>   		res |= repo_config_set_in_file_gently(the_repository, opts_file,
>   				"options.default-msg-cleanup", NULL,
>   				describe_cleanup_mode(opts->default_msg_cleanup));
> +	if (opts->skip_commit_summary)
> +		res |= repo_config_set_in_file_gently(the_repository, opts_file,
> +					"options.skip-commit-summary", NULL, "true");
>   	return res;
>   }
>   
> diff --git a/sequencer.h b/sequencer.h
> index 304ba4b4d3..1767fd737e 100644
> --- a/sequencer.h
> +++ b/sequencer.h
> @@ -52,6 +52,7 @@ struct replay_opts {
>   	int keep_redundant_commits;
>   	int verbose;
>   	int quiet;
> +	int skip_commit_summary;
>   	int reschedule_failed_exec;
>   	int committer_date_is_author_date;
>   	int ignore_date;
> 
Patrick Steinhardt· Sep 15, 2025, 09:32 UTC · re: Phillip Wood · lore

Re: [PATCH RFC v3 01/18] sequencer: optionally skip printing commit summary

On Wed, Sep 10, 2025 at 03:01:54PM +0100, Phillip Wood wrote:
Show 14 quoted lines
> On 04/09/2025 15:27, Patrick Steinhardt wrote:
> > diff --git a/sequencer.c b/sequencer.c
> > index aaf2e4df64..7066cdc939 100644
> > --- a/sequencer.c
> > +++ b/sequencer.c
> > @@ -1742,7 +1742,7 @@ static int do_commit(struct repository *r,
> >   			refs_delete_ref(get_main_ref_store(r), "",
> >   					"CHERRY_PICK_HEAD", NULL, REF_NO_DEREF);
> >   			unlink(git_path_merge_msg(r));
> > -			if (!is_rebase_i(opts))
> > +			if (!is_rebase_i(opts) && !opts->skip_commit_summary)
> 
> I think it would be cleaner to make rebase set the new option and remove
> !is_rebase_i(opts) here.
Good suggestion, will do.
Patrick
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 02/18] sequencer: add option to rewind HEAD after picking commits

While the sequencer infrastructure knows to rewind "HEAD" to whatever it was pointing to before a rebase, it doesn't do the same for non-rebase operations like cherry-picks. This is because the expectation is that the user directly picks commits on top of whatever "HEAD" points to, and we advance the reference pointed to by "HEAD" instead of updating it directly.

We're about to introduce a new command though that needs to detach "HEAD" while being more similar to git-cherry-pick(1) rathen than to git-rebase(1). As such, we'll want to restore "HEAD" to point to the branch that we started on while not using the more heavy-weight rebase machinery.

Introduce a new option `restore_head_target` to do so. Persist the option into the sequencer configuration so that it persists across different processes, e.g. when we need to stop due to a merge conflict.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 sequencer.c | 27 ++++++++++++++++++++++++++-
 sequencer.h |  3 +++
 2 files changed, 29 insertions(+), 1 deletion(-)
Show changes to 2 files +29 −1

sequencer.c, sequencer.h

diff --git a/sequencer.c b/sequencer.c
index 7066cdc939..bff181df76 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -413,6 +413,7 @@ void replay_opts_release(struct replay_opts *opts)
 	struct replay_ctx *ctx = opts->ctx;
 
 	free(opts->gpg_sign);
+	free(opts->restore_head_target);
 	free(opts->reflog_action);
 	free(opts->default_strategy);
 	free(opts->strategy);
@@ -3142,6 +3143,8 @@ static int populate_opts_cb(const char *key, const char *value,
 	} else if (!strcmp(key, "options.skip-commit-summary")) {
 		opts->skip_commit_summary =
 			git_config_bool_or_int(key, value, ctx->kvi, &error_flag);
+	} else if (!strcmp(key, "options.restore-head-target")) {
+		git_config_string_dup(&opts->restore_head_target, key, value);
 	} else {
 		return error(_("invalid key: %s"), key);
 	}
@@ -3709,6 +3712,10 @@ static int save_opts(struct replay_opts *opts)
 	if (opts->skip_commit_summary)
 		res |= repo_config_set_in_file_gently(the_repository, opts_file,
 					"options.skip-commit-summary", NULL, "true");
+	if (opts->restore_head_target)
+		res |= repo_config_set_in_file_gently(the_repository, opts_file,
+				"options.restore-head-target", NULL, opts->restore_head_target);
+
 	return res;
 }
 
@@ -5177,6 +5184,23 @@ static int pick_commits(struct repository *r,
 			return -1;
 	}
 
+	if (opts->restore_head_target) {
+		struct reset_head_opts reset_opts = { 0 };
+		const char *msg;
+
+		msg = reflog_message(opts, "finish", "returning to %s", opts->restore_head_target);
+
+		reset_opts.branch = opts->restore_head_target;
+		reset_opts.flags = RESET_HEAD_REFS_ONLY;
+		reset_opts.branch_msg = msg;
+		reset_opts.head_msg = msg;
+
+		if (reset_head(r, &reset_opts)) {
+			error(_("could not switch HEAD back to %s"), opts->restore_head_target);
+			return -1;
+		}
+	}
+
 	/*
 	 * Sequence of picks finished successfully; cleanup by
 	 * removing the .git/sequencer directory
@@ -5533,7 +5557,8 @@ int sequencer_pick_revisions(struct repository *r,
 	if (opts->revs->cmdline.nr == 1 &&
 	    opts->revs->cmdline.rev->whence == REV_CMD_REV &&
 	    opts->revs->no_walk &&
-	    !opts->revs->cmdline.rev->flags) {
+	    !opts->revs->cmdline.rev->flags &&
+	    !opts->restore_head_target) {
 		struct commit *cmit;
 
 		if (prepare_revision_walk(opts->revs)) {
diff --git a/sequencer.h b/sequencer.h
index 1767fd737e..a905f6afc7 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -72,6 +72,9 @@ struct replay_opts {
 	/* Reflog */
 	char *reflog_action;
 
+	/* Reference to which HEAD shall be reset to after the operation. */
+	char *restore_head_target;
+
 	/* placeholder commit for -i --root */
 	struct object_id squash_onto;
 	int have_squash_onto;
-- 
2.51.0.417.g1ba7204a04.dirty
Phillip Wood· Sep 10, 2025, 14:04 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v3 02/18] sequencer: add option to rewind HEAD after picking commits

Hi Patrick
On 04/09/2025 15:27, Patrick Steinhardt wrote:
Show 16 quoted lines
> While the sequencer infrastructure knows to rewind "HEAD" to whatever it
> was pointing to before a rebase, it doesn't do the same for non-rebase
> operations like cherry-picks. This is because the expectation is that
> the user directly picks commits on top of whatever "HEAD" points to, and
> we advance the reference pointed to by "HEAD" instead of updating it
> directly.
> 
> We're about to introduce a new command though that needs to detach
> "HEAD" while being more similar to git-cherry-pick(1) rathen than to
> git-rebase(1). As such, we'll want to restore "HEAD" to point to the
> branch that we started on while not using the more heavy-weight rebase
> machinery.
> 
> Introduce a new option `restore_head_target` to do so. Persist the
> option into the sequencer configuration so that it persists across
> different processes, e.g. when we need to stop due to a merge conflict.

As with the last patch, can we use this new option in "git rebase"? The sequencer is already a nest of conditionals, it would be nice to minimize the number of new ones.

Thanks
Phillip
Show 87 quoted lines
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>   sequencer.c | 27 ++++++++++++++++++++++++++-
>   sequencer.h |  3 +++
>   2 files changed, 29 insertions(+), 1 deletion(-)
> 
> diff --git a/sequencer.c b/sequencer.c
> index 7066cdc939..bff181df76 100644
> --- a/sequencer.c
> +++ b/sequencer.c
> @@ -413,6 +413,7 @@ void replay_opts_release(struct replay_opts *opts)
>   	struct replay_ctx *ctx = opts->ctx;
>   
>   	free(opts->gpg_sign);
> +	free(opts->restore_head_target);
>   	free(opts->reflog_action);
>   	free(opts->default_strategy);
>   	free(opts->strategy);
> @@ -3142,6 +3143,8 @@ static int populate_opts_cb(const char *key, const char *value,
>   	} else if (!strcmp(key, "options.skip-commit-summary")) {
>   		opts->skip_commit_summary =
>   			git_config_bool_or_int(key, value, ctx->kvi, &error_flag);
> +	} else if (!strcmp(key, "options.restore-head-target")) {
> +		git_config_string_dup(&opts->restore_head_target, key, value);
>   	} else {
>   		return error(_("invalid key: %s"), key);
>   	}
> @@ -3709,6 +3712,10 @@ static int save_opts(struct replay_opts *opts)
>   	if (opts->skip_commit_summary)
>   		res |= repo_config_set_in_file_gently(the_repository, opts_file,
>   					"options.skip-commit-summary", NULL, "true");
> +	if (opts->restore_head_target)
> +		res |= repo_config_set_in_file_gently(the_repository, opts_file,
> +				"options.restore-head-target", NULL, opts->restore_head_target);
> +
>   	return res;
>   }
>   
> @@ -5177,6 +5184,23 @@ static int pick_commits(struct repository *r,
>   			return -1;
>   	}
>   
> +	if (opts->restore_head_target) {
> +		struct reset_head_opts reset_opts = { 0 };
> +		const char *msg;
> +
> +		msg = reflog_message(opts, "finish", "returning to %s", opts->restore_head_target);
> +
> +		reset_opts.branch = opts->restore_head_target;
> +		reset_opts.flags = RESET_HEAD_REFS_ONLY;
> +		reset_opts.branch_msg = msg;
> +		reset_opts.head_msg = msg;
> +
> +		if (reset_head(r, &reset_opts)) {
> +			error(_("could not switch HEAD back to %s"), opts->restore_head_target);
> +			return -1;
> +		}
> +	}
> +
>   	/*
>   	 * Sequence of picks finished successfully; cleanup by
>   	 * removing the .git/sequencer directory
> @@ -5533,7 +5557,8 @@ int sequencer_pick_revisions(struct repository *r,
>   	if (opts->revs->cmdline.nr == 1 &&
>   	    opts->revs->cmdline.rev->whence == REV_CMD_REV &&
>   	    opts->revs->no_walk &&
> -	    !opts->revs->cmdline.rev->flags) {
> +	    !opts->revs->cmdline.rev->flags &&
> +	    !opts->restore_head_target) {
>   		struct commit *cmit;
>   
>   		if (prepare_revision_walk(opts->revs)) {
> diff --git a/sequencer.h b/sequencer.h
> index 1767fd737e..a905f6afc7 100644
> --- a/sequencer.h
> +++ b/sequencer.h
> @@ -72,6 +72,9 @@ struct replay_opts {
>   	/* Reflog */
>   	char *reflog_action;
>   
> +	/* Reference to which HEAD shall be reset to after the operation. */
> +	char *restore_head_target;
> +
>   	/* placeholder commit for -i --root */
>   	struct object_id squash_onto;
>   	int have_squash_onto;
> 
Patrick Steinhardt· Sep 15, 2025, 09:32 UTC · re: Phillip Wood · lore

Re: [PATCH RFC v3 02/18] sequencer: add option to rewind HEAD after picking commits

On Wed, Sep 10, 2025 at 03:04:00PM +0100, Phillip Wood wrote:
Show 23 quoted lines
> Hi Patrick
> 
> On 04/09/2025 15:27, Patrick Steinhardt wrote:
> > While the sequencer infrastructure knows to rewind "HEAD" to whatever it
> > was pointing to before a rebase, it doesn't do the same for non-rebase
> > operations like cherry-picks. This is because the expectation is that
> > the user directly picks commits on top of whatever "HEAD" points to, and
> > we advance the reference pointed to by "HEAD" instead of updating it
> > directly.
> > 
> > We're about to introduce a new command though that needs to detach
> > "HEAD" while being more similar to git-cherry-pick(1) rathen than to
> > git-rebase(1). As such, we'll want to restore "HEAD" to point to the
> > branch that we started on while not using the more heavy-weight rebase
> > machinery.
> > 
> > Introduce a new option `restore_head_target` to do so. Persist the
> > option into the sequencer configuration so that it persists across
> > different processes, e.g. when we need to stop due to a merge conflict.
> 
> As with the last patch, can we use this new option in "git rebase"? The
> sequencer is already a nest of conditionals, it would be nice to minimize
> the number of new ones.

You probably refer to the condition in `sequencer_pick_revisions()` here? Everything else is basically new code.

Honestly, I don't dare touching that condition -- it's already quite complex, and I wouldn't be surprised if changing it in any way would cause regressions.

Patrick
Phillip Wood· Sep 15, 2025, 14:10 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v3 02/18] sequencer: add option to rewind HEAD after picking commits

On 15/09/2025 10:32, Patrick Steinhardt wrote:
Show 7 quoted lines
> On Wed, Sep 10, 2025 at 03:04:00PM +0100, Phillip Wood wrote:
>> Hi Patrick
>>
>> On 04/09/2025 15:27, Patrick Steinhardt wrote:
> 
> You probably refer to the condition in `sequencer_pick_revisions()`
> here? Everything else is basically new code.

I was thinking of the code that restores HEAD at the end of pick_commits() as rebase does something similar already (it looks like you're using the same reflog message). Though thinking about it again I'm not sure if "git history" detaches HEAD at the start like rebase does. Rewriting the history with a detached HEAD is probably a good idea as it will stop the branch reflog being polluted. One nice aspect of rebase detaching HEAD is that at the end you can check the result by running

     git range-diff $branch@{1}...HEAD
Thanks
Phillip
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 03/18] sequencer: introduce new history editing mode

Introduce a new history editing mode into our sequencer machinery. This mode is basically the same as `REBASE_CHERRY`, but will be used by the new git-history(1) command that is to be introduced in a subsequent commit.

Note that the advice already points towards the git-history(1) command. This advice is bogus right now, but we'll introduce the relevant infra in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 sequencer.c | 116 +++++++++++++++++++++++++++++++++++++++++++++++-------------
 sequencer.h |   3 +-
 2 files changed, 93 insertions(+), 26 deletions(-)
Show changes to 2 files +93 −26

sequencer.c, sequencer.h

diff --git a/sequencer.c b/sequencer.c
index bff181df76..9a66e7d128 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -465,6 +465,8 @@ static const char *action_name(const struct replay_opts *opts)
 		return N_("cherry-pick");
 	case REPLAY_INTERACTIVE_REBASE:
 		return N_("rebase");
+	case REPLAY_HISTORY_EDIT:
+		return N_("history edit");
 	}
 	die(_("unknown action: %d"), opts->action);
 }
@@ -557,6 +559,13 @@ static void print_advice(struct repository *r, int show_hint,
 					    "You can instead skip this commit with \"git revert --skip\".\n"
 					    "To abort and get back to the state before \"git revert\",\n"
 					    "run \"git revert --abort\"."));
+		else if (opts->action == REPLAY_HISTORY_EDIT)
+			advise_if_enabled(ADVICE_MERGE_CONFLICT,
+					  _("After resolving the conflicts, mark them with\n"
+					    "\"git add/rm <pathspec>\", then run\n"
+					    "\"git history continue\".\n"
+					    "To abort and get back to the state before \"git history\",\n"
+					    "run \"git history abort\"."));
 		else
 			BUG("unexpected pick action in print_advice()");
 	}
@@ -1742,6 +1751,8 @@ static int do_commit(struct repository *r,
 		if (!res) {
 			refs_delete_ref(get_main_ref_store(r), "",
 					"CHERRY_PICK_HEAD", NULL, REF_NO_DEREF);
+			refs_delete_ref(get_main_ref_store(r), "",
+					"HISTORY_EDIT_HEAD", NULL, REF_NO_DEREF);
 			unlink(git_path_merge_msg(r));
 			if (!is_rebase_i(opts) && !opts->skip_commit_summary)
 				print_commit_summary(r, NULL, &oid,
@@ -2491,16 +2502,24 @@ static int do_pick_commit(struct repository *r,
 	 * However, if the merge did not even start, then we don't want to
 	 * write it at all.
 	 */
-	if ((command == TODO_PICK || command == TODO_REWORD ||
-	     command == TODO_EDIT) && !opts->no_commit &&
-	    (res == 0 || res == 1) &&
-	    refs_update_ref(get_main_ref_store(the_repository), NULL, "CHERRY_PICK_HEAD", &commit->object.oid, NULL,
-			    REF_NO_DEREF, UPDATE_REFS_MSG_ON_ERR))
-		res = -1;
-	if (command == TODO_REVERT && ((opts->no_commit && res == 0) || res == 1) &&
-	    refs_update_ref(get_main_ref_store(the_repository), NULL, "REVERT_HEAD", &commit->object.oid, NULL,
-			    REF_NO_DEREF, UPDATE_REFS_MSG_ON_ERR))
-		res = -1;
+	if (opts->action == REPLAY_HISTORY_EDIT && command == TODO_PICK &&
+	    !opts->no_commit && (res == 0 || res == 1)) {
+		if (refs_update_ref(get_main_ref_store(the_repository), NULL,
+				    "HISTORY_EDIT_HEAD", &commit->object.oid, NULL,
+				    REF_NO_DEREF, UPDATE_REFS_MSG_ON_ERR))
+			res = -1;
+	} else if ((command == TODO_PICK || command == TODO_REWORD ||
+		   command == TODO_EDIT) && !opts->no_commit && (res == 0 || res == 1)) {
+		if (refs_update_ref(get_main_ref_store(the_repository), NULL,
+				    "CHERRY_PICK_HEAD", &commit->object.oid, NULL,
+				    REF_NO_DEREF, UPDATE_REFS_MSG_ON_ERR))
+			res = -1;
+	} else if (command == TODO_REVERT && ((opts->no_commit && res == 0) || res == 1)) {
+		if (refs_update_ref(get_main_ref_store(the_repository), NULL,
+				    "REVERT_HEAD", &commit->object.oid, NULL,
+				    REF_NO_DEREF, UPDATE_REFS_MSG_ON_ERR))
+			res = -1;
+	}
 
 	if (res) {
 		error(command == TODO_REVERT
@@ -2526,6 +2545,8 @@ static int do_pick_commit(struct repository *r,
 		unlink(git_path_merge_msg(r));
 		refs_delete_ref(get_main_ref_store(r), "", "AUTO_MERGE",
 				NULL, REF_NO_DEREF);
+		refs_delete_ref(get_main_ref_store(r), "", "HISTORY_EDIT_HEAD",
+				NULL, REF_NO_DEREF);
 		fprintf(stderr,
 			_("dropping %s %s -- patch contents already upstream\n"),
 			oid_to_hex(&commit->object.oid), msg.subject);
@@ -2843,12 +2864,17 @@ static int parse_insn_line(struct repository *r, struct replay_opts *opts,
 	return 0;
 }
 
-int sequencer_get_last_command(struct repository *r UNUSED, enum replay_action *action)
+int sequencer_get_last_command(struct repository *r, enum replay_action *action)
 {
 	const char *todo_file, *bol;
 	struct strbuf buf = STRBUF_INIT;
 	int ret = 0;
 
+	if (refs_ref_exists(get_main_ref_store(r), "HISTORY_EDIT_HEAD")) {
+		*action = REPLAY_HISTORY_EDIT;
+		return 0;
+	}
+
 	todo_file = git_path_todo_file();
 	if (strbuf_read_file(&buf, todo_file, 0) < 0) {
 		if (errno == ENOENT || errno == ENOTDIR)
@@ -2995,6 +3021,15 @@ void sequencer_post_commit_cleanup(struct repository *r, int verbose)
 		need_cleanup = 1;
 	}
 
+	if (refs_ref_exists(get_main_ref_store(r), "HISTORY_EDIT_HEAD")) {
+		if (!refs_delete_ref(get_main_ref_store(r), "",
+				     "HISTORY_EDIT_HEAD", NULL, REF_NO_DEREF) &&
+		    verbose)
+			warning(_("cancelling a history edit in progress"));
+		opts.action = REPLAY_HISTORY_EDIT;
+		need_cleanup = 1;
+	}
+
 	if (refs_ref_exists(get_main_ref_store(r), "REVERT_HEAD")) {
 		if (!refs_delete_ref(get_main_ref_store(r), "", "REVERT_HEAD",
 				     NULL, REF_NO_DEREF) &&
@@ -3028,6 +3063,19 @@ static void todo_list_write_total_nr(struct todo_list *todo_list)
 	}
 }
 
+static enum todo_command action_to_command(enum replay_action action)
+{
+	switch (action) {
+	case REPLAY_PICK:
+	case REPLAY_HISTORY_EDIT:
+		return TODO_PICK;
+	case REPLAY_REVERT:
+		return TODO_REVERT;
+	default:
+		BUG("unsupported action %d", action);
+	}
+}
+
 static int read_populate_todo(struct repository *r,
 			      struct todo_list *todo_list,
 			      struct replay_opts *opts)
@@ -3052,17 +3100,19 @@ static int read_populate_todo(struct repository *r,
 		return error(_("no commits parsed."));
 
 	if (!is_rebase_i(opts)) {
-		enum todo_command valid =
-			opts->action == REPLAY_PICK ? TODO_PICK : TODO_REVERT;
+		enum todo_command valid = action_to_command(opts->action);
 		int i;
 
-		for (i = 0; i < todo_list->nr; i++)
+		for (i = 0; i < todo_list->nr; i++) {
 			if (valid == todo_list->items[i].command)
 				continue;
 			else if (valid == TODO_PICK)
-				return error(_("cannot cherry-pick during a revert."));
+				return error(_("cannot cherry-pick during a %s."),
+					     action_name(opts));
 			else
-				return error(_("cannot revert during a cherry-pick."));
+				return error(_("cannot revert during a %s."),
+					     action_name(opts));
+		}
 	}
 
 	if (is_rebase_i(opts)) {
@@ -3353,15 +3403,16 @@ int write_basic_state(struct replay_opts *opts, const char *head_name,
 static int walk_revs_populate_todo(struct todo_list *todo_list,
 				struct replay_opts *opts)
 {
-	enum todo_command command = opts->action == REPLAY_PICK ?
-		TODO_PICK : TODO_REVERT;
-	const char *command_string = todo_command_info[command].str;
+	enum todo_command command;
+	const char *command_string;
 	const char *encoding;
 	struct commit *commit;
 
 	if (prepare_revs(opts))
 		return -1;
 
+	command = action_to_command(opts->action);
+	command_string = todo_command_info[command].str;
 	encoding = get_log_output_encoding();
 
 	while ((commit = get_revision(opts->revs))) {
@@ -3412,6 +3463,11 @@ static int create_seq_dir(struct repository *r)
 			in_progress_advice =
 			_("try \"git cherry-pick (--continue | %s--abort | --quit)\"");
 			break;
+		case REPLAY_HISTORY_EDIT:
+			in_progress_error = _("history edit is already in progress");
+			in_progress_advice =
+			_("try \"git history (continue | abort | quit)\"");
+			break;
 		default:
 			BUG("unexpected action in create_seq_dir");
 		}
@@ -3472,13 +3528,14 @@ static int reset_merge(const struct object_id *oid)
 	return run_command(&cmd);
 }
 
-static int rollback_single_pick(struct repository *r)
+static int rollback_single_pick(struct repository *r, struct replay_opts *opts)
 {
 	struct object_id head_oid;
 
 	if (!refs_ref_exists(get_main_ref_store(r), "CHERRY_PICK_HEAD") &&
+	    !refs_ref_exists(get_main_ref_store(r), "HISTORY_EDIT_HEAD") &&
 	    !refs_ref_exists(get_main_ref_store(r), "REVERT_HEAD"))
-		return error(_("no cherry-pick or revert in progress"));
+		return error(_("no %s in progress"), action_name(opts));
 	if (refs_read_ref_full(get_main_ref_store(the_repository), "HEAD", 0, &head_oid, NULL))
 		return error(_("cannot resolve HEAD"));
 	if (is_null_oid(&head_oid))
@@ -3509,7 +3566,7 @@ int sequencer_rollback(struct repository *r, struct replay_opts *opts)
 		 * If CHERRY_PICK_HEAD or REVERT_HEAD indicates
 		 * a single-cherry-pick in progress, abort that.
 		 */
-		return rollback_single_pick(r);
+		return rollback_single_pick(r, opts);
 	}
 	if (!f)
 		return error_errno(_("cannot open '%s'"), git_path_head_file());
@@ -5213,8 +5270,9 @@ static int continue_single_pick(struct repository *r, struct replay_opts *opts)
 	struct child_process cmd = CHILD_PROCESS_INIT;
 
 	if (!refs_ref_exists(get_main_ref_store(r), "CHERRY_PICK_HEAD") &&
+	    !refs_ref_exists(get_main_ref_store(r), "HISTORY_EDIT_HEAD") &&
 	    !refs_ref_exists(get_main_ref_store(r), "REVERT_HEAD"))
-		return error(_("no cherry-pick or revert in progress"));
+		return error(_("no %s in progress"), action_name(opts));
 
 	cmd.git_cmd = 1;
 	strvec_push(&cmd.args, "commit");
@@ -5393,6 +5451,14 @@ static int commit_staged_changes(struct repository *r,
 			goto out;
 		}
 
+		if (refs_ref_exists(get_main_ref_store(r),
+				    "HISTORY_EDIT_HEAD") &&
+		    refs_delete_ref(get_main_ref_store(r), "",
+				    "HISTORY_EDIT_HEAD", NULL, REF_NO_DEREF)) {
+			ret = error(_("could not remove HISTORY_EDIT_HEAD"));
+			goto out;
+		}
+
 		if (unlink(git_path_merge_msg(r)) && errno != ENOENT) {
 			ret = error_errno(_("could not remove '%s'"),
 					  git_path_merge_msg(r));
@@ -5471,6 +5537,7 @@ int sequencer_continue(struct repository *r, struct replay_opts *opts)
 		/* Verify that the conflict has been resolved */
 		if (refs_ref_exists(get_main_ref_store(r),
 				    "CHERRY_PICK_HEAD") ||
+		    refs_ref_exists(get_main_ref_store(r), "HISTORY_EDIT_HEAD") ||
 		    refs_ref_exists(get_main_ref_store(r), "REVERT_HEAD")) {
 			res = continue_single_pick(r, opts);
 			if (res)
@@ -5505,8 +5572,7 @@ static int single_pick(struct repository *r,
 	int check_todo;
 	struct todo_item item;
 
-	item.command = opts->action == REPLAY_PICK ?
-			TODO_PICK : TODO_REVERT;
+	item.command = action_to_command(opts->action);
 	item.commit = cmit;
 
 	return do_pick_commit(r, &item, opts, 0, &check_todo);
diff --git a/sequencer.h b/sequencer.h
index a905f6afc7..082fbe3e35 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -21,7 +21,8 @@ extern const char *rebase_resolvemsg;
 enum replay_action {
 	REPLAY_REVERT,
 	REPLAY_PICK,
-	REPLAY_INTERACTIVE_REBASE
+	REPLAY_INTERACTIVE_REBASE,
+	REPLAY_HISTORY_EDIT,
 };
 
 enum commit_msg_cleanup_mode {
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 04/18] sequencer: stop using `the_repository` in `sequencer_remove_state()`

Refactor `sequencer_remove_state()` to stop using `the_repository` in favor of a passed-in repository.

A lot of the other code in our sequencer infrastructure still uses `the_repository`, but this bigger refactoring is left for another day.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/rebase.c |  4 ++--
 builtin/revert.c |  2 +-
 sequencer.c      | 18 +++++++++---------
 sequencer.h      |  2 +-
 4 files changed, 13 insertions(+), 13 deletions(-)
Show changes to 4 files +13 −13

builtin/rebase.c, builtin/revert.c, sequencer.c, sequencer.h

diff --git a/builtin/rebase.c b/builtin/rebase.c
index 3c85768d29..66824ae136 100644
--- a/builtin/rebase.c
+++ b/builtin/rebase.c
@@ -568,7 +568,7 @@ static int finish_rebase(struct rebase_options *opts)
 		struct replay_opts replay = REPLAY_OPTS_INIT;
 
 		replay.action = REPLAY_INTERACTIVE_REBASE;
-		ret = sequencer_remove_state(&replay);
+		ret = sequencer_remove_state(the_repository, &replay);
 		replay_opts_release(&replay);
 	} else {
 		strbuf_addstr(&dir, opts->state_dir);
@@ -1405,7 +1405,7 @@ int cmd_rebase(int argc,
 			struct replay_opts replay = REPLAY_OPTS_INIT;
 
 			replay.action = REPLAY_INTERACTIVE_REBASE;
-			ret = sequencer_remove_state(&replay);
+			ret = sequencer_remove_state(the_repository, &replay);
 			replay_opts_release(&replay);
 		} else {
 			strbuf_reset(&buf);
diff --git a/builtin/revert.c b/builtin/revert.c
index c3f92b585d..6456cf2171 100644
--- a/builtin/revert.c
+++ b/builtin/revert.c
@@ -263,7 +263,7 @@ static int run_sequencer(int argc, const char **argv, const char *prefix,
 	free(options);
 
 	if (cmd == 'q') {
-		int ret = sequencer_remove_state(opts);
+		int ret = sequencer_remove_state(the_repository, opts);
 		if (!ret)
 			remove_branch_state(the_repository, 0);
 		return ret;
diff --git a/sequencer.c b/sequencer.c
index 9a66e7d128..36e4db8526 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -425,7 +425,7 @@ void replay_opts_release(struct replay_opts *opts)
 	free(opts->ctx);
 }
 
-int sequencer_remove_state(struct replay_opts *opts)
+int sequencer_remove_state(struct repository *repo, struct replay_opts *opts)
 {
 	struct strbuf buf = STRBUF_INIT;
 	int ret = 0;
@@ -437,7 +437,7 @@ int sequencer_remove_state(struct replay_opts *opts)
 			char *eol = strchr(p, '\n');
 			if (eol)
 				*eol = '\0';
-			if (refs_delete_ref(get_main_ref_store(the_repository), "(rebase) cleanup", p, NULL, 0) < 0) {
+			if (refs_delete_ref(get_main_ref_store(repo), "(rebase) cleanup", p, NULL, 0) < 0) {
 				warning(_("could not delete '%s'"), p);
 				ret = -1;
 			}
@@ -3048,7 +3048,7 @@ void sequencer_post_commit_cleanup(struct repository *r, int verbose)
 	if (!have_finished_the_last_pick())
 		goto out;
 
-	sequencer_remove_state(&opts);
+	sequencer_remove_state(the_repository, &opts);
 out:
 	replay_opts_release(&opts);
 }
@@ -3595,7 +3595,7 @@ int sequencer_rollback(struct repository *r, struct replay_opts *opts)
 	if (reset_merge(&oid))
 		goto fail;
 	strbuf_release(&buf);
-	return sequencer_remove_state(opts);
+	return sequencer_remove_state(the_repository, opts);
 fail:
 	strbuf_release(&buf);
 	return -1;
@@ -4897,7 +4897,7 @@ static int checkout_onto(struct repository *r, struct replay_opts *opts,
 	};
 	if (reset_head(r, &ropts)) {
 		apply_autostash(rebase_path_autostash());
-		sequencer_remove_state(opts);
+		sequencer_remove_state(the_repository, opts);
 		return error(_("could not detach HEAD"));
 	}
 
@@ -5262,7 +5262,7 @@ static int pick_commits(struct repository *r,
 	 * Sequence of picks finished successfully; cleanup by
 	 * removing the .git/sequencer directory
 	 */
-	return sequencer_remove_state(opts);
+	return sequencer_remove_state(the_repository, opts);
 }
 
 static int continue_single_pick(struct repository *r, struct replay_opts *opts)
@@ -6593,7 +6593,7 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
 
 	if (count_commands(todo_list) == 0) {
 		apply_autostash(rebase_path_autostash());
-		sequencer_remove_state(opts);
+		sequencer_remove_state(the_repository, opts);
 
 		return error(_("nothing to do"));
 	}
@@ -6604,12 +6604,12 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
 		return -1;
 	else if (res == -2) {
 		apply_autostash(rebase_path_autostash());
-		sequencer_remove_state(opts);
+		sequencer_remove_state(the_repository, opts);
 
 		return -1;
 	} else if (res == -3) {
 		apply_autostash(rebase_path_autostash());
-		sequencer_remove_state(opts);
+		sequencer_remove_state(the_repository, opts);
 		todo_list_release(&new_todo);
 
 		return error(_("nothing to do"));
diff --git a/sequencer.h b/sequencer.h
index 082fbe3e35..0e0e7301b8 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -170,7 +170,7 @@ int sequencer_continue(struct repository *repo, struct replay_opts *opts);
 int sequencer_rollback(struct repository *repo, struct replay_opts *opts);
 int sequencer_skip(struct repository *repo, struct replay_opts *opts);
 void replay_opts_release(struct replay_opts *opts);
-int sequencer_remove_state(struct replay_opts *opts);
+int sequencer_remove_state(struct repository *repo, struct replay_opts *opts);
 
 #define TODO_LIST_KEEP_EMPTY (1U << 0)
 #define TODO_LIST_SHORTEN_IDS (1U << 1)
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 05/18] sequencer: wire up "rewritten-hook" for REPLAY_HISTORY_EDIT

While the sequencer already knows to drive REPLAY_HISTORY_EDIT, we currently skip the execution of the "rewritten-hook" as it is only specific to interactive rebases. We do want to execute this hook though for commits we're rewriting in the upcoming git-history(1) command.

Wire up the infrastructure so that we also execute this hook with REPLAY_HISTORY_EDIT.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 sequencer.c | 79 +++++++++++++++++++++++++++++++++++++++++++------------------
 1 file changed, 56 insertions(+), 23 deletions(-)
Show changes to sequencer.c +56 −23
diff --git a/sequencer.c b/sequencer.c
index 36e4db8526..61447e5ccf 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -70,6 +70,8 @@ static GIT_PATH_FUNC(git_path_seq_dir, "sequencer")
 static GIT_PATH_FUNC(git_path_todo_file, "sequencer/todo")
 static GIT_PATH_FUNC(git_path_opts_file, "sequencer/opts")
 static GIT_PATH_FUNC(git_path_head_file, "sequencer/head")
+static GIT_PATH_FUNC(git_path_rewritten_list_file, "sequencer/rewritten-list")
+static GIT_PATH_FUNC(git_path_rewritten_pending_file, "sequencer/rewritten-pending")
 static GIT_PATH_FUNC(git_path_abort_safety_file, "sequencer/abort-safety")
 
 static GIT_PATH_FUNC(rebase_path, "rebase-merge")
@@ -2170,15 +2172,25 @@ static int update_squash_messages(struct repository *r,
 	return res;
 }
 
-static void flush_rewritten_pending(void)
+static void flush_rewritten_pending(struct replay_opts *opts)
 {
 	struct strbuf buf = STRBUF_INIT;
 	struct object_id newoid;
+	const char *pending_path;
+	const char *list_path;
 	FILE *out;
 
-	if (strbuf_read_file(&buf, rebase_path_rewritten_pending(), (GIT_MAX_HEXSZ + 1) * 2) > 0 &&
+	if (opts->action == REPLAY_HISTORY_EDIT) {
+		pending_path = git_path_rewritten_pending_file();
+		list_path = git_path_rewritten_list_file();
+	} else {
+		pending_path = rebase_path_rewritten_pending();
+		list_path = rebase_path_rewritten_list();
+	}
+
+	if (strbuf_read_file(&buf, pending_path, (GIT_MAX_HEXSZ + 1) * 2) > 0 &&
 	    !repo_get_oid(the_repository, "HEAD", &newoid) &&
-	    (out = fopen_or_warn(rebase_path_rewritten_list(), "a"))) {
+	    (out = fopen_or_warn(list_path, "a"))) {
 		char *bol = buf.buf, *eol;
 
 		while (*bol) {
@@ -2190,16 +2202,24 @@ static void flush_rewritten_pending(void)
 			bol = eol + 1;
 		}
 		fclose(out);
-		unlink(rebase_path_rewritten_pending());
+		unlink(pending_path);
 	}
 	strbuf_release(&buf);
 }
 
 static void record_in_rewritten(struct object_id *oid,
-		enum todo_command next_command)
+				enum todo_command next_command,
+				struct replay_opts *opts)
 {
-	FILE *out = fopen_or_warn(rebase_path_rewritten_pending(), "a");
+	const char *path;
+	FILE *out;
 
+	if (opts->action == REPLAY_HISTORY_EDIT)
+		path = git_path_rewritten_pending_file();
+	else
+		path = rebase_path_rewritten_pending();
+
+	out = fopen_or_warn(path, "a");
 	if (!out)
 		return;
 
@@ -2207,7 +2227,7 @@ static void record_in_rewritten(struct object_id *oid,
 	fclose(out);
 
 	if (!is_fixup(next_command))
-		flush_rewritten_pending();
+		flush_rewritten_pending(opts);
 }
 
 static int should_edit(struct replay_opts *opts) {
@@ -4984,9 +5004,9 @@ static int pick_one_commit(struct repository *r,
 		return error_with_patch(r, commit,
 					arg, item->arg_len, opts, res, !res);
 	}
-	if (is_rebase_i(opts) && !res)
+	if ((is_rebase_i(opts) || opts->action == REPLAY_HISTORY_EDIT) && !res)
 		record_in_rewritten(&item->commit->object.oid,
-				    peek_command(todo_list, 1));
+				    peek_command(todo_list, 1), opts);
 	if (res && is_fixup(item->command)) {
 		if (res == 1)
 			intend_to_amend();
@@ -5020,6 +5040,7 @@ static int pick_commits(struct repository *r,
 			struct todo_list *todo_list,
 			struct replay_opts *opts)
 {
+	struct strbuf head_ref = STRBUF_INIT, buf = STRBUF_INIT;
 	struct replay_ctx *ctx = opts->ctx;
 	int res = 0, reschedule = 0;
 
@@ -5106,7 +5127,7 @@ static int pick_commits(struct repository *r,
 				reschedule = 1;
 			else if (item->commit)
 				record_in_rewritten(&item->commit->object.oid,
-						    peek_command(todo_list, 1));
+						    peek_command(todo_list, 1), opts);
 			if (res > 0)
 				/* failed with merge conflicts */
 				return error_with_patch(r, item->commit,
@@ -5142,9 +5163,6 @@ static int pick_commits(struct repository *r,
 	}
 
 	if (is_rebase_i(opts)) {
-		struct strbuf head_ref = STRBUF_INIT, buf = STRBUF_INIT;
-		struct stat st;
-
 		if (read_oneliner(&head_ref, rebase_path_head_name(), 0) &&
 				starts_with(head_ref.buf, "refs/")) {
 			const char *msg;
@@ -5206,13 +5224,24 @@ static int pick_commits(struct repository *r,
 			}
 			release_revisions(&log_tree_opt);
 		}
-		flush_rewritten_pending();
-		if (!stat(rebase_path_rewritten_list(), &st) &&
-				st.st_size > 0) {
+	}
+
+	if (is_rebase_i(opts) || opts->action == REPLAY_HISTORY_EDIT) {
+		const char *rewritten_list_path;
+		struct stat st;
+
+		flush_rewritten_pending(opts);
+
+		if (opts->action == REPLAY_HISTORY_EDIT)
+			rewritten_list_path = git_path_rewritten_list_file();
+		else
+			rewritten_list_path = rebase_path_rewritten_list();
+
+		if (!stat(rewritten_list_path, &st) && st.st_size > 0) {
 			struct child_process child = CHILD_PROCESS_INIT;
 			struct run_hooks_opt hook_opt = RUN_HOOKS_OPT_INIT;
 
-			child.in = open(rebase_path_rewritten_list(), O_RDONLY);
+			child.in = open(rewritten_list_path, O_RDONLY);
 			child.git_cmd = 1;
 			strvec_push(&child.args, "notes");
 			strvec_push(&child.args, "copy");
@@ -5220,10 +5249,13 @@ static int pick_commits(struct repository *r,
 			/* we don't care if this copying failed */
 			run_command(&child);
 
-			hook_opt.path_to_stdin = rebase_path_rewritten_list();
-			strvec_push(&hook_opt.args, "rebase");
+			hook_opt.path_to_stdin = rewritten_list_path;
+			strvec_push(&hook_opt.args, is_rebase_i(opts) ? "rebase" : "history");
 			run_hooks_opt(r, "post-rewrite", &hook_opt);
 		}
+	}
+
+	if (is_rebase_i(opts)) {
 		apply_autostash(rebase_path_autostash());
 
 		if (!opts->quiet) {
@@ -5555,7 +5587,7 @@ int sequencer_continue(struct repository *r, struct replay_opts *opts)
 		if (read_oneliner(&buf, rebase_path_stopped_sha(),
 				  READ_ONELINER_SKIP_IF_EMPTY) &&
 		    !get_oid_hex(buf.buf, &oid))
-			record_in_rewritten(&oid, peek_command(&todo_list, 0));
+			record_in_rewritten(&oid, peek_command(&todo_list, 0), opts);
 		strbuf_release(&buf);
 	}
 
@@ -6396,7 +6428,8 @@ int todo_list_write_to_file(struct repository *r, struct todo_list *todo_list,
 /* skip picking commits whose parents are unchanged */
 static int skip_unnecessary_picks(struct repository *r,
 				  struct todo_list *todo_list,
-				  struct object_id *base_oid)
+				  struct object_id *base_oid,
+				  struct replay_opts *opts)
 {
 	struct object_id *parent_oid;
 	int i;
@@ -6435,7 +6468,7 @@ static int skip_unnecessary_picks(struct repository *r,
 		todo_list->done_nr += i;
 
 		if (is_fixup(peek_command(todo_list, 0)))
-			record_in_rewritten(base_oid, peek_command(todo_list, 0));
+			record_in_rewritten(base_oid, peek_command(todo_list, 0), opts);
 	}
 
 	return 0;
@@ -6630,7 +6663,7 @@ int complete_action(struct repository *r, struct replay_opts *opts, unsigned fla
 		BUG("invalid todo list after expanding IDs:\n%s",
 		    new_todo.buf.buf);
 
-	if (opts->allow_ff && skip_unnecessary_picks(r, &new_todo, &oid)) {
+	if (opts->allow_ff && skip_unnecessary_picks(r, &new_todo, &oid, opts)) {
 		todo_list_release(&new_todo);
 		return error(_("could not skip unnecessary pick commands"));
 	}
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 06/18] cache-tree: allow writing in-memory index as tree

The function `write_in_core_index_as_tree()` takes a repository and writes its index into a tree object. What this function cannot do though is to take an _arbitrary_ in-memory index.

Introduce a new `struct index_state` parameter so that the caller can pass a different index than the one belonging to the repository. This will be used in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/checkout.c | 3 ++-
 cache-tree.c       | 5 ++---
 cache-tree.h       | 3 ++-
 3 files changed, 6 insertions(+), 5 deletions(-)
Show changes to 3 files +6 −5

builtin/checkout.c, cache-tree.c, cache-tree.h

diff --git a/builtin/checkout.c b/builtin/checkout.c
index f9453473fe2..43583c8d1be 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -902,7 +902,8 @@ static int merge_working_tree(const struct checkout_opts *opts,
 					   0);
 			init_ui_merge_options(&o, the_repository);
 			o.verbosity = 0;
-			work = write_in_core_index_as_tree(the_repository);
+			work = write_in_core_index_as_tree(the_repository,
+							   the_repository->index);
 
 			ret = reset_tree(new_tree,
 					 opts, 1,
diff --git a/cache-tree.c b/cache-tree.c
index 66ef2becbe0..029ec933abe 100644
--- a/cache-tree.c
+++ b/cache-tree.c
@@ -699,11 +699,11 @@ static int write_index_as_tree_internal(struct object_id *oid,
 	return 0;
 }
 
-struct tree* write_in_core_index_as_tree(struct repository *repo) {
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state) {
 	struct object_id o;
 	int was_valid, ret;
 
-	struct index_state *index_state	= repo->index;
 	was_valid = index_state->cache_tree &&
 		    cache_tree_fully_valid(index_state->cache_tree);
 
@@ -723,7 +723,6 @@ struct tree* write_in_core_index_as_tree(struct repository *repo) {
 	return lookup_tree(repo, &index_state->cache_tree->oid);
 }
 
-
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix)
 {
 	int entries, was_valid;
diff --git a/cache-tree.h b/cache-tree.h
index b82c4963e7c..f8bddae5235 100644
--- a/cache-tree.h
+++ b/cache-tree.h
@@ -47,7 +47,8 @@ int cache_tree_verify(struct repository *, struct index_state *);
 #define WRITE_TREE_UNMERGED_INDEX (-2)
 #define WRITE_TREE_PREFIX_ERROR (-3)
 
-struct tree* write_in_core_index_as_tree(struct repository *repo);
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state);
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix);
 void prime_cache_tree(struct repository *, struct index_state *, struct tree *);
 
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 07/18] builtin: add new "history" command

When rewriting history via git-rebase(1) there are a couple of very common use cases:

  - The ordering of two commits should be reversed.
  - A commit should be split up into two commits.
  - A commit should be dropped from the history completely.
  - Multiple commits should be squashed into one.

While these operations are all doable, it often feels needlessly cludgy to do so by doing an interactive rebase, using the editor to say what one wants, and then perform the actions. Furthermore, some operations like splitting up a commit into two are way more involved than that and require a whole series of commands.

Add a new "history" command to plug this gap. This command will have several different subcommands to imperatively rewrite history for common use cases like the above. These commands will be implemented in subsequent commits.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 .gitignore                     |  1 +
 Documentation/git-history.adoc | 45 ++++++++++++++++++++++++++++++++++++++++++
 Documentation/meson.build      |  1 +
 Makefile                       |  1 +
 builtin.h                      |  1 +
 builtin/history.c              | 20 +++++++++++++++++++
 command-list.txt               |  1 +
 git.c                          |  1 +
 meson.build                    |  1 +
 t/meson.build                  |  3 ++-
 t/t3450-history.sh             | 12 +++++++++++
 11 files changed, 86 insertions(+), 1 deletion(-)
Show changes to 11 files +86 −1

.gitignore, Documentation/git-history.adoc, Documentation/meson.build, Makefile, builtin.h, builtin/history.c, command-list.txt, git.c, meson.build, t/meson.build, t/t3450-history.sh

diff --git a/.gitignore b/.gitignore
index 04c444404e..3932d4d618 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,6 +77,7 @@
 /git-grep
 /git-hash-object
 /git-help
+/git-history
 /git-hook
 /git-http-backend
 /git-http-fetch
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
new file mode 100644
index 0000000000..1537960374
--- /dev/null
+++ b/Documentation/git-history.adoc
@@ -0,0 +1,45 @@
+git-history(1)
+==============
+
+NAME
+----
+git-history - EXPERIMENTAL: Rewrite history of the current branch
+
+SYNOPSIS
+--------
+[synopsis]
+git history [<options>]
+
+DESCRIPTION
+-----------
+
+Rewrite history by rearranging or modifying specific commits in the
+history.
+
+This command is similar to linkgit:git-rebase[1] and uses the same
+underlying machinery. You should use rebases if you either want to
+reapply a range of commits onto a different base, or interactive rebases
+if you want to edit a range of commits.
+
+Note that this command does not (yet) work with histories that contain
+merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
+flag instead.
+
+THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
+
+COMMANDS
+--------
+
+This command requires a subcommand. Several subcommands are available to
+rewrite history in different ways:
+
+CONFIGURATION
+-------------
+
+include::includes/cmd-config-section-all.adoc[]
+
+include::config/sequencer.adoc[]
+
+GIT
+---
+Part of the linkgit:git[1] suite
diff --git a/Documentation/meson.build b/Documentation/meson.build
index 4404c623f0..a30b5307fd 100644
--- a/Documentation/meson.build
+++ b/Documentation/meson.build
@@ -64,6 +64,7 @@ manpages = {
   'git-gui.adoc' : 1,
   'git-hash-object.adoc' : 1,
   'git-help.adoc' : 1,
+  'git-history.adoc' : 1,
   'git-hook.adoc' : 1,
   'git-http-backend.adoc' : 1,
   'git-http-fetch.adoc' : 1,
diff --git a/Makefile b/Makefile
index e11340c1ae..bed6eda5e6 100644
--- a/Makefile
+++ b/Makefile
@@ -1261,6 +1261,7 @@ BUILTIN_OBJS += builtin/get-tar-commit-id.o
 BUILTIN_OBJS += builtin/grep.o
 BUILTIN_OBJS += builtin/hash-object.o
 BUILTIN_OBJS += builtin/help.o
+BUILTIN_OBJS += builtin/history.o
 BUILTIN_OBJS += builtin/hook.o
 BUILTIN_OBJS += builtin/index-pack.o
 BUILTIN_OBJS += builtin/init-db.o
diff --git a/builtin.h b/builtin.h
index bff13e3069..2934f4479a 100644
--- a/builtin.h
+++ b/builtin.h
@@ -172,6 +172,7 @@ int cmd_get_tar_commit_id(int argc, const char **argv, const char *prefix, struc
 int cmd_grep(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hash_object(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_help(int argc, const char **argv, const char *prefix, struct repository *repo);
+int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hook(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);
diff --git a/builtin/history.c b/builtin/history.c
new file mode 100644
index 0000000000..d1a40368e0
--- /dev/null
+++ b/builtin/history.c
@@ -0,0 +1,20 @@
+#include "builtin.h"
+#include "gettext.h"
+#include "parse-options.h"
+
+int cmd_history(int argc,
+		const char **argv,
+		const char *prefix,
+		struct repository *repo UNUSED)
+{
+	const char * const usage[] = {
+		N_("git history [<options>]"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	return 0;
+}
diff --git a/command-list.txt b/command-list.txt
index b7ade3ab9f..f95f0ce926 100644
--- a/command-list.txt
+++ b/command-list.txt
@@ -115,6 +115,7 @@ git-grep                                mainporcelain           info
 git-gui                                 mainporcelain
 git-hash-object                         plumbingmanipulators
 git-help                                ancillaryinterrogators          complete
+git-history                             mainporcelain           history
 git-hook                                purehelpers
 git-http-backend                        synchingrepositories
 git-http-fetch                          synchelpers
diff --git a/git.c b/git.c
index 83eac0aeab..9d2cba2906 100644
--- a/git.c
+++ b/git.c
@@ -560,6 +560,7 @@ static struct cmd_struct commands[] = {
 	{ "grep", cmd_grep, RUN_SETUP_GENTLY },
 	{ "hash-object", cmd_hash_object },
 	{ "help", cmd_help },
+	{ "history", cmd_history, RUN_SETUP },
 	{ "hook", cmd_hook, RUN_SETUP },
 	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
 	{ "init", cmd_init_db },
diff --git a/meson.build b/meson.build
index 5dd299b496..0e40778a23 100644
--- a/meson.build
+++ b/meson.build
@@ -603,6 +603,7 @@ builtin_sources = [
   'builtin/grep.c',
   'builtin/hash-object.c',
   'builtin/help.c',
+  'builtin/history.c',
   'builtin/hook.c',
   'builtin/index-pack.c',
   'builtin/init-db.c',
diff --git a/t/meson.build b/t/meson.build
index bbeba1a8d5..966d7c14f4 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -376,6 +376,7 @@ integration_tests = [
   't3436-rebase-more-options.sh',
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
+  't3450-history.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
@@ -1214,4 +1215,4 @@ if perl.found() and time.found()
       timeout: 0,
     )
   endforeach
-endif
\ No newline at end of file
+endif
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
new file mode 100755
index 0000000000..9eb1ed6749
--- /dev/null
+++ b/t/t3450-history.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+
+test_description='tests for git-history command'
+
+. ./test-lib.sh
+
+test_expect_success 'refuses to do anything without subcommand' '
+	test_must_fail git history 2>err &&
+	test_grep foo err
+'
+
+test_done
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 08/18] builtin/history: introduce subcommands to manage interrupted rewrites

Introduce subcommands to manage the sequencer state for git-history(1). These aren't really useful yet, but will become useful in subsequent commits where we will introduce git-history(1) subcommands that actually edit history.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  20 +++++++-
 builtin/history.c              | 114 +++++++++++++++++++++++++++++++++++++++--
 t/t3450-history.sh             |  32 +++++++++++-
 3 files changed, 161 insertions(+), 5 deletions(-)
Show changes to 3 files +161 −5

Documentation/git-history.adoc, builtin/history.c, t/t3450-history.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 1537960374..3e9a789b83 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -8,7 +8,9 @@ git-history - EXPERIMENTAL: Rewrite history of the current branch
 SYNOPSIS
 --------
 [synopsis]
-git history [<options>]
+git history abort
+git history continue
+git history quit
 
 DESCRIPTION
 -----------
@@ -33,6 +35,22 @@ COMMANDS
 This command requires a subcommand. Several subcommands are available to
 rewrite history in different ways:
 
+The following commands are used to manage an interrupted history-rewriting
+operation:
+
+`abort`::
+	Abort the history-rewriting operation and reset HEAD to the original
+	branch.
+
+`continue`::
+	Restart the history-rewriting process after having resolved a merge
+	conflict.
+
+`quit`::
+	Abort the history-rewriting operation but `HEAD` is not reset back to
+	the original branch. The index and working tree are also left unchanged
+	as a result.
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index d1a40368e0..0ad45dbfef 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,20 +1,128 @@
 #include "builtin.h"
+#include "branch.h"
 #include "gettext.h"
 #include "parse-options.h"
+#include "sequencer.h"
+
+static int cmd_history_abort(int argc,
+			     const char **argv,
+			     const char *prefix,
+			     struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history abort"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct replay_opts opts = REPLAY_OPTS_INIT;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc) {
+		ret = error(_("command does not take arguments"));
+		goto out;
+	}
+
+	opts.action = REPLAY_HISTORY_EDIT;
+	ret = sequencer_rollback(repo, &opts);
+	if (ret)
+		goto out;
+
+	ret = 0;
+
+out:
+	replay_opts_release(&opts);
+	return ret;
+}
+
+static int cmd_history_continue(int argc,
+				const char **argv,
+				const char *prefix,
+				struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history continue"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct replay_opts opts = REPLAY_OPTS_INIT;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc) {
+		ret = error(_("command does not take arguments"));
+		goto out;
+	}
+
+	opts.action = REPLAY_HISTORY_EDIT;
+	ret = sequencer_continue(repo, &opts);
+	if (ret)
+		goto out;
+
+	ret = 0;
+
+out:
+	replay_opts_release(&opts);
+	return ret;
+}
+
+static int cmd_history_quit(int argc,
+			    const char **argv,
+			    const char *prefix,
+			    struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history quit"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct replay_opts opts = REPLAY_OPTS_INIT;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc) {
+		ret = error(_("command does not take arguments"));
+		goto out;
+	}
+
+	opts.action = REPLAY_HISTORY_EDIT;
+	ret = sequencer_remove_state(repo, &opts);
+	if (ret)
+		goto out;
+	remove_branch_state(repo, 0);
+
+	ret = 0;
+
+out:
+	replay_opts_release(&opts);
+	return ret;
+}
 
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
-		struct repository *repo UNUSED)
+		struct repository *repo)
 {
 	const char * const usage[] = {
-		N_("git history [<options>]"),
+		N_("git history abort"),
+		N_("git history continue"),
+		N_("git history quit"),
 		NULL,
 	};
+	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
+		OPT_SUBCOMMAND("abort", &fn, cmd_history_abort),
+		OPT_SUBCOMMAND("continue", &fn, cmd_history_continue),
+		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
 		OPT_END(),
 	};
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
-	return 0;
+	return fn(argc, argv, prefix, repo);
 }
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
index 9eb1ed6749..aa9d44c03b 100755
--- a/t/t3450-history.sh
+++ b/t/t3450-history.sh
@@ -6,7 +6,37 @@ test_description='tests for git-history command'
 
 test_expect_success 'refuses to do anything without subcommand' '
 	test_must_fail git history 2>err &&
-	test_grep foo err
+	test_grep "need a subcommand" err
+'
+
+test_expect_success 'abort complains about arguments' '
+	test_must_fail git history abort foo 2>err &&
+	test_grep "command does not take arguments" err
+'
+
+test_expect_success 'abort complains when no history edit is active' '
+	test_must_fail git history abort 2>err &&
+	test_grep "no history edit in progress" err
+'
+
+test_expect_success 'continue complains about arguments' '
+	test_must_fail git history continue foo 2>err &&
+	test_grep "command does not take arguments" err
+'
+
+test_expect_success 'continue complains when no history edit is active' '
+	test_must_fail git history continue 2>err &&
+	test_grep "no history edit in progress" err
+'
+
+test_expect_success 'quit complains about arguments' '
+	test_must_fail git history quit foo 2>err &&
+	test_grep "command does not take arguments" err
+'
+
+test_expect_success 'quit does not complain when no history edit is active' '
+	git history quit 2>err &&
+	test_must_be_empty err
 '
 
 test_done
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 09/18] builtin/history: implement "drop" subcommand

It is a fairly common operation to perform an interactive rebase so that one of the commits can be dropped from history. Doing this is not very hard in general, but still requires the user to perform multiple steps:

  1. Identify the commit in question that is to be dropped.
  2. Perform an interactive rebase on top of that commit's parent.
  3. Edit the instruction sheet to drop that commit.

This is needlessly complex for such a supposedly-trivial operation. Furthermore, the second step doesn't account for certain edge cases like for example dropping the root commit.

Introduce a new "drop" subcommand to make this use case significantly simpler: all the user needs to do is to say `git history drop $COMMIT` and they're done.

Note that for now, this command only allows users to drop a single commit at once. It should be easy enough though to expand the command at a later point in time to support dropping whole commit ranges.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  27 ++++
 builtin/history.c              | 287 +++++++++++++++++++++++++++++++++++++++++
 t/meson.build                  |   1 +
 t/t3451-history-drop.sh        | 207 +++++++++++++++++++++++++++++
 4 files changed, 522 insertions(+)
Show changes to 4 files +522 −0

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3451-history-drop.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 3e9a789b83..39c9f1e25e 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -11,6 +11,7 @@ SYNOPSIS
 git history abort
 git history continue
 git history quit
+git history drop <commit>
 
 DESCRIPTION
 -----------
@@ -35,6 +36,15 @@ COMMANDS
 This command requires a subcommand. Several subcommands are available to
 rewrite history in different ways:
 
+`drop <commit>`::
+	Drop a commit from the history and reapply all children of that
+	commit on top of the commit's parent. The commit that is to be
+	dropped must be reachable from the currently checked-out commit.
++
+Dropping the root commit converts the child of that commit into the new
+root commit. It is invalid to drop a root commit that does not have any
+child commits, as that would lead to an empty branch.
+
 The following commands are used to manage an interrupted history-rewriting
 operation:
 
@@ -51,6 +61,23 @@ operation:
 	the original branch. The index and working tree are also left unchanged
 	as a result.
 
+EXAMPLES
+--------
+
+Drop a commit from history
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+----------
+$ git log --oneline
+2d4cd6d third
+125a0f3 second
+e098c27 first
+$ git history drop HEAD~
+$ git log
+b1bc1bd third
+e098c27 first
+----------
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index 0ad45dbfef..2132b6a441 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,7 +1,16 @@
 #include "builtin.h"
 #include "branch.h"
+#include "commit.h"
+#include "commit-reach.h"
+#include "config.h"
+#include "environment.h"
 #include "gettext.h"
+#include "hex.h"
+#include "object-name.h"
 #include "parse-options.h"
+#include "refs.h"
+#include "reset.h"
+#include "revision.h"
 #include "sequencer.h"
 
 static int cmd_history_abort(int argc,
@@ -104,6 +113,282 @@ static int cmd_history_quit(int argc,
 	return ret;
 }
 
+static int collect_commits(struct repository *repo,
+			   struct commit *old_commit,
+			   struct commit *new_commit,
+			   struct strvec *out)
+{
+	struct setup_revision_opt revision_opts = {
+		.assume_dashdash = 1,
+	};
+	struct strvec revisions = STRVEC_INIT;
+	struct commit_list *from_list = NULL;
+	struct commit *child;
+	struct rev_info rev = { 0 };
+	int ret;
+
+	/*
+	 * Check that the old commit actually is an ancestor of HEAD. If not
+	 * the whole request becomes nonsensical.
+	*/
+	if (old_commit) {
+		commit_list_insert(old_commit, &from_list);
+		if (!repo_is_descendant_of(repo, new_commit, from_list)) {
+			ret = error(_("commit must be reachable from current HEAD commit"));
+			goto out;
+		}
+	}
+
+	repo_init_revisions(repo, &rev, NULL);
+	strvec_push(&revisions, "");
+	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
+	if (old_commit)
+		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
+	if (setup_revisions(revisions.nr, revisions.v, &rev, &revision_opts) != 1 ||
+	    prepare_revision_walk(&rev)) {
+		ret = error(_("revision walk setup failed"));
+		goto out;
+	}
+
+	while ((child = get_revision(&rev))) {
+		if (old_commit && !child->parents)
+			BUG("revision walk did not find child commit");
+		if (child->parents && child->parents->next) {
+			ret = error(_("cannot rearrange commit history with merges"));
+			goto out;
+		}
+
+		strvec_push(out, oid_to_hex(&child->object.oid));
+
+		if (child->parents && old_commit &&
+		    commit_list_contains(old_commit, child->parents))
+			break;
+	}
+
+	/*
+	 * Revisions are in newest-order-first. We have to reverse the
+	 * array though so that we pick the oldest commits first.
+	 */
+	for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
+		SWAP(out->v[i], out->v[j]);
+
+	ret = 0;
+
+out:
+	free_commit_list(from_list);
+	strvec_clear(&revisions);
+	release_revisions(&rev);
+	reset_revision_walk();
+	return ret;
+}
+
+static int apply_commits(struct repository *repo,
+			 const struct strvec *commits,
+			 struct commit *head,
+			 struct commit *base,
+			 const char *action)
+{
+	struct setup_revision_opt revision_opts = {
+		.assume_dashdash = 1,
+	};
+	struct replay_opts replay_opts = REPLAY_OPTS_INIT;
+	struct reset_head_opts reset_opts = { 0 };
+	struct object_id root_commit;
+	struct strvec args = STRVEC_INIT;
+	struct strbuf buf = STRBUF_INIT;
+	char hex[GIT_MAX_HEXSZ + 1];
+	int ref_flags, ret;
+
+	/*
+	 * We have performed all safety checks, so we now prepare
+	 * replaying the commits.
+	*/
+	replay_opts.action = REPLAY_HISTORY_EDIT;
+	sequencer_init_config(&replay_opts);
+	replay_opts.quiet = 1;
+	replay_opts.skip_commit_summary = 1;
+	if (!replay_opts.strategy && replay_opts.default_strategy) {
+		replay_opts.strategy = replay_opts.default_strategy;
+		replay_opts.default_strategy = NULL;
+	}
+
+	strvec_push(&args, "");
+	strvec_pushv(&args, commits->v);
+
+	replay_opts.revs = xmalloc(sizeof(*replay_opts.revs));
+	repo_init_revisions(repo, replay_opts.revs, NULL);
+	replay_opts.revs->no_walk = 1;
+	replay_opts.revs->unsorted_input = 1;
+	if (setup_revisions(args.nr, args.v, replay_opts.revs,
+			    &revision_opts) != 1) {
+		ret = error(_("setting up revisions failed"));
+		goto out;
+	}
+
+	/*
+	 * If we're dropping the root commit we first need to create
+	 * a new empty root. We then instruct the seqencer machinery to
+	 * squash that root commit with the first commit we're picking
+	 * onto it.
+	 */
+	if (!base->parents) {
+		if (commit_tree("", 0, repo->hash_algo->empty_tree, NULL,
+				&root_commit, NULL, NULL) < 0) {
+			ret = error(_("Could not create new root commit"));
+			goto out;
+		}
+
+		replay_opts.squash_onto = root_commit;
+		replay_opts.have_squash_onto = 1;
+		reset_opts.oid = &root_commit;
+	} else {
+		reset_opts.oid = &base->parents->item->object.oid;
+	}
+
+	replay_opts.restore_head_target =
+		xstrdup_or_null(refs_resolve_ref_unsafe(get_main_ref_store(repo),
+							"HEAD", 0, NULL, &ref_flags));
+	if (!(ref_flags & REF_ISSYMREF))
+		FREE_AND_NULL(replay_opts.restore_head_target);
+
+	/*
+	 * Perform a hard-reset to the parent of our commit that is to
+	 * be dropped. This is the new base onto which we'll pick all
+	 * the descendants.
+	 */
+	strbuf_addf(&buf, "%s (start): checkout %s", action,
+		    oid_to_hex_r(hex, reset_opts.oid));
+	reset_opts.orig_head = &head->object.oid;
+	reset_opts.flags = RESET_HEAD_DETACH | RESET_ORIG_HEAD;
+	reset_opts.head_msg = buf.buf;
+	reset_opts.default_reflog_action = action;
+	if (reset_head(repo, &reset_opts) < 0) {
+		ret = error(_("could not switch to %s"), oid_to_hex(reset_opts.oid));
+		goto out;
+	}
+
+	ret = sequencer_pick_revisions(repo, &replay_opts);
+	if (ret < 0) {
+		ret = error(_("could not pick commits"));
+		goto out;
+	} else if (ret > 0) {
+		/*
+		 * A positive return value indicates we've got a merge
+		 * conflict. Bail out, but don't print a message as
+		 * `sequencer_pick_revisions()` already printed enough
+		 * information.
+		 */
+		ret = -1;
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	replay_opts_release(&replay_opts);
+	strbuf_release(&buf);
+	strvec_clear(&args);
+	return ret;
+}
+
+static int cmd_history_drop(int argc,
+			    const char **argv,
+			    const char *prefix,
+			    struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history drop <commit>"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct commit *commit_to_drop, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct strbuf buf = STRBUF_INIT;
+	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);
+
+	commit_to_drop = lookup_commit_reference_by_name(argv[0]);
+	if (!commit_to_drop) {
+		ret = error(_("commit to be dropped cannot be found: %s"), argv[0]);
+		goto out;
+	}
+	if (commit_to_drop->parents && commit_to_drop->parents->next) {
+		ret = error(_("commit to be dropped must not be a merge commit"));
+		goto out;
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	if (oideq(&commit_to_drop->object.oid, &head->object.oid)) {
+		/*
+		 * If we want to drop the tip of the current branch we don't
+		 * have to perform any rebase at all. Instead, we simply
+		 * perform a hard reset to the parent commit.
+		 */
+		struct reset_head_opts reset_opts = {
+			.orig_head = &head->object.oid,
+			.flags = RESET_ORIG_HEAD,
+			.default_reflog_action = "drop",
+		};
+		char hex[GIT_MAX_HEXSZ + 1];
+
+		if (!commit_to_drop->parents) {
+			ret = error(_("cannot drop the only commit on this branch"));
+			goto out;
+		}
+
+		oid_to_hex_r(hex, &commit_to_drop->parents->item->object.oid);
+		strbuf_addf(&buf, "drop (start): checkout %s", hex);
+		reset_opts.oid = &commit_to_drop->parents->item->object.oid;
+		reset_opts.head_msg = buf.buf;
+
+		if (reset_head(repo, &reset_opts) < 0) {
+			ret = error(_("could not switch to %s"), hex);
+			goto out;
+		}
+	} else {
+		/*
+		 * Prepare a revision walk from old commit to the commit that is
+		 * about to be dropped. This serves three purposes:
+		 *
+		 *   - We verify that the history doesn't contain any merges.
+		 *     For now, merges aren't yet handled by us.
+		 *
+		 *   - We need to find the child of the commit-to-be-dropped.
+		 *     This child is what will be adopted by the parent of the
+		 *     commit that we are about to drop.
+		 *
+		 *   - We compute the list of commits-to-be-picked.
+		 */
+		ret = collect_commits(repo, commit_to_drop, head, &commits);
+		if (ret < 0)
+			goto out;
+
+		ret = apply_commits(repo, &commits, head, commit_to_drop, "drop");
+		if (ret < 0)
+			goto out;
+	}
+
+	ret = 0;
+
+out:
+	strvec_clear(&commits);
+	strbuf_release(&buf);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -113,6 +398,7 @@ int cmd_history(int argc,
 		N_("git history abort"),
 		N_("git history continue"),
 		N_("git history quit"),
+		N_("git history drop <commit>"),
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -120,6 +406,7 @@ int cmd_history(int argc,
 		OPT_SUBCOMMAND("abort", &fn, cmd_history_abort),
 		OPT_SUBCOMMAND("continue", &fn, cmd_history_continue),
 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
+		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 966d7c14f4..8189c6c561 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -377,6 +377,7 @@ integration_tests = [
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
+  't3451-history-drop.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3451-history-drop.sh b/t/t3451-history-drop.sh
new file mode 100755
index 0000000000..4fc295ef21
--- /dev/null
+++ b/t/t3451-history-drop.sh
@@ -0,0 +1,207 @@
+#!/bin/sh
+
+test_description='tests for git-history drop subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history drop HEAD 2>err &&
+		test_grep "commit to be dropped must not be a merge commit" err &&
+		test_must_fail git history drop HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work when history becomes empty' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		test_must_fail git history drop HEAD 2>err &&
+		test_grep "cannot drop the only commit on this branch" err
+	)
+'
+
+test_expect_success 'refuses to work with changes in the worktree or index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		test_commit file file &&
+		echo foo >file &&
+		test_must_fail git history drop HEAD 2>err &&
+		test_grep "Your local changes to the following files would be overwritten" err &&
+		git add file &&
+		test_must_fail git history drop HEAD 2>err &&
+		test_grep "Your local changes to the following files would be overwritten" err
+	)
+'
+
+test_expect_success 'can drop tip of a branch' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		git history drop HEAD &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can drop commit in the middle' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+
+		git symbolic-ref HEAD >expect &&
+		git history drop HEAD~2 &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		fifth
+		fourth
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'correct order is retained' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history drop HEAD~3 &&
+		cat >expect <<-EOF &&
+		fifth
+		fourth
+		third
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'hooks are executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		write_script .git/hooks/prepare-commit-msg <<-EOF &&
+		echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-commit <<-EOF &&
+		echo "post-commit" >>"$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-rewrite <<-EOF &&
+		{
+			echo "post-rewrite: \$@"
+			cat
+		} >>"$(pwd)/hooks.log"
+		EOF
+
+		git history drop HEAD~ &&
+		cat >expect <<-EOF &&
+		prepare-commit-msg: .git/COMMIT_EDITMSG message
+		post-commit
+		post-rewrite: history
+		$(git rev-parse third) $(git rev-parse HEAD)
+		EOF
+		test_cmp expect hooks.log
+	)
+'
+
+test_expect_success 'can drop root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history drop HEAD~2 &&
+		cat >expect <<-EOF &&
+		third
+		second
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'conflicts are detected' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		echo original >file &&
+		git add . &&
+		git commit -m original &&
+		echo modified >file &&
+		git commit -am modified &&
+
+		test_must_fail git history drop HEAD~ >err 2>&1 &&
+		test_grep CONFLICT err &&
+		test_grep "git history continue" err &&
+		echo resolved >file &&
+		git add file &&
+		git history continue &&
+
+		cat >expect <<-EOF &&
+		modified
+		base
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+		echo resolved >expect &&
+		git cat-file -p HEAD:file >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 10/18] builtin/history: implement "reorder" subcommand

When working in projects where having nice commits matters it's quite common that developers end up reordering commits a lot. This is typically done via interactive rebases, where they can then rearrange commits in the instruction sheet.

Still, this operation is a frequent-enough operation to provide a more direct way of doing this imperatively. As such, introduce a new "reorder" subcommand where users can reorder a commit A to come after or before another commit B:

    $ git log --oneline
    a978f73 fifth
    57594ee fourth
    04eb1c4 third
    d535e30 second
    bf7438d first
    $ git history reorder :/fourth --before=:/second
    $ git log --oneline
    1610fe0 fifth
    444f97d third
    2f90797 second
    b0ae659 fourth
    bf7438d first
    $ git history reorder :/fourth --after=:/second
    $ git log --oneline
    c48729d fifth
    f44a46e third
    26693b8 fourth
    8cb4171 second
    bf7438d first
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  33 +++++
 builtin/history.c              | 130 +++++++++++++++++++
 t/meson.build                  |   1 +
 t/t3452-history-reorder.sh     | 278 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 442 insertions(+)
Show changes to 4 files +442 −0

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3452-history-reorder.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 39c9f1e25e..b36cd925dd 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -12,6 +12,7 @@ git history abort
 git history continue
 git history quit
 git history drop <commit>
+git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
 
 DESCRIPTION
 -----------
@@ -45,6 +46,12 @@ Dropping the root commit converts the child of that commit into the new
 root commit. It is invalid to drop a root commit that does not have any
 child commits, as that would lead to an empty branch.
 
+`reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)`::
+	Move the commit so that it becomes either the parent of
+	<following-commit> or the child of <preceding-commit>. The commits must
+	be related to one another and must be reachable from the current `HEAD`
+	commit.
+
 The following commands are used to manage an interrupted history-rewriting
 operation:
 
@@ -78,6 +85,32 @@ b1bc1bd third
 e098c27 first
 ----------
 
+Reorder a commit
+~~~~~~~~~~~~~~~~
+
+----------
+$ git log --oneline
+a978f73 fifth
+57594ee fourth
+04eb1c4 third
+d535e30 second
+bf7438d first
+$ git history reorder :/fourth --before=:/second
+$ git log --oneline
+1610fe0 fifth
+444f97d third
+2f90797 second
+b0ae659 fourth
+bf7438d first
+$ git history reorder :/fourth --after=:/second
+$ git log --oneline
+c48729d fifth
+f44a46e third
+26693b8 fourth
+8cb4171 second
+bf7438d first
+----------
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index 2132b6a441..16b516856e 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -182,6 +182,33 @@ static int collect_commits(struct repository *repo,
 	return ret;
 }
 
+static void replace_commits(struct strvec *commits,
+			    const struct object_id *commit_to_replace,
+			    const struct object_id *replacements,
+			    size_t replacements_nr)
+{
+	char commit_to_replace_oid[GIT_MAX_HEXSZ + 1];
+	struct strvec replacement_oids = STRVEC_INIT;
+	bool found = false;
+	size_t i;
+
+	oid_to_hex_r(commit_to_replace_oid, commit_to_replace);
+	for (i = 0; i < replacements_nr; i++)
+		strvec_push(&replacement_oids, oid_to_hex(&replacements[i]));
+
+	for (i = 0; i < commits->nr; i++) {
+		if (strcmp(commits->v[i], commit_to_replace_oid))
+			continue;
+		strvec_splice(commits, i, 1, replacement_oids.v, replacement_oids.nr);
+		found = true;
+		break;
+	}
+	if (!found)
+		BUG("could not find commit to replace");
+
+	strvec_clear(&replacement_oids);
+}
+
 static int apply_commits(struct repository *repo,
 			 const struct strvec *commits,
 			 struct commit *head,
@@ -389,6 +416,107 @@ static int cmd_history_drop(int argc,
 	return ret;
 }
 
+static int cmd_history_reorder(int argc,
+			       const char **argv,
+			       const char *prefix,
+			       struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
+		NULL,
+	};
+	const char *before = NULL, *after = NULL;
+	struct option options[] = {
+		OPT_STRING(0, "before", &before, N_("commit"), N_("reorder before this commit")),
+		OPT_STRING(0, "after", &after, N_("commit"), N_("reorder after this commit")),
+		OPT_END(),
+	};
+	struct commit *commit_to_reorder, *head, *anchor, *old;
+	struct strvec commits = STRVEC_INIT;
+	struct object_id replacement[2];
+	struct commit_list *list = NULL;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc != 1)
+		die(_("command expects a single revision"));
+	if (!before && !after)
+		die(_("exactly one option of 'before' or 'after' must be given"));
+	die_for_incompatible_opt2(!!before, "before", !!after, "after");
+
+	repo_config(repo, git_default_config, NULL);
+
+	commit_to_reorder = lookup_commit_reference_by_name(argv[0]);
+	if (!commit_to_reorder)
+		die(_("commit to be reordered cannot be found: %s"), argv[0]);
+	if (commit_to_reorder->parents && commit_to_reorder->parents->next)
+		die(_("commit to be reordered must not be a merge commit"));
+
+	anchor = lookup_commit_reference_by_name(before ? before : after);
+	if (!commit_to_reorder)
+		die(_("anchor commit cannot be found: %s"), before ? before : after);
+
+	if (oideq(&commit_to_reorder->object.oid, &anchor->object.oid))
+		die(_("commit to reorder and anchor must not be the same"));
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head)
+		die(_("could not resolve HEAD to a commit"));
+
+	commit_list_append(commit_to_reorder, &list);
+	if (!repo_is_descendant_of(repo, commit_to_reorder, list))
+		die(_("reordered commit must be reachable from current HEAD commit"));
+
+	/*
+	 * There is no requirement for the user to have either one of the
+	 * provided commits be the parent or child. We thus have to figure out
+	 * ourselves which one is which.
+	*/
+	if (repo_is_descendant_of(repo, anchor, list))
+		old = commit_to_reorder;
+	else
+		old = anchor;
+
+	/*
+	 * Select the whole range of commits, including the boundary commit
+	 * itself. In case the old commit is the root commit we simply pass no
+	 * boundary.
+	*/
+	ret = collect_commits(repo, old->parents ? old->parents->item : NULL,
+			      head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/*
+	 * Perform the reordering of commits in the strvec. This is done by:
+	 *
+	 *   - Deleting the to-be-reordered commit from the range of commits.
+	 *
+	 *   - Replacing the anchor commit with the anchor commit plus the
+	 *     to-be-reordered commit.
+	 */
+	if (before) {
+		replacement[0] = commit_to_reorder->object.oid;
+		replacement[1] = anchor->object.oid;
+	} else {
+		replacement[0] = anchor->object.oid;
+		replacement[1] = commit_to_reorder->object.oid;
+	}
+	replace_commits(&commits, &commit_to_reorder->object.oid, NULL, 0);
+	replace_commits(&commits, &anchor->object.oid, replacement, ARRAY_SIZE(replacement));
+
+	ret = apply_commits(repo, &commits, head, old, "reorder");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	free_commit_list(list);
+	strvec_clear(&commits);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -399,6 +527,7 @@ int cmd_history(int argc,
 		N_("git history continue"),
 		N_("git history quit"),
 		N_("git history drop <commit>"),
+		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -407,6 +536,7 @@ int cmd_history(int argc,
 		OPT_SUBCOMMAND("continue", &fn, cmd_history_continue),
 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
 		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
+		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 8189c6c561..2bf7bcab5a 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -378,6 +378,7 @@ integration_tests = [
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
   't3451-history-drop.sh',
+  't3452-history-reorder.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3452-history-reorder.sh b/t/t3452-history-reorder.sh
new file mode 100755
index 0000000000..2e9d64a9fd
--- /dev/null
+++ b/t/t3452-history-reorder.sh
@@ -0,0 +1,278 @@
+#!/bin/sh
+
+test_description='tests for git-history reorder subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history reorder HEAD --before=HEAD~ 2>err &&
+		test_grep "commit to be reordered must not be a merge commit" err &&
+		test_must_fail git history reorder HEAD~ --after=HEAD 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work with changes in the worktree or index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		test_commit file file &&
+		echo foo >file &&
+		test_must_fail git history reorder HEAD --before=HEAD~ 2>err &&
+		test_grep "Your local changes to the following files would be overwritten" err &&
+		git add file &&
+		test_must_fail git history reorder HEAD --before=HEAD~ 2>err &&
+		test_grep "Your local changes to the following files would be overwritten" err
+	)
+'
+
+test_expect_success 'requires exactly one of --before or --after' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_must_fail git history reorder HEAD 2>err &&
+		test_grep "exactly one option of ${SQ}before${SQ} or ${SQ}after${SQ} must be given" err &&
+		test_must_fail git history reorder HEAD --before=a --after=b 2>err &&
+		test_grep "options ${SQ}before${SQ} and ${SQ}after${SQ} cannot be used together" err
+	)
+'
+
+test_expect_success 'refuses to reorder commit with itself' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_must_fail git history reorder HEAD --after=HEAD 2>err &&
+		test_grep "commit to reorder and anchor must not be the same" err
+	)
+'
+
+test_expect_success '--before can move commit back in history' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history reorder :/fourth --before=:/second &&
+		cat >expect <<-EOF &&
+		fifth
+		third
+		second
+		fourth
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--before can move commit forward in history' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history reorder :/second --before=:/fourth &&
+		cat >expect <<-EOF &&
+		fifth
+		fourth
+		second
+		third
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--before can make a commit a root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history reorder :/third --before=:/first &&
+		cat >expect <<-EOF &&
+		second
+		first
+		third
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--after can move commit back in history' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history reorder :/fourth --after=:/second &&
+		cat >expect <<-EOF &&
+		fifth
+		third
+		fourth
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--after can move commit forward in history' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		test_commit fourth &&
+		test_commit fifth &&
+		git history reorder :/second --after=:/fourth &&
+		cat >expect <<-EOF &&
+		fifth
+		second
+		fourth
+		third
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success '--after can make commit the tip' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history reorder :/first --after=:/third &&
+		cat >expect <<-EOF &&
+		first
+		third
+		second
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'hooks are executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		write_script .git/hooks/prepare-commit-msg <<-EOF &&
+		echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-commit <<-EOF &&
+		echo "post-commit" >>"$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-rewrite <<-EOF &&
+		{
+			echo "post-rewrite: \$@"
+			cat
+		} >>"$(pwd)/hooks.log"
+		EOF
+
+		git history reorder :/third --before=:/second &&
+		cat >expect <<-EOF &&
+		second
+		third
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		prepare-commit-msg: .git/COMMIT_EDITMSG message
+		post-commit
+		prepare-commit-msg: .git/COMMIT_EDITMSG message
+		post-commit
+		post-rewrite: history
+		$(git rev-parse third) $(git rev-parse HEAD~)
+		$(git rev-parse second) $(git rev-parse HEAD)
+		EOF
+		test_cmp expect hooks.log
+	)
+'
+
+test_expect_success 'conflicts are detected' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		echo base >file &&
+		git add file &&
+		git commit -m base &&
+		echo "first edit" >file &&
+		git commit -am "first edit" &&
+		echo "second edit" >file &&
+		git commit -am "second edit" &&
+
+		git symbolic-ref HEAD >expect-head &&
+		test_must_fail git history reorder HEAD --before=HEAD~ &&
+		test_must_fail git symbolic-ref HEAD &&
+		echo "second edit" >file &&
+		git add file &&
+		test_must_fail git history continue &&
+		echo "first edit" >file &&
+		git add file &&
+		git history continue &&
+
+		cat >expect <<-EOF &&
+		first edit
+		second edit
+		base
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+
+		git symbolic-ref HEAD >actual-head &&
+		test_cmp expect-head actual-head
+	)
+'
+
+test_done
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 11/18] add-patch: split out header from "add-interactive.h"

While we have a "add-patch.c" code file, its declarations are part of "add-interactive.h". This makes it somewhat harder than necessary to find relevant code and to identify clear boundaries between the two subsystems.

Split up concerns and move declarations that relate to "add-patch.c" into a new "add-patch.h" header.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.h | 23 +++--------------------
 add-patch.c       |  1 +
 add-patch.h       | 26 ++++++++++++++++++++++++++
 3 files changed, 30 insertions(+), 20 deletions(-)
Show changes to 3 files +30 −20

add-interactive.h, add-patch.c, add-patch.h

diff --git a/add-interactive.h b/add-interactive.h
index 4213dcd67b..fb95b6ee05 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -1,14 +1,11 @@
 #ifndef ADD_INTERACTIVE_H
 #define ADD_INTERACTIVE_H
 
+#include "add-patch.h"
 #include "color.h"
 
-struct add_p_opt {
-	int context;
-	int interhunkcontext;
-};
-
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+struct pathspec;
+struct repository;
 
 struct add_i_state {
 	struct repository *r;
@@ -32,21 +29,7 @@ void init_add_i_state(struct add_i_state *s, struct repository *r,
 		      struct add_p_opt *add_p_opt);
 void clear_add_i_state(struct add_i_state *s);
 
-struct repository;
-struct pathspec;
 int run_add_i(struct repository *r, const struct pathspec *ps,
 	      struct add_p_opt *add_p_opt);
 
-enum add_p_mode {
-	ADD_P_ADD,
-	ADD_P_STASH,
-	ADD_P_RESET,
-	ADD_P_CHECKOUT,
-	ADD_P_WORKTREE,
-};
-
-int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
-	      const struct pathspec *ps);
-
 #endif
diff --git a/add-patch.c b/add-patch.c
index 302e6ba7d9..e2b002fa73 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -3,6 +3,7 @@
 
 #include "git-compat-util.h"
 #include "add-interactive.h"
+#include "add-patch.h"
 #include "advice.h"
 #include "editor.h"
 #include "environment.h"
diff --git a/add-patch.h b/add-patch.h
new file mode 100644
index 0000000000..4394c74107
--- /dev/null
+++ b/add-patch.h
@@ -0,0 +1,26 @@
+#ifndef ADD_PATCH_H
+#define ADD_PATCH_H
+
+struct pathspec;
+struct repository;
+
+struct add_p_opt {
+	int context;
+	int interhunkcontext;
+};
+
+#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+
+enum add_p_mode {
+	ADD_P_ADD,
+	ADD_P_STASH,
+	ADD_P_RESET,
+	ADD_P_CHECKOUT,
+	ADD_P_WORKTREE,
+};
+
+int run_add_p(struct repository *r, enum add_p_mode mode,
+	      struct add_p_opt *o, const char *revision,
+	      const struct pathspec *ps);
+
+#endif
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 12/18] add-patch: split out `struct interactive_options`

The `struct add_p_opt` is reused both by our the infra for "git add -p" and "git add -i". Users of `run_add_i()` for example are expected to pass `struct add_p_opt`. This is somewhat confusing and raises the question which options apply to what part of the stack.

But things are even more confusing than that: while callers are expected to pass in `struct add_p_opt`, these options ultimately get used to initialize a `struct add_i_state` that is used by both subsystems. So we are basically going full circle here.

Refactor the code and split out a new `struct interactive_options` that hosts common options used by both. These options are then applied to a `struct interactive_config` that hosts common configuration.

This refactoring doesn't yet fully detangle the two subsystems from one another, as we still end up calling `init_add_i_state()` in the "git add -p" subsystem. This will be fixed in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.c  | 151 +++++++++++++----------------------------------------
 add-interactive.h  |  20 ++-----
 add-patch.c        | 145 +++++++++++++++++++++++++++++++++++++++++---------
 add-patch.h        |  33 ++++++++++--
 builtin/add.c      |  22 ++++----
 builtin/checkout.c |   4 +-
 builtin/commit.c   |  16 +++---
 builtin/reset.c    |  16 +++---
 builtin/stash.c    |  46 ++++++++--------
 commit.h           |   2 +-
 10 files changed, 243 insertions(+), 212 deletions(-)
Show changes to 10 files +243 −212

add-interactive.c, add-interactive.h, add-patch.c, add-patch.h, builtin/add.c, builtin/checkout.c, builtin/commit.c, builtin/reset.c, builtin/stash.c, commit.h

diff --git a/add-interactive.c b/add-interactive.c
index 3e692b47ec..3babc3e013 100644
--- a/add-interactive.c
+++ b/add-interactive.c
@@ -3,7 +3,6 @@
 #include "git-compat-util.h"
 #include "add-interactive.h"
 #include "color.h"
-#include "config.h"
 #include "diffcore.h"
 #include "gettext.h"
 #include "hash.h"
@@ -20,96 +19,18 @@
 #include "prompt.h"
 #include "tree.h"
 
-static void init_color(struct repository *r, struct add_i_state *s,
-		       const char *section_and_slot, char *dst,
-		       const char *default_color)
-{
-	char *key = xstrfmt("color.%s", section_and_slot);
-	const char *value;
-
-	if (!s->use_color)
-		dst[0] = '\0';
-	else if (repo_config_get_value(r, key, &value) ||
-		 color_parse(value, dst))
-		strlcpy(dst, default_color, COLOR_MAXLEN);
-
-	free(key);
-}
-
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt)
+		      struct interactive_options *opts)
 {
-	const char *value;
-
 	s->r = r;
-	s->context = -1;
-	s->interhunkcontext = -1;
-
-	if (repo_config_get_value(r, "color.interactive", &value))
-		s->use_color = -1;
-	else
-		s->use_color =
-			git_config_colorbool("color.interactive", value);
-	s->use_color = want_color(s->use_color);
-
-	init_color(r, s, "interactive.header", s->header_color, GIT_COLOR_BOLD);
-	init_color(r, s, "interactive.help", s->help_color, GIT_COLOR_BOLD_RED);
-	init_color(r, s, "interactive.prompt", s->prompt_color,
-		   GIT_COLOR_BOLD_BLUE);
-	init_color(r, s, "interactive.error", s->error_color,
-		   GIT_COLOR_BOLD_RED);
-
-	init_color(r, s, "diff.frag", s->fraginfo_color,
-		   diff_get_color(s->use_color, DIFF_FRAGINFO));
-	init_color(r, s, "diff.context", s->context_color, "fall back");
-	if (!strcmp(s->context_color, "fall back"))
-		init_color(r, s, "diff.plain", s->context_color,
-			   diff_get_color(s->use_color, DIFF_CONTEXT));
-	init_color(r, s, "diff.old", s->file_old_color,
-		diff_get_color(s->use_color, DIFF_FILE_OLD));
-	init_color(r, s, "diff.new", s->file_new_color,
-		diff_get_color(s->use_color, DIFF_FILE_NEW));
-
-	strlcpy(s->reset_color,
-		s->use_color ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
-
-	FREE_AND_NULL(s->interactive_diff_filter);
-	repo_config_get_string(r, "interactive.difffilter",
-			       &s->interactive_diff_filter);
-
-	FREE_AND_NULL(s->interactive_diff_algorithm);
-	repo_config_get_string(r, "diff.algorithm",
-			       &s->interactive_diff_algorithm);
-
-	if (!repo_config_get_int(r, "diff.context", &s->context))
-		if (s->context < 0)
-			die(_("%s cannot be negative"), "diff.context");
-	if (!repo_config_get_int(r, "diff.interHunkContext", &s->interhunkcontext))
-		if (s->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "diff.interHunkContext");
-
-	repo_config_get_bool(r, "interactive.singlekey", &s->use_single_key);
-	if (s->use_single_key)
-		setbuf(stdin, NULL);
-
-	if (add_p_opt->context != -1) {
-		if (add_p_opt->context < 0)
-			die(_("%s cannot be negative"), "--unified");
-		s->context = add_p_opt->context;
-	}
-	if (add_p_opt->interhunkcontext != -1) {
-		if (add_p_opt->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "--inter-hunk-context");
-		s->interhunkcontext = add_p_opt->interhunkcontext;
-	}
+	interactive_config_init(&s->cfg, r, opts);
 }
 
 void clear_add_i_state(struct add_i_state *s)
 {
-	FREE_AND_NULL(s->interactive_diff_filter);
-	FREE_AND_NULL(s->interactive_diff_algorithm);
+	interactive_config_clear(&s->cfg);
 	memset(s, 0, sizeof(*s));
-	s->use_color = -1;
+	interactive_config_clear(&s->cfg);
 }
 
 /*
@@ -262,7 +183,7 @@ static void list(struct add_i_state *s, struct string_list *list, int *selected,
 		return;
 
 	if (opts->header)
-		color_fprintf_ln(stdout, s->header_color,
+		color_fprintf_ln(stdout, s->cfg.header_color,
 				 "%s", opts->header);
 
 	for (i = 0; i < list->nr; i++) {
@@ -330,7 +251,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 		list(s, &items->items, items->selected, &opts->list_opts);
 
-		color_fprintf(stdout, s->prompt_color, "%s", opts->prompt);
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", opts->prompt);
 		fputs(singleton ? "> " : ">> ", stdout);
 		fflush(stdout);
 
@@ -408,7 +329,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 			if (from < 0 || from >= items->items.nr ||
 			    (singleton && from + 1 != to)) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("Huh (%s)?"), p);
 				break;
 			} else if (singleton) {
@@ -968,7 +889,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 				free(files->items.items[i].string);
 			} else if (item->index.unmerged ||
 				 item->worktree.unmerged) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("ignoring unmerged: %s"),
 						 files->items.items[i].string);
 				free(item);
@@ -990,9 +911,9 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 	opts->prompt = N_("Patch update");
 	count = list_and_choose(s, files, opts);
 	if (count > 0) {
-		struct add_p_opt add_p_opt = {
-			.context = s->context,
-			.interhunkcontext = s->interhunkcontext,
+		struct interactive_options opts = {
+			.context = s->cfg.context,
+			.interhunkcontext = s->cfg.interhunkcontext,
 		};
 		struct strvec args = STRVEC_INIT;
 		struct pathspec ps_selected = { 0 };
@@ -1004,7 +925,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 		parse_pathspec(&ps_selected,
 			       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,
 			       PATHSPEC_LITERAL_PATH, "", args.v);
-		res = run_add_p(s->r, ADD_P_ADD, &add_p_opt, NULL, &ps_selected);
+		res = run_add_p(s->r, ADD_P_ADD, &opts, NULL, &ps_selected);
 		strvec_clear(&args);
 		clear_pathspec(&ps_selected);
 	}
@@ -1040,10 +961,10 @@ static int run_diff(struct add_i_state *s, const struct pathspec *ps,
 		struct child_process cmd = CHILD_PROCESS_INIT;
 
 		strvec_pushl(&cmd.args, "git", "diff", "-p", "--cached", NULL);
-		if (s->context != -1)
-			strvec_pushf(&cmd.args, "--unified=%i", s->context);
-		if (s->interhunkcontext != -1)
-			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->interhunkcontext);
+		if (s->cfg.context != -1)
+			strvec_pushf(&cmd.args, "--unified=%i", s->cfg.context);
+		if (s->cfg.interhunkcontext != -1)
+			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
 		strvec_pushl(&cmd.args, oid_to_hex(!is_initial ? &oid :
 			     s->r->hash_algo->empty_tree), "--", NULL);
 		for (i = 0; i < files->items.nr; i++)
@@ -1061,17 +982,17 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 		    struct prefix_item_list *files UNUSED,
 		    struct list_and_choose_options *opts UNUSED)
 {
-	color_fprintf_ln(stdout, s->help_color, "status        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "status        - %s",
 			 _("show paths with changes"));
-	color_fprintf_ln(stdout, s->help_color, "update        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "update        - %s",
 			 _("add working tree state to the staged set of changes"));
-	color_fprintf_ln(stdout, s->help_color, "revert        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "revert        - %s",
 			 _("revert staged set of changes back to the HEAD version"));
-	color_fprintf_ln(stdout, s->help_color, "patch         - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "patch         - %s",
 			 _("pick hunks and update selectively"));
-	color_fprintf_ln(stdout, s->help_color, "diff          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "diff          - %s",
 			 _("view diff between HEAD and index"));
-	color_fprintf_ln(stdout, s->help_color, "add untracked - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "add untracked - %s",
 			 _("add contents of untracked files to the staged set of changes"));
 
 	return 0;
@@ -1079,21 +1000,21 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 
 static void choose_prompt_help(struct add_i_state *s)
 {
-	color_fprintf_ln(stdout, s->help_color, "%s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "%s",
 			 _("Prompt help:"));
-	color_fprintf_ln(stdout, s->help_color, "1          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "1          - %s",
 			 _("select a single item"));
-	color_fprintf_ln(stdout, s->help_color, "3-5        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "3-5        - %s",
 			 _("select a range of items"));
-	color_fprintf_ln(stdout, s->help_color, "2-3,6-9    - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "2-3,6-9    - %s",
 			 _("select multiple ranges"));
-	color_fprintf_ln(stdout, s->help_color, "foo        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "foo        - %s",
 			 _("select item based on unique prefix"));
-	color_fprintf_ln(stdout, s->help_color, "-...       - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "-...       - %s",
 			 _("unselect specified items"));
-	color_fprintf_ln(stdout, s->help_color, "*          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "*          - %s",
 			 _("choose all items"));
-	color_fprintf_ln(stdout, s->help_color, "           - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "           - %s",
 			 _("(empty) finish selecting"));
 }
 
@@ -1128,7 +1049,7 @@ static void print_command_item(int i, int selected UNUSED,
 
 static void command_prompt_help(struct add_i_state *s)
 {
-	const char *help_color = s->help_color;
+	const char *help_color = s->cfg.help_color;
 	color_fprintf_ln(stdout, help_color, "%s", _("Prompt help:"));
 	color_fprintf_ln(stdout, help_color, "1          - %s",
 			 _("select a numbered item"));
@@ -1139,7 +1060,7 @@ static void command_prompt_help(struct add_i_state *s)
 }
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt)
+	      struct interactive_options *interactive_opts)
 {
 	struct add_i_state s = { NULL };
 	struct print_command_item_data data = { "[", "]" };
@@ -1182,15 +1103,15 @@ int run_add_i(struct repository *r, const struct pathspec *ps,
 			->util = util;
 	}
 
-	init_add_i_state(&s, r, add_p_opt);
+	init_add_i_state(&s, r, interactive_opts);
 
 	/*
 	 * When color was asked for, use the prompt color for
 	 * highlighting, otherwise use square brackets.
 	 */
-	if (s.use_color) {
-		data.color = s.prompt_color;
-		data.reset = s.reset_color;
+	if (s.cfg.use_color) {
+		data.color = s.cfg.prompt_color;
+		data.reset = s.cfg.reset_color;
 	}
 	print_file_item_data.color = data.color;
 	print_file_item_data.reset = data.reset;
diff --git a/add-interactive.h b/add-interactive.h
index fb95b6ee05..eefa2edc7c 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -2,34 +2,20 @@
 #define ADD_INTERACTIVE_H
 
 #include "add-patch.h"
-#include "color.h"
 
 struct pathspec;
 struct repository;
 
 struct add_i_state {
 	struct repository *r;
-	int use_color;
-	char header_color[COLOR_MAXLEN];
-	char help_color[COLOR_MAXLEN];
-	char prompt_color[COLOR_MAXLEN];
-	char error_color[COLOR_MAXLEN];
-	char reset_color[COLOR_MAXLEN];
-	char fraginfo_color[COLOR_MAXLEN];
-	char context_color[COLOR_MAXLEN];
-	char file_old_color[COLOR_MAXLEN];
-	char file_new_color[COLOR_MAXLEN];
-
-	int use_single_key;
-	char *interactive_diff_filter, *interactive_diff_algorithm;
-	int context, interhunkcontext;
+	struct interactive_config cfg;
 };
 
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt);
+		      struct interactive_options *opts);
 void clear_add_i_state(struct add_i_state *s);
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt);
+	      struct interactive_options *opts);
 
 #endif
diff --git a/add-patch.c b/add-patch.c
index e2b002fa73..45bc254e0c 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -5,6 +5,8 @@
 #include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "config.h"
+#include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
@@ -279,6 +281,99 @@ struct add_p_state {
 	const char *revision;
 };
 
+static void init_color(struct repository *r,
+		       struct interactive_config *cfg,
+		       const char *section_and_slot, char *dst,
+		       const char *default_color)
+{
+	char *key = xstrfmt("color.%s", section_and_slot);
+	const char *value;
+
+	if (!cfg->use_color)
+		dst[0] = '\0';
+	else if (repo_config_get_value(r, key, &value) ||
+		 color_parse(value, dst))
+		strlcpy(dst, default_color, COLOR_MAXLEN);
+
+	free(key);
+}
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts)
+{
+	const char *value;
+
+	cfg->context = -1;
+	cfg->interhunkcontext = -1;
+
+	if (repo_config_get_value(r, "color.interactive", &value))
+		cfg->use_color = -1;
+	else
+		cfg->use_color =
+			git_config_colorbool("color.interactive", value);
+	cfg->use_color = want_color(cfg->use_color);
+
+	init_color(r, cfg, "interactive.header", cfg->header_color, GIT_COLOR_BOLD);
+	init_color(r, cfg, "interactive.help", cfg->help_color, GIT_COLOR_BOLD_RED);
+	init_color(r, cfg, "interactive.prompt", cfg->prompt_color,
+		   GIT_COLOR_BOLD_BLUE);
+	init_color(r, cfg, "interactive.error", cfg->error_color,
+		   GIT_COLOR_BOLD_RED);
+
+	init_color(r, cfg, "diff.frag", cfg->fraginfo_color,
+		   diff_get_color(cfg->use_color, DIFF_FRAGINFO));
+	init_color(r, cfg, "diff.context", cfg->context_color, "fall back");
+	if (!strcmp(cfg->context_color, "fall back"))
+		init_color(r, cfg, "diff.plain", cfg->context_color,
+			   diff_get_color(cfg->use_color, DIFF_CONTEXT));
+	init_color(r, cfg, "diff.old", cfg->file_old_color,
+		diff_get_color(cfg->use_color, DIFF_FILE_OLD));
+	init_color(r, cfg, "diff.new", cfg->file_new_color,
+		diff_get_color(cfg->use_color, DIFF_FILE_NEW));
+
+	strlcpy(cfg->reset_color,
+		cfg->use_color ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
+
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	repo_config_get_string(r, "interactive.difffilter",
+			       &cfg->interactive_diff_filter);
+
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	repo_config_get_string(r, "diff.algorithm",
+			       &cfg->interactive_diff_algorithm);
+
+	if (!repo_config_get_int(r, "diff.context", &cfg->context))
+		if (cfg->context < 0)
+			die(_("%s cannot be negative"), "diff.context");
+	if (!repo_config_get_int(r, "diff.interHunkContext", &cfg->interhunkcontext))
+		if (cfg->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "diff.interHunkContext");
+
+	repo_config_get_bool(r, "interactive.singlekey", &cfg->use_single_key);
+	if (cfg->use_single_key)
+		setbuf(stdin, NULL);
+
+	if (opts->context != -1) {
+		if (opts->context < 0)
+			die(_("%s cannot be negative"), "--unified");
+		cfg->context = opts->context;
+	}
+	if (opts->interhunkcontext != -1) {
+		if (opts->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "--inter-hunk-context");
+		cfg->interhunkcontext = opts->interhunkcontext;
+	}
+}
+
+void interactive_config_clear(struct interactive_config *cfg)
+{
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	memset(cfg, 0, sizeof(*cfg));
+	cfg->use_color = -1;
+}
+
 static void add_p_state_clear(struct add_p_state *s)
 {
 	size_t i;
@@ -299,9 +394,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.error_color, stdout);
+	fputs(s->s.cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.reset_color);
+	puts(s->s.cfg.reset_color);
 	va_end(args);
 }
 
@@ -424,12 +519,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.context);
-	if (s->s.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.interhunkcontext);
-	if (s->s.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.interactive_diff_algorithm);
+	if (s->s.cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
+	if (s->s.cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
+	if (s->s.cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -460,7 +555,7 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 
 	if (want_color_fd(1, -1)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.interactive_diff_filter;
+		const char *diff_filter = s->s.cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -693,7 +788,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.fraginfo_color);
+			strbuf_addstr(out, s->s.cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -715,7 +810,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.reset_color);
+			strbuf_addf(out, "%s\n", s->s.cfg.reset_color);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1103,12 +1198,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.file_old_color :
+			      s->s.cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.file_new_color :
-			      s->s.context_color);
+			      s->s.cfg.file_new_color :
+			      s->s.cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.reset_color);
+		strbuf_addstr(&s->colored, s->s.cfg.reset_color);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1227,7 +1322,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.use_single_key) {
+	if (s->s.cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1241,7 +1336,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1522,15 +1617,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.reset_color)
-			fputs(s->s.reset_color, stdout);
+		if (*s->s.cfg.reset_color)
+			fputs(s->s.cfg.reset_color, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			break;
@@ -1687,7 +1782,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.header_color,
+				color_fprintf_ln(stdout, s->s.cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1705,7 +1800,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.help_color, "%s",
+			color_fprintf(stdout, s->s.cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1723,7 +1818,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.help_color,
+				color_fprintf_ln(stdout, s->s.cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1764,7 +1859,7 @@ static int patch_update_file(struct add_p_state *s,
 }
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
@@ -1772,7 +1867,7 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, o);
+	init_add_i_state(&s.s, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
diff --git a/add-patch.h b/add-patch.h
index 4394c74107..51c0d7bce9 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -1,15 +1,42 @@
 #ifndef ADD_PATCH_H
 #define ADD_PATCH_H
 
+#include "color.h"
+
 struct pathspec;
 struct repository;
 
-struct add_p_opt {
+struct interactive_options {
 	int context;
 	int interhunkcontext;
 };
 
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+#define INTERACTIVE_OPTIONS_INIT { \
+	.context = -1, \
+	.interhunkcontext = -1, \
+}
+
+struct interactive_config {
+	int use_color;
+	char header_color[COLOR_MAXLEN];
+	char help_color[COLOR_MAXLEN];
+	char prompt_color[COLOR_MAXLEN];
+	char error_color[COLOR_MAXLEN];
+	char reset_color[COLOR_MAXLEN];
+	char fraginfo_color[COLOR_MAXLEN];
+	char context_color[COLOR_MAXLEN];
+	char file_old_color[COLOR_MAXLEN];
+	char file_new_color[COLOR_MAXLEN];
+
+	int use_single_key;
+	char *interactive_diff_filter, *interactive_diff_algorithm;
+	int context, interhunkcontext;
+};
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts);
+void interactive_config_clear(struct interactive_config *cfg);
 
 enum add_p_mode {
 	ADD_P_ADD,
@@ -20,7 +47,7 @@ enum add_p_mode {
 };
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
 #endif
diff --git a/builtin/add.c b/builtin/add.c
index 0235854f80..a94c826c14 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -30,7 +30,7 @@ static const char * const builtin_add_usage[] = {
 	NULL
 };
 static int patch_interactive, add_interactive, edit_interactive;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int take_worktree_changes;
 static int add_renormalize;
 static int pathspec_file_nul;
@@ -159,7 +159,7 @@ static int refresh(struct repository *repo, int verbose, const struct pathspec *
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt)
+		    int patch, struct interactive_options *interactive_opts)
 {
 	struct pathspec pathspec;
 	int ret;
@@ -171,9 +171,9 @@ int interactive_add(struct repository *repo,
 		       prefix, argv);
 
 	if (patch)
-		ret = !!run_add_p(repo, ADD_P_ADD, add_p_opt, NULL, &pathspec);
+		ret = !!run_add_p(repo, ADD_P_ADD, interactive_opts, NULL, &pathspec);
 	else
-		ret = !!run_add_i(repo, &pathspec, add_p_opt);
+		ret = !!run_add_i(repo, &pathspec, interactive_opts);
 
 	clear_pathspec(&pathspec);
 	return ret;
@@ -255,8 +255,8 @@ static struct option builtin_add_options[] = {
 	OPT_GROUP(""),
 	OPT_BOOL('i', "interactive", &add_interactive, N_("interactive picking")),
 	OPT_BOOL('p', "patch", &patch_interactive, N_("select hunks interactively")),
-	OPT_DIFF_UNIFIED(&add_p_opt.context),
-	OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+	OPT_DIFF_UNIFIED(&interactive_opts.context),
+	OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 	OPT_BOOL('e', "edit", &edit_interactive, N_("edit current diff and apply")),
 	OPT__FORCE(&ignored_too, N_("allow adding otherwise ignored files"), 0),
 	OPT_BOOL('u', "update", &take_worktree_changes, N_("update tracked files")),
@@ -398,9 +398,9 @@ int cmd_add(int argc,
 	prepare_repo_settings(repo);
 	repo->settings.command_requires_full_index = 0;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (patch_interactive)
@@ -410,11 +410,11 @@ int cmd_add(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--dry-run", "--interactive/--patch");
 		if (pathspec_from_file)
 			die(_("options '%s' and '%s' cannot be used together"), "--pathspec-from-file", "--interactive/--patch");
-		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &add_p_opt));
+		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &interactive_opts));
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
diff --git a/builtin/checkout.c b/builtin/checkout.c
index 43583c8d1b..0b90f398fe 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -546,7 +546,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 
 	if (opts->patch_mode) {
 		enum add_p_mode patch_mode;
-		struct add_p_opt add_p_opt = {
+		struct interactive_options interactive_opts = {
 			.context = opts->patch_context,
 			.interhunkcontext = opts->patch_interhunk_context,
 		};
@@ -575,7 +575,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 		else
 			BUG("either flag must have been set, worktree=%d, index=%d",
 			    opts->checkout_worktree, opts->checkout_index);
-		return !!run_add_p(the_repository, patch_mode, &add_p_opt,
+		return !!run_add_p(the_repository, patch_mode, &interactive_opts,
 				   rev, &opts->pathspec);
 	}
 
diff --git a/builtin/commit.c b/builtin/commit.c
index b5b9608813..767351fd87 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -123,7 +123,7 @@ static const char *edit_message, *use_message;
 static char *fixup_message, *fixup_commit, *squash_message;
 static const char *fixup_prefix;
 static int all, also, interactive, patch_interactive, only, amend, signoff;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int edit_flag = -1; /* unspecified */
 static int quiet, verbose, no_verify, allow_empty, dry_run, renew_authorship;
 static int config_commit_verbose = -1; /* unspecified */
@@ -356,9 +356,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 	const char *ret;
 	char *path = NULL;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (is_status)
@@ -407,7 +407,7 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 		setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-		if (interactive_add(the_repository, argv, prefix, patch_interactive, &add_p_opt) != 0)
+		if (interactive_add(the_repository, argv, prefix, patch_interactive, &interactive_opts) != 0)
 			die(_("interactive add failed"));
 
 		the_repository->index_file = old_repo_index_file;
@@ -432,9 +432,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		ret = get_lock_file_path(&index_lock);
 		goto out;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
@@ -1738,8 +1738,8 @@ int cmd_commit(int argc,
 		OPT_BOOL('i', "include", &also, N_("add specified files to index for commit")),
 		OPT_BOOL(0, "interactive", &interactive, N_("interactively add files")),
 		OPT_BOOL('p', "patch", &patch_interactive, N_("interactively add changes")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('o', "only", &only, N_("commit only specified files")),
 		OPT_BOOL('n', "no-verify", &no_verify, N_("bypass pre-commit and commit-msg hooks")),
 		OPT_BOOL(0, "dry-run", &dry_run, N_("show what would be committed")),
diff --git a/builtin/reset.c b/builtin/reset.c
index ed35802af1..088449e120 100644
--- a/builtin/reset.c
+++ b/builtin/reset.c
@@ -346,7 +346,7 @@ int cmd_reset(int argc,
 	struct object_id oid;
 	struct pathspec pathspec;
 	int intent_to_add = 0;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	const struct option options[] = {
 		OPT__QUIET(&quiet, N_("be quiet, only report errors")),
 		OPT_BOOL(0, "no-refresh", &no_refresh,
@@ -371,8 +371,8 @@ int cmd_reset(int argc,
 			       PARSE_OPT_OPTARG,
 			       option_parse_recurse_submodules_worktree_updater),
 		OPT_BOOL('p', "patch", &patch_mode, N_("select hunks interactively")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('N', "intent-to-add", &intent_to_add,
 				N_("record only the fact that removed paths will be added later")),
 		OPT_PATHSPEC_FROM_FILE(&pathspec_from_file),
@@ -423,9 +423,9 @@ int cmd_reset(int argc,
 		oidcpy(&oid, &tree->object.oid);
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	prepare_repo_settings(the_repository);
@@ -436,12 +436,12 @@ int cmd_reset(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--patch", "--{hard,mixed,soft}");
 		trace2_cmd_mode("patch-interactive");
 		update_ref_status = !!run_add_p(the_repository, ADD_P_RESET,
-						&add_p_opt, rev, &pathspec);
+						&interactive_opts, rev, &pathspec);
 		goto cleanup;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
diff --git a/builtin/stash.c b/builtin/stash.c
index 1977e50df2..5070b8a88f 100644
--- a/builtin/stash.c
+++ b/builtin/stash.c
@@ -1302,7 +1302,7 @@ static int stash_staged(struct stash_info *info, struct strbuf *out_patch,
 
 static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 		       struct strbuf *out_patch, int quiet,
-		       struct add_p_opt *add_p_opt)
+		       struct interactive_options *interactive_opts)
 {
 	int ret = 0;
 	struct child_process cp_read_tree = CHILD_PROCESS_INIT;
@@ -1327,7 +1327,7 @@ static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 	old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 	setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-	ret = !!run_add_p(the_repository, ADD_P_STASH, add_p_opt, NULL, ps);
+	ret = !!run_add_p(the_repository, ADD_P_STASH, interactive_opts, NULL, ps);
 
 	the_repository->index_file = old_repo_index_file;
 	if (old_index_env && *old_index_env)
@@ -1422,7 +1422,8 @@ static int stash_working_tree(struct stash_info *info, const struct pathspec *ps
 }
 
 static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_buf,
-			   int include_untracked, int patch_mode, struct add_p_opt *add_p_opt,
+			   int include_untracked, int patch_mode,
+			   struct interactive_options *interactive_opts,
 			   int only_staged, struct stash_info *info, struct strbuf *patch,
 			   int quiet)
 {
@@ -1504,7 +1505,7 @@ static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_b
 		untracked_commit_option = 1;
 	}
 	if (patch_mode) {
-		ret = stash_patch(info, ps, patch, quiet, add_p_opt);
+		ret = stash_patch(info, ps, patch, quiet, interactive_opts);
 		if (ret < 0) {
 			if (!quiet)
 				fprintf_ln(stderr, _("Cannot save the current "
@@ -1590,7 +1591,8 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED,
 }
 
 static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int quiet,
-			 int keep_index, int patch_mode, struct add_p_opt *add_p_opt,
+			 int keep_index, int patch_mode,
+			 struct interactive_options *interactive_opts,
 			 int include_untracked, int only_staged)
 {
 	int ret = 0;
@@ -1662,7 +1664,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q
 	if (stash_msg)
 		strbuf_addstr(&stash_msg_buf, stash_msg);
 	if (do_create_stash(ps, &stash_msg_buf, include_untracked, patch_mode,
-			    add_p_opt, only_staged, &info, &patch, quiet)) {
+			    interactive_opts, only_staged, &info, &patch, quiet)) {
 		ret = -1;
 		goto done;
 	}
@@ -1835,7 +1837,7 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	char *pathspec_from_file = NULL;
 	struct pathspec ps;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1843,8 +1845,8 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1901,19 +1903,19 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	}
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index, patch_mode,
-			    &add_p_opt, include_untracked, only_staged);
+			    &interactive_opts, include_untracked, only_staged);
 
 	clear_pathspec(&ps);
 	free(pathspec_from_file);
@@ -1938,7 +1940,7 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	struct pathspec ps;
 	struct strbuf stash_msg_buf = STRBUF_INIT;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1946,8 +1948,8 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1967,20 +1969,20 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 
 	memset(&ps, 0, sizeof(ps));
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index,
-			    patch_mode, &add_p_opt, include_untracked,
+			    patch_mode, &interactive_opts, include_untracked,
 			    only_staged);
 
 	strbuf_release(&stash_msg_buf);
diff --git a/commit.h b/commit.h
index 1d6e0c7518..7b6e59d6c1 100644
--- a/commit.h
+++ b/commit.h
@@ -258,7 +258,7 @@ int for_each_commit_graft(each_commit_graft_fn, void *);
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt);
+		    int patch, struct interactive_options *opts);
 
 struct commit_extra_header {
 	struct commit_extra_header *next;
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 13/18] add-patch: remove dependency on "add-interactive" subsystem

With the preceding commit we have split out interactive configuration that is used by both "git add -p" and "git add -i". But we still initialize that configuration in the "add -p" subsystem by calling `init_add_i_state()`, even though we only do so to initialize the interactive configuration as well as a repository pointer.

Stop doing so and instead store and initialize the interactive configuration in `struct add_p_state` directly.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 68 ++++++++++++++++++++++++++++++++-----------------------------
 1 file changed, 36 insertions(+), 32 deletions(-)
Show changes to add-patch.c +36 −32
diff --git a/add-patch.c b/add-patch.c
index 45bc254e0c..1bcbc91de9 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -2,7 +2,6 @@
 #define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
-#include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
 #include "config.h"
@@ -263,7 +262,8 @@ struct hunk {
 };
 
 struct add_p_state {
-	struct add_i_state s;
+	struct repository *r;
+	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
 	/* parsed diff */
@@ -385,7 +385,7 @@ static void add_p_state_clear(struct add_p_state *s)
 	for (i = 0; i < s->file_diff_nr; i++)
 		free(s->file_diff[i].hunk);
 	free(s->file_diff);
-	clear_add_i_state(&s->s);
+	interactive_config_clear(&s->cfg);
 }
 
 __attribute__((format (printf, 2, 3)))
@@ -394,9 +394,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.cfg.error_color, stdout);
+	fputs(s->cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.cfg.reset_color);
+	puts(s->cfg.reset_color);
 	va_end(args);
 }
 
@@ -414,7 +414,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->s.r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
 }
 
 static int parse_range(const char **p,
@@ -519,12 +519,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.cfg.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
-	if (s->s.cfg.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
-	if (s->s.cfg.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
+	if (s->cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->cfg.context);
+	if (s->cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
+	if (s->cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -555,7 +555,7 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 
 	if (want_color_fd(1, -1)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.cfg.interactive_diff_filter;
+		const char *diff_filter = s->cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -788,7 +788,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.cfg.fraginfo_color);
+			strbuf_addstr(out, s->cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -810,7 +810,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.cfg.reset_color);
+			strbuf_addf(out, "%s\n", s->cfg.reset_color);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1198,12 +1198,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.cfg.file_old_color :
+			      s->cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.cfg.file_new_color :
-			      s->s.cfg.context_color);
+			      s->cfg.file_new_color :
+			      s->cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.cfg.reset_color);
+		strbuf_addstr(&s->colored, s->cfg.reset_color);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1322,7 +1322,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.cfg.use_single_key) {
+	if (s->cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1336,7 +1336,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1617,15 +1617,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.cfg.reset_color)
-			fputs(s->s.cfg.reset_color, stdout);
+		if (*s->cfg.reset_color)
+			fputs(s->cfg.reset_color, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			break;
@@ -1782,7 +1782,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.cfg.header_color,
+				color_fprintf_ln(stdout, s->cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1800,7 +1800,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.cfg.help_color, "%s",
+			color_fprintf(stdout, s->cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1818,7 +1818,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.cfg.help_color,
+				color_fprintf_ln(stdout, s->cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1838,7 +1838,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->s.r->index);
+		discard_index(s->r->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1849,8 +1849,8 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->s.r) >= 0)
-			repo_refresh_and_write_index(s->s.r, REFRESH_QUIET, 0,
+		if (repo_read_index(s->r) >= 0)
+			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
 	}
 
@@ -1863,11 +1863,15 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
-		{ r }, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT
+		.r = r,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, opts);
+	interactive_config_init(&s.cfg, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 14/18] add-patch: add support for in-memory index patching

With `run_add_p()` callers have the ability to apply changes from a specific revision to a repository's index. This infra supports several different modes, like for example applying changes to the index, worktree or both.

One feature that is missing though is the ability to apply changes to an in-memory index different from the repository's index. Add a new function `run_add_p_index()` to plug this gap.

This new function will be used in a subsequent commit.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 add-patch.h |   8 +++++
 2 files changed, 115 insertions(+), 3 deletions(-)
Show changes to 2 files +115 −3

add-patch.c, add-patch.h

diff --git a/add-patch.c b/add-patch.c
index 1bcbc91de9..2a72c7b931 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -4,11 +4,13 @@
 #include "git-compat-util.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "commit.h"
 #include "config.h"
 #include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "object-name.h"
 #include "pager.h"
 #include "read-cache-ll.h"
@@ -263,6 +265,8 @@ struct hunk {
 
 struct add_p_state {
 	struct repository *r;
+	struct index_state *index;
+	const char *index_file;
 	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
@@ -414,7 +418,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->index_file);
 }
 
 static int parse_range(const char **p,
@@ -1838,7 +1842,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->r->index);
+		discard_index(s->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1849,9 +1853,11 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->r) >= 0)
+		if (read_index_from(s->index, s->index_file, s->r->gitdir) >= 0 &&
+		    s->index == s->r->index) {
 			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
+		}
 	}
 
 	putchar('\n');
@@ -1864,6 +1870,8 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 {
 	struct add_p_state s = {
 		.r = r,
+		.index = r->index,
+		.index_file = r->index_file,
 		.answer = STRBUF_INIT,
 		.buf = STRBUF_INIT,
 		.plain = STRBUF_INIT,
@@ -1922,3 +1930,99 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	add_p_state_clear(&s);
 	return 0;
 }
+
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps)
+{
+	struct patch_mode mode = {
+		.apply_args = { "--cached", NULL },
+		.apply_check_args = { "--cached", NULL },
+		.prompt_mode = {
+			N_("Stage mode change [y,n,q,a,d%s,?]? "),
+			N_("Stage deletion [y,n,q,a,d%s,?]? "),
+			N_("Stage addition [y,n,q,a,d%s,?]? "),
+			N_("Stage this hunk [y,n,q,a,d%s,?]? ")
+		},
+		.edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
+				     "will immediately be marked for staging."),
+		.help_patch_text =
+			N_("y - stage this hunk\n"
+			   "n - do not stage this hunk\n"
+			   "q - quit; do not stage this hunk or any of the remaining "
+				"ones\n"
+			   "a - stage this hunk and all later hunks in the file\n"
+			   "d - do not stage this hunk or any of the later hunks in "
+				"the file\n"),
+		.index_only = 1,
+	};
+	struct add_p_state s = {
+		.r = r,
+		.index = index,
+		.index_file = index_file,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
+		.mode = &mode,
+		.revision = revision,
+	};
+	struct strbuf parent_revision = STRBUF_INIT;
+	char parent_tree_oid[GIT_MAX_HEXSZ + 1];
+	size_t binary_count = 0;
+	struct commit *commit;
+	int ret;
+
+	commit = lookup_commit_reference_by_name(revision);
+	if (!commit) {
+		err(&s, _("Revision does not refer to a commit"));
+		ret = -1;
+		goto out;
+	}
+
+	if (commit->parents)
+		oid_to_hex_r(parent_tree_oid, get_commit_tree_oid(commit->parents->item));
+	else
+		oid_to_hex_r(parent_tree_oid, r->hash_algo->empty_tree);
+
+	strbuf_addf(&parent_revision, "%s~", revision);
+	mode.diff_cmd[0] = "diff-tree";
+	mode.diff_cmd[1] = "-r";
+	mode.diff_cmd[2] = parent_tree_oid;
+
+	interactive_config_init(&s.cfg, r, opts);
+
+	if (parse_diff(&s, ps) < 0) {
+		ret = -1;
+		goto out;
+	}
+
+	for (size_t i = 0; i < s.file_diff_nr; i++) {
+		if (s.file_diff[i].binary && !s.file_diff[i].hunk_nr)
+			binary_count++;
+		else if (patch_update_file(&s, s.file_diff + i))
+			break;
+	}
+
+	if (s.file_diff_nr == 0) {
+		err(&s, _("No changes."));
+		ret = -1;
+		goto out;
+	}
+
+	if (binary_count == s.file_diff_nr) {
+		err(&s, _("Only binary files changed."));
+		ret = -1;
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	strbuf_release(&parent_revision);
+	add_p_state_clear(&s);
+	return ret;
+}
diff --git a/add-patch.h b/add-patch.h
index 51c0d7bce9..d0edfec936 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -3,6 +3,7 @@
 
 #include "color.h"
 
+struct index_state;
 struct pathspec;
 struct repository;
 
@@ -50,4 +51,11 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps);
+
 #endif
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 15/18] wt-status: provide function to expose status for trees

The "wt-status" subsystem is responsible for printing status information around the current state of the working tree. This most importantly includes information around whether the working tree or the index have any changes.

We're about to introduce a new command though where the changes in neither of them are actually relevant to us. Instead, what we want is to format the changes between two different trees. While it is a little bit of a stretch to add this as functionality to _working tree_ status, it doesn't make any sense to open-code this functionality, either.

Implement a new function `wt_status_collect_changes_trees()` that diffs two trees and formats the status accordingly. This function is not yet used, but will be in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 wt-status.c | 24 ++++++++++++++++++++++++
 wt-status.h |  3 +++
 2 files changed, 27 insertions(+)
Show changes to 2 files +27 −0

wt-status.c, wt-status.h

diff --git a/wt-status.c b/wt-status.c
index 454601afa15..f09309d12e3 100644
--- a/wt-status.c
+++ b/wt-status.c
@@ -612,6 +612,30 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q,
 	}
 }
 
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish)
+{
+	struct diff_options opts = { 0 };
+
+	repo_diff_setup(s->repo, &opts);
+	opts.output_format = DIFF_FORMAT_CALLBACK;
+	opts.format_callback = wt_status_collect_updated_cb;
+	opts.format_callback_data = s;
+	opts.detect_rename = s->detect_rename >= 0 ? s->detect_rename : opts.detect_rename;
+	opts.rename_limit = s->rename_limit >= 0 ? s->rename_limit : opts.rename_limit;
+	opts.rename_score = s->rename_score >= 0 ? s->rename_score : opts.rename_score;
+	opts.flags.recursive = 1;
+	diff_setup_done(&opts);
+
+	diff_tree_oid(old_treeish, new_treeish, "", &opts);
+	diffcore_std(&opts);
+	diff_flush(&opts);
+	wt_status_get_state(s->repo, &s->state, 0);
+
+	diff_free(&opts);
+}
+
 static void wt_status_collect_changes_worktree(struct wt_status *s)
 {
 	struct rev_info rev;
diff --git a/wt-status.h b/wt-status.h
index 4e377ce62b8..b262e345f79 100644
--- a/wt-status.h
+++ b/wt-status.h
@@ -153,6 +153,9 @@ void wt_status_add_cut_line(struct wt_status *s);
 void wt_status_prepare(struct repository *r, struct wt_status *s);
 void wt_status_print(struct wt_status *s);
 void wt_status_collect(struct wt_status *s);
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish);
 /*
  * Frees the buffers allocated by wt_status_collect.
  */
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 16/18] sequencer: allow callers to provide mappings for the old commit

When executing the "rewritten-hook" we provide a list of commit mappings that tell the hook the original commit ID as well as the commit ID that specific commit was rewritten to. Typically, these should always be different from one another, as otherwise there wouldn't have been a rewrite of those commits in the first place.

With two upcoming subcommands for git-history(1) that is not the case though, as we have already written the new commits ahead of time. We only use the sequencer infrastructure in that case to insert those commits at the correct position in the graph. This has the consequence that original and rewritten object IDs will be the exact same, which is quite unhelpful.

Introduce infrastructure so that the caller can tell us the original object ID for such already-rewritten objects.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 sequencer.c |  7 +++++++
 sequencer.h | 14 ++++++++++++++
 2 files changed, 21 insertions(+)
Show changes to 2 files +21 −0

sequencer.c, sequencer.h

diff --git a/sequencer.c b/sequencer.c
index 61447e5ccf..72d26b0eef 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -2214,6 +2214,13 @@ static void record_in_rewritten(struct object_id *oid,
 	const char *path;
 	FILE *out;
 
+	if (opts->old_oid_mappings) {
+		struct replay_oid_mapping *mapping =
+			oidmap_get(opts->old_oid_mappings, oid);
+		if (mapping)
+			oid = &mapping->rewritten_oid;
+	}
+
 	if (opts->action == REPLAY_HISTORY_EDIT)
 		path = git_path_rewritten_pending_file();
 	else
diff --git a/sequencer.h b/sequencer.h
index 0e0e7301b8..e6cc8aeb5d 100644
--- a/sequencer.h
+++ b/sequencer.h
@@ -1,6 +1,7 @@
 #ifndef SEQUENCER_H
 #define SEQUENCER_H
 
+#include "oidmap.h"
 #include "strbuf.h"
 #include "strvec.h"
 #include "wt-status.h"
@@ -35,6 +36,12 @@ enum commit_msg_cleanup_mode {
 struct replay_ctx;
 struct replay_ctx* replay_ctx_new(void);
 
+/* Used as entry for the `original_oid_map`. */
+struct replay_oid_mapping {
+	struct oidmap_entry entry;
+	struct object_id rewritten_oid;
+};
+
 struct replay_opts {
 	enum replay_action action;
 
@@ -83,6 +90,13 @@ struct replay_opts {
 	/* Only used by REPLAY_NONE */
 	struct rev_info *revs;
 
+	/*
+	 * Used by the post-rewrite hook to fix up old object IDs. This can be
+	 * used to rewrite the old object ID to whatever is stored as value in
+	 * this map. The map contains `struct replay_oid_mapping` entries.
+	 */
+	const struct oidmap *old_oid_mappings;
+
 	/* Private use */
 	struct replay_ctx *ctx;
 };
-- 
2.51.0.417.g1ba7204a04.dirty
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 17/18] builtin/history: implement "split" subcommand

It is quite a common use case that one wants to split up one commit into multiple commits by moving parts of the changes of the original commit out into a separate commit. This is quite an involved operation though:

  1. Identify the commit in question that is to be dropped.
  2. Perform an interactive rebase on top of that commit's parent.
  3. Modify the instruction sheet to "edit" the commit that is to be
     split up.
  4. Drop the commit via "git reset HEAD~".
  5. Stage changes that should go into the first commit and commit it.
  6. Stage changes that should go into the second commit and commit it.
  7. Finalize the rebase.

This is quite complex, and overall I would claim that most people who are not experts in Git would struggle with this flow.

Introduce a new "split" subcommand for git-history(1) to make this way easier. All the user needs to do is to say `git history split $COMMIT`. From hereon, Git asks the user which parts of the commit shall be moved out into a separate commit and, once done, asks the user for the commit message. Git then creates that split-out commit and applies the original commit on top of it.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  60 ++++++
 builtin/history.c              | 313 ++++++++++++++++++++++++++-
 t/meson.build                  |   1 +
 t/t3453-history-split.sh       | 468 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 840 insertions(+), 2 deletions(-)
Show changes to 4 files +838 −2

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3453-history-split.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index b36cd925dd..6f0c64b90e 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -13,6 +13,7 @@ git history continue
 git history quit
 git history drop <commit>
 git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
+git history split [<options>] <commit> [--] [<pathspec>...]
 
 DESCRIPTION
 -----------
@@ -52,6 +53,26 @@ child commits, as that would lead to an empty branch.
 	be related to one another and must be reachable from the current `HEAD`
 	commit.
 
+`split [--message=<message>] <commit> [--] [<pathspec>...]`::
+	Interactively split up <commit> into two commits by choosing
+	hunks introduced by it that will be moved into the new split-out
+	commit. These hunks will then be written into a new commit that
+	becomes the parent of the previous commit. The original commit
+	stays intact, except that its parent will be the newly split-out
+	commit.
++
+The commit message of the new commit will be asked for by launching the
+configured editor, unless it has been specified with the `-m` option.
+Authorship of the commit will be the same as for the original commit.
++
+If passed, _<pathspec>_ can be used to limit which changes shall be split out
+of the original commit. Files not matching any of the pathspecs will remain
+part of the original commit. For more details, see the 'pathspec' entry in
+linkgit:gitglossary[7].
++
+It is invalid to select either all or no hunks, as that would lead to
+one of the commits becoming empty.
+
 The following commands are used to manage an interrupted history-rewriting
 operation:
 
@@ -111,6 +132,45 @@ f44a46e third
 bf7438d first
 ----------
 
+Split a commit
+~~~~~~~~~~~~~~
+
+----------
+$ git log --stat --oneline
+3f81232 (HEAD -> main) original
+ bar | 1 +
+ foo | 1 +
+ 2 files changed, 2 insertions(+)
+
+$ git history split HEAD --message="split-out commit"
+diff --git a/bar b/bar
+new file mode 100644
+index 0000000..5716ca5
+--- /dev/null
++++ b/bar
+@@ -0,0 +1 @@
++bar
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? y
+
+diff --git a/foo b/foo
+new file mode 100644
+index 0000000..257cc56
+--- /dev/null
++++ b/foo
+@@ -0,0 +1 @@
++foo
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? n
+
+$ git log --stat --oneline
+7cebe64 (HEAD -> main) original
+ foo | 1 +
+ 1 file changed, 1 insertion(+)
+d1582f3 split-out commit
+ bar | 1 +
+ 1 file changed, 1 insertion(+)
+----------
+
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index 16b516856e..df04b8dfc6 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,17 +1,27 @@
+/* Required for `comment_line_str`. */
+#define USE_THE_REPOSITORY_VARIABLE
+
 #include "builtin.h"
 #include "branch.h"
+#include "cache-tree.h"
 #include "commit.h"
 #include "commit-reach.h"
 #include "config.h"
+#include "editor.h"
 #include "environment.h"
 #include "gettext.h"
 #include "hex.h"
 #include "object-name.h"
 #include "parse-options.h"
+#include "path.h"
+#include "pathspec.h"
+#include "read-cache-ll.h"
 #include "refs.h"
 #include "reset.h"
 #include "revision.h"
+#include "run-command.h"
 #include "sequencer.h"
+#include "sparse-index.h"
 
 static int cmd_history_abort(int argc,
 			     const char **argv,
@@ -213,6 +223,7 @@ static int apply_commits(struct repository *repo,
 			 const struct strvec *commits,
 			 struct commit *head,
 			 struct commit *base,
+			 const struct oidmap *rewritten_commits,
 			 const char *action)
 {
 	struct setup_revision_opt revision_opts = {
@@ -238,6 +249,7 @@ static int apply_commits(struct repository *repo,
 		replay_opts.strategy = replay_opts.default_strategy;
 		replay_opts.default_strategy = NULL;
 	}
+	replay_opts.old_oid_mappings = rewritten_commits;
 
 	strvec_push(&args, "");
 	strvec_pushv(&args, commits->v);
@@ -403,7 +415,8 @@ static int cmd_history_drop(int argc,
 		if (ret < 0)
 			goto out;
 
-		ret = apply_commits(repo, &commits, head, commit_to_drop, "drop");
+		ret = apply_commits(repo, &commits, head, commit_to_drop,
+				    NULL, "drop");
 		if (ret < 0)
 			goto out;
 	}
@@ -505,7 +518,7 @@ static int cmd_history_reorder(int argc,
 	replace_commits(&commits, &commit_to_reorder->object.oid, NULL, 0);
 	replace_commits(&commits, &anchor->object.oid, replacement, ARRAY_SIZE(replacement));
 
-	ret = apply_commits(repo, &commits, head, old, "reorder");
+	ret = apply_commits(repo, &commits, head, old, NULL, "reorder");
 	if (ret < 0)
 		goto out;
 
@@ -517,6 +530,300 @@ static int cmd_history_reorder(int argc,
 	return ret;
 }
 
+static void change_data_free(void *util, const char *str UNUSED)
+{
+	struct wt_status_change_data *d = util;
+	free(d->rename_source);
+	free(d);
+}
+
+static int fill_commit_message(struct repository *repo,
+			       const struct object_id *old_tree,
+			       const struct object_id *new_tree,
+			       const char *default_message,
+			       const char *provided_message,
+			       const char *action,
+			       struct strbuf *out)
+{
+	if (!provided_message) {
+		const char *path = git_path_commit_editmsg();
+		const char *hint =
+			_("Please enter the commit message for the %s changes. Lines starting\n"
+			  "with '%s' will be kept; you may remove them yourself if you want to.\n");
+		int verbose = 1;
+
+		strbuf_addstr(out, default_message);
+		strbuf_addch(out, '\n');
+		strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
+		write_file_buf(path, out->buf, out->len);
+
+		repo_config_get_bool(repo, "commit.verbose", &verbose);
+		if (verbose) {
+			struct wt_status s;
+
+			wt_status_prepare(repo, &s);
+			FREE_AND_NULL(s.branch);
+			s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
+			s.commit_template = 1;
+			s.colopts = 0;
+			s.display_comment_prefix = 1;
+			s.hints = 0;
+			s.use_color = 0;
+			s.whence = FROM_COMMIT;
+			s.committable = 1;
+
+			s.fp = fopen(git_path_commit_editmsg(), "a");
+			if (!s.fp)
+				return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
+
+			wt_status_collect_changes_trees(&s, old_tree, new_tree);
+			wt_status_print(&s);
+			wt_status_collect_free_buffers(&s);
+			string_list_clear_func(&s.change, change_data_free);
+		}
+
+		strbuf_reset(out);
+		if (launch_editor(path, out, NULL)) {
+			fprintf(stderr, _("Please supply the message using the -m option.\n"));
+			return -1;
+		}
+		strbuf_stripspace(out, comment_line_str);
+	} else {
+		strbuf_addstr(out, provided_message);
+	}
+
+	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
+
+	if (!out->len) {
+		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
+		return -1;
+	}
+
+	return 0;
+}
+
+static int split_commit(struct repository *repo,
+			struct commit *original_commit,
+			struct pathspec *pathspec,
+			const char *commit_message,
+			struct object_id *out)
+{
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
+	struct strbuf index_file = STRBUF_INIT, split_message = STRBUF_INIT;
+	struct child_process read_tree_cmd = CHILD_PROCESS_INIT;
+	struct index_state index = INDEX_STATE_INIT(repo);
+	struct object_id original_commit_tree_oid, parent_tree_oid;
+	const char *original_message, *original_body, *ptr;
+	char original_commit_oid[GIT_MAX_HEXSZ + 1];
+	char *original_author = NULL;
+	struct commit_list *parents = NULL;
+	struct commit *first_commit;
+	struct tree *split_tree;
+	size_t len;
+	int ret;
+
+	if (original_commit->parents)
+		parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
+	else
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
+
+	/*
+	 * Construct the first commit. This is done by taking the original
+	 * commit parent's tree and selectively patching changes from the diff
+	 * between that parent and its child.
+	 */
+	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
+
+	read_tree_cmd.git_cmd = 1;
+	strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
+	strvec_push(&read_tree_cmd.args, "read-tree");
+	strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
+	ret = run_command(&read_tree_cmd);
+	if (ret < 0)
+		goto out;
+
+	ret = read_index_from(&index, index_file.buf, repo->gitdir);
+	if (ret < 0) {
+		ret = error(_("failed reading temporary index"));
+		goto out;
+	}
+
+	oid_to_hex_r(original_commit_oid, &original_commit->object.oid);
+	ret = run_add_p_index(repo, &index, index_file.buf, &interactive_opts,
+			      original_commit_oid, pathspec);
+	if (ret < 0)
+		goto out;
+
+	split_tree = write_in_core_index_as_tree(repo, &index);
+	if (!split_tree) {
+		ret = error(_("failed split tree"));
+		goto out;
+	}
+
+	unlink(index_file.buf);
+
+	/*
+	 * We disallow the cases where either the split-out commit or the
+	 * original commit would become empty. Consequently, if we see that the
+	 * new tree ID matches either of those trees we abort.
+	 */
+	if (oideq(&split_tree->object.oid, &parent_tree_oid)) {
+		ret = error(_("split commit is empty"));
+		goto out;
+	} else if (oideq(&split_tree->object.oid, &original_commit_tree_oid)) {
+		ret = error(_("split commit tree matches original commit"));
+		goto out;
+	}
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+
+	ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
+				  "", commit_message, "split-out", &split_message);
+	if (ret < 0)
+		goto out;
+
+	ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
+			  original_commit->parents, &out[0], original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing split-out commit"));
+		goto out;
+	}
+
+	/*
+	 * The second commit is much simpler to construct, as we can simply use
+	 * the original commit details, except that we adjust its parent to be
+	 * the newly split-out commit.
+	 */
+	find_commit_subject(original_message, &original_body);
+	first_commit = lookup_commit_reference(repo, &out[0]);
+	commit_list_append(first_commit, &parents);
+
+	ret = commit_tree(original_body, strlen(original_body), &original_commit_tree_oid,
+			  parents, &out[1], original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing second commit"));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	if (index_file.len)
+		unlink(index_file.buf);
+	strbuf_release(&split_message);
+	strbuf_release(&index_file);
+	free_commit_list(parents);
+	free(original_author);
+	release_index(&index);
+	return ret;
+}
+
+static int cmd_history_split(int argc,
+			     const char **argv,
+			     const char *prefix,
+			     struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history split [<options>] <commit>"),
+		NULL,
+	};
+	const char *commit_message = NULL;
+	struct option options[] = {
+		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
+		OPT_END(),
+	};
+	struct oidmap rewritten_commits = OIDMAP_INIT;
+	struct commit *original_commit, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct commit_list *list = NULL;
+	struct object_id split_commits[2];
+	struct replay_oid_mapping mapping[2] = { 0 };
+	struct pathspec pathspec = { 0 };
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc < 1) {
+		ret = error(_("command expects a revision"));
+		goto out;
+	}
+	repo_config(repo, git_default_config, NULL);
+
+	original_commit = lookup_commit_reference_by_name(argv[0]);
+	if (!original_commit) {
+		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
+		goto out;
+	}
+
+	if (original_commit->parents && original_commit->parents->next) {
+		ret = error(_("commit to be split must not be a merge commit"));
+		goto out;
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	commit_list_append(original_commit, &list);
+	if (!repo_is_descendant_of(repo, original_commit, list)) {
+		ret = error (_("split commit must be reachable from current HEAD commit"));
+		goto out;
+	}
+
+	parse_pathspec(&pathspec, 0,
+		       PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
+		       prefix, argv + 1);
+
+	/*
+	 * Collect the list of commits that we'll have to reapply now already.
+	 * This ensures that we'll abort early on in case the range of commits
+	 * contains merges, which we do not yet handle.
+	 */
+	ret = collect_commits(repo, original_commit->parents ? original_commit->parents->item : NULL,
+			      head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/*
+	 * Then we split up the commit and replace the original commit with the
+	 * new new ones.
+	 */
+	ret = split_commit(repo, original_commit, &pathspec,
+			   commit_message, split_commits);
+	if (ret < 0)
+		goto out;
+
+	mapping[0].entry.oid = split_commits[0];
+	mapping[0].rewritten_oid = original_commit->object.oid;
+	oidmap_put(&rewritten_commits, &mapping[0]);
+	mapping[1].entry.oid = split_commits[1];
+	mapping[1].rewritten_oid = original_commit->object.oid;
+	oidmap_put(&rewritten_commits, &mapping[1]);
+
+	replace_commits(&commits, &original_commit->object.oid,
+			split_commits, ARRAY_SIZE(split_commits));
+
+	ret = apply_commits(repo, &commits, head, original_commit,
+			    &rewritten_commits, "split");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	oidmap_clear(&rewritten_commits, 0);
+	clear_pathspec(&pathspec);
+	strvec_clear(&commits);
+	free_commit_list(list);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -528,6 +835,7 @@ int cmd_history(int argc,
 		N_("git history quit"),
 		N_("git history drop <commit>"),
 		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
+		N_("git history split [<options>] <commit> [--] [<pathspec>...]"),
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -537,6 +845,7 @@ int cmd_history(int argc,
 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
 		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
 		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
+		OPT_SUBCOMMAND("split", &fn, cmd_history_split),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 2bf7bcab5a..b3d33c8588 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -379,6 +379,7 @@ integration_tests = [
   't3450-history.sh',
   't3451-history-drop.sh',
   't3452-history-reorder.sh',
+  't3453-history-split.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3453-history-split.sh b/t/t3453-history-split.sh
new file mode 100755
index 0000000000..a6a652e7df
--- /dev/null
+++ b/t/t3453-history-split.sh
@@ -0,0 +1,468 @@
+#!/bin/sh
+
+test_description='tests for git-history split subcommand'
+
+. ./test-lib.sh
+
+set_fake_editor () {
+	write_script fake-editor.sh <<-\EOF &&
+	echo "split-out commit" >"$1"
+	EOF
+	test_set_editor "$(pwd)"/fake-editor.sh
+}
+
+expect_log () {
+	git log --format="%s" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+expect_tree_entries () {
+	git ls-tree --name-only "$1" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history split HEAD 2>err &&
+		test_grep "commit to be split must not be a merge commit" err &&
+		test_must_fail git history split HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work with changes in the worktree or index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		echo changed >bar &&
+		test_must_fail git history split -m message HEAD 2>err <<-EOF &&
+		y
+		n
+		EOF
+		test_grep "Your local changes to the following files would be overwritten" err &&
+
+		git add bar &&
+		test_must_fail git history split -m message HEAD 2>err <<-EOF &&
+		y
+		n
+		EOF
+		test_grep "Your local changes to the following files would be overwritten" err
+	)
+'
+
+test_expect_success 'can split up tip commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git symbolic-ref HEAD >expect &&
+		set_fake_editor &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		initial
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m root &&
+		test_commit tip &&
+
+		set_fake_editor &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		root
+		split-out commit
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up in-between commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+		test_commit tip &&
+
+		set_fake_editor &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		split-me
+		split-out commit
+		initial
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can pick multiple hunks' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar baz foo qux &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "split-out commit" <<-EOF &&
+		y
+		n
+		y
+		n
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		baz
+		foo
+		qux
+		EOF
+	)
+'
+
+
+test_expect_success 'can use only last hunk' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "split-out commit" <<-EOF &&
+		n
+		y
+		EOF
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD -m "" <<-EOF 2>err &&
+		y
+		n
+		EOF
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_expect_success 'can specify message via option' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "message option" <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF
+		split-me
+		message option
+		EOF
+	)
+'
+
+test_expect_success 'commit message editor sees split-out changes' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		echo "some commit message" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		cat >expect <<-EOF &&
+
+		# Please enter the commit message for the split-out changes. Lines starting
+		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
+		# Changes to be committed:
+		#	new file:   bar
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		expect_log <<-EOF
+		split-me
+		some commit message
+		EOF
+	)
+'
+
+test_expect_success 'skips change summary with commit.verbose=false' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		echo "some commit message" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+
+		git -c commit.verbose=false history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		cat >expect <<-EOF &&
+
+		# Please enter the commit message for the split-out changes. Lines starting
+		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		expect_log <<-EOF
+		split-me
+		some commit message
+		EOF
+	)
+'
+
+test_expect_success 'can use pathspec to limit what gets split' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "message option" -- foo <<-EOF &&
+		y
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'refuses to create empty split-out commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		n
+		n
+		EOF
+		test_grep "split commit is empty" err
+	)
+'
+
+test_expect_success 'hooks are executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+		old_head=$(git rev-parse HEAD) &&
+
+		write_script .git/hooks/prepare-commit-msg <<-EOF &&
+		echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-commit <<-EOF &&
+		echo "post-commit" >>"$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-rewrite <<-EOF &&
+		{
+			echo "post-rewrite: \$@"
+			cat
+		} >>"$(pwd)/hooks.log"
+		EOF
+
+		set_fake_editor &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		EOF
+
+		cat >expect <<-EOF &&
+		prepare-commit-msg: .git/COMMIT_EDITMSG message
+		post-commit
+		prepare-commit-msg: .git/COMMIT_EDITMSG message
+		post-commit
+		post-rewrite: history
+		$old_head $(git rev-parse HEAD~)
+		$old_head $(git rev-parse HEAD)
+		EOF
+		test_cmp expect hooks.log
+	)
+'
+
+test_expect_success 'refuses to create empty original commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		y
+		y
+		EOF
+		test_grep "split commit tree matches original commit" err
+	)
+'
+
+test_done
-- 
2.51.0.417.g1ba7204a04.dirty
Phillip Wood· Sep 10, 2025, 14:04 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v3 17/18] builtin/history: implement "split" subcommand

Hi Patrick
On 04/09/2025 15:27, Patrick Steinhardt wrote:
Show 28 quoted lines
> It is quite a common use case that one wants to split up one commit into
> multiple commits by moving parts of the changes of the original commit
> out into a separate commit. This is quite an involved operation though:
> 
>    1. Identify the commit in question that is to be dropped.
> 
>    2. Perform an interactive rebase on top of that commit's parent.
> 
>    3. Modify the instruction sheet to "edit" the commit that is to be
>       split up.
> 
>    4. Drop the commit via "git reset HEAD~".
> 
>    5. Stage changes that should go into the first commit and commit it.
> 
>    6. Stage changes that should go into the second commit and commit it.
> 
>    7. Finalize the rebase.
> 
> This is quite complex, and overall I would claim that most people who
> are not experts in Git would struggle with this flow.
> 
> Introduce a new "split" subcommand for git-history(1) to make this way
> easier. All the user needs to do is to say `git history split $COMMIT`.
>  From hereon, Git asks the user which parts of the commit shall be moved
> out into a separate commit and, once done, asks the user for the commit
> message. Git then creates that split-out commit and applies the original
> commit on top of it.

I like the idea of this command, but I think it would be much better to prompt the user to edit the orginal message after creating each new commit rather than asking them to write a new message for the first commit that we create and then not letting them edit the message for the second commit. We've got no way of knowing how they are splitting the commit - they could be keeping most of the canges from the orginial in the first commit in which case they probably want something simiar to the orginial commit message for that one, or, they could be spitting out something which means they need to edit the message when creating the second commit.

If this was implemented in the sequencer then we'd be able to reuse the existing code for creating commits and editing commit messages. It would also make the "split" command available to "rebase -i".

Thanks
Phillip
Show 964 quoted lines
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>   Documentation/git-history.adoc |  60 ++++++
>   builtin/history.c              | 313 ++++++++++++++++++++++++++-
>   t/meson.build                  |   1 +
>   t/t3453-history-split.sh       | 468 +++++++++++++++++++++++++++++++++++++++++
>   4 files changed, 840 insertions(+), 2 deletions(-)
> 
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> index b36cd925dd..6f0c64b90e 100644
> --- a/Documentation/git-history.adoc
> +++ b/Documentation/git-history.adoc
> @@ -13,6 +13,7 @@ git history continue
>   git history quit
>   git history drop <commit>
>   git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
> +git history split [<options>] <commit> [--] [<pathspec>...]
>   
>   DESCRIPTION
>   -----------
> @@ -52,6 +53,26 @@ child commits, as that would lead to an empty branch.
>   	be related to one another and must be reachable from the current `HEAD`
>   	commit.
>   
> +`split [--message=<message>] <commit> [--] [<pathspec>...]`::
> +	Interactively split up <commit> into two commits by choosing
> +	hunks introduced by it that will be moved into the new split-out
> +	commit. These hunks will then be written into a new commit that
> +	becomes the parent of the previous commit. The original commit
> +	stays intact, except that its parent will be the newly split-out
> +	commit.
> ++
> +The commit message of the new commit will be asked for by launching the
> +configured editor, unless it has been specified with the `-m` option.
> +Authorship of the commit will be the same as for the original commit.
> ++
> +If passed, _<pathspec>_ can be used to limit which changes shall be split out
> +of the original commit. Files not matching any of the pathspecs will remain
> +part of the original commit. For more details, see the 'pathspec' entry in
> +linkgit:gitglossary[7].
> ++
> +It is invalid to select either all or no hunks, as that would lead to
> +one of the commits becoming empty.
> +
>   The following commands are used to manage an interrupted history-rewriting
>   operation:
>   
> @@ -111,6 +132,45 @@ f44a46e third
>   bf7438d first
>   ----------
>   
> +Split a commit
> +~~~~~~~~~~~~~~
> +
> +----------
> +$ git log --stat --oneline
> +3f81232 (HEAD -> main) original
> + bar | 1 +
> + foo | 1 +
> + 2 files changed, 2 insertions(+)
> +
> +$ git history split HEAD --message="split-out commit"
> +diff --git a/bar b/bar
> +new file mode 100644
> +index 0000000..5716ca5
> +--- /dev/null
> ++++ b/bar
> +@@ -0,0 +1 @@
> ++bar
> +(1/1) Stage addition [y,n,q,a,d,e,p,?]? y
> +
> +diff --git a/foo b/foo
> +new file mode 100644
> +index 0000000..257cc56
> +--- /dev/null
> ++++ b/foo
> +@@ -0,0 +1 @@
> ++foo
> +(1/1) Stage addition [y,n,q,a,d,e,p,?]? n
> +
> +$ git log --stat --oneline
> +7cebe64 (HEAD -> main) original
> + foo | 1 +
> + 1 file changed, 1 insertion(+)
> +d1582f3 split-out commit
> + bar | 1 +
> + 1 file changed, 1 insertion(+)
> +----------
> +
> +
>   CONFIGURATION
>   -------------
>   
> diff --git a/builtin/history.c b/builtin/history.c
> index 16b516856e..df04b8dfc6 100644
> --- a/builtin/history.c
> +++ b/builtin/history.c
> @@ -1,17 +1,27 @@
> +/* Required for `comment_line_str`. */
> +#define USE_THE_REPOSITORY_VARIABLE
> +
>   #include "builtin.h"
>   #include "branch.h"
> +#include "cache-tree.h"
>   #include "commit.h"
>   #include "commit-reach.h"
>   #include "config.h"
> +#include "editor.h"
>   #include "environment.h"
>   #include "gettext.h"
>   #include "hex.h"
>   #include "object-name.h"
>   #include "parse-options.h"
> +#include "path.h"
> +#include "pathspec.h"
> +#include "read-cache-ll.h"
>   #include "refs.h"
>   #include "reset.h"
>   #include "revision.h"
> +#include "run-command.h"
>   #include "sequencer.h"
> +#include "sparse-index.h"
>   
>   static int cmd_history_abort(int argc,
>   			     const char **argv,
> @@ -213,6 +223,7 @@ static int apply_commits(struct repository *repo,
>   			 const struct strvec *commits,
>   			 struct commit *head,
>   			 struct commit *base,
> +			 const struct oidmap *rewritten_commits,
>   			 const char *action)
>   {
>   	struct setup_revision_opt revision_opts = {
> @@ -238,6 +249,7 @@ static int apply_commits(struct repository *repo,
>   		replay_opts.strategy = replay_opts.default_strategy;
>   		replay_opts.default_strategy = NULL;
>   	}
> +	replay_opts.old_oid_mappings = rewritten_commits;
>   
>   	strvec_push(&args, "");
>   	strvec_pushv(&args, commits->v);
> @@ -403,7 +415,8 @@ static int cmd_history_drop(int argc,
>   		if (ret < 0)
>   			goto out;
>   
> -		ret = apply_commits(repo, &commits, head, commit_to_drop, "drop");
> +		ret = apply_commits(repo, &commits, head, commit_to_drop,
> +				    NULL, "drop");
>   		if (ret < 0)
>   			goto out;
>   	}
> @@ -505,7 +518,7 @@ static int cmd_history_reorder(int argc,
>   	replace_commits(&commits, &commit_to_reorder->object.oid, NULL, 0);
>   	replace_commits(&commits, &anchor->object.oid, replacement, ARRAY_SIZE(replacement));
>   
> -	ret = apply_commits(repo, &commits, head, old, "reorder");
> +	ret = apply_commits(repo, &commits, head, old, NULL, "reorder");
>   	if (ret < 0)
>   		goto out;
>   
> @@ -517,6 +530,300 @@ static int cmd_history_reorder(int argc,
>   	return ret;
>   }
>   
> +static void change_data_free(void *util, const char *str UNUSED)
> +{
> +	struct wt_status_change_data *d = util;
> +	free(d->rename_source);
> +	free(d);
> +}
> +
> +static int fill_commit_message(struct repository *repo,
> +			       const struct object_id *old_tree,
> +			       const struct object_id *new_tree,
> +			       const char *default_message,
> +			       const char *provided_message,
> +			       const char *action,
> +			       struct strbuf *out)
> +{
> +	if (!provided_message) {
> +		const char *path = git_path_commit_editmsg();
> +		const char *hint =
> +			_("Please enter the commit message for the %s changes. Lines starting\n"
> +			  "with '%s' will be kept; you may remove them yourself if you want to.\n");
> +		int verbose = 1;
> +
> +		strbuf_addstr(out, default_message);
> +		strbuf_addch(out, '\n');
> +		strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
> +		write_file_buf(path, out->buf, out->len);
> +
> +		repo_config_get_bool(repo, "commit.verbose", &verbose);
> +		if (verbose) {
> +			struct wt_status s;
> +
> +			wt_status_prepare(repo, &s);
> +			FREE_AND_NULL(s.branch);
> +			s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
> +			s.commit_template = 1;
> +			s.colopts = 0;
> +			s.display_comment_prefix = 1;
> +			s.hints = 0;
> +			s.use_color = 0;
> +			s.whence = FROM_COMMIT;
> +			s.committable = 1;
> +
> +			s.fp = fopen(git_path_commit_editmsg(), "a");
> +			if (!s.fp)
> +				return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
> +
> +			wt_status_collect_changes_trees(&s, old_tree, new_tree);
> +			wt_status_print(&s);
> +			wt_status_collect_free_buffers(&s);
> +			string_list_clear_func(&s.change, change_data_free);
> +		}
> +
> +		strbuf_reset(out);
> +		if (launch_editor(path, out, NULL)) {
> +			fprintf(stderr, _("Please supply the message using the -m option.\n"));
> +			return -1;
> +		}
> +		strbuf_stripspace(out, comment_line_str);
> +	} else {
> +		strbuf_addstr(out, provided_message);
> +	}
> +
> +	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
> +
> +	if (!out->len) {
> +		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
> +		return -1;
> +	}
> +
> +	return 0;
> +}
> +
> +static int split_commit(struct repository *repo,
> +			struct commit *original_commit,
> +			struct pathspec *pathspec,
> +			const char *commit_message,
> +			struct object_id *out)
> +{
> +	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
> +	struct strbuf index_file = STRBUF_INIT, split_message = STRBUF_INIT;
> +	struct child_process read_tree_cmd = CHILD_PROCESS_INIT;
> +	struct index_state index = INDEX_STATE_INIT(repo);
> +	struct object_id original_commit_tree_oid, parent_tree_oid;
> +	const char *original_message, *original_body, *ptr;
> +	char original_commit_oid[GIT_MAX_HEXSZ + 1];
> +	char *original_author = NULL;
> +	struct commit_list *parents = NULL;
> +	struct commit *first_commit;
> +	struct tree *split_tree;
> +	size_t len;
> +	int ret;
> +
> +	if (original_commit->parents)
> +		parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
> +	else
> +		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
> +	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
> +
> +	/*
> +	 * Construct the first commit. This is done by taking the original
> +	 * commit parent's tree and selectively patching changes from the diff
> +	 * between that parent and its child.
> +	 */
> +	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
> +
> +	read_tree_cmd.git_cmd = 1;
> +	strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
> +	strvec_push(&read_tree_cmd.args, "read-tree");
> +	strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
> +	ret = run_command(&read_tree_cmd);
> +	if (ret < 0)
> +		goto out;
> +
> +	ret = read_index_from(&index, index_file.buf, repo->gitdir);
> +	if (ret < 0) {
> +		ret = error(_("failed reading temporary index"));
> +		goto out;
> +	}
> +
> +	oid_to_hex_r(original_commit_oid, &original_commit->object.oid);
> +	ret = run_add_p_index(repo, &index, index_file.buf, &interactive_opts,
> +			      original_commit_oid, pathspec);
> +	if (ret < 0)
> +		goto out;
> +
> +	split_tree = write_in_core_index_as_tree(repo, &index);
> +	if (!split_tree) {
> +		ret = error(_("failed split tree"));
> +		goto out;
> +	}
> +
> +	unlink(index_file.buf);
> +
> +	/*
> +	 * We disallow the cases where either the split-out commit or the
> +	 * original commit would become empty. Consequently, if we see that the
> +	 * new tree ID matches either of those trees we abort.
> +	 */
> +	if (oideq(&split_tree->object.oid, &parent_tree_oid)) {
> +		ret = error(_("split commit is empty"));
> +		goto out;
> +	} else if (oideq(&split_tree->object.oid, &original_commit_tree_oid)) {
> +		ret = error(_("split commit tree matches original commit"));
> +		goto out;
> +	}
> +
> +	/* We retain authorship of the original commit. */
> +	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
> +	ptr = find_commit_header(original_message, "author", &len);
> +	if (ptr)
> +		original_author = xmemdupz(ptr, len);
> +
> +	ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
> +				  "", commit_message, "split-out", &split_message);
> +	if (ret < 0)
> +		goto out;
> +
> +	ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
> +			  original_commit->parents, &out[0], original_author, NULL);
> +	if (ret < 0) {
> +		ret = error(_("failed writing split-out commit"));
> +		goto out;
> +	}
> +
> +	/*
> +	 * The second commit is much simpler to construct, as we can simply use
> +	 * the original commit details, except that we adjust its parent to be
> +	 * the newly split-out commit.
> +	 */
> +	find_commit_subject(original_message, &original_body);
> +	first_commit = lookup_commit_reference(repo, &out[0]);
> +	commit_list_append(first_commit, &parents);
> +
> +	ret = commit_tree(original_body, strlen(original_body), &original_commit_tree_oid,
> +			  parents, &out[1], original_author, NULL);
> +	if (ret < 0) {
> +		ret = error(_("failed writing second commit"));
> +		goto out;
> +	}
> +
> +	ret = 0;
> +
> +out:
> +	if (index_file.len)
> +		unlink(index_file.buf);
> +	strbuf_release(&split_message);
> +	strbuf_release(&index_file);
> +	free_commit_list(parents);
> +	free(original_author);
> +	release_index(&index);
> +	return ret;
> +}
> +
> +static int cmd_history_split(int argc,
> +			     const char **argv,
> +			     const char *prefix,
> +			     struct repository *repo)
> +{
> +	const char * const usage[] = {
> +		N_("git history split [<options>] <commit>"),
> +		NULL,
> +	};
> +	const char *commit_message = NULL;
> +	struct option options[] = {
> +		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
> +		OPT_END(),
> +	};
> +	struct oidmap rewritten_commits = OIDMAP_INIT;
> +	struct commit *original_commit, *head;
> +	struct strvec commits = STRVEC_INIT;
> +	struct commit_list *list = NULL;
> +	struct object_id split_commits[2];
> +	struct replay_oid_mapping mapping[2] = { 0 };
> +	struct pathspec pathspec = { 0 };
> +	int ret;
> +
> +	argc = parse_options(argc, argv, prefix, options, usage, 0);
> +	if (argc < 1) {
> +		ret = error(_("command expects a revision"));
> +		goto out;
> +	}
> +	repo_config(repo, git_default_config, NULL);
> +
> +	original_commit = lookup_commit_reference_by_name(argv[0]);
> +	if (!original_commit) {
> +		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
> +		goto out;
> +	}
> +
> +	if (original_commit->parents && original_commit->parents->next) {
> +		ret = error(_("commit to be split must not be a merge commit"));
> +		goto out;
> +	}
> +
> +	head = lookup_commit_reference_by_name("HEAD");
> +	if (!head) {
> +		ret = error(_("could not resolve HEAD to a commit"));
> +		goto out;
> +	}
> +
> +	commit_list_append(original_commit, &list);
> +	if (!repo_is_descendant_of(repo, original_commit, list)) {
> +		ret = error (_("split commit must be reachable from current HEAD commit"));
> +		goto out;
> +	}
> +
> +	parse_pathspec(&pathspec, 0,
> +		       PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
> +		       prefix, argv + 1);
> +
> +	/*
> +	 * Collect the list of commits that we'll have to reapply now already.
> +	 * This ensures that we'll abort early on in case the range of commits
> +	 * contains merges, which we do not yet handle.
> +	 */
> +	ret = collect_commits(repo, original_commit->parents ? original_commit->parents->item : NULL,
> +			      head, &commits);
> +	if (ret < 0)
> +		goto out;
> +
> +	/*
> +	 * Then we split up the commit and replace the original commit with the
> +	 * new new ones.
> +	 */
> +	ret = split_commit(repo, original_commit, &pathspec,
> +			   commit_message, split_commits);
> +	if (ret < 0)
> +		goto out;
> +
> +	mapping[0].entry.oid = split_commits[0];
> +	mapping[0].rewritten_oid = original_commit->object.oid;
> +	oidmap_put(&rewritten_commits, &mapping[0]);
> +	mapping[1].entry.oid = split_commits[1];
> +	mapping[1].rewritten_oid = original_commit->object.oid;
> +	oidmap_put(&rewritten_commits, &mapping[1]);
> +
> +	replace_commits(&commits, &original_commit->object.oid,
> +			split_commits, ARRAY_SIZE(split_commits));
> +
> +	ret = apply_commits(repo, &commits, head, original_commit,
> +			    &rewritten_commits, "split");
> +	if (ret < 0)
> +		goto out;
> +
> +	ret = 0;
> +
> +out:
> +	oidmap_clear(&rewritten_commits, 0);
> +	clear_pathspec(&pathspec);
> +	strvec_clear(&commits);
> +	free_commit_list(list);
> +	return ret;
> +}
> +
>   int cmd_history(int argc,
>   		const char **argv,
>   		const char *prefix,
> @@ -528,6 +835,7 @@ int cmd_history(int argc,
>   		N_("git history quit"),
>   		N_("git history drop <commit>"),
>   		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
> +		N_("git history split [<options>] <commit> [--] [<pathspec>...]"),
>   		NULL,
>   	};
>   	parse_opt_subcommand_fn *fn = NULL;
> @@ -537,6 +845,7 @@ int cmd_history(int argc,
>   		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
>   		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
>   		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
> +		OPT_SUBCOMMAND("split", &fn, cmd_history_split),
>   		OPT_END(),
>   	};
>   
> diff --git a/t/meson.build b/t/meson.build
> index 2bf7bcab5a..b3d33c8588 100644
> --- a/t/meson.build
> +++ b/t/meson.build
> @@ -379,6 +379,7 @@ integration_tests = [
>     't3450-history.sh',
>     't3451-history-drop.sh',
>     't3452-history-reorder.sh',
> +  't3453-history-split.sh',
>     't3500-cherry.sh',
>     't3501-revert-cherry-pick.sh',
>     't3502-cherry-pick-merge.sh',
> diff --git a/t/t3453-history-split.sh b/t/t3453-history-split.sh
> new file mode 100755
> index 0000000000..a6a652e7df
> --- /dev/null
> +++ b/t/t3453-history-split.sh
> @@ -0,0 +1,468 @@
> +#!/bin/sh
> +
> +test_description='tests for git-history split subcommand'
> +
> +. ./test-lib.sh
> +
> +set_fake_editor () {
> +	write_script fake-editor.sh <<-\EOF &&
> +	echo "split-out commit" >"$1"
> +	EOF
> +	test_set_editor "$(pwd)"/fake-editor.sh
> +}
> +
> +expect_log () {
> +	git log --format="%s" >actual &&
> +	cat >expect &&
> +	test_cmp expect actual
> +}
> +
> +expect_tree_entries () {
> +	git ls-tree --name-only "$1" >actual &&
> +	cat >expect &&
> +	test_cmp expect actual
> +}
> +
> +test_expect_success 'refuses to work with merge commits' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit base &&
> +		git branch branch &&
> +		test_commit ours &&
> +		git switch branch &&
> +		test_commit theirs &&
> +		git switch - &&
> +		git merge theirs &&
> +		test_must_fail git history split HEAD 2>err &&
> +		test_grep "commit to be split must not be a merge commit" err &&
> +		test_must_fail git history split HEAD~ 2>err &&
> +		test_grep "cannot rearrange commit history with merges" err
> +	)
> +'
> +
> +test_expect_success 'refuses to work with changes in the worktree or index' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit initial &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		echo changed >bar &&
> +		test_must_fail git history split -m message HEAD 2>err <<-EOF &&
> +		y
> +		n
> +		EOF
> +		test_grep "Your local changes to the following files would be overwritten" err &&
> +
> +		git add bar &&
> +		test_must_fail git history split -m message HEAD 2>err <<-EOF &&
> +		y
> +		n
> +		EOF
> +		test_grep "Your local changes to the following files would be overwritten" err
> +	)
> +'
> +
> +test_expect_success 'can split up tip commit' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit initial &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		git symbolic-ref HEAD >expect &&
> +		set_fake_editor &&
> +		git history split HEAD <<-EOF &&
> +		y
> +		n
> +		EOF
> +		git symbolic-ref HEAD >actual &&
> +		test_cmp expect actual &&
> +
> +		expect_log <<-EOF &&
> +		split-me
> +		split-out commit
> +		initial
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		bar
> +		initial.t
> +		EOF
> +
> +		expect_tree_entries HEAD <<-EOF
> +		bar
> +		foo
> +		initial.t
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'can split up root commit' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m root &&
> +		test_commit tip &&
> +
> +		set_fake_editor &&
> +		git history split HEAD~ <<-EOF &&
> +		y
> +		n
> +		EOF
> +
> +		expect_log <<-EOF &&
> +		tip
> +		root
> +		split-out commit
> +		EOF
> +
> +		expect_tree_entries HEAD~2 <<-EOF &&
> +		bar
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		bar
> +		foo
> +		EOF
> +
> +		expect_tree_entries HEAD <<-EOF
> +		bar
> +		foo
> +		tip.t
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'can split up in-between commit' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit initial &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +		test_commit tip &&
> +
> +		set_fake_editor &&
> +		git history split HEAD~ <<-EOF &&
> +		y
> +		n
> +		EOF
> +
> +		expect_log <<-EOF &&
> +		tip
> +		split-me
> +		split-out commit
> +		initial
> +		EOF
> +
> +		expect_tree_entries HEAD~2 <<-EOF &&
> +		bar
> +		initial.t
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		bar
> +		foo
> +		initial.t
> +		EOF
> +
> +		expect_tree_entries HEAD <<-EOF
> +		bar
> +		foo
> +		initial.t
> +		tip.t
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'can pick multiple hunks' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar baz foo qux &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		git history split HEAD -m "split-out commit" <<-EOF &&
> +		y
> +		n
> +		y
> +		n
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		bar
> +		foo
> +		EOF
> +
> +		expect_tree_entries HEAD <<-EOF
> +		bar
> +		baz
> +		foo
> +		qux
> +		EOF
> +	)
> +'
> +
> +
> +test_expect_success 'can use only last hunk' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		git history split HEAD -m "split-out commit" <<-EOF &&
> +		n
> +		y
> +		EOF
> +
> +		expect_log <<-EOF &&
> +		split-me
> +		split-out commit
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		foo
> +		EOF
> +
> +		expect_tree_entries HEAD <<-EOF
> +		bar
> +		foo
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'aborts with empty commit message' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		test_must_fail git history split HEAD -m "" <<-EOF 2>err &&
> +		y
> +		n
> +		EOF
> +		test_grep "Aborting commit due to empty commit message." err
> +	)
> +'
> +
> +test_expect_success 'can specify message via option' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		git history split HEAD -m "message option" <<-EOF &&
> +		y
> +		n
> +		EOF
> +
> +		expect_log <<-EOF
> +		split-me
> +		message option
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'commit message editor sees split-out changes' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		write_script fake-editor.sh <<-\EOF &&
> +		cp "$1" . &&
> +		echo "some commit message" >>"$1"
> +		EOF
> +		test_set_editor "$(pwd)"/fake-editor.sh &&
> +
> +		git history split HEAD <<-EOF &&
> +		y
> +		n
> +		EOF
> +
> +		cat >expect <<-EOF &&
> +
> +		# Please enter the commit message for the split-out changes. Lines starting
> +		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
> +		# Changes to be committed:
> +		#	new file:   bar
> +		#
> +		EOF
> +		test_cmp expect COMMIT_EDITMSG &&
> +
> +		expect_log <<-EOF
> +		split-me
> +		some commit message
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'skips change summary with commit.verbose=false' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		write_script fake-editor.sh <<-\EOF &&
> +		cp "$1" . &&
> +		echo "some commit message" >>"$1"
> +		EOF
> +		test_set_editor "$(pwd)"/fake-editor.sh &&
> +
> +		git -c commit.verbose=false history split HEAD <<-EOF &&
> +		y
> +		n
> +		EOF
> +
> +		cat >expect <<-EOF &&
> +
> +		# Please enter the commit message for the split-out changes. Lines starting
> +		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
> +		EOF
> +		test_cmp expect COMMIT_EDITMSG &&
> +
> +		expect_log <<-EOF
> +		split-me
> +		some commit message
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'can use pathspec to limit what gets split' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		git history split HEAD -m "message option" -- foo <<-EOF &&
> +		y
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		foo
> +		EOF
> +
> +		expect_tree_entries HEAD <<-EOF
> +		bar
> +		foo
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'refuses to create empty split-out commit' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit base &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		test_must_fail git history split HEAD 2>err <<-EOF &&
> +		n
> +		n
> +		EOF
> +		test_grep "split commit is empty" err
> +	)
> +'
> +
> +test_expect_success 'hooks are executed for rewritten commits' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +		old_head=$(git rev-parse HEAD) &&
> +
> +		write_script .git/hooks/prepare-commit-msg <<-EOF &&
> +		echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
> +		EOF
> +		write_script .git/hooks/post-commit <<-EOF &&
> +		echo "post-commit" >>"$(pwd)/hooks.log"
> +		EOF
> +		write_script .git/hooks/post-rewrite <<-EOF &&
> +		{
> +			echo "post-rewrite: \$@"
> +			cat
> +		} >>"$(pwd)/hooks.log"
> +		EOF
> +
> +		set_fake_editor &&
> +		git history split HEAD <<-EOF &&
> +		y
> +		n
> +		EOF
> +
> +		expect_log <<-EOF &&
> +		split-me
> +		split-out commit
> +		EOF
> +
> +		cat >expect <<-EOF &&
> +		prepare-commit-msg: .git/COMMIT_EDITMSG message
> +		post-commit
> +		prepare-commit-msg: .git/COMMIT_EDITMSG message
> +		post-commit
> +		post-rewrite: history
> +		$old_head $(git rev-parse HEAD~)
> +		$old_head $(git rev-parse HEAD)
> +		EOF
> +		test_cmp expect hooks.log
> +	)
> +'
> +
> +test_expect_success 'refuses to create empty original commit' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		test_must_fail git history split HEAD 2>err <<-EOF &&
> +		y
> +		y
> +		EOF
> +		test_grep "split commit tree matches original commit" err
> +	)
> +'
> +
> +test_done
> 
Patrick Steinhardt· Sep 15, 2025, 09:32 UTC · re: Phillip Wood · lore

Re: [PATCH RFC v3 17/18] builtin/history: implement "split" subcommand

On Wed, Sep 10, 2025 at 03:04:49PM +0100, Phillip Wood wrote:
Show 39 quoted lines
> On 04/09/2025 15:27, Patrick Steinhardt wrote:
> > It is quite a common use case that one wants to split up one commit into
> > multiple commits by moving parts of the changes of the original commit
> > out into a separate commit. This is quite an involved operation though:
> > 
> >    1. Identify the commit in question that is to be dropped.
> > 
> >    2. Perform an interactive rebase on top of that commit's parent.
> > 
> >    3. Modify the instruction sheet to "edit" the commit that is to be
> >       split up.
> > 
> >    4. Drop the commit via "git reset HEAD~".
> > 
> >    5. Stage changes that should go into the first commit and commit it.
> > 
> >    6. Stage changes that should go into the second commit and commit it.
> > 
> >    7. Finalize the rebase.
> > 
> > This is quite complex, and overall I would claim that most people who
> > are not experts in Git would struggle with this flow.
> > 
> > Introduce a new "split" subcommand for git-history(1) to make this way
> > easier. All the user needs to do is to say `git history split $COMMIT`.
> >  From hereon, Git asks the user which parts of the commit shall be moved
> > out into a separate commit and, once done, asks the user for the commit
> > message. Git then creates that split-out commit and applies the original
> > commit on top of it.
> 
> I like the idea of this command, but I think it would be much better to
> prompt the user to edit the orginal message after creating each new commit
> rather than asking them to write a new message for the first commit that we
> create and then not letting them edit the message for the second commit.
> We've got no way of knowing how they are splitting the commit - they could
> be keeping most of the canges from the orginial in the first commit in which
> case they probably want something simiar to the orginial commit message for
> that one, or, they could be spitting out something which means they need to
> edit the message when creating the second commit.
Yeah, Junio already said the same. I can do that.
> If this was implemented in the sequencer then we'd be able to reuse the
> existing code for creating commits and editing commit messages. It would
> also make the "split" command available to "rebase -i".
True, having it available in "rebase -i" would be nice indeed.
Patrick
Patrick Steinhardt· Sep 4, 2025, 14:27 UTC · re: Patrick Steinhardt · lore

[PATCH RFC v3 18/18] builtin/history: implement "reword" subcommand

Implement a new "reword" subcommand for git-history(1). This subcommand is essentially the same as if a user performed an interactive rebase with a single commit changed to use the "reword" verb.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |   5 +
 builtin/history.c              | 104 +++++++++++++++++++++
 t/meson.build                  |   1 +
 t/t3454-history-reword.sh      | 202 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 312 insertions(+)
Show changes to 4 files +312 −0

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3454-history-reword.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 6f0c64b90e..cbbcef3582 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -13,6 +13,7 @@ git history continue
 git history quit
 git history drop <commit>
 git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
+git history reword [<options>] <commit>
 git history split [<options>] <commit> [--] [<pathspec>...]
 
 DESCRIPTION
@@ -53,6 +54,10 @@ child commits, as that would lead to an empty branch.
 	be related to one another and must be reachable from the current `HEAD`
 	commit.
 
+`reword <commit> [--message=<message>]`::
+	Rewrite the commit message of the specified commit. All the other
+	details of this commit remain unchanged.
+
 `split [--message=<message>] <commit> [--] [<pathspec>...]`::
 	Interactively split up <commit> into two commits by choosing
 	hunks introduced by it that will be moved into the new split-out
diff --git a/builtin/history.c b/builtin/history.c
index df04b8dfc6..39acf4df28 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -723,6 +723,108 @@ static int split_commit(struct repository *repo,
 	return ret;
 }
 
+static int cmd_history_reword(int argc,
+			      const char **argv,
+			      const char *prefix,
+			      struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history reword [<options>] <commit>"),
+		NULL,
+	};
+	const char *commit_message = NULL;
+	struct option options[] = {
+		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
+		OPT_END(),
+	};
+	struct strbuf final_message = STRBUF_INIT;
+	struct commit *original_commit, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct object_id parent_tree_oid, original_commit_tree_oid;
+	struct object_id rewritten_commit;
+	const char *original_message, *original_body, *ptr;
+	struct oidmap rewritten_commits = OIDMAP_INIT;
+	struct replay_oid_mapping mapping = { 0 };
+	char *original_author = NULL;
+	size_t len;
+	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);
+
+	original_commit = lookup_commit_reference_by_name(argv[0]);
+	if (!original_commit) {
+		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
+		goto out;
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	/*
+	 * Collect the list of commits that we'll have to reapply now already.
+	 * This ensures that we'll abort early on in case the range of commits
+	 * contains merges, which we do not yet handle.
+	 */
+	ret = collect_commits(repo, original_commit->parents ? original_commit->parents->item : NULL,
+			      head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+	find_commit_subject(original_message, &original_body);
+
+	if (original_commit->parents)
+		parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
+	else
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
+
+	ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
+				  original_body, commit_message, "reworded", &final_message);
+	if (ret < 0)
+		goto out;
+
+	ret = commit_tree(final_message.buf, final_message.len,
+			  &repo_get_commit_tree(repo, original_commit)->object.oid,
+			  original_commit->parents, &rewritten_commit, original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing reworded commit"));
+		goto out;
+	}
+
+	replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
+
+	mapping.entry.oid = rewritten_commit;
+	mapping.rewritten_oid = original_commit->object.oid;
+	oidmap_put(&rewritten_commits, &mapping);
+
+	ret = apply_commits(repo, &commits, head, original_commit,
+			    &rewritten_commits, "reword");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	oidmap_clear(&rewritten_commits, 0);
+	strbuf_release(&final_message);
+	strvec_clear(&commits);
+	free(original_author);
+	return ret;
+}
+
 static int cmd_history_split(int argc,
 			     const char **argv,
 			     const char *prefix,
@@ -835,6 +937,7 @@ int cmd_history(int argc,
 		N_("git history quit"),
 		N_("git history drop <commit>"),
 		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
+		N_("git history reword [<options>] <commit>"),
 		N_("git history split [<options>] <commit> [--] [<pathspec>...]"),
 		NULL,
 	};
@@ -845,6 +948,7 @@ int cmd_history(int argc,
 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
 		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
 		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
+		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 b3d33c8588..948223f453 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -380,6 +380,7 @@ integration_tests = [
   't3451-history-drop.sh',
   't3452-history-reorder.sh',
   't3453-history-split.sh',
+  't3454-history-reword.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3454-history-reword.sh b/t/t3454-history-reword.sh
new file mode 100755
index 0000000000..97bdd755fa
--- /dev/null
+++ b/t/t3454-history-reword.sh
@@ -0,0 +1,202 @@
+#!/bin/sh
+
+test_description='tests for git-history reword subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history reword HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err &&
+		test_must_fail git history reword HEAD 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work with changes in the worktree or index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base file &&
+		echo foo >file &&
+		test_must_fail git history reword HEAD 2>err &&
+		test_grep "Your local changes to the following files would be overwritten" err &&
+		git add file &&
+		test_must_fail git history reword HEAD 2>err &&
+		test_grep "Your local changes to the following files would be overwritten" err
+	)
+'
+
+test_expect_success 'can reword tip of a branch' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		git history reword -m "third reworded" HEAD &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		third reworded
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can reword commit in the middle' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		git history reword -m "second reworded" HEAD~ &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can reword root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history reword -m "first reworded" HEAD~2 &&
+
+		cat >expect <<-EOF &&
+		third
+		second
+		first reworded
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can use editor to rewrite commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		printf "\namend a comment\n" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+		git history reword HEAD &&
+
+		cat >expect <<-EOF &&
+		first
+
+		# Please enter the commit message for the reworded changes. Lines starting
+		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
+		# Changes to be committed:
+		#	new file:   first.t
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		cat >expect <<-EOF &&
+		first
+
+		amend a comment
+
+		EOF
+		git log --format=%B >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'hooks are executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		write_script .git/hooks/prepare-commit-msg <<-EOF &&
+		echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-commit <<-EOF &&
+		echo "post-commit" >>"$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-rewrite <<-EOF &&
+		{
+			echo "post-rewrite: \$@"
+			cat
+		} >>"$(pwd)/hooks.log"
+		EOF
+
+		git history reword -m "second reworded" HEAD~ &&
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		prepare-commit-msg: .git/COMMIT_EDITMSG message
+		post-commit
+		prepare-commit-msg: .git/COMMIT_EDITMSG message
+		post-commit
+		post-rewrite: history
+		$(git rev-parse second) $(git rev-parse HEAD~)
+		$(git rev-parse third) $(git rev-parse HEAD)
+		EOF
+		test_cmp expect hooks.log
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		test_must_fail git history reword -m "" HEAD 2>err &&
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_done
-- 
2.51.0.417.g1ba7204a04.dirty
Phillip Wood· Sep 10, 2025, 14:05 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v3 18/18] builtin/history: implement "reword" subcommand

Hi Patrick
On 04/09/2025 15:27, Patrick Steinhardt wrote:
> Implement a new "reword" subcommand for git-history(1). This subcommand
> is essentially the same as if a user performed an interactive rebase
> with a single commit changed to use the "reword" verb.

The sequencer already knows how to reword a commit, it would be much simpler to reuse that code.

Thanks
Phillip
Show 382 quoted lines
> 
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>   Documentation/git-history.adoc |   5 +
>   builtin/history.c              | 104 +++++++++++++++++++++
>   t/meson.build                  |   1 +
>   t/t3454-history-reword.sh      | 202 +++++++++++++++++++++++++++++++++++++++++
>   4 files changed, 312 insertions(+)
> 
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> index 6f0c64b90e..cbbcef3582 100644
> --- a/Documentation/git-history.adoc
> +++ b/Documentation/git-history.adoc
> @@ -13,6 +13,7 @@ git history continue
>   git history quit
>   git history drop <commit>
>   git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
> +git history reword [<options>] <commit>
>   git history split [<options>] <commit> [--] [<pathspec>...]
>   
>   DESCRIPTION
> @@ -53,6 +54,10 @@ child commits, as that would lead to an empty branch.
>   	be related to one another and must be reachable from the current `HEAD`
>   	commit.
>   
> +`reword <commit> [--message=<message>]`::
> +	Rewrite the commit message of the specified commit. All the other
> +	details of this commit remain unchanged.
> +
>   `split [--message=<message>] <commit> [--] [<pathspec>...]`::
>   	Interactively split up <commit> into two commits by choosing
>   	hunks introduced by it that will be moved into the new split-out
> diff --git a/builtin/history.c b/builtin/history.c
> index df04b8dfc6..39acf4df28 100644
> --- a/builtin/history.c
> +++ b/builtin/history.c
> @@ -723,6 +723,108 @@ static int split_commit(struct repository *repo,
>   	return ret;
>   }
>   
> +static int cmd_history_reword(int argc,
> +			      const char **argv,
> +			      const char *prefix,
> +			      struct repository *repo)
> +{
> +	const char * const usage[] = {
> +		N_("git history reword [<options>] <commit>"),
> +		NULL,
> +	};
> +	const char *commit_message = NULL;
> +	struct option options[] = {
> +		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
> +		OPT_END(),
> +	};
> +	struct strbuf final_message = STRBUF_INIT;
> +	struct commit *original_commit, *head;
> +	struct strvec commits = STRVEC_INIT;
> +	struct object_id parent_tree_oid, original_commit_tree_oid;
> +	struct object_id rewritten_commit;
> +	const char *original_message, *original_body, *ptr;
> +	struct oidmap rewritten_commits = OIDMAP_INIT;
> +	struct replay_oid_mapping mapping = { 0 };
> +	char *original_author = NULL;
> +	size_t len;
> +	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);
> +
> +	original_commit = lookup_commit_reference_by_name(argv[0]);
> +	if (!original_commit) {
> +		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
> +		goto out;
> +	}
> +
> +	head = lookup_commit_reference_by_name("HEAD");
> +	if (!head) {
> +		ret = error(_("could not resolve HEAD to a commit"));
> +		goto out;
> +	}
> +
> +	/*
> +	 * Collect the list of commits that we'll have to reapply now already.
> +	 * This ensures that we'll abort early on in case the range of commits
> +	 * contains merges, which we do not yet handle.
> +	 */
> +	ret = collect_commits(repo, original_commit->parents ? original_commit->parents->item : NULL,
> +			      head, &commits);
> +	if (ret < 0)
> +		goto out;
> +
> +	/* We retain authorship of the original commit. */
> +	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
> +	ptr = find_commit_header(original_message, "author", &len);
> +	if (ptr)
> +		original_author = xmemdupz(ptr, len);
> +	find_commit_subject(original_message, &original_body);
> +
> +	if (original_commit->parents)
> +		parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
> +	else
> +		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
> +	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
> +
> +	ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
> +				  original_body, commit_message, "reworded", &final_message);
> +	if (ret < 0)
> +		goto out;
> +
> +	ret = commit_tree(final_message.buf, final_message.len,
> +			  &repo_get_commit_tree(repo, original_commit)->object.oid,
> +			  original_commit->parents, &rewritten_commit, original_author, NULL);
> +	if (ret < 0) {
> +		ret = error(_("failed writing reworded commit"));
> +		goto out;
> +	}
> +
> +	replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
> +
> +	mapping.entry.oid = rewritten_commit;
> +	mapping.rewritten_oid = original_commit->object.oid;
> +	oidmap_put(&rewritten_commits, &mapping);
> +
> +	ret = apply_commits(repo, &commits, head, original_commit,
> +			    &rewritten_commits, "reword");
> +	if (ret < 0)
> +		goto out;
> +
> +	ret = 0;
> +
> +out:
> +	oidmap_clear(&rewritten_commits, 0);
> +	strbuf_release(&final_message);
> +	strvec_clear(&commits);
> +	free(original_author);
> +	return ret;
> +}
> +
>   static int cmd_history_split(int argc,
>   			     const char **argv,
>   			     const char *prefix,
> @@ -835,6 +937,7 @@ int cmd_history(int argc,
>   		N_("git history quit"),
>   		N_("git history drop <commit>"),
>   		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
> +		N_("git history reword [<options>] <commit>"),
>   		N_("git history split [<options>] <commit> [--] [<pathspec>...]"),
>   		NULL,
>   	};
> @@ -845,6 +948,7 @@ int cmd_history(int argc,
>   		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
>   		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
>   		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
> +		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 b3d33c8588..948223f453 100644
> --- a/t/meson.build
> +++ b/t/meson.build
> @@ -380,6 +380,7 @@ integration_tests = [
>     't3451-history-drop.sh',
>     't3452-history-reorder.sh',
>     't3453-history-split.sh',
> +  't3454-history-reword.sh',
>     't3500-cherry.sh',
>     't3501-revert-cherry-pick.sh',
>     't3502-cherry-pick-merge.sh',
> diff --git a/t/t3454-history-reword.sh b/t/t3454-history-reword.sh
> new file mode 100755
> index 0000000000..97bdd755fa
> --- /dev/null
> +++ b/t/t3454-history-reword.sh
> @@ -0,0 +1,202 @@
> +#!/bin/sh
> +
> +test_description='tests for git-history reword subcommand'
> +
> +. ./test-lib.sh
> +
> +test_expect_success 'refuses to work with merge commits' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit base &&
> +		git branch branch &&
> +		test_commit ours &&
> +		git switch branch &&
> +		test_commit theirs &&
> +		git switch - &&
> +		git merge theirs &&
> +		test_must_fail git history reword HEAD~ 2>err &&
> +		test_grep "cannot rearrange commit history with merges" err &&
> +		test_must_fail git history reword HEAD 2>err &&
> +		test_grep "cannot rearrange commit history with merges" err
> +	)
> +'
> +
> +test_expect_success 'refuses to work with changes in the worktree or index' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit base file &&
> +		echo foo >file &&
> +		test_must_fail git history reword HEAD 2>err &&
> +		test_grep "Your local changes to the following files would be overwritten" err &&
> +		git add file &&
> +		test_must_fail git history reword HEAD 2>err &&
> +		test_grep "Your local changes to the following files would be overwritten" err
> +	)
> +'
> +
> +test_expect_success 'can reword tip of a branch' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit first &&
> +		test_commit second &&
> +		test_commit third &&
> +
> +		git symbolic-ref HEAD >expect &&
> +		git history reword -m "third reworded" HEAD &&
> +		git symbolic-ref HEAD >actual &&
> +		test_cmp expect actual &&
> +
> +		cat >expect <<-EOF &&
> +		third reworded
> +		second
> +		first
> +		EOF
> +		git log --format=%s >actual &&
> +		test_cmp expect actual
> +	)
> +'
> +
> +test_expect_success 'can reword commit in the middle' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit first &&
> +		test_commit second &&
> +		test_commit third &&
> +
> +		git symbolic-ref HEAD >expect &&
> +		git history reword -m "second reworded" HEAD~ &&
> +		git symbolic-ref HEAD >actual &&
> +		test_cmp expect actual &&
> +
> +		cat >expect <<-EOF &&
> +		third
> +		second reworded
> +		first
> +		EOF
> +		git log --format=%s >actual &&
> +		test_cmp expect actual
> +	)
> +'
> +
> +test_expect_success 'can reword root commit' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit first &&
> +		test_commit second &&
> +		test_commit third &&
> +		git history reword -m "first reworded" HEAD~2 &&
> +
> +		cat >expect <<-EOF &&
> +		third
> +		second
> +		first reworded
> +		EOF
> +		git log --format=%s >actual &&
> +		test_cmp expect actual
> +	)
> +'
> +
> +test_expect_success 'can use editor to rewrite commit message' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit first &&
> +
> +		write_script fake-editor.sh <<-\EOF &&
> +		cp "$1" . &&
> +		printf "\namend a comment\n" >>"$1"
> +		EOF
> +		test_set_editor "$(pwd)"/fake-editor.sh &&
> +		git history reword HEAD &&
> +
> +		cat >expect <<-EOF &&
> +		first
> +
> +		# Please enter the commit message for the reworded changes. Lines starting
> +		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
> +		# Changes to be committed:
> +		#	new file:   first.t
> +		#
> +		EOF
> +		test_cmp expect COMMIT_EDITMSG &&
> +
> +		cat >expect <<-EOF &&
> +		first
> +
> +		amend a comment
> +
> +		EOF
> +		git log --format=%B >actual &&
> +		test_cmp expect actual
> +	)
> +'
> +
> +test_expect_success 'hooks are executed for rewritten commits' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit first &&
> +		test_commit second &&
> +		test_commit third &&
> +
> +		write_script .git/hooks/prepare-commit-msg <<-EOF &&
> +		echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
> +		EOF
> +		write_script .git/hooks/post-commit <<-EOF &&
> +		echo "post-commit" >>"$(pwd)/hooks.log"
> +		EOF
> +		write_script .git/hooks/post-rewrite <<-EOF &&
> +		{
> +			echo "post-rewrite: \$@"
> +			cat
> +		} >>"$(pwd)/hooks.log"
> +		EOF
> +
> +		git history reword -m "second reworded" HEAD~ &&
> +
> +		cat >expect <<-EOF &&
> +		third
> +		second reworded
> +		first
> +		EOF
> +		git log --format=%s >actual &&
> +		test_cmp expect actual &&
> +
> +		cat >expect <<-EOF &&
> +		prepare-commit-msg: .git/COMMIT_EDITMSG message
> +		post-commit
> +		prepare-commit-msg: .git/COMMIT_EDITMSG message
> +		post-commit
> +		post-rewrite: history
> +		$(git rev-parse second) $(git rev-parse HEAD~)
> +		$(git rev-parse third) $(git rev-parse HEAD)
> +		EOF
> +		test_cmp expect hooks.log
> +	)
> +'
> +
> +test_expect_success 'aborts with empty commit message' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit first &&
> +
> +		test_must_fail git history reword -m "" HEAD 2>err &&
> +		test_grep "Aborting commit due to empty commit message." err
> +	)
> +'
> +
> +test_done
> 
Patrick Steinhardt· Sep 15, 2025, 09:32 UTC · re: Phillip Wood · lore

Re: [PATCH RFC v3 18/18] builtin/history: implement "reword" subcommand

On Wed, Sep 10, 2025 at 03:05:04PM +0100, Phillip Wood wrote:
Show 7 quoted lines
> On 04/09/2025 15:27, Patrick Steinhardt wrote:
> > Implement a new "reword" subcommand for git-history(1). This subcommand
> > is essentially the same as if a user performed an interactive rebase
> > with a single commit changed to use the "reword" verb.
> 
> The sequencer already knows how to reword a commit, it would be much simpler
> to reuse that code.

I'll drop the second half of this patch series for now to reduce the scope of this series a bit. But once I send the second half I'll have a look at whether this can be simplified.

Patrick
Phillip Wood· Sep 15, 2025, 14:10 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v3 18/18] builtin/history: implement "reword" subcommand

On 15/09/2025 10:32, Patrick Steinhardt wrote:
Show 12 quoted lines
> On Wed, Sep 10, 2025 at 03:05:04PM +0100, Phillip Wood wrote:
>> On 04/09/2025 15:27, Patrick Steinhardt wrote:
>>> Implement a new "reword" subcommand for git-history(1). This subcommand
>>> is essentially the same as if a user performed an interactive rebase
>>> with a single commit changed to use the "reword" verb.
>>
>> The sequencer already knows how to reword a commit, it would be much simpler
>> to reuse that code.
> 
> I'll drop the second half of this patch series for now to reduce the
> scope of this series a bit. But once I send the second half I'll have a
> look at whether this can be simplified.

If we passed a todo-list rather than just a list of commits to the sequencer then it would be as simple as writing "reword $oid"[*] in the todo-list.

Thanks
Phillip

[*] I'm probably simplifying slightly as we might want to tweak the hooks that the sequencer runs for "git-history" but it should be fairly easy.

Patrick Steinhardt· Sep 16, 2025, 08:09 UTC · re: Phillip Wood · lore

Re: [PATCH RFC v3 18/18] builtin/history: implement "reword" subcommand

On Mon, Sep 15, 2025 at 03:10:56PM +0100, Phillip Wood wrote:
Show 16 quoted lines
> On 15/09/2025 10:32, Patrick Steinhardt wrote:
> > On Wed, Sep 10, 2025 at 03:05:04PM +0100, Phillip Wood wrote:
> > > On 04/09/2025 15:27, Patrick Steinhardt wrote:
> > > > Implement a new "reword" subcommand for git-history(1). This subcommand
> > > > is essentially the same as if a user performed an interactive rebase
> > > > with a single commit changed to use the "reword" verb.
> > > 
> > > The sequencer already knows how to reword a commit, it would be much simpler
> > > to reuse that code.
> > 
> > I'll drop the second half of this patch series for now to reduce the
> > scope of this series a bit. But once I send the second half I'll have a
> > look at whether this can be simplified.
> 
> If we passed a todo-list rather than just a list of commits to the sequencer
> then it would be as simple as writing "reword $oid"[*] in the todo-list.

One downside though is that we'll now be in interactive-rebase mode instead of in history-editing mode. We could of course introduce history-editing mode as somewhat of an alias for interactive-rebases. But the required changes are non-trivial and all over the place in "sequencer.c", so I eventually stopped pursuing that route.

I still think it should be possible to at least separate out the actual operations and share them across the sequencer and git-history(1) so that we can avoid some of the duplication.

Patrick
Phillip Wood· Sep 16, 2025, 08:42 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v3 18/18] builtin/history: implement "reword" subcommand

On 16/09/2025 09:09, Patrick Steinhardt wrote:
Show 23 quoted lines
> On Mon, Sep 15, 2025 at 03:10:56PM +0100, Phillip Wood wrote:
>> On 15/09/2025 10:32, Patrick Steinhardt wrote:
>>> On Wed, Sep 10, 2025 at 03:05:04PM +0100, Phillip Wood wrote:
>>>> On 04/09/2025 15:27, Patrick Steinhardt wrote:
>>>>> Implement a new "reword" subcommand for git-history(1). This subcommand
>>>>> is essentially the same as if a user performed an interactive rebase
>>>>> with a single commit changed to use the "reword" verb.
>>>>
>>>> The sequencer already knows how to reword a commit, it would be much simpler
>>>> to reuse that code.
>>>
>>> I'll drop the second half of this patch series for now to reduce the
>>> scope of this series a bit. But once I send the second half I'll have a
>>> look at whether this can be simplified.
>>
>> If we passed a todo-list rather than just a list of commits to the sequencer
>> then it would be as simple as writing "reword $oid"[*] in the todo-list.
> 
> One downside though is that we'll now be in interactive-rebase mode
> instead of in history-editing mode. We could of course introduce
> history-editing mode as somewhat of an alias for interactive-rebases.
> But the required changes are non-trivial and all over the place in
> "sequencer.c", so I eventually stopped pursuing that route.

I'm not sure I understand. At git-history uses the sequencer to cherry-pick commits by calling sequencer_pick_revisions()[1]. Both cherry-pick and rebase share the same todo-list format and the main loop in pick_commits() processes that list in the same way for both commands. As I said in a previous mail I think we should add a new entry point to the sequencer that takes a todo list rather than a list of revisions but that should be simple enough and then we get most of the functionality we want such as rewording commits and updating refs more or less for free. So I'm not sure what you mean by "we'll now be in interactive-rebase" mode.

There are good arguments for not using the sequencer at all so that we don't update the worktree each time we pick a commit (that would be a lot more work though), but I cannot currently see a good reason for the approach of using the sequencer to cherry-pick commits but implementing all the other operations separately.

Thanks
Phillip

[1] At some point we should figure out how to teach "git status" to distinguish between "git cherry-pick" and "git history" as I think at the moment both probably look like a cherry-pick.

Show 6 quoted lines
> I still think it should be possible to at least separate out the actual
> operations and share them across the sequencer and git-history(1) so
> that we can avoid some of the duplication.
> 
> Patrick
> 
Kristoffer Haugsbakk· Sep 5, 2025, 10:29 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v3 00/18] Introduce git-history(1) command for easy history editing

On Thu, Sep 4, 2025, at 16:27, Patrick Steinhardt wrote:
Show 23 quoted lines
> Hi,
>[snip]
> I thus had a look at implementing some of these commands in Git itself,
> where the result is this patch series. Specifically, the following
> commands are introduced by this patch series:
>
>   - `git history drop` to drop a specific commit. This is basically the
>     same as jj-abandon(1).
>
>   - `git history reorder` to reorder a specific commit before or after
>     another commit. This is inspired by jj-new(1).
>
>   - `git history split` takes a commit and splits it into two. This is
>     basically the same as jj-split(1).
>
> If this is something we want to have I think it'd be just a starting
> point. There's other commands that I think are quite common and that
> might make sense to introduce eventually:
>
>   - An equivalent to jj-absorb(1) would be awesome to have.
>
>   - `git history reword` to change only the commit message of a specific
>     commit.
The cover letter is a bit outdated. Reword has been here since v2.
>
>   - `git history squash` to squash together multiple commits into one.
>
>[snip]
Patrick Steinhardt· Sep 5, 2025, 11:29 UTC · re: Kristoffer Haugsbakk · lore

Re: [PATCH RFC v3 00/18] Introduce git-history(1) command for easy history editing

On Fri, Sep 05, 2025 at 12:29:55PM +0200, Kristoffer Haugsbakk wrote:
Show 26 quoted lines
> On Thu, Sep 4, 2025, at 16:27, Patrick Steinhardt wrote:
> > Hi,
> >[snip]
> > I thus had a look at implementing some of these commands in Git itself,
> > where the result is this patch series. Specifically, the following
> > commands are introduced by this patch series:
> >
> >   - `git history drop` to drop a specific commit. This is basically the
> >     same as jj-abandon(1).
> >
> >   - `git history reorder` to reorder a specific commit before or after
> >     another commit. This is inspired by jj-new(1).
> >
> >   - `git history split` takes a commit and splits it into two. This is
> >     basically the same as jj-split(1).
> >
> > If this is something we want to have I think it'd be just a starting
> > point. There's other commands that I think are quite common and that
> > might make sense to introduce eventually:
> >
> >   - An equivalent to jj-absorb(1) would be awesome to have.
> >
> >   - `git history reword` to change only the commit message of a specific
> >     commit.
> 
> The cover letter is a bit outdated. Reword has been here since v2.
Good point, addressed locally.
Patrick
Elijah Newren· Sep 7, 2025, 06:46 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v3 00/18] Introduce git-history(1) command for easy history editing

On Thu, Sep 4, 2025 at 11:43 PM Patrick Steinhardt <ps@pks.im> wrote:
Show 20 quoted lines
>
> Hi,
>
> over recent months I've been playing around with Jujutsu quite
> frequently. While I still prefer using Git, there's been a couple
> features in it that I really like and that I'd like to have in Git, as
> well.
>
> A copule of these features relate to history editing. Most importantly,
> I really dig the following commands:
>
>   - jj-abandon(1) to drop a specific commit from your history.
>
>   - jj-absorb(1) to take some changes and automatically apply them to
>     commits in your history that last modified the respective hunks.
>
>   - jj-split(1) to split a commit into two.
>
>   - jj-new(1) to insert a new commit after or before a specific other
>     commit.

Cool, I had been thinking of adding some jj-like functionality as well, to git-replay in my case, though I was more interested in fixing up the infrastructure to handle replaying merges sanely first.

Show 32 quoted lines
> Not all of these commands can be ported directly into Git. jj-new(1) for
> example doesn't really make a ton of sense for us, I'd claim. But some
> of these commands _do_ make sense.
>
> I thus had a look at implementing some of these commands in Git itself,
> where the result is this patch series. Specifically, the following
> commands are introduced by this patch series:
>
>   - `git history drop` to drop a specific commit. This is basically the
>     same as jj-abandon(1).
>
>   - `git history reorder` to reorder a specific commit before or after
>     another commit. This is inspired by jj-new(1).
>
>   - `git history split` takes a commit and splits it into two. This is
>     basically the same as jj-split(1).
>
> If this is something we want to have I think it'd be just a starting
> point. There's other commands that I think are quite common and that
> might make sense to introduce eventually:
>
>   - An equivalent to jj-absorb(1) would be awesome to have.
>
>   - `git history reword` to change only the commit message of a specific
>     commit.
>
>   - `git history squash` to squash together multiple commits into one.
>
> In the end, I'd like us to learn from what people like about Jujutsu and
> apply those learnings to Git. We won't be able to apply all learnings
> from Jujutsu, as the workflow is quite different there due to the lack
> of the index. But other things we certainly can apply to Git directly.

So, this brings up a question. Should we have git-rebase & git-cherry-pick & git-replay & git-history, or should we consolidate? I had envisioned having git-replay consolidate both cherry-pick and rebase functionality into one (then got pulled away by combination of work reassgniment & multiple life crises hitting at once taking my focus away for quite some time). But now we're going in the other direction. And further along that other direction is another extreme -- just having these be top-level commands, e.g. "git reorder", "git split", etc.

In a separate conversation we had (and I hope I'm paraphrasing correctly; if not please correct me), you mentioned you wanted git-history to be the home of history rewriting, and viewed git-replay as just a server side thing (whereas I created git-replay specifically as a user-focusing thing and then Christian changed it into a server-side thing since that part was complete and enough for his purposes). But if git history is the home of history editing, how far does that go? Do we have a "git history reset"? "git history commit"? "git history fast-export/fast-import" "git history filter-repo"? Or is it just the home for certain kinds of history rewriting operations? If so, which ones?

That all said, I'm a big fan of the idea of incorporating more of jj capabilities, and you clearly marked the command as experimental (thanks!), which leave us room to adjust later if we don't like this path. So I don't want to serve as a roadblock, I just think it's a useful conversation to have...

Phillip Wood· Sep 10, 2025, 14:05 UTC · re: Elijah Newren · lore

Re: [PATCH RFC v3 00/18] Introduce git-history(1) command for easy history editing

On 07/09/2025 07:46, Elijah Newren wrote:
Show 8 quoted lines
> On Thu, Sep 4, 2025 at 11:43 PM Patrick Steinhardt <ps@pks.im> wrote:
>>
>> Hi,
>>
>> over recent months I've been playing around with Jujutsu quite
>> frequently. While I still prefer using Git, there's been a couple
>> features in it that I really like and that I'd like to have in Git, as
>> well.
Fantastic, thanks for working on this
Show 16 quoted lines
>> A copule of these features relate to history editing. Most importantly,
>> I really dig the following commands:
>>
>>    - jj-abandon(1) to drop a specific commit from your history.
>>
>>    - jj-absorb(1) to take some changes and automatically apply them to
>>      commits in your history that last modified the respective hunks.
>>
>>    - jj-split(1) to split a commit into two.
>>
>>    - jj-new(1) to insert a new commit after or before a specific other
>>      commit.
> 
> Cool, I had been thinking of adding some jj-like functionality as
> well, to git-replay in my case, though I was more interested in fixing
> up the infrastructure to handle replaying merges sanely first.

Interesting, one of the questions I have about this series is whether it makes sense to use the sequencer or extend git-replay. I do like the idea and name "history" though. I've been using a wrapper around git-rebase that I call "git-rewrite" that lets me amend, reword, or drop commits and rewrite all the branches that contain the modified commit for quite a while now. I find amending the commit directly rather than using fixup commits and then squashing much more convenient and it avoids the problem of the fixup having conflicts when you try and squash it (though not the conflicts caused by amending the commit of course). One feature I particularly like, which we might want to add to "git history" in the future, is being able to specify a filename, line pair instead of a commit name. The script then uses "git diff" to map the line number to the file in HEAD and "git blame" to find the relevant commit. This makes it easy to start a rewrite directly from my editor when I see something that needs fixing up.

If we do want to use the sequencer then I think we need to decide exactly what behavior we want from the new command with regard to running hooks and copying commit headers and implement that behavior rather than just accepting the status quo which is largely a historical accident. I would suggest that for commits that we're not modifing we should not be running any hooks. For commits that are reworded we should be running the "commit-msg" hook and possibly "prepare-commit-msg" as well but no others. Where we're amending the commit content or splitting a commit then we should be running the "pre-commit" hook as well. Currently "git-replay" copies any extra commit headers when it creates a new commit whereas "git-rebase" does not. There is some discussion at [1] where people were pushing back against copying extra headers by default.

I think it would make sense in the long run to update all the branches that contain the modified commit. To do that we can use the sequencer's "update-ref" command. To that end I think we should add a new entry point to the sequencer that takes a todo list rather than a list of commits to pick. That would also allow us to implement the "split" command in the sequencer and reuse the infrastructure that already exists for rewording commits. The way this series is currently implemented makes it hard to extend in the future because it is based around cherry-picking commits rather than creating a todo list for the sequencer to execute.

Thanks
Phillip
Show 16 quoted lines
>> Not all of these commands can be ported directly into Git. jj-new(1) for
>> example doesn't really make a ton of sense for us, I'd claim. But some
>> of these commands _do_ make sense.
>>
>> I thus had a look at implementing some of these commands in Git itself,
>> where the result is this patch series. Specifically, the following
>> commands are introduced by this patch series:
>>
>>    - `git history drop` to drop a specific commit. This is basically the
>>      same as jj-abandon(1).
>>
>>    - `git history reorder` to reorder a specific commit before or after
>>      another commit. This is inspired by jj-new(1).
>>
>>    - `git history split` takes a commit and splits it into two. This is
>>      basically the same as jj-split(1).
 >>>> If this is something we want to have I think it'd be just a starting
Show 43 quoted lines
>> point. There's other commands that I think are quite common and that
>> might make sense to introduce eventually:
>>
>>    - An equivalent to jj-absorb(1) would be awesome to have.
>>
>>    - `git history reword` to change only the commit message of a specific
>>      commit.
>>
>>    - `git history squash` to squash together multiple commits into one.
>>
>> In the end, I'd like us to learn from what people like about Jujutsu and
>> apply those learnings to Git. We won't be able to apply all learnings
>> from Jujutsu, as the workflow is quite different there due to the lack
>> of the index. But other things we certainly can apply to Git directly.
> 
> So, this brings up a question.  Should we have git-rebase &
> git-cherry-pick & git-replay & git-history, or should we consolidate?
> I had envisioned having git-replay consolidate both cherry-pick and
> rebase functionality into one (then got pulled away by combination of
> work reassgniment & multiple life crises hitting at once taking my
> focus away for quite some time).  But now we're going in the other
> direction.  And further along that other direction is another extreme
> -- just having these be top-level commands, e.g. "git reorder", "git
> split", etc.
> 
> In a separate conversation we had (and I hope I'm paraphrasing
> correctly; if not please correct me), you mentioned you wanted
> git-history to be the home of history rewriting, and viewed git-replay
> as just a server side thing (whereas I created git-replay specifically
> as a user-focusing thing and then Christian changed it into a
> server-side thing since that part was complete and enough for his
> purposes).  But if git history is the home of history editing, how far
> does that go?  Do we have a "git history reset"?  "git history
> commit"?  "git history fast-export/fast-import"  "git history
> filter-repo"?  Or is it just the home for certain kinds of history
> rewriting operations?  If so, which ones?
> 
> That all said, I'm a big fan of the idea of incorporating more of jj
> capabilities, and you clearly marked the command as experimental
> (thanks!), which leave us room to adjust later if we don't like this
> path.  So I don't want to serve as a roadblock, I just think it's a
> useful conversation to have...
> 
Phillip Wood· Sep 10, 2025, 14:08 UTC · re: Phillip Wood · lore

Re: [PATCH RFC v3 00/18] Introduce git-history(1) command for easy history editing

On 10/09/2025 15:05, Phillip Wood wrote:
> 
> new commit whereas "git-rebase" does not. There is some discussion at 
> [1] where people were pushing back against copying extra headers by 
> default.
Sorry I forget to add the link before sending

[1] https://lore.kernel.org/git/pull.1902.git.1744041163929.gitgitgadget@gmail.com

Patrick Steinhardt· Sep 15, 2025, 09:33 UTC · re: Elijah Newren · lore

Re: [PATCH RFC v3 00/18] Introduce git-history(1) command for easy history editing

On Sat, Sep 06, 2025 at 11:46:48PM -0700, Elijah Newren wrote:
Show 9 quoted lines
> So, this brings up a question.  Should we have git-rebase &
> git-cherry-pick & git-replay & git-history, or should we consolidate?
> I had envisioned having git-replay consolidate both cherry-pick and
> rebase functionality into one (then got pulled away by combination of
> work reassgniment & multiple life crises hitting at once taking my
> focus away for quite some time).  But now we're going in the other
> direction.  And further along that other direction is another extreme
> -- just having these be top-level commands, e.g. "git reorder", "git
> split", etc.

Yeah, we should consolidate from my point of view. With the current status quo I'd say that:

  - git-replay(1) becomes the home for all plumbing-level functionality
    used by scripts and on the server-side.
  - git-history(1) becomes the home for history editing functionality
    that is user-facing. Potentially, we could also move (or rather
    alias) git-rebase(1) into git-history(1) to complete that vision at
    one point in time.

The main reason why I propose to introduce a top-level command with different subcommands is that it helps users discover related functionality. If we had "git reorder", "git split" et cetera as separate subcommands it would be way harder for a user to find out "what commands do I have to modify history?"

In the worst case, users can still create an alias for git-split(1).
Show 11 quoted lines
> In a separate conversation we had (and I hope I'm paraphrasing
> correctly; if not please correct me), you mentioned you wanted
> git-history to be the home of history rewriting, and viewed git-replay
> as just a server side thing (whereas I created git-replay specifically
> as a user-focusing thing and then Christian changed it into a
> server-side thing since that part was complete and enough for his
> purposes).  But if git history is the home of history editing, how far
> does that go?  Do we have a "git history reset"?  "git history
> commit"?  "git history fast-export/fast-import"  "git history
> filter-repo"?  Or is it just the home for certain kinds of history
> rewriting operations?  If so, which ones?

My take in once sentence: git-history(1) modifies a preexisting sequence of commits. With that definition we rule out:

  - "git history commit" because this creates a new commit on top.
  - "git history fast-export" because this doesn't edit the commit
    sequence.
  - "git history fast-import" because this imports nonexistent commits.
  - "git history reset" (which I assume is an alias of git-reset(1))
    because this command doesn't only care about commits, but it also
    modifies the working tree depending on the mode.

Something like "git history filter-repo" would probably be in the picture though, as it matches the above definition.

I was also wondering whether "git history" is too broad with that definition in mind. At one point in time I though about "git histedit" instead, which may be a bit of a better fit?

Show 5 quoted lines
> That all said, I'm a big fan of the idea of incorporating more of jj
> capabilities, and you clearly marked the command as experimental
> (thanks!), which leave us room to adjust later if we don't like this
> path.  So I don't want to serve as a roadblock, I just think it's a
> useful conversation to have...
Definitely, thanks a lot for your thoughts!
Patrick
Oswald Buddenhagen· Sep 16, 2025, 11:23 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v3 00/18] Introduce git-history(1) command for easy history editing

On Mon, Sep 15, 2025 at 11:33:59AM +0200, Patrick Steinhardt wrote:
Show 9 quoted lines
>On Sat, Sep 06, 2025 at 11:46:48PM -0700, Elijah Newren wrote:
>> So, this brings up a question.  Should we have git-rebase &
>> git-cherry-pick & git-replay & git-history, or should we consolidate?
>> [...]
>
>The main reason why I propose to introduce a top-level command with
>different subcommands is that it helps users discover related
>functionality.
>
i think this is better addressed with proper documentation.
>In the worst case, users can still create an alias for git-split(1).
>

it seems backwards to basically require aliases for efficient use. this would also lead to a mismatch between what people write into how-tos and what is actually in use (aliases are individual and therefore inconsistent, so one needs to use the full form in writing).

>My take in once sentence: git-history(1) modifies a preexisting
>sequence of commits.
>

the details of this definition are arbitrary, and you already noticed that there are grey areas. we will find more when we start looking for them.

so, obviously, i'm in favor of atomizing.
>I was also wondering whether "git history" is too broad with that
>definition in mind. At one point in time I though about "git histedit"
>instead, which may be a bit of a better fit?
>

a natural name for this would be "revise", which, not coindicentally at all, is actually an existing 3rd-party tool with a quite similar scope. but then, see above.

Junio C Hamano· Sep 10, 2025, 20:05 UTC · re: Patrick Steinhardt · lore

Re: [PATCH RFC v3 00/18] Introduce git-history(1) command for easy history editing

Patrick Steinhardt <ps@pks.im> writes:
> Note: this patch series is growing quite large overall. I'll send one
> last version of the complete series with the RFC tag, but after that
> I'll probably split the series into two and stop after introducing the
> "reorder" command.

I haven't merged this to 'seen', not because I do not like what it does, but simply because I do not have enough concentration to deal with conflicts with some in-flight topics (IIRC it textually overlapped with Peff's add-i color topic).

Thanks.
Patrick Steinhardt· Sep 15, 2025, 09:32 UTC · re: Junio C Hamano · lore

Re: [PATCH RFC v3 00/18] Introduce git-history(1) command for easy history editing

On Wed, Sep 10, 2025 at 01:05:57PM -0700, Junio C Hamano wrote:
Show 11 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
> 
> > Note: this patch series is growing quite large overall. I'll send one
> > last version of the complete series with the RFC tag, but after that
> > I'll probably split the series into two and stop after introducing the
> > "reorder" command.
> 
> I haven't merged this to 'seen', not because I do not like what it
> does, but simply because I do not have enough concentration to deal
> with conflicts with some in-flight topics (IIRC it textually overlapped
> with Peff's add-i color topic).

That's fair, it's been in RFC state anyway. I'll trim down the size of this series, resolve conflicts with 'seen' and then send a new version.

Patrick
Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 00/12] Introduce git-history(1) command for easy history editing

Hi,

over recent months I've been playing around with Jujutsu quite frequently. While I still prefer using Git, there's been a couple features in it that I really like and that I'd like to have in Git, as well.

A copule of these features relate to history editing. Most importantly, I really dig the following commands:

  - jj-abandon(1) to drop a specific commit from your history.
  - jj-absorb(1) to take some changes and automatically apply them to
    commits in your history that last modified the respective hunks.
  - jj-split(1) to split a commit into two.
  - jj-new(1) to insert a new commit after or before a specific other
    commit.

Not all of these commands can be ported directly into Git. jj-new(1) for example doesn't really make a ton of sense for us, I'd claim. But some of these commands _do_ make sense.

This patch series is a starting point for such a command. I've significantly slimmed it down from the first couple revisions now following the discussions at the Contributor's Summit yesterday. This was my intent anyway, as I already mentioned on the last iteration.

Changes in v2:
  - Add a new "reword" subcommand.
  - List git-history(1) in "command-list.txt".
  - Add some missing error handling.
  - Simplify calling convention of `apply_commits()` to handle root
    commits internally instead of requiring every caller to do so.
  - Add tests to verify that git-history(1) refuses to work with changes
    in the worktree or index.
  - Mark git-history(1) as experimental.
  - Introduce commands to manage interrupted history edits.
  - A bunch of improvements to the manpage.
  - Link to v1: https://lore.kernel.org/r/20250819-b4-pks-history-builtin-v1-0-9b77c32688fe@pks.im
Changes in v3:
  - Add logic to drive the "post-rewrite" hook and add tests to verify
    that all hooks are executed as expected.
  - Deduplicate logic to turn a replay action into a todo command.
  - Move the addition of tests for the top-level git-history(1) command
    to the correct commit.
  - Some smaller commit message fixes.
  - Honor "commit.verbose".
  - Fix copy-paste error with an error message.
  - Link to v2: https://lore.kernel.org/r/20250824-b4-pks-history-builtin-v2-0-964ac12f65bd@pks.im
Changes in v4:
  - I've rebuilt the patch series. It is now based on 821f583da6 (The
    thirteenth batcn, 2025-09-29) with sa/replay-atomic-ref-updates
    at 665c66a743 (replay: make atomic ref updates the default behavior,
    2025-09-27) merged into it. This should fix all conflicts with seen.
  - I've reworked this patch series to use the same infra as
    git-replay(1), as discussed during the Contributor's Summit.
  - I've slimmed down the patch series to only tackle those commands
    that cannot result in a conflict to keep it simple. I also learned
    that Elijah has been working on a "git replay edit" command, so I
    dropped that command so that we can instead use his version.
  - During the Contributor's Summit we have agreed that for now, we
    won't care about hook execution just yet. This may be backfilled at
    a later point in time.
  - I dropped "commit.verbose" handling for now, as my understanding of
    it was wrong at first. This is something we should backfill.
  - Link to v3: https://lore.kernel.org/r/20250904-b4-pks-history-builtin-v3-0-509053514755@pks.im
Thanks!
Patrick
---
Patrick Steinhardt (12):
      wt-status: provide function to expose status for trees
      replay: extract logic to pick commits
      replay: stop using `the_repository`
      replay: parse commits before dereferencing them
      builtin: add new "history" command
      builtin/history: implement "reword" subcommand
      add-patch: split out header from "add-interactive.h"
      add-patch: split out `struct interactive_options`
      add-patch: remove dependency on "add-interactive" subsystem
      add-patch: add support for in-memory index patching
      cache-tree: allow writing in-memory index as tree
      builtin/history: implement "split" subcommand
 .gitignore                     |   1 +
 Documentation/git-history.adoc | 114 ++++++++
 Documentation/meson.build      |   1 +
 Makefile                       |   2 +
 add-interactive.c              | 174 +++---------
 add-interactive.h              |  46 +--
 add-patch.c                    | 295 +++++++++++++++++---
 add-patch.h                    |  64 +++++
 builtin.h                      |   1 +
 builtin/add.c                  |  22 +-
 builtin/checkout.c             |   7 +-
 builtin/commit.c               |  16 +-
 builtin/history.c              | 614 +++++++++++++++++++++++++++++++++++++++++
 builtin/replay.c               | 110 +-------
 builtin/reset.c                |  16 +-
 builtin/stash.c                |  46 +--
 cache-tree.c                   |   5 +-
 cache-tree.h                   |   3 +-
 command-list.txt               |   1 +
 commit.h                       |   2 +-
 git.c                          |   1 +
 meson.build                    |   2 +
 replay.c                       | 118 ++++++++
 replay.h                       |  18 ++
 t/meson.build                  |   3 +
 t/t3450-history.sh             |  17 ++
 t/t3451-history-reword.sh      | 202 ++++++++++++++
 t/t3452-history-split.sh       | 432 +++++++++++++++++++++++++++++
 wt-status.c                    |  24 ++
 wt-status.h                    |   3 +
 30 files changed, 1984 insertions(+), 376 deletions(-)
Range-diff versus v3:
 1:  166ba26bea <  -:  ---------- sequencer: optionally skip printing commit summary
 2:  60173823a9 <  -:  ---------- sequencer: add option to rewind HEAD after picking commits
 3:  74d21ea445 <  -:  ---------- sequencer: introduce new history editing mode
 4:  b1c052afc7 <  -:  ---------- sequencer: stop using `the_repository` in `sequencer_remove_state()`
 5:  0884c4fd96 <  -:  ---------- sequencer: wire up "rewritten-hook" for REPLAY_HISTORY_EDIT
15:  b77e378615 =  1:  695fc789d5 wt-status: provide function to expose status for trees
 -:  ---------- >  2:  55707c8cb3 replay: extract logic to pick commits
 -:  ---------- >  3:  1308a0bbbb replay: stop using `the_repository`
 -:  ---------- >  4:  d153d366b5 replay: parse commits before dereferencing them
 7:  243d36cb45 !  5:  820a2f4c17 builtin: add new "history" command
    @@ builtin/history.c (new)
     +	};
     +
     +	argc = parse_options(argc, argv, prefix, options, usage, 0);
    ++	if (argc)
    ++		usagef("unrecognized argument: %s", argv[0]);
     +	return 0;
     +}
     
    @@ t/meson.build: integration_tests = [
        't3500-cherry.sh',
        't3501-revert-cherry-pick.sh',
        't3502-cherry-pick-merge.sh',
    -@@ t/meson.build: if perl.found() and time.found()
    -       timeout: 0,
    -     )
    -   endforeach
    --endif
    - \ No newline at end of file
    -+endif
     
      ## t/t3450-history.sh (new) ##
     @@
    @@ t/t3450-history.sh (new)
     +
     +. ./test-lib.sh
     +
    -+test_expect_success 'refuses to do anything without subcommand' '
    -+	test_must_fail git history 2>err &&
    -+	test_grep foo err
    ++test_expect_success 'does nothing without any arguments' '
    ++	git history >out 2>&1 &&
    ++	test_must_be_empty out
    ++'
    ++
    ++test_expect_success 'raises an error with unknown argument' '
    ++	test_must_fail git history garbage 2>err &&
    ++	test_grep "unrecognized argument: garbage" err
     +'
     +
     +test_done
18:  0a1ff48827 !  6:  43af6dc4dc builtin/history: implement "reword" subcommand
    @@ Commit message
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
     
      ## Documentation/git-history.adoc ##
    -@@ Documentation/git-history.adoc: git history continue
    - git history quit
    - git history drop <commit>
    - git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
    +@@ Documentation/git-history.adoc: SYNOPSIS
    + --------
    + [synopsis]
    + git history [<options>]
     +git history reword [<options>] <commit>
    - git history split [<options>] <commit> [--] [<pathspec>...]
      
      DESCRIPTION
    -@@ Documentation/git-history.adoc: child commits, as that would lead to an empty branch.
    - 	be related to one another and must be reachable from the current `HEAD`
    - 	commit.
    + -----------
    +@@ Documentation/git-history.adoc: COMMANDS
    + This command requires a subcommand. Several subcommands are available to
    + rewrite history in different ways:
      
     +`reword <commit> [--message=<message>]`::
     +	Rewrite the commit message of the specified commit. All the other
    -+	details of this commit remain unchanged.
    ++	details of this commit remain unchanged. If no commit message is
    ++	provided, then this command will spawn an editor with the current
    ++	message of that commit.
     +
    - `split [--message=<message>] <commit> [--] [<pathspec>...]`::
    - 	Interactively split up <commit> into two commits by choosing
    - 	hunks introduced by it that will be moved into the new split-out
    + CONFIGURATION
    + -------------
    + 
     
      ## builtin/history.c ##
    -@@ builtin/history.c: static int split_commit(struct repository *repo,
    - 	return ret;
    - }
    - 
    +@@
    ++#define USE_THE_REPOSITORY_VARIABLE
    ++
    + #include "builtin.h"
    ++#include "commit-reach.h"
    ++#include "commit.h"
    ++#include "config.h"
    ++#include "editor.h"
    ++#include "environment.h"
    + #include "gettext.h"
    ++#include "hex.h"
    ++#include "oidmap.h"
    + #include "parse-options.h"
    ++#include "refs.h"
    ++#include "replay.h"
    ++#include "reset.h"
    ++#include "revision.h"
    ++#include "sequencer.h"
    ++#include "strvec.h"
    ++#include "tree.h"
    ++#include "wt-status.h"
    ++
    ++static int collect_commits(struct repository *repo,
    ++			   struct commit *old_commit,
    ++			   struct commit *new_commit,
    ++			   struct strvec *out)
    ++{
    ++	struct setup_revision_opt revision_opts = {
    ++		.assume_dashdash = 1,
    ++	};
    ++	struct strvec revisions = STRVEC_INIT;
    ++	struct commit_list *from_list = NULL;
    ++	struct commit *child;
    ++	struct rev_info rev = { 0 };
    ++	int ret;
    ++
    ++	/*
    ++	 * Check that the old commit actually is an ancestor of HEAD. If not
    ++	 * the whole request becomes nonsensical.
    ++	*/
    ++	if (old_commit) {
    ++		commit_list_insert(old_commit, &from_list);
    ++		if (!repo_is_descendant_of(repo, new_commit, from_list)) {
    ++			ret = error(_("commit must be reachable from current HEAD commit"));
    ++			goto out;
    ++		}
    ++	}
    ++
    ++	repo_init_revisions(repo, &rev, NULL);
    ++	strvec_push(&revisions, "");
    ++	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
    ++	if (old_commit)
    ++		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
    ++
    ++	setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
    ++	if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
    ++		ret = error(_("revision walk setup failed"));
    ++		goto out;
    ++	}
    ++
    ++	while ((child = get_revision(&rev))) {
    ++		if (old_commit && !child->parents)
    ++			BUG("revision walk did not find child commit");
    ++		if (child->parents && child->parents->next) {
    ++			ret = error(_("cannot rearrange commit history with merges"));
    ++			goto out;
    ++		}
    ++
    ++		strvec_push(out, oid_to_hex(&child->object.oid));
    ++
    ++		if (child->parents && old_commit &&
    ++		    commit_list_contains(old_commit, child->parents))
    ++			break;
    ++	}
    ++
    ++	/*
    ++	 * Revisions are in newest-order-first. We have to reverse the
    ++	 * array though so that we pick the oldest commits first.
    ++	 */
    ++	for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
    ++		SWAP(out->v[i], out->v[j]);
    ++
    ++	ret = 0;
    ++
    ++out:
    ++	free_commit_list(from_list);
    ++	strvec_clear(&revisions);
    ++	release_revisions(&rev);
    ++	reset_revision_walk();
    ++	return ret;
    ++}
    ++
    ++static void replace_commits(struct strvec *commits,
    ++			    const struct object_id *commit_to_replace,
    ++			    const struct object_id *replacements,
    ++			    size_t replacements_nr)
    ++{
    ++	char commit_to_replace_oid[GIT_MAX_HEXSZ + 1];
    ++	struct strvec replacement_oids = STRVEC_INIT;
    ++	bool found = false;
    ++
    ++	oid_to_hex_r(commit_to_replace_oid, commit_to_replace);
    ++	for (size_t i = 0; i < replacements_nr; i++)
    ++		strvec_push(&replacement_oids, oid_to_hex(&replacements[i]));
    ++
    ++	for (size_t i = 0; i < commits->nr; i++) {
    ++		if (strcmp(commits->v[i], commit_to_replace_oid))
    ++			continue;
    ++		strvec_splice(commits, i, 1, replacement_oids.v, replacement_oids.nr);
    ++		found = true;
    ++		break;
    ++	}
    ++	if (!found)
    ++		BUG("could not find commit to replace");
    ++
    ++	strvec_clear(&replacement_oids);
    ++}
    ++
    ++static int apply_commits(struct repository *repo,
    ++			 const struct strvec *commits,
    ++			 struct commit *onto,
    ++			 struct commit *orig_head,
    ++			 const char *action)
    ++{
    ++	struct reset_head_opts reset_opts = { 0 };
    ++	struct merge_options merge_opts = { 0 };
    ++	struct merge_result result = { 0 };
    ++	struct strbuf buf = STRBUF_INIT;
    ++	kh_oid_map_t *replayed_commits;
    ++	int ret;
    ++
    ++	replayed_commits = kh_init_oid_map();
    ++
    ++	init_basic_merge_options(&merge_opts, repo);
    ++	merge_opts.show_rename_progress = 0;
    ++
    ++	for (size_t i = 0; i < commits->nr; i++) {
    ++		struct object_id commit_id;
    ++		struct commit *commit;
    ++		const char *end;
    ++		int hash_result;
    ++		khint_t pos;
    ++
    ++		if (parse_oid_hex_algop(commits->v[i], &commit_id, &end,
    ++					repo->hash_algo)) {
    ++			ret = error(_("invalid object ID: %s"), commits->v[i]);
    ++			goto out;
    ++		}
    ++
    ++		commit = lookup_commit(repo, &commit_id);
    ++		if (!commit || repo_parse_commit(repo, commit)) {
    ++			ret = error(_("failed to look up commit: %s"), oid_to_hex(&commit_id));
    ++			goto out;
    ++		}
    ++
    ++		if (!onto) {
    ++			onto = commit;
    ++			result.clean = 1;
    ++			result.tree = repo_get_commit_tree(repo, commit);
    ++		} else {
    ++			onto = replay_pick_regular_commit(repo, commit, replayed_commits,
    ++							  onto, &merge_opts, &result);
    ++			if (!onto)
    ++				break;
    ++		}
    ++
    ++		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hash_result);
    ++		if (hash_result == 0) {
    ++			ret = error(_("duplicate rewritten commit: %s\n"),
    ++				    oid_to_hex(&commit->object.oid));
    ++			goto out;
    ++		}
    ++		kh_value(replayed_commits, pos) = onto;
    ++	}
    ++
    ++	if (!result.clean) {
    ++		ret = error(_("could not merge"));
    ++		goto out;
    ++	}
    ++
    ++	reset_opts.oid = &onto->object.oid;
    ++	strbuf_addf(&buf, "%s: switch to rewritten %s", action, oid_to_hex(reset_opts.oid));
    ++	reset_opts.flags = RESET_HEAD_REFS_ONLY | RESET_ORIG_HEAD;
    ++	reset_opts.orig_head = &orig_head->object.oid;
    ++	reset_opts.default_reflog_action = action;
    ++	if (reset_head(repo, &reset_opts) < 0) {
    ++		ret = error(_("could not switch to %s"), oid_to_hex(reset_opts.oid));
    ++		goto out;
    ++	}
    ++
    ++	ret = 0;
    ++
    ++out:
    ++	kh_destroy_oid_map(replayed_commits);
    ++	merge_finalize(&merge_opts, &result);
    ++	strbuf_release(&buf);
    ++	return ret;
    ++}
    ++
    ++static void change_data_free(void *util, const char *str UNUSED)
    ++{
    ++	struct wt_status_change_data *d = util;
    ++	free(d->rename_source);
    ++	free(d);
    ++}
    ++
    ++static int fill_commit_message(struct repository *repo,
    ++			       const struct object_id *old_tree,
    ++			       const struct object_id *new_tree,
    ++			       const char *default_message,
    ++			       const char *provided_message,
    ++			       const char *action,
    ++			       struct strbuf *out)
    ++{
    ++	if (!provided_message) {
    ++		const char *path = git_path_commit_editmsg();
    ++		const char *hint =
    ++			_("Please enter the commit message for the %s changes. Lines starting\n"
    ++     "with '%s' will be kept; you may remove them yourself if you want to.\n");
    ++		struct wt_status s;
    ++
    ++		strbuf_addstr(out, default_message);
    ++		strbuf_addch(out, '\n');
    ++		strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
    ++		write_file_buf(path, out->buf, out->len);
    ++
    ++		wt_status_prepare(repo, &s);
    ++		FREE_AND_NULL(s.branch);
    ++		s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
    ++		s.commit_template = 1;
    ++		s.colopts = 0;
    ++		s.display_comment_prefix = 1;
    ++		s.hints = 0;
    ++		s.use_color = 0;
    ++		s.whence = FROM_COMMIT;
    ++		s.committable = 1;
    ++
    ++		s.fp = fopen(git_path_commit_editmsg(), "a");
    ++		if (!s.fp)
    ++			return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
    ++
    ++		wt_status_collect_changes_trees(&s, old_tree, new_tree);
    ++		wt_status_print(&s);
    ++		wt_status_collect_free_buffers(&s);
    ++		string_list_clear_func(&s.change, change_data_free);
    ++
    ++		strbuf_reset(out);
    ++		if (launch_editor(path, out, NULL)) {
    ++			fprintf(stderr, _("Please supply the message using the -m option.\n"));
    ++			return -1;
    ++		}
    ++		strbuf_stripspace(out, comment_line_str);
    ++	} else {
    ++		strbuf_addstr(out, provided_message);
    ++	}
    ++
    ++	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
    ++
    ++	if (!out->len) {
    ++		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
    ++		return -1;
    ++	}
    ++
    ++	return 0;
    ++}
    ++
     +static int cmd_history_reword(int argc,
     +			      const char **argv,
     +			      const char *prefix,
    @@ builtin/history.c: static int split_commit(struct repository *repo,
     +		OPT_END(),
     +	};
     +	struct strbuf final_message = STRBUF_INIT;
    -+	struct commit *original_commit, *head;
    ++	struct commit *original_commit, *parent, *head;
     +	struct strvec commits = STRVEC_INIT;
     +	struct object_id parent_tree_oid, original_commit_tree_oid;
     +	struct object_id rewritten_commit;
     +	const char *original_message, *original_body, *ptr;
    -+	struct oidmap rewritten_commits = OIDMAP_INIT;
    -+	struct replay_oid_mapping mapping = { 0 };
     +	char *original_author = NULL;
     +	size_t len;
     +	int ret;
    @@ builtin/history.c: static int split_commit(struct repository *repo,
     +		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
     +		goto out;
     +	}
    ++	if (repo_parse_commit(repo, original_commit)) {
    ++		ret = error(_("unable to parse commit %s"),
    ++			    oid_to_hex(&original_commit->object.oid));
    ++		goto out;
    ++	}
    ++	original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
    ++
    ++	parent = original_commit->parents ? original_commit->parents->item : NULL;
    ++	if (parent) {
    ++		if (repo_parse_commit(repo, parent)) {
    ++			ret = error(_("unable to parse commit %s"),
    ++				    oid_to_hex(&parent->object.oid));
    ++			goto out;
    ++		}
    ++		parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
    ++	} else {
    ++		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
    ++	}
     +
     +	head = lookup_commit_reference_by_name("HEAD");
     +	if (!head) {
    @@ builtin/history.c: static int split_commit(struct repository *repo,
     +	 * This ensures that we'll abort early on in case the range of commits
     +	 * contains merges, which we do not yet handle.
     +	 */
    -+	ret = collect_commits(repo, original_commit->parents ? original_commit->parents->item : NULL,
    -+			      head, &commits);
    ++	ret = collect_commits(repo, parent, head, &commits);
     +	if (ret < 0)
     +		goto out;
     +
    @@ builtin/history.c: static int split_commit(struct repository *repo,
     +		original_author = xmemdupz(ptr, len);
     +	find_commit_subject(original_message, &original_body);
     +
    -+	if (original_commit->parents)
    -+		parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
    -+	else
    -+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
    -+	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
    -+
     +	ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
     +				  original_body, commit_message, "reworded", &final_message);
     +	if (ret < 0)
    @@ builtin/history.c: static int split_commit(struct repository *repo,
     +
     +	replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
     +
    -+	mapping.entry.oid = rewritten_commit;
    -+	mapping.rewritten_oid = original_commit->object.oid;
    -+	oidmap_put(&rewritten_commits, &mapping);
    -+
    -+	ret = apply_commits(repo, &commits, head, original_commit,
    -+			    &rewritten_commits, "reword");
    ++	ret = apply_commits(repo, &commits, parent, head, "reword");
     +	if (ret < 0)
     +		goto out;
     +
     +	ret = 0;
     +
     +out:
    -+	oidmap_clear(&rewritten_commits, 0);
     +	strbuf_release(&final_message);
     +	strvec_clear(&commits);
     +	free(original_author);
     +	return ret;
     +}
    -+
    - static int cmd_history_split(int argc,
    - 			     const char **argv,
    - 			     const char *prefix,
    -@@ builtin/history.c: int cmd_history(int argc,
    - 		N_("git history quit"),
    - 		N_("git history drop <commit>"),
    - 		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
    + 
    + int cmd_history(int argc,
    + 		const char **argv,
    + 		const char *prefix,
    +-		struct repository *repo UNUSED)
    ++		struct repository *repo)
    + {
    + 	const char * const usage[] = {
    + 		N_("git history [<options>]"),
     +		N_("git history reword [<options>] <commit>"),
    - 		N_("git history split [<options>] <commit> [--] [<pathspec>...]"),
      		NULL,
      	};
    -@@ builtin/history.c: int cmd_history(int argc,
    - 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
    - 		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
    - 		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
    ++	parse_opt_subcommand_fn *fn = NULL;
    + 	struct option options[] = {
     +		OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
    - 		OPT_SUBCOMMAND("split", &fn, cmd_history_split),
      		OPT_END(),
      	};
    + 
    + 	argc = parse_options(argc, argv, prefix, options, usage, 0);
    +-	if (argc)
    +-		usagef("unrecognized argument: %s", argv[0]);
    +-	return 0;
    ++	return fn(argc, argv, prefix, repo);
    + }
     
      ## t/meson.build ##
     @@ t/meson.build: integration_tests = [
    -   't3451-history-drop.sh',
    -   't3452-history-reorder.sh',
    -   't3453-history-split.sh',
    -+  't3454-history-reword.sh',
    +   't3437-rebase-fixup-options.sh',
    +   't3438-rebase-broken-files.sh',
    +   't3450-history.sh',
    ++  't3451-history-reword.sh',
        't3500-cherry.sh',
        't3501-revert-cherry-pick.sh',
        't3502-cherry-pick-merge.sh',
     
    - ## t/t3454-history-reword.sh (new) ##
    + ## t/t3450-history.sh ##
    +@@ t/t3450-history.sh: test_description='tests for git-history command'
    + . ./test-lib.sh
    + 
    + test_expect_success 'does nothing without any arguments' '
    +-	git history >out 2>&1 &&
    +-	test_must_be_empty out
    ++	test_must_fail git history 2>err &&
    ++	test_grep "need a subcommand" err
    + '
    + 
    + test_expect_success 'raises an error with unknown argument' '
    + 	test_must_fail git history garbage 2>err &&
    +-	test_grep "unrecognized argument: garbage" err
    ++	test_grep "unknown subcommand: .garbage." err
    + '
    + 
    + test_done
    +
    + ## t/t3451-history-reword.sh (new) ##
     @@
     +#!/bin/sh
     +
    @@ t/t3454-history-reword.sh (new)
     +	)
     +'
     +
    -+test_expect_success 'refuses to work with changes in the worktree or index' '
    -+	test_when_finished "rm -rf repo" &&
    -+	git init repo &&
    -+	(
    -+		cd repo &&
    -+		test_commit base file &&
    -+		echo foo >file &&
    -+		test_must_fail git history reword HEAD 2>err &&
    -+		test_grep "Your local changes to the following files would be overwritten" err &&
    -+		git add file &&
    -+		test_must_fail git history reword HEAD 2>err &&
    -+		test_grep "Your local changes to the following files would be overwritten" err
    -+	)
    -+'
    -+
     +test_expect_success 'can reword tip of a branch' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3454-history-reword.sh (new)
     +	)
     +'
     +
    ++# For now, git-history(1) does not yet execute any hooks. This is subject to
    ++# change in the future, and if it does this test here is expected to start
    ++# failing. In other words, this test is not an endorsement of the current
    ++# status quo.
     +test_expect_success 'hooks are executed for rewritten commits' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3454-history-reword.sh (new)
     +		test_commit third &&
     +
     +		write_script .git/hooks/prepare-commit-msg <<-EOF &&
    -+		echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
    ++		touch "$(pwd)/hooks.log
     +		EOF
     +		write_script .git/hooks/post-commit <<-EOF &&
    -+		echo "post-commit" >>"$(pwd)/hooks.log"
    ++		touch "$(pwd)/hooks.log
     +		EOF
     +		write_script .git/hooks/post-rewrite <<-EOF &&
    -+		{
    -+			echo "post-rewrite: \$@"
    -+			cat
    -+		} >>"$(pwd)/hooks.log"
    ++		touch "$(pwd)/hooks.log
     +		EOF
     +
     +		git history reword -m "second reworded" HEAD~ &&
    @@ t/t3454-history-reword.sh (new)
     +		git log --format=%s >actual &&
     +		test_cmp expect actual &&
     +
    -+		cat >expect <<-EOF &&
    -+		prepare-commit-msg: .git/COMMIT_EDITMSG message
    -+		post-commit
    -+		prepare-commit-msg: .git/COMMIT_EDITMSG message
    -+		post-commit
    -+		post-rewrite: history
    -+		$(git rev-parse second) $(git rev-parse HEAD~)
    -+		$(git rev-parse third) $(git rev-parse HEAD)
    -+		EOF
    -+		test_cmp expect hooks.log
    ++		test_path_is_missing hooks.log
     +	)
     +'
     +
    @@ t/t3454-history-reword.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'retains changes in the worktree and index' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		touch a b &&
    ++		git add . &&
    ++		git commit -m "initial commit" &&
    ++		echo foo >a &&
    ++		echo bar >b &&
    ++		git add b &&
    ++		git history reword HEAD -m message &&
    ++		cat >expect <<-\EOF &&
    ++		 M a
    ++		M  b
    ++		?? actual
    ++		?? expect
    ++		EOF
    ++		git status --porcelain >actual &&
    ++		test_cmp expect actual
    ++	)
    ++'
    ++
     +test_done
11:  01cab3d8a5 =  7:  62e11acdfe add-patch: split out header from "add-interactive.h"
12:  6726e2ee02 !  8:  e0b65245fb add-patch: split out `struct interactive_options`
    @@ add-interactive.c
      #include "prompt.h"
      #include "tree.h"
      
    --static void init_color(struct repository *r, struct add_i_state *s,
    +-static void init_color(struct repository *r, enum git_colorbool use_color,
     -		       const char *section_and_slot, char *dst,
     -		       const char *default_color)
     -{
     -	char *key = xstrfmt("color.%s", section_and_slot);
     -	const char *value;
     -
    --	if (!s->use_color)
    +-	if (!want_color(use_color))
     -		dst[0] = '\0';
     -	else if (repo_config_get_value(r, key, &value) ||
     -		 color_parse(value, dst))
    @@ add-interactive.c
     -
     -	free(key);
     -}
    +-
    +-static enum git_colorbool check_color_config(struct repository *r, const char *var)
    +-{
    +-	const char *value;
    +-	enum git_colorbool ret;
    +-
    +-	if (repo_config_get_value(r, var, &value))
    +-		ret = GIT_COLOR_UNKNOWN;
    +-	else
    +-		ret = git_config_colorbool(var, value);
    +-
    +-	/*
    +-	 * Do not rely on want_color() to fall back to color.ui for us. It uses
    +-	 * the value parsed by git_color_config(), which may not have been
    +-	 * called by the main command.
    +-	 */
    +-	if (ret == GIT_COLOR_UNKNOWN &&
    +-	    !repo_config_get_value(r, "color.ui", &value))
    +-		ret = git_config_colorbool("color.ui", value);
    +-
    +-	return ret;
    +-}
     -
      void init_add_i_state(struct add_i_state *s, struct repository *r,
     -		      struct add_p_opt *add_p_opt)
     +		      struct interactive_options *opts)
      {
    --	const char *value;
    --
      	s->r = r;
     -	s->context = -1;
     -	s->interhunkcontext = -1;
     -
    --	if (repo_config_get_value(r, "color.interactive", &value))
    --		s->use_color = -1;
    --	else
    --		s->use_color =
    --			git_config_colorbool("color.interactive", value);
    --	s->use_color = want_color(s->use_color);
    +-	s->use_color_interactive = check_color_config(r, "color.interactive");
     -
    --	init_color(r, s, "interactive.header", s->header_color, GIT_COLOR_BOLD);
    --	init_color(r, s, "interactive.help", s->help_color, GIT_COLOR_BOLD_RED);
    --	init_color(r, s, "interactive.prompt", s->prompt_color,
    --		   GIT_COLOR_BOLD_BLUE);
    --	init_color(r, s, "interactive.error", s->error_color,
    --		   GIT_COLOR_BOLD_RED);
    +-	init_color(r, s->use_color_interactive, "interactive.header",
    +-		   s->header_color, GIT_COLOR_BOLD);
    +-	init_color(r, s->use_color_interactive, "interactive.help",
    +-		   s->help_color, GIT_COLOR_BOLD_RED);
    +-	init_color(r, s->use_color_interactive, "interactive.prompt",
    +-		   s->prompt_color, GIT_COLOR_BOLD_BLUE);
    +-	init_color(r, s->use_color_interactive, "interactive.error",
    +-		   s->error_color, GIT_COLOR_BOLD_RED);
    +-	strlcpy(s->reset_color_interactive,
    +-		want_color(s->use_color_interactive) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
     -
    --	init_color(r, s, "diff.frag", s->fraginfo_color,
    --		   diff_get_color(s->use_color, DIFF_FRAGINFO));
    --	init_color(r, s, "diff.context", s->context_color, "fall back");
    --	if (!strcmp(s->context_color, "fall back"))
    --		init_color(r, s, "diff.plain", s->context_color,
    --			   diff_get_color(s->use_color, DIFF_CONTEXT));
    --	init_color(r, s, "diff.old", s->file_old_color,
    --		diff_get_color(s->use_color, DIFF_FILE_OLD));
    --	init_color(r, s, "diff.new", s->file_new_color,
    --		diff_get_color(s->use_color, DIFF_FILE_NEW));
    +-	s->use_color_diff = check_color_config(r, "color.diff");
     -
    --	strlcpy(s->reset_color,
    --		s->use_color ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
    +-	init_color(r, s->use_color_diff, "diff.frag", s->fraginfo_color,
    +-		   diff_get_color(s->use_color_diff, DIFF_FRAGINFO));
    +-	init_color(r, s->use_color_diff, "diff.context", s->context_color,
    +-		   "fall back");
    +-	if (!strcmp(s->context_color, "fall back"))
    +-		init_color(r, s->use_color_diff, "diff.plain",
    +-			   s->context_color,
    +-			   diff_get_color(s->use_color_diff, DIFF_CONTEXT));
    +-	init_color(r, s->use_color_diff, "diff.old", s->file_old_color,
    +-		   diff_get_color(s->use_color_diff, DIFF_FILE_OLD));
    +-	init_color(r, s->use_color_diff, "diff.new", s->file_new_color,
    +-		   diff_get_color(s->use_color_diff, DIFF_FILE_NEW));
    +-	strlcpy(s->reset_color_diff,
    +-		want_color(s->use_color_diff) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
     -
     -	FREE_AND_NULL(s->interactive_diff_filter);
     -	repo_config_get_string(r, "interactive.difffilter",
    @@ add-interactive.c
     -	FREE_AND_NULL(s->interactive_diff_algorithm);
     +	interactive_config_clear(&s->cfg);
      	memset(s, 0, sizeof(*s));
    --	s->use_color = -1;
    +-	s->use_color_interactive = GIT_COLOR_UNKNOWN;
    +-	s->use_color_diff = GIT_COLOR_UNKNOWN;
     +	interactive_config_clear(&s->cfg);
      }
      
    @@ add-interactive.c: int run_add_i(struct repository *r, const struct pathspec *ps
      	 * When color was asked for, use the prompt color for
      	 * highlighting, otherwise use square brackets.
      	 */
    --	if (s.use_color) {
    +-	if (want_color(s.use_color_interactive)) {
     -		data.color = s.prompt_color;
    --		data.reset = s.reset_color;
    -+	if (s.cfg.use_color) {
    +-		data.reset = s.reset_color_interactive;
    ++	if (want_color(s.cfg.use_color_interactive)) {
     +		data.color = s.cfg.prompt_color;
    -+		data.reset = s.cfg.reset_color;
    ++		data.reset = s.cfg.reset_color_interactive;
      	}
      	print_file_item_data.color = data.color;
      	print_file_item_data.reset = data.reset;
    @@ add-interactive.h
      
      struct add_i_state {
      	struct repository *r;
    --	int use_color;
    +-	enum git_colorbool use_color_interactive;
    +-	enum git_colorbool use_color_diff;
     -	char header_color[COLOR_MAXLEN];
     -	char help_color[COLOR_MAXLEN];
     -	char prompt_color[COLOR_MAXLEN];
     -	char error_color[COLOR_MAXLEN];
    --	char reset_color[COLOR_MAXLEN];
    +-	char reset_color_interactive[COLOR_MAXLEN];
    +-
     -	char fraginfo_color[COLOR_MAXLEN];
     -	char context_color[COLOR_MAXLEN];
     -	char file_old_color[COLOR_MAXLEN];
     -	char file_new_color[COLOR_MAXLEN];
    +-	char reset_color_diff[COLOR_MAXLEN];
     -
     -	int use_single_key;
     -	char *interactive_diff_filter, *interactive_diff_algorithm;
    @@ add-patch.c: struct add_p_state {
      };
      
     +static void init_color(struct repository *r,
    -+		       struct interactive_config *cfg,
    ++		       enum git_colorbool use_color,
     +		       const char *section_and_slot, char *dst,
     +		       const char *default_color)
     +{
     +	char *key = xstrfmt("color.%s", section_and_slot);
     +	const char *value;
     +
    -+	if (!cfg->use_color)
    ++	if (!want_color(use_color))
     +		dst[0] = '\0';
     +	else if (repo_config_get_value(r, key, &value) ||
     +		 color_parse(value, dst))
    @@ add-patch.c: struct add_p_state {
     +	free(key);
     +}
     +
    ++static enum git_colorbool check_color_config(struct repository *r, const char *var)
    ++{
    ++	const char *value;
    ++	enum git_colorbool ret;
    ++
    ++	if (repo_config_get_value(r, var, &value))
    ++		ret = GIT_COLOR_UNKNOWN;
    ++	else
    ++		ret = git_config_colorbool(var, value);
    ++
    ++	/*
    ++	 * Do not rely on want_color() to fall back to color.ui for us. It uses
    ++	 * the value parsed by git_color_config(), which may not have been
    ++	 * called by the main command.
    ++	 */
    ++	if (ret == GIT_COLOR_UNKNOWN &&
    ++	    !repo_config_get_value(r, "color.ui", &value))
    ++		ret = git_config_colorbool("color.ui", value);
    ++
    ++	return ret;
    ++}
    ++
     +void interactive_config_init(struct interactive_config *cfg,
     +			     struct repository *r,
     +			     struct interactive_options *opts)
     +{
    -+	const char *value;
    -+
     +	cfg->context = -1;
     +	cfg->interhunkcontext = -1;
     +
    -+	if (repo_config_get_value(r, "color.interactive", &value))
    -+		cfg->use_color = -1;
    -+	else
    -+		cfg->use_color =
    -+			git_config_colorbool("color.interactive", value);
    -+	cfg->use_color = want_color(cfg->use_color);
    ++	cfg->use_color_interactive = check_color_config(r, "color.interactive");
     +
    -+	init_color(r, cfg, "interactive.header", cfg->header_color, GIT_COLOR_BOLD);
    -+	init_color(r, cfg, "interactive.help", cfg->help_color, GIT_COLOR_BOLD_RED);
    -+	init_color(r, cfg, "interactive.prompt", cfg->prompt_color,
    -+		   GIT_COLOR_BOLD_BLUE);
    -+	init_color(r, cfg, "interactive.error", cfg->error_color,
    -+		   GIT_COLOR_BOLD_RED);
    ++	init_color(r, cfg->use_color_interactive, "interactive.header",
    ++		   cfg->header_color, GIT_COLOR_BOLD);
    ++	init_color(r, cfg->use_color_interactive, "interactive.help",
    ++		   cfg->help_color, GIT_COLOR_BOLD_RED);
    ++	init_color(r, cfg->use_color_interactive, "interactive.prompt",
    ++		   cfg->prompt_color, GIT_COLOR_BOLD_BLUE);
    ++	init_color(r, cfg->use_color_interactive, "interactive.error",
    ++		   cfg->error_color, GIT_COLOR_BOLD_RED);
    ++	strlcpy(cfg->reset_color_interactive,
    ++		want_color(cfg->use_color_interactive) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
     +
    -+	init_color(r, cfg, "diff.frag", cfg->fraginfo_color,
    -+		   diff_get_color(cfg->use_color, DIFF_FRAGINFO));
    -+	init_color(r, cfg, "diff.context", cfg->context_color, "fall back");
    -+	if (!strcmp(cfg->context_color, "fall back"))
    -+		init_color(r, cfg, "diff.plain", cfg->context_color,
    -+			   diff_get_color(cfg->use_color, DIFF_CONTEXT));
    -+	init_color(r, cfg, "diff.old", cfg->file_old_color,
    -+		diff_get_color(cfg->use_color, DIFF_FILE_OLD));
    -+	init_color(r, cfg, "diff.new", cfg->file_new_color,
    -+		diff_get_color(cfg->use_color, DIFF_FILE_NEW));
    ++	cfg->use_color_diff = check_color_config(r, "color.diff");
     +
    -+	strlcpy(cfg->reset_color,
    -+		cfg->use_color ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
    ++	init_color(r, cfg->use_color_diff, "diff.frag", cfg->fraginfo_color,
    ++		   diff_get_color(cfg->use_color_diff, DIFF_FRAGINFO));
    ++	init_color(r, cfg->use_color_diff, "diff.context", cfg->context_color,
    ++		   "fall back");
    ++	if (!strcmp(cfg->context_color, "fall back"))
    ++		init_color(r, cfg->use_color_diff, "diff.plain",
    ++			   cfg->context_color,
    ++			   diff_get_color(cfg->use_color_diff, DIFF_CONTEXT));
    ++	init_color(r, cfg->use_color_diff, "diff.old", cfg->file_old_color,
    ++		diff_get_color(cfg->use_color_diff, DIFF_FILE_OLD));
    ++	init_color(r, cfg->use_color_diff, "diff.new", cfg->file_new_color,
    ++		diff_get_color(cfg->use_color_diff, DIFF_FILE_NEW));
    ++	strlcpy(cfg->reset_color_diff,
    ++		want_color(cfg->use_color_diff) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
     +
     +	FREE_AND_NULL(cfg->interactive_diff_filter);
     +	repo_config_get_string(r, "interactive.difffilter",
    @@ add-patch.c: struct add_p_state {
     +	FREE_AND_NULL(cfg->interactive_diff_filter);
     +	FREE_AND_NULL(cfg->interactive_diff_algorithm);
     +	memset(cfg, 0, sizeof(*cfg));
    -+	cfg->use_color = -1;
    ++	cfg->use_color_interactive = GIT_COLOR_UNKNOWN;
    ++	cfg->use_color_diff = GIT_COLOR_UNKNOWN;
     +}
     +
      static void add_p_state_clear(struct add_p_state *s)
    @@ add-patch.c: static void err(struct add_p_state *s, const char *fmt, ...)
     -	fputs(s->s.error_color, stdout);
     +	fputs(s->s.cfg.error_color, stdout);
      	vprintf(fmt, args);
    --	puts(s->s.reset_color);
    -+	puts(s->s.cfg.reset_color);
    +-	puts(s->s.reset_color_interactive);
    ++	puts(s->s.cfg.reset_color_interactive);
      	va_end(args);
      }
      
    @@ add-patch.c: static int parse_diff(struct add_p_state *s, const struct pathspec
      		struct object_id oid;
      		strvec_push(&args,
     @@ add-patch.c: static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
    + 	}
    + 	strbuf_complete_line(plain);
      
    - 	if (want_color_fd(1, -1)) {
    +-	if (want_color_fd(1, s->s.use_color_diff)) {
    ++	if (want_color_fd(1, s->s.cfg.use_color_diff)) {
      		struct child_process colored_cp = CHILD_PROCESS_INIT;
     -		const char *diff_filter = s->s.interactive_diff_filter;
     +		const char *diff_filter = s->s.cfg.interactive_diff_filter;
    @@ add-patch.c: static void render_hunk(struct add_p_state *s, struct hunk *hunk,
      		if (len)
      			strbuf_add(out, p, len);
      		else if (colored)
    --			strbuf_addf(out, "%s\n", s->s.reset_color);
    -+			strbuf_addf(out, "%s\n", s->s.cfg.reset_color);
    +-			strbuf_addf(out, "%s\n", s->s.reset_color_diff);
    ++			strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
      		else
      			strbuf_addch(out, '\n');
      	}
    @@ add-patch.c: static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
     +			      s->s.cfg.file_new_color :
     +			      s->s.cfg.context_color);
      		strbuf_add(&s->colored, plain + current, eol - current);
    --		strbuf_addstr(&s->colored, s->s.reset_color);
    -+		strbuf_addstr(&s->colored, s->s.cfg.reset_color);
    +-		strbuf_addstr(&s->colored, s->s.reset_color_diff);
    ++		strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
      		if (next > eol)
      			strbuf_add(&s->colored, plain + eol, next - eol);
      		current = next;
    @@ add-patch.c: static int patch_update_file(struct add_p_state *s,
      						: 1));
      		printf(_(s->mode->prompt_mode[prompt_mode_type]),
      		       s->buf.buf);
    --		if (*s->s.reset_color)
    --			fputs(s->s.reset_color, stdout);
    -+		if (*s->s.cfg.reset_color)
    -+			fputs(s->s.cfg.reset_color, stdout);
    +-		if (*s->s.reset_color_interactive)
    +-			fputs(s->s.reset_color_interactive, stdout);
    ++		if (*s->s.cfg.reset_color_interactive)
    ++			fputs(s->s.cfg.reset_color_interactive, stdout);
      		fflush(stdout);
      		if (read_single_character(s) == EOF)
      			break;
    @@ add-patch.h
     +}
     +
     +struct interactive_config {
    -+	int use_color;
    ++	enum git_colorbool use_color_interactive;
    ++	enum git_colorbool use_color_diff;
     +	char header_color[COLOR_MAXLEN];
     +	char help_color[COLOR_MAXLEN];
     +	char prompt_color[COLOR_MAXLEN];
     +	char error_color[COLOR_MAXLEN];
    -+	char reset_color[COLOR_MAXLEN];
    ++	char reset_color_interactive[COLOR_MAXLEN];
    ++
     +	char fraginfo_color[COLOR_MAXLEN];
     +	char context_color[COLOR_MAXLEN];
     +	char file_old_color[COLOR_MAXLEN];
     +	char file_new_color[COLOR_MAXLEN];
    ++	char reset_color_diff[COLOR_MAXLEN];
     +
     +	int use_single_key;
     +	char *interactive_diff_filter, *interactive_diff_algorithm;
13:  fe853dff04 !  9:  3fef9306d3 add-patch: remove dependency on "add-interactive" subsystem
    @@ add-patch.c: static void err(struct add_p_state *s, const char *fmt, ...)
     -	fputs(s->s.cfg.error_color, stdout);
     +	fputs(s->cfg.error_color, stdout);
      	vprintf(fmt, args);
    --	puts(s->s.cfg.reset_color);
    -+	puts(s->cfg.reset_color);
    +-	puts(s->s.cfg.reset_color_interactive);
    ++	puts(s->cfg.reset_color_interactive);
      	va_end(args);
      }
      
    @@ add-patch.c: static int parse_diff(struct add_p_state *s, const struct pathspec
      		struct object_id oid;
      		strvec_push(&args,
     @@ add-patch.c: static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
    + 	}
    + 	strbuf_complete_line(plain);
      
    - 	if (want_color_fd(1, -1)) {
    +-	if (want_color_fd(1, s->s.cfg.use_color_diff)) {
    ++	if (want_color_fd(1, s->cfg.use_color_diff)) {
      		struct child_process colored_cp = CHILD_PROCESS_INIT;
     -		const char *diff_filter = s->s.cfg.interactive_diff_filter;
     +		const char *diff_filter = s->cfg.interactive_diff_filter;
    @@ add-patch.c: static void render_hunk(struct add_p_state *s, struct hunk *hunk,
      		if (len)
      			strbuf_add(out, p, len);
      		else if (colored)
    --			strbuf_addf(out, "%s\n", s->s.cfg.reset_color);
    -+			strbuf_addf(out, "%s\n", s->cfg.reset_color);
    +-			strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
    ++			strbuf_addf(out, "%s\n", s->cfg.reset_color_diff);
      		else
      			strbuf_addch(out, '\n');
      	}
    @@ add-patch.c: static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
     +			      s->cfg.file_new_color :
     +			      s->cfg.context_color);
      		strbuf_add(&s->colored, plain + current, eol - current);
    --		strbuf_addstr(&s->colored, s->s.cfg.reset_color);
    -+		strbuf_addstr(&s->colored, s->cfg.reset_color);
    +-		strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
    ++		strbuf_addstr(&s->colored, s->cfg.reset_color_diff);
      		if (next > eol)
      			strbuf_add(&s->colored, plain + eol, next - eol);
      		current = next;
    @@ add-patch.c: static int patch_update_file(struct add_p_state *s,
      						: 1));
      		printf(_(s->mode->prompt_mode[prompt_mode_type]),
      		       s->buf.buf);
    --		if (*s->s.cfg.reset_color)
    --			fputs(s->s.cfg.reset_color, stdout);
    -+		if (*s->cfg.reset_color)
    -+			fputs(s->cfg.reset_color, stdout);
    +-		if (*s->s.cfg.reset_color_interactive)
    +-			fputs(s->s.cfg.reset_color_interactive, stdout);
    ++		if (*s->cfg.reset_color_interactive)
    ++			fputs(s->cfg.reset_color_interactive, stdout);
      		fflush(stdout);
      		if (read_single_character(s) == EOF)
      			break;
14:  37f6cc2a69 = 10:  9948b99601 add-patch: add support for in-memory index patching
 6:  e18b8b86c0 = 11:  19dfc229c9 cache-tree: allow writing in-memory index as tree
 8:  8c6179ef85 <  -:  ---------- builtin/history: introduce subcommands to manage interrupted rewrites
 9:  f5c931a657 <  -:  ---------- builtin/history: implement "drop" subcommand
10:  3ab680968c <  -:  ---------- builtin/history: implement "reorder" subcommand
16:  a66825ed24 <  -:  ---------- sequencer: allow callers to provide mappings for the old commit
17:  731b243497 ! 12:  f72e364f92 builtin/history: implement "split" subcommand
    @@ Commit message
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
     
      ## Documentation/git-history.adoc ##
    -@@ Documentation/git-history.adoc: git history continue
    - git history quit
    - git history drop <commit>
    - git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)
    +@@ Documentation/git-history.adoc: SYNOPSIS
    + [synopsis]
    + git history [<options>]
    + git history reword [<options>] <commit>
     +git history split [<options>] <commit> [--] [<pathspec>...]
      
      DESCRIPTION
      -----------
    -@@ Documentation/git-history.adoc: child commits, as that would lead to an empty branch.
    - 	be related to one another and must be reachable from the current `HEAD`
    - 	commit.
    +@@ Documentation/git-history.adoc: rewrite history in different ways:
    + 	provided, then this command will spawn an editor with the current
    + 	message of that commit.
      
     +`split [--message=<message>] <commit> [--] [<pathspec>...]`::
     +	Interactively split up <commit> into two commits by choosing
    @@ Documentation/git-history.adoc: child commits, as that would lead to an empty br
     +It is invalid to select either all or no hunks, as that would lead to
     +one of the commits becoming empty.
     +
    - The following commands are used to manage an interrupted history-rewriting
    - operation:
    + CONFIGURATION
    + -------------
      
    -@@ Documentation/git-history.adoc: f44a46e third
    - bf7438d first
    - ----------
    +@@ Documentation/git-history.adoc: include::includes/cmd-config-section-all.adoc[]
      
    + include::config/sequencer.adoc[]
    + 
    ++EXAMPLES
    ++--------
    ++
     +Split a commit
     +~~~~~~~~~~~~~~
     +
    @@ -0,0 +1 @@
     + 1 file changed, 1 insertion(+)
     +----------
     +
    -+
    - CONFIGURATION
    - -------------
    - 
    + GIT
    + ---
    + Part of the linkgit:git[1] suite
     
      ## builtin/history.c ##
     @@
    -+/* Required for `comment_line_str`. */
    -+#define USE_THE_REPOSITORY_VARIABLE
    -+
    + #define USE_THE_REPOSITORY_VARIABLE
    + 
      #include "builtin.h"
    - #include "branch.h"
     +#include "cache-tree.h"
    - #include "commit.h"
      #include "commit-reach.h"
    + #include "commit.h"
      #include "config.h"
    -+#include "editor.h"
    - #include "environment.h"
    - #include "gettext.h"
    +@@
      #include "hex.h"
    - #include "object-name.h"
    + #include "oidmap.h"
      #include "parse-options.h"
     +#include "path.h"
    -+#include "pathspec.h"
    -+#include "read-cache-ll.h"
    ++#include "read-cache.h"
      #include "refs.h"
    + #include "replay.h"
      #include "reset.h"
      #include "revision.h"
     +#include "run-command.h"
      #include "sequencer.h"
    -+#include "sparse-index.h"
    - 
    - static int cmd_history_abort(int argc,
    - 			     const char **argv,
    -@@ builtin/history.c: static int apply_commits(struct repository *repo,
    - 			 const struct strvec *commits,
    - 			 struct commit *head,
    - 			 struct commit *base,
    -+			 const struct oidmap *rewritten_commits,
    - 			 const char *action)
    - {
    - 	struct setup_revision_opt revision_opts = {
    -@@ builtin/history.c: static int apply_commits(struct repository *repo,
    - 		replay_opts.strategy = replay_opts.default_strategy;
    - 		replay_opts.default_strategy = NULL;
    - 	}
    -+	replay_opts.old_oid_mappings = rewritten_commits;
    - 
    - 	strvec_push(&args, "");
    - 	strvec_pushv(&args, commits->v);
    -@@ builtin/history.c: static int cmd_history_drop(int argc,
    - 		if (ret < 0)
    - 			goto out;
    - 
    --		ret = apply_commits(repo, &commits, head, commit_to_drop, "drop");
    -+		ret = apply_commits(repo, &commits, head, commit_to_drop,
    -+				    NULL, "drop");
    - 		if (ret < 0)
    - 			goto out;
    - 	}
    -@@ builtin/history.c: static int cmd_history_reorder(int argc,
    - 	replace_commits(&commits, &commit_to_reorder->object.oid, NULL, 0);
    - 	replace_commits(&commits, &anchor->object.oid, replacement, ARRAY_SIZE(replacement));
    - 
    --	ret = apply_commits(repo, &commits, head, old, "reorder");
    -+	ret = apply_commits(repo, &commits, head, old, NULL, "reorder");
    - 	if (ret < 0)
    - 		goto out;
    - 
    -@@ builtin/history.c: static int cmd_history_reorder(int argc,
    + #include "strvec.h"
    + #include "tree.h"
    +@@ builtin/history.c: static int cmd_history_reword(int argc,
      	return ret;
      }
      
    -+static void change_data_free(void *util, const char *str UNUSED)
    -+{
    -+	struct wt_status_change_data *d = util;
    -+	free(d->rename_source);
    -+	free(d);
    -+}
    -+
    -+static int fill_commit_message(struct repository *repo,
    -+			       const struct object_id *old_tree,
    -+			       const struct object_id *new_tree,
    -+			       const char *default_message,
    -+			       const char *provided_message,
    -+			       const char *action,
    -+			       struct strbuf *out)
    -+{
    -+	if (!provided_message) {
    -+		const char *path = git_path_commit_editmsg();
    -+		const char *hint =
    -+			_("Please enter the commit message for the %s changes. Lines starting\n"
    -+			  "with '%s' will be kept; you may remove them yourself if you want to.\n");
    -+		int verbose = 1;
    -+
    -+		strbuf_addstr(out, default_message);
    -+		strbuf_addch(out, '\n');
    -+		strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
    -+		write_file_buf(path, out->buf, out->len);
    -+
    -+		repo_config_get_bool(repo, "commit.verbose", &verbose);
    -+		if (verbose) {
    -+			struct wt_status s;
    -+
    -+			wt_status_prepare(repo, &s);
    -+			FREE_AND_NULL(s.branch);
    -+			s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
    -+			s.commit_template = 1;
    -+			s.colopts = 0;
    -+			s.display_comment_prefix = 1;
    -+			s.hints = 0;
    -+			s.use_color = 0;
    -+			s.whence = FROM_COMMIT;
    -+			s.committable = 1;
    -+
    -+			s.fp = fopen(git_path_commit_editmsg(), "a");
    -+			if (!s.fp)
    -+				return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
    -+
    -+			wt_status_collect_changes_trees(&s, old_tree, new_tree);
    -+			wt_status_print(&s);
    -+			wt_status_collect_free_buffers(&s);
    -+			string_list_clear_func(&s.change, change_data_free);
    -+		}
    -+
    -+		strbuf_reset(out);
    -+		if (launch_editor(path, out, NULL)) {
    -+			fprintf(stderr, _("Please supply the message using the -m option.\n"));
    -+			return -1;
    -+		}
    -+		strbuf_stripspace(out, comment_line_str);
    -+	} else {
    -+		strbuf_addstr(out, provided_message);
    -+	}
    -+
    -+	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
    -+
    -+	if (!out->len) {
    -+		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
    -+		return -1;
    -+	}
    -+
    -+	return 0;
    -+}
    -+
     +static int split_commit(struct repository *repo,
     +			struct commit *original_commit,
     +			struct pathspec *pathspec,
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
     +
     +	/*
    -+	 * Construct the first commit. This is done by taking the original
    -+	 * commit parent's tree and selectively patching changes from the diff
    -+	 * between that parent and its child.
    -+	 */
    ++	* Construct the first commit. This is done by taking the original
    ++	* commit parent's tree and selectively patching changes from the diff
    ++	* between that parent and its child.
    ++	*/
     +	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
     +
     +	read_tree_cmd.git_cmd = 1;
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +	unlink(index_file.buf);
     +
     +	/*
    -+	 * We disallow the cases where either the split-out commit or the
    -+	 * original commit would become empty. Consequently, if we see that the
    -+	 * new tree ID matches either of those trees we abort.
    -+	 */
    ++	* We disallow the cases where either the split-out commit or the
    ++	* original commit would become empty. Consequently, if we see that the
    ++	* new tree ID matches either of those trees we abort.
    ++	*/
     +	if (oideq(&split_tree->object.oid, &parent_tree_oid)) {
     +		ret = error(_("split commit is empty"));
     +		goto out;
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +	}
     +
     +	/*
    -+	 * The second commit is much simpler to construct, as we can simply use
    -+	 * the original commit details, except that we adjust its parent to be
    -+	 * the newly split-out commit.
    -+	 */
    ++	* The second commit is much simpler to construct, as we can simply use
    ++	* the original commit details, except that we adjust its parent to be
    ++	* the newly split-out commit.
    ++	*/
     +	find_commit_subject(original_message, &original_body);
     +	first_commit = lookup_commit_reference(repo, &out[0]);
     +	commit_list_append(first_commit, &parents);
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +		OPT_END(),
     +	};
     +	struct oidmap rewritten_commits = OIDMAP_INIT;
    -+	struct commit *original_commit, *head;
    ++	struct commit *original_commit, *parent, *head;
     +	struct strvec commits = STRVEC_INIT;
     +	struct commit_list *list = NULL;
     +	struct object_id split_commits[2];
    -+	struct replay_oid_mapping mapping[2] = { 0 };
     +	struct pathspec pathspec = { 0 };
     +	int ret;
     +
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +		goto out;
     +	}
     +
    ++	parent = original_commit->parents ? original_commit->parents->item : NULL;
    ++	if (parent && repo_parse_commit(repo, parent)) {
    ++		ret = error(_("unable to parse commit %s"),
    ++			    oid_to_hex(&parent->object.oid));
    ++		goto out;
    ++	}
    ++
     +	head = lookup_commit_reference_by_name("HEAD");
     +	if (!head) {
     +		ret = error(_("could not resolve HEAD to a commit"));
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +	}
     +
     +	parse_pathspec(&pathspec, 0,
    -+		       PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
    -+		       prefix, argv + 1);
    ++		PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
    ++		prefix, argv + 1);
     +
     +	/*
    -+	 * Collect the list of commits that we'll have to reapply now already.
    -+	 * This ensures that we'll abort early on in case the range of commits
    -+	 * contains merges, which we do not yet handle.
    -+	 */
    -+	ret = collect_commits(repo, original_commit->parents ? original_commit->parents->item : NULL,
    -+			      head, &commits);
    ++	* Collect the list of commits that we'll have to reapply now already.
    ++	* This ensures that we'll abort early on in case the range of commits
    ++	* contains merges, which we do not yet handle.
    ++	*/
    ++	ret = collect_commits(repo, parent, head, &commits);
     +	if (ret < 0)
     +		goto out;
     +
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
     +	if (ret < 0)
     +		goto out;
     +
    -+	mapping[0].entry.oid = split_commits[0];
    -+	mapping[0].rewritten_oid = original_commit->object.oid;
    -+	oidmap_put(&rewritten_commits, &mapping[0]);
    -+	mapping[1].entry.oid = split_commits[1];
    -+	mapping[1].rewritten_oid = original_commit->object.oid;
    -+	oidmap_put(&rewritten_commits, &mapping[1]);
    -+
     +	replace_commits(&commits, &original_commit->object.oid,
     +			split_commits, ARRAY_SIZE(split_commits));
     +
    -+	ret = apply_commits(repo, &commits, head, original_commit,
    -+			    &rewritten_commits, "split");
    ++	ret = apply_commits(repo, &commits, parent, head, "split");
     +	if (ret < 0)
     +		goto out;
     +
    @@ builtin/history.c: static int cmd_history_reorder(int argc,
      		const char **argv,
      		const char *prefix,
     @@ builtin/history.c: int cmd_history(int argc,
    - 		N_("git history quit"),
    - 		N_("git history drop <commit>"),
    - 		N_("git history reorder <commit> (--before=<following-commit>|--after=<preceding-commit>)"),
    + 	const char * const usage[] = {
    + 		N_("git history [<options>]"),
    + 		N_("git history reword [<options>] <commit>"),
     +		N_("git history split [<options>] <commit> [--] [<pathspec>...]"),
      		NULL,
      	};
      	parse_opt_subcommand_fn *fn = NULL;
    -@@ builtin/history.c: int cmd_history(int argc,
    - 		OPT_SUBCOMMAND("quit", &fn, cmd_history_quit),
    - 		OPT_SUBCOMMAND("drop", &fn, cmd_history_drop),
    - 		OPT_SUBCOMMAND("reorder", &fn, cmd_history_reorder),
    + 	struct option options[] = {
    + 		OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
     +		OPT_SUBCOMMAND("split", &fn, cmd_history_split),
      		OPT_END(),
      	};
    @@ builtin/history.c: int cmd_history(int argc,
     
      ## t/meson.build ##
     @@ t/meson.build: integration_tests = [
    +   't3438-rebase-broken-files.sh',
        't3450-history.sh',
    -   't3451-history-drop.sh',
    -   't3452-history-reorder.sh',
    -+  't3453-history-split.sh',
    +   't3451-history-reword.sh',
    ++  't3452-history-split.sh',
        't3500-cherry.sh',
        't3501-revert-cherry-pick.sh',
        't3502-cherry-pick-merge.sh',
     
    - ## t/t3453-history-split.sh (new) ##
    + ## t/t3452-history-split.sh (new) ##
     @@
     +#!/bin/sh
     +
    @@ t/t3453-history-split.sh (new)
     +	)
     +'
     +
    -+test_expect_success 'refuses to work with changes in the worktree or index' '
    -+	test_when_finished "rm -rf repo" &&
    -+	git init repo &&
    -+	(
    -+		cd repo &&
    -+		test_commit initial &&
    -+		touch bar foo &&
    -+		git add . &&
    -+		git commit -m split-me &&
    -+
    -+		echo changed >bar &&
    -+		test_must_fail git history split -m message HEAD 2>err <<-EOF &&
    -+		y
    -+		n
    -+		EOF
    -+		test_grep "Your local changes to the following files would be overwritten" err &&
    -+
    -+		git add bar &&
    -+		test_must_fail git history split -m message HEAD 2>err <<-EOF &&
    -+		y
    -+		n
    -+		EOF
    -+		test_grep "Your local changes to the following files would be overwritten" err
    -+	)
    -+'
    -+
     +test_expect_success 'can split up tip commit' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3453-history-split.sh (new)
     +	)
     +'
     +
    -+test_expect_success 'skips change summary with commit.verbose=false' '
    -+	test_when_finished "rm -rf repo" &&
    -+	git init repo &&
    -+	(
    -+		cd repo &&
    -+		touch bar foo &&
    -+		git add . &&
    -+		git commit -m split-me &&
    -+
    -+		write_script fake-editor.sh <<-\EOF &&
    -+		cp "$1" . &&
    -+		echo "some commit message" >>"$1"
    -+		EOF
    -+		test_set_editor "$(pwd)"/fake-editor.sh &&
    -+
    -+		git -c commit.verbose=false history split HEAD <<-EOF &&
    -+		y
    -+		n
    -+		EOF
    -+
    -+		cat >expect <<-EOF &&
    -+
    -+		# Please enter the commit message for the split-out changes. Lines starting
    -+		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
    -+		EOF
    -+		test_cmp expect COMMIT_EDITMSG &&
    -+
    -+		expect_log <<-EOF
    -+		split-me
    -+		some commit message
    -+		EOF
    -+	)
    -+'
    -+
     +test_expect_success 'can use pathspec to limit what gets split' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3453-history-split.sh (new)
     +		old_head=$(git rev-parse HEAD) &&
     +
     +		write_script .git/hooks/prepare-commit-msg <<-EOF &&
    -+		echo "prepare-commit-msg: \$@" >>"$(pwd)/hooks.log"
    ++		touch "$(pwd)/hooks.log"
     +		EOF
     +		write_script .git/hooks/post-commit <<-EOF &&
    -+		echo "post-commit" >>"$(pwd)/hooks.log"
    ++		touch "$(pwd)/hooks.log"
     +		EOF
     +		write_script .git/hooks/post-rewrite <<-EOF &&
    -+		{
    -+			echo "post-rewrite: \$@"
    -+			cat
    -+		} >>"$(pwd)/hooks.log"
    ++		touch "$(pwd)/hooks.log"
     +		EOF
     +
     +		set_fake_editor &&
    @@ t/t3453-history-split.sh (new)
     +		split-out commit
     +		EOF
     +
    -+		cat >expect <<-EOF &&
    -+		prepare-commit-msg: .git/COMMIT_EDITMSG message
    -+		post-commit
    -+		prepare-commit-msg: .git/COMMIT_EDITMSG message
    -+		post-commit
    -+		post-rewrite: history
    -+		$old_head $(git rev-parse HEAD~)
    -+		$old_head $(git rev-parse HEAD)
    -+		EOF
    -+		test_cmp expect hooks.log
    ++		test_path_is_missing hooks.log
     +	)
     +'
     +
    @@ t/t3453-history-split.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'retains changes in the worktree and index' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		echo a >a &&
    ++		echo b >b &&
    ++		git add . &&
    ++		git commit -m "initial commit" &&
    ++		echo a-modified >a &&
    ++		echo b-modified >b &&
    ++		git add b &&
    ++		git history split HEAD -m a-only <<-EOF &&
    ++		y
    ++		n
    ++		EOF
    ++
    ++		expect_tree_entries HEAD~ <<-EOF &&
    ++		a
    ++		EOF
    ++		expect_tree_entries HEAD <<-EOF &&
    ++		a
    ++		b
    ++		EOF
    ++
    ++		cat >expect <<-\EOF &&
    ++		 M a
    ++		M  b
    ++		?? actual
    ++		?? expect
    ++		EOF
    ++		git status --porcelain >actual &&
    ++		test_cmp expect actual
    ++	)
    ++'
    ++
     +test_done

--- base-commit: 896ecf94ed941815749901ff6ad06d7141904cb2 change-id: 20250819-b4-pks-history-builtin-83398f9a05f0

Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 01/12] wt-status: provide function to expose status for trees

The "wt-status" subsystem is responsible for printing status information around the current state of the working tree. This most importantly includes information around whether the working tree or the index have any changes.

We're about to introduce a new command though where the changes in neither of them are actually relevant to us. Instead, what we want is to format the changes between two different trees. While it is a little bit of a stretch to add this as functionality to _working tree_ status, it doesn't make any sense to open-code this functionality, either.

Implement a new function `wt_status_collect_changes_trees()` that diffs two trees and formats the status accordingly. This function is not yet used, but will be in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 wt-status.c | 24 ++++++++++++++++++++++++
 wt-status.h |  3 +++
 2 files changed, 27 insertions(+)
Show changes to 2 files +27 −0

wt-status.c, wt-status.h

diff --git a/wt-status.c b/wt-status.c
index 8ffe6d3988..b66edbfca6 100644
--- a/wt-status.c
+++ b/wt-status.c
@@ -612,6 +612,30 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q,
 	}
 }
 
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish)
+{
+	struct diff_options opts = { 0 };
+
+	repo_diff_setup(s->repo, &opts);
+	opts.output_format = DIFF_FORMAT_CALLBACK;
+	opts.format_callback = wt_status_collect_updated_cb;
+	opts.format_callback_data = s;
+	opts.detect_rename = s->detect_rename >= 0 ? s->detect_rename : opts.detect_rename;
+	opts.rename_limit = s->rename_limit >= 0 ? s->rename_limit : opts.rename_limit;
+	opts.rename_score = s->rename_score >= 0 ? s->rename_score : opts.rename_score;
+	opts.flags.recursive = 1;
+	diff_setup_done(&opts);
+
+	diff_tree_oid(old_treeish, new_treeish, "", &opts);
+	diffcore_std(&opts);
+	diff_flush(&opts);
+	wt_status_get_state(s->repo, &s->state, 0);
+
+	diff_free(&opts);
+}
+
 static void wt_status_collect_changes_worktree(struct wt_status *s)
 {
 	struct rev_info rev;
diff --git a/wt-status.h b/wt-status.h
index e40a27214a..924d7a5fa9 100644
--- a/wt-status.h
+++ b/wt-status.h
@@ -153,6 +153,9 @@ void wt_status_add_cut_line(struct wt_status *s);
 void wt_status_prepare(struct repository *r, struct wt_status *s);
 void wt_status_print(struct wt_status *s);
 void wt_status_collect(struct wt_status *s);
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish);
 /*
  * Frees the buffers allocated by wt_status_collect.
  */
-- 
2.51.0.700.g236ee7b076.dirty
Karthik Nayak· Oct 14, 2025, 08:49 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 01/12] wt-status: provide function to expose status for trees

Patrick Steinhardt <ps@pks.im> writes:
Show 6 quoted lines
> The "wt-status" subsystem is responsible for printing status information
> around the current state of the working tree. This most importantly
> includes information around whether the working tree or the index have
> any changes.
>
> We're about to introduce a new command though where the changes in
Nit: s/though//
Show 27 quoted lines
> neither of them are actually relevant to us. Instead, what we want is to
> format the changes between two different trees. While it is a little bit
> of a stretch to add this as functionality to _working tree_ status, it
> doesn't make any sense to open-code this functionality, either.
>
> Implement a new function `wt_status_collect_changes_trees()` that diffs
> two trees and formats the status accordingly. This function is not yet
> used, but will be in a subsequent commit.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  wt-status.c | 24 ++++++++++++++++++++++++
>  wt-status.h |  3 +++
>  2 files changed, 27 insertions(+)
>
> diff --git a/wt-status.c b/wt-status.c
> index 8ffe6d3988..b66edbfca6 100644
> --- a/wt-status.c
> +++ b/wt-status.c
> @@ -612,6 +612,30 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q,
>  	}
>  }
>
> +void wt_status_collect_changes_trees(struct wt_status *s,
> +				     const struct object_id *old_treeish,
> +				     const struct object_id *new_treeish)
> +{

So, my understanding here is that we want to diff two trees `old_treeish` and `new_treeish` and then finally store the status change in `wt_status`

Show 9 quoted lines
> +	struct diff_options opts = { 0 };
> +
> +	repo_diff_setup(s->repo, &opts);
> +	opts.output_format = DIFF_FORMAT_CALLBACK;
> +	opts.format_callback = wt_status_collect_updated_cb;
> +	opts.format_callback_data = s;
> +	opts.detect_rename = s->detect_rename >= 0 ? s->detect_rename : opts.detect_rename;
> +	opts.rename_limit = s->rename_limit >= 0 ? s->rename_limit : opts.rename_limit;
> +	opts.rename_score = s->rename_score >= 0 ? s->rename_score : opts.rename_score;
Curious, why do we need a '>= 0' check here?
> +	opts.flags.recursive = 1;
> +	diff_setup_done(&opts);
> +
>

So first we setup the diff options, with the right callbacks so that the relevant information is added to the `wt_status`.

> +	diff_tree_oid(old_treeish, new_treeish, "", &opts);
> +	diffcore_std(&opts);
> +	diff_flush(&opts);

This is the part which calls the callback function with the relevant information and callback data.

> +	wt_status_get_state(s->repo, &s->state, 0);
> +

Based on the list of diff data in `s->change`, we add the status print information. Okay makes sense.

Show 23 quoted lines
> +	diff_free(&opts);
> +}
> +
>  static void wt_status_collect_changes_worktree(struct wt_status *s)
>  {
>  	struct rev_info rev;
> diff --git a/wt-status.h b/wt-status.h
> index e40a27214a..924d7a5fa9 100644
> --- a/wt-status.h
> +++ b/wt-status.h
> @@ -153,6 +153,9 @@ void wt_status_add_cut_line(struct wt_status *s);
>  void wt_status_prepare(struct repository *r, struct wt_status *s);
>  void wt_status_print(struct wt_status *s);
>  void wt_status_collect(struct wt_status *s);
> +void wt_status_collect_changes_trees(struct wt_status *s,
> +				     const struct object_id *old_treeish,
> +				     const struct object_id *new_treeish);
>  /*
>   * Frees the buffers allocated by wt_status_collect.
>   */
>
> --
> 2.51.0.700.g236ee7b076.dirty
So this function will be used in an upcoming patch, looks good.
Patrick Steinhardt· Oct 21, 2025, 11:43 UTC · re: Karthik Nayak · lore

Re: [PATCH v4 01/12] wt-status: provide function to expose status for trees

On Tue, Oct 14, 2025 at 04:49:14AM -0400, Karthik Nayak wrote:
Show 17 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
> > diff --git a/wt-status.c b/wt-status.c
> > index 8ffe6d3988..b66edbfca6 100644
> > --- a/wt-status.c
> > +++ b/wt-status.c
> > @@ -612,6 +612,30 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q,
> >  	}
> >  }
> >
> > +void wt_status_collect_changes_trees(struct wt_status *s,
> > +				     const struct object_id *old_treeish,
> > +				     const struct object_id *new_treeish)
> > +{
> 
> So, my understanding here is that we want to diff two trees
> `old_treeish` and `new_treeish` and then finally store the status change
> in `wt_status`
Exactly.
Show 11 quoted lines
> > +	struct diff_options opts = { 0 };
> > +
> > +	repo_diff_setup(s->repo, &opts);
> > +	opts.output_format = DIFF_FORMAT_CALLBACK;
> > +	opts.format_callback = wt_status_collect_updated_cb;
> > +	opts.format_callback_data = s;
> > +	opts.detect_rename = s->detect_rename >= 0 ? s->detect_rename : opts.detect_rename;
> > +	opts.rename_limit = s->rename_limit >= 0 ? s->rename_limit : opts.rename_limit;
> > +	opts.rename_score = s->rename_score >= 0 ? s->rename_score : opts.rename_score;
> 
> Curious, why do we need a '>= 0' check here?

I'm mostly just mirroring the same behaviour that we already have in `wt_status_collect_chanegs_worktree()`.

Patrick
Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 02/12] replay: extract logic to pick commits

We're about to add a new git-history(1) command that will reuse some of the same infrastructure as git-replay(1). To prepare for this, extract the logic to pick a commit into a new "replay.c" file so that it can be shared between both commands.

Rename the function to have a "replay_" prefix to clearly indicate its subsystem.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Makefile         |   1 +
 builtin/replay.c | 110 ++--------------------------------------------------
 meson.build      |   1 +
 replay.c         | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 replay.h         |  18 +++++++++
 5 files changed, 138 insertions(+), 107 deletions(-)
Show changes to 5 files +138 −107

Makefile, builtin/replay.c, meson.build, replay.c, replay.h

diff --git a/Makefile b/Makefile
index 4c95affadb..5960c80736 100644
--- a/Makefile
+++ b/Makefile
@@ -1137,6 +1137,7 @@ LIB_OBJS += refs/ref-cache.o
 LIB_OBJS += refspec.o
 LIB_OBJS += remote.o
 LIB_OBJS += replace-object.o
+LIB_OBJS += replay.o
 LIB_OBJS += repo-settings.o
 LIB_OBJS += repository.o
 LIB_OBJS += rerere.o
diff --git a/builtin/replay.c b/builtin/replay.c
index b6f9d53560..e9d6559b47 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -2,7 +2,6 @@
  * "git replay" builtin command
  */
 
-#define USE_THE_REPOSITORY_VARIABLE
 #define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
@@ -15,18 +14,12 @@
 #include "object-name.h"
 #include "parse-options.h"
 #include "refs.h"
+#include "replay.h"
 #include "revision.h"
 #include "strmap.h"
 #include <oidset.h>
 #include <tree.h>
 
-static const char *short_commit_name(struct repository *repo,
-				     struct commit *commit)
-{
-	return repo_find_unique_abbrev(repo, &commit->object.oid,
-				       DEFAULT_ABBREV);
-}
-
 static struct commit *peel_committish(struct repository *repo, const char *name)
 {
 	struct object *obj;
@@ -39,59 +32,6 @@ static struct commit *peel_committish(struct repository *repo, const char *name)
 						  OBJ_COMMIT);
 }
 
-static char *get_author(const char *message)
-{
-	size_t len;
-	const char *a;
-
-	a = find_commit_header(message, "author", &len);
-	if (a)
-		return xmemdupz(a, len);
-
-	return NULL;
-}
-
-static struct commit *create_commit(struct repository *repo,
-				    struct tree *tree,
-				    struct commit *based_on,
-				    struct commit *parent)
-{
-	struct object_id ret;
-	struct object *obj = NULL;
-	struct commit_list *parents = NULL;
-	char *author;
-	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
-	struct commit_extra_header *extra = NULL;
-	struct strbuf msg = STRBUF_INIT;
-	const char *out_enc = get_commit_output_encoding();
-	const char *message = repo_logmsg_reencode(repo, based_on,
-						   NULL, out_enc);
-	const char *orig_message = NULL;
-	const char *exclude_gpgsig[] = { "gpgsig", NULL };
-
-	commit_list_insert(parent, &parents);
-	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
-	find_commit_subject(message, &orig_message);
-	strbuf_addstr(&msg, orig_message);
-	author = get_author(message);
-	reset_ident_date();
-	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
-				 &ret, author, NULL, sign_commit, extra)) {
-		error(_("failed to write commit object"));
-		goto out;
-	}
-
-	obj = parse_object(repo, &ret);
-
-out:
-	repo_unuse_commit_buffer(the_repository, based_on, message);
-	free_commit_extra_headers(extra);
-	free_commit_list(parents);
-	strbuf_release(&msg);
-	free(author);
-	return (struct commit *)obj;
-}
-
 struct ref_info {
 	struct commit *onto;
 	struct strset positive_refs;
@@ -240,50 +180,6 @@ static void determine_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
-{
-	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
-	if (pos == kh_end(replayed_commits))
-		return fallback;
-	return kh_value(replayed_commits, pos);
-}
-
-static struct commit *pick_regular_commit(struct repository *repo,
-					  struct commit *pickme,
-					  kh_oid_map_t *replayed_commits,
-					  struct commit *onto,
-					  struct merge_options *merge_opt,
-					  struct merge_result *result)
-{
-	struct commit *base, *replayed_base;
-	struct tree *pickme_tree, *base_tree;
-
-	base = pickme->parents->item;
-	replayed_base = mapped_commit(replayed_commits, base, onto);
-
-	result->tree = repo_get_commit_tree(repo, replayed_base);
-	pickme_tree = repo_get_commit_tree(repo, pickme);
-	base_tree = repo_get_commit_tree(repo, base);
-
-	merge_opt->branch1 = short_commit_name(repo, replayed_base);
-	merge_opt->branch2 = short_commit_name(repo, pickme);
-	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
-
-	merge_incore_nonrecursive(merge_opt,
-				  base_tree,
-				  result->tree,
-				  pickme_tree,
-				  result);
-
-	free((char*)merge_opt->ancestor);
-	merge_opt->ancestor = NULL;
-	if (!result->clean)
-		return NULL;
-	return create_commit(repo, result->tree, pickme, replayed_base);
-}
-
 static int add_ref_to_transaction(struct ref_transaction *transaction,
 				  const char *refname,
 				  const struct object_id *new_oid,
@@ -459,8 +355,8 @@ int cmd_replay(int argc,
 		if (commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
 
-		last_commit = pick_regular_commit(repo, commit, replayed_commits,
-						  onto, &merge_opt, &result);
+		last_commit = replay_pick_regular_commit(repo, commit, replayed_commits,
+							 onto, &merge_opt, &result);
 		if (!last_commit)
 			break;
 
diff --git a/meson.build b/meson.build
index b3dfcc0497..c320bdba9f 100644
--- a/meson.build
+++ b/meson.build
@@ -463,6 +463,7 @@ libgit_sources = [
   'reftable/writer.c',
   'remote.c',
   'replace-object.c',
+  'replay.c',
   'repo-settings.c',
   'repository.c',
   'rerere.c',
diff --git a/replay.c b/replay.c
new file mode 100644
index 0000000000..e22ce39940
--- /dev/null
+++ b/replay.c
@@ -0,0 +1,115 @@
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "git-compat-util.h"
+#include "commit.h"
+#include "environment.h"
+#include "gettext.h"
+#include "ident.h"
+#include "object.h"
+#include "object-name.h"
+#include "replay.h"
+#include "tree.h"
+
+static const char *short_commit_name(struct repository *repo,
+				     struct commit *commit)
+{
+	return repo_find_unique_abbrev(repo, &commit->object.oid,
+				       DEFAULT_ABBREV);
+}
+
+static char *get_author(const char *message)
+{
+	size_t len;
+	const char *a;
+
+	a = find_commit_header(message, "author", &len);
+	if (a)
+		return xmemdupz(a, len);
+
+	return NULL;
+}
+
+static struct commit *create_commit(struct repository *repo,
+				    struct tree *tree,
+				    struct commit *based_on,
+				    struct commit *parent)
+{
+	struct object_id ret;
+	struct object *obj = NULL;
+	struct commit_list *parents = NULL;
+	char *author;
+	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
+	struct commit_extra_header *extra = NULL;
+	struct strbuf msg = STRBUF_INIT;
+	const char *out_enc = get_commit_output_encoding();
+	const char *message = repo_logmsg_reencode(repo, based_on,
+						   NULL, out_enc);
+	const char *orig_message = NULL;
+	const char *exclude_gpgsig[] = { "gpgsig", NULL };
+
+	commit_list_insert(parent, &parents);
+	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
+	find_commit_subject(message, &orig_message);
+	strbuf_addstr(&msg, orig_message);
+	author = get_author(message);
+	reset_ident_date();
+	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
+				 &ret, author, NULL, sign_commit, extra)) {
+		error(_("failed to write commit object"));
+		goto out;
+	}
+
+	obj = parse_object(repo, &ret);
+
+out:
+	repo_unuse_commit_buffer(the_repository, based_on, message);
+	free_commit_extra_headers(extra);
+	free_commit_list(parents);
+	strbuf_release(&msg);
+	free(author);
+	return (struct commit *)obj;
+}
+
+static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
+				    struct commit *commit,
+				    struct commit *fallback)
+{
+	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
+	if (pos == kh_end(replayed_commits))
+		return fallback;
+	return kh_value(replayed_commits, pos);
+}
+
+struct commit *replay_pick_regular_commit(struct repository *repo,
+					  struct commit *pickme,
+					  kh_oid_map_t *replayed_commits,
+					  struct commit *onto,
+					  struct merge_options *merge_opt,
+					  struct merge_result *result)
+{
+	struct commit *base, *replayed_base;
+	struct tree *pickme_tree, *base_tree;
+
+	base = pickme->parents->item;
+	replayed_base = mapped_commit(replayed_commits, base, onto);
+
+	result->tree = repo_get_commit_tree(repo, replayed_base);
+	pickme_tree = repo_get_commit_tree(repo, pickme);
+	base_tree = repo_get_commit_tree(repo, base);
+
+	merge_opt->branch1 = short_commit_name(repo, replayed_base);
+	merge_opt->branch2 = short_commit_name(repo, pickme);
+	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
+
+	merge_incore_nonrecursive(merge_opt,
+				  base_tree,
+				  result->tree,
+				  pickme_tree,
+				  result);
+
+	free((char*)merge_opt->ancestor);
+	merge_opt->ancestor = NULL;
+	if (!result->clean)
+		return NULL;
+	return create_commit(repo, result->tree, pickme, replayed_base);
+}
diff --git a/replay.h b/replay.h
new file mode 100644
index 0000000000..a461b5c234
--- /dev/null
+++ b/replay.h
@@ -0,0 +1,18 @@
+#ifndef REPLAY_H
+#define REPLAY_H
+
+#include "khash.h"
+#include "merge-ort.h"
+#include "repository.h"
+
+struct commit;
+struct tree;
+
+struct commit *replay_pick_regular_commit(struct repository *repo,
+					  struct commit *pickme,
+					  kh_oid_map_t *replayed_commits,
+					  struct commit *onto,
+					  struct merge_options *merge_opt,
+					  struct merge_result *result);
+
+#endif
-- 
2.51.0.700.g236ee7b076.dirty
Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 03/12] replay: stop using `the_repository`

In `create_commit()` we're using `the_repository` even though we already have a repository passed to use as an argument. Fix this.

Note that we still cannot get rid of `USE_THE_REPOSITORY_VARIABLE`. This is because we use `DEFAULT_ABBREV and `get_commit_output_encoding()`, both of which are stored as global variables that can be modified via the Git configuration.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 replay.c | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
Show changes to replay.c +1 −1
diff --git a/replay.c b/replay.c
index e22ce39940..13d75d8054 100644
--- a/replay.c
+++ b/replay.c
@@ -62,7 +62,7 @@ static struct commit *create_commit(struct repository *repo,
 	obj = parse_object(repo, &ret);
 
 out:
-	repo_unuse_commit_buffer(the_repository, based_on, message);
+	repo_unuse_commit_buffer(repo, based_on, message);
 	free_commit_extra_headers(extra);
 	free_commit_list(parents);
 	strbuf_release(&msg);
-- 
2.51.0.700.g236ee7b076.dirty
Karthik Nayak· Oct 14, 2025, 08:53 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 03/12] replay: stop using `the_repository`

Patrick Steinhardt <ps@pks.im> writes:
> In `create_commit()` we're using `the_repository` even though we already
> have a repository passed to use as an argument. Fix this.
>

I was thinking about this as I read your previous patch. This makes sense.

Show 5 quoted lines
> Note that we still cannot get rid of `USE_THE_REPOSITORY_VARIABLE`. This
> is because we use `DEFAULT_ABBREV and `get_commit_output_encoding()`,
> both of which are stored as global variables that can be modified via
> the Git configuration.
>
That's a bummer. But one less step needed is a win.
Show 21 quoted lines
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  replay.c | 2 +-
>  1 file changed, 1 insertion(+), 1 deletion(-)
>
> diff --git a/replay.c b/replay.c
> index e22ce39940..13d75d8054 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -62,7 +62,7 @@ static struct commit *create_commit(struct repository *repo,
>  	obj = parse_object(repo, &ret);
>
>  out:
> -	repo_unuse_commit_buffer(the_repository, based_on, message);
> +	repo_unuse_commit_buffer(repo, based_on, message);
>  	free_commit_extra_headers(extra);
>  	free_commit_list(parents);
>  	strbuf_release(&msg);
>
> --
> 2.51.0.700.g236ee7b076.dirty
Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 04/12] replay: parse commits before dereferencing them

When looking up a commit it may not be parsed yet. Callers that wish to access the fields of `struct commit` have to call `repo_parse_commit()` first so that it is guaranteed to be populated.

We didn't yet care about doing so, because code paths that lead to `pick_regular_commit()` in "builtin/replay.c" already implicitly parsed the commits. But now that the function is exposed to outside callers it's quite easy to get this wrong.

Make the function easier to use by calling `repo_parse_commit()`.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 replay.c | 3 +++
 1 file changed, 3 insertions(+)
Show changes to replay.c +3 −0
diff --git a/replay.c b/replay.c
index 13d75d8054..c3628d2488 100644
--- a/replay.c
+++ b/replay.c
@@ -90,6 +90,9 @@ struct commit *replay_pick_regular_commit(struct repository *repo,
 	struct commit *base, *replayed_base;
 	struct tree *pickme_tree, *base_tree;
 
+	if (repo_parse_commit(repo, pickme))
+		return NULL;
+
 	base = pickme->parents->item;
 	replayed_base = mapped_commit(replayed_commits, base, onto);
 
-- 
2.51.0.700.g236ee7b076.dirty
Karthik Nayak· Oct 14, 2025, 08:57 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 04/12] replay: parse commits before dereferencing them

Patrick Steinhardt <ps@pks.im> writes:
Show 9 quoted lines
> When looking up a commit it may not be parsed yet. Callers that wish to
> access the fields of `struct commit` have to call `repo_parse_commit()`
> first so that it is guaranteed to be populated.
>
> We didn't yet care about doing so, because code paths that lead to
> `pick_regular_commit()` in "builtin/replay.c" already implicitly parsed
> the commits. But now that the function is exposed to outside callers
> it's quite easy to get this wrong.
>

So I was wondering, wouldn't this duplicate the call made to `pick_regular_commit()` and end up parsing the commit twice. But seems like down the stack in `repo_parse_commit_internal()`, we check for `item->object.parsed` and only parse if it hasn't been already parsed. So this change is welcome.

Show 24 quoted lines
> Make the function easier to use by calling `repo_parse_commit()`.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  replay.c | 3 +++
>  1 file changed, 3 insertions(+)
>
> diff --git a/replay.c b/replay.c
> index 13d75d8054..c3628d2488 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -90,6 +90,9 @@ struct commit *replay_pick_regular_commit(struct repository *repo,
>  	struct commit *base, *replayed_base;
>  	struct tree *pickme_tree, *base_tree;
>
> +	if (repo_parse_commit(repo, pickme))
> +		return NULL;
> +
>  	base = pickme->parents->item;
>  	replayed_base = mapped_commit(replayed_commits, base, onto);
>
>
> --
> 2.51.0.700.g236ee7b076.dirty
Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 05/12] builtin: add new "history" command

When rewriting history via git-rebase(1) there are a couple of very common use cases:

  - The ordering of two commits should be reversed.
  - A commit should be split up into two commits.
  - A commit should be dropped from the history completely.
  - Multiple commits should be squashed into one.

While these operations are all doable, it often feels needlessly cludgy to do so by doing an interactive rebase, using the editor to say what one wants, and then perform the actions. Furthermore, some operations like splitting up a commit into two are way more involved than that and require a whole series of commands.

Add a new "history" command to plug this gap. This command will have several different subcommands to imperatively rewrite history for common use cases like the above. These commands will be implemented in subsequent commits.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 .gitignore                     |  1 +
 Documentation/git-history.adoc | 45 ++++++++++++++++++++++++++++++++++++++++++
 Documentation/meson.build      |  1 +
 Makefile                       |  1 +
 builtin.h                      |  1 +
 builtin/history.c              | 22 +++++++++++++++++++++
 command-list.txt               |  1 +
 git.c                          |  1 +
 meson.build                    |  1 +
 t/meson.build                  |  1 +
 t/t3450-history.sh             | 17 ++++++++++++++++
 11 files changed, 92 insertions(+)
Show changes to 11 files +92 −0

.gitignore, Documentation/git-history.adoc, Documentation/meson.build, Makefile, builtin.h, builtin/history.c, command-list.txt, git.c, meson.build, t/meson.build, t/t3450-history.sh

diff --git a/.gitignore b/.gitignore
index 802ce70e48..3de9f9f16f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -77,6 +77,7 @@
 /git-grep
 /git-hash-object
 /git-help
+/git-history
 /git-hook
 /git-http-backend
 /git-http-fetch
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
new file mode 100644
index 0000000000..1537960374
--- /dev/null
+++ b/Documentation/git-history.adoc
@@ -0,0 +1,45 @@
+git-history(1)
+==============
+
+NAME
+----
+git-history - EXPERIMENTAL: Rewrite history of the current branch
+
+SYNOPSIS
+--------
+[synopsis]
+git history [<options>]
+
+DESCRIPTION
+-----------
+
+Rewrite history by rearranging or modifying specific commits in the
+history.
+
+This command is similar to linkgit:git-rebase[1] and uses the same
+underlying machinery. You should use rebases if you either want to
+reapply a range of commits onto a different base, or interactive rebases
+if you want to edit a range of commits.
+
+Note that this command does not (yet) work with histories that contain
+merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
+flag instead.
+
+THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
+
+COMMANDS
+--------
+
+This command requires a subcommand. Several subcommands are available to
+rewrite history in different ways:
+
+CONFIGURATION
+-------------
+
+include::includes/cmd-config-section-all.adoc[]
+
+include::config/sequencer.adoc[]
+
+GIT
+---
+Part of the linkgit:git[1] suite
diff --git a/Documentation/meson.build b/Documentation/meson.build
index e34965c5b0..36500879e4 100644
--- a/Documentation/meson.build
+++ b/Documentation/meson.build
@@ -64,6 +64,7 @@ manpages = {
   'git-gui.adoc' : 1,
   'git-hash-object.adoc' : 1,
   'git-help.adoc' : 1,
+  'git-history.adoc' : 1,
   'git-hook.adoc' : 1,
   'git-http-backend.adoc' : 1,
   'git-http-fetch.adoc' : 1,
diff --git a/Makefile b/Makefile
index 5960c80736..4e405509e9 100644
--- a/Makefile
+++ b/Makefile
@@ -1262,6 +1262,7 @@ BUILTIN_OBJS += builtin/get-tar-commit-id.o
 BUILTIN_OBJS += builtin/grep.o
 BUILTIN_OBJS += builtin/hash-object.o
 BUILTIN_OBJS += builtin/help.o
+BUILTIN_OBJS += builtin/history.o
 BUILTIN_OBJS += builtin/hook.o
 BUILTIN_OBJS += builtin/index-pack.o
 BUILTIN_OBJS += builtin/init-db.o
diff --git a/builtin.h b/builtin.h
index 1b35565fbd..93c91d07d4 100644
--- a/builtin.h
+++ b/builtin.h
@@ -172,6 +172,7 @@ int cmd_get_tar_commit_id(int argc, const char **argv, const char *prefix, struc
 int cmd_grep(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hash_object(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_help(int argc, const char **argv, const char *prefix, struct repository *repo);
+int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hook(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);
diff --git a/builtin/history.c b/builtin/history.c
new file mode 100644
index 0000000000..f6fe32610b
--- /dev/null
+++ b/builtin/history.c
@@ -0,0 +1,22 @@
+#include "builtin.h"
+#include "gettext.h"
+#include "parse-options.h"
+
+int cmd_history(int argc,
+		const char **argv,
+		const char *prefix,
+		struct repository *repo UNUSED)
+{
+	const char * const usage[] = {
+		N_("git history [<options>]"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc)
+		usagef("unrecognized argument: %s", argv[0]);
+	return 0;
+}
diff --git a/command-list.txt b/command-list.txt
index accd3d0c4b..f9005cf459 100644
--- a/command-list.txt
+++ b/command-list.txt
@@ -115,6 +115,7 @@ git-grep                                mainporcelain           info
 git-gui                                 mainporcelain
 git-hash-object                         plumbingmanipulators
 git-help                                ancillaryinterrogators          complete
+git-history                             mainporcelain           history
 git-hook                                purehelpers
 git-http-backend                        synchingrepositories
 git-http-fetch                          synchelpers
diff --git a/git.c b/git.c
index d020eef021..c7c13cea67 100644
--- a/git.c
+++ b/git.c
@@ -560,6 +560,7 @@ static struct cmd_struct commands[] = {
 	{ "grep", cmd_grep, RUN_SETUP_GENTLY },
 	{ "hash-object", cmd_hash_object },
 	{ "help", cmd_help },
+	{ "history", cmd_history, RUN_SETUP },
 	{ "hook", cmd_hook, RUN_SETUP },
 	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
 	{ "init", cmd_init_db },
diff --git a/meson.build b/meson.build
index c320bdba9f..7630a8fb0a 100644
--- a/meson.build
+++ b/meson.build
@@ -604,6 +604,7 @@ builtin_sources = [
   'builtin/grep.c',
   'builtin/hash-object.c',
   'builtin/help.c',
+  'builtin/history.c',
   'builtin/hook.c',
   'builtin/index-pack.c',
   'builtin/init-db.c',
diff --git a/t/meson.build b/t/meson.build
index 7974795fe4..8b31eb0858 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -382,6 +382,7 @@ integration_tests = [
   't3436-rebase-more-options.sh',
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
+  't3450-history.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
new file mode 100755
index 0000000000..417c343d43
--- /dev/null
+++ b/t/t3450-history.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+test_description='tests for git-history command'
+
+. ./test-lib.sh
+
+test_expect_success 'does nothing without any arguments' '
+	git history >out 2>&1 &&
+	test_must_be_empty out
+'
+
+test_expect_success 'raises an error with unknown argument' '
+	test_must_fail git history garbage 2>err &&
+	test_grep "unrecognized argument: garbage" err
+'
+
+test_done
-- 
2.51.0.700.g236ee7b076.dirty
Kristoffer Haugsbakk· Oct 2, 2025, 09:26 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 05/12] builtin: add new "history" command

On Wed, Oct 1, 2025, at 17:57, Patrick Steinhardt wrote:
>[snip]
>
> While these operations are all doable, it often feels needlessly cludgy
s/cludgy/kludgey/
Show 6 quoted lines
> to do so by doing an interactive rebase, using the editor to say what
> one wants, and then perform the actions. Furthermore, some operations
> like splitting up a commit into two are way more involved than that and
> require a whole series of commands.
>
>[snip]
Karthik Nayak· Oct 14, 2025, 09:07 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 05/12] builtin: add new "history" command

Patrick Steinhardt <ps@pks.im> writes:
Show 20 quoted lines
> When rewriting history via git-rebase(1) there are a couple of very
> common use cases:
>
>   - The ordering of two commits should be reversed.
>
>   - A commit should be split up into two commits.
>
>   - A commit should be dropped from the history completely.
>
>   - Multiple commits should be squashed into one.
>
> While these operations are all doable, it often feels needlessly cludgy
> to do so by doing an interactive rebase, using the editor to say what
> one wants, and then perform the actions. Furthermore, some operations
> like splitting up a commit into two are way more involved than that and
> require a whole series of commands.
>
> Add a new "history" command to plug this gap. This command will have
> several different subcommands to imperatively rewrite history for common
> use cases like the above. These commands will be implemented in
Nit: s/commands/subcommands
> subsequent commits.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
[snip]
Show 29 quoted lines
> new file mode 100644
> index 0000000000..1537960374
> --- /dev/null
> +++ b/Documentation/git-history.adoc
> @@ -0,0 +1,45 @@
> +git-history(1)
> +==============
> +
> +NAME
> +----
> +git-history - EXPERIMENTAL: Rewrite history of the current branch
> +
> +SYNOPSIS
> +--------
> +[synopsis]
> +git history [<options>]
> +
> +DESCRIPTION
> +-----------
> +
> +Rewrite history by rearranging or modifying specific commits in the
> +history.
> +
> +This command is similar to linkgit:git-rebase[1] and uses the same
> +underlying machinery. You should use rebases if you either want to
> +reapply a range of commits onto a different base, or interactive rebases
> +if you want to edit a range of commits.
> +
>

The either..or in the last sentence is a bit confusing; as it is not an either between 'want to reapply a range of commit onto a different base' & 'interactive rebases'.

Perhaps we can simply s/either//
Show 22 quoted lines
> +Note that this command does not (yet) work with histories that contain
> +merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
> +flag instead.
> +
> +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
> +
> +COMMANDS
> +--------
> +
> +This command requires a subcommand. Several subcommands are available to
> +rewrite history in different ways:
> +
> +CONFIGURATION
> +-------------
> +
> +include::includes/cmd-config-section-all.adoc[]
> +
> +include::config/sequencer.adoc[]
> +
> +GIT
> +---
> +Part of the linkgit:git[1] suite
[snip]
Show 19 quoted lines
> diff --git a/builtin/history.c b/builtin/history.c
> new file mode 100644
> index 0000000000..f6fe32610b
> --- /dev/null
> +++ b/builtin/history.c
> @@ -0,0 +1,22 @@
> +#include "builtin.h"
> +#include "gettext.h"
> +#include "parse-options.h"
> +
> +int cmd_history(int argc,
> +		const char **argv,
> +		const char *prefix,
> +		struct repository *repo UNUSED)
> +{
> +	const char * const usage[] = {
> +		N_("git history [<options>]"),
> +		NULL,
> +	};
Nit: We have pointer alignment set to 'Right' in our styling guide and
also mentioned in our 'Documentation/CodingGuidelines'
   When declaring pointers, the star sides with the variable
   name, i.e. "char *string", not "char* string" or
   "char * string".  This makes it easier to understand code
   like "char *string, c;".
The rest of the patch looks good!
[snip]
Patrick Steinhardt· Oct 21, 2025, 11:43 UTC · re: Karthik Nayak · lore

Re: [PATCH v4 05/12] builtin: add new "history" command

On Tue, Oct 14, 2025 at 05:07:03AM -0400, Karthik Nayak wrote:
Show 36 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
> > new file mode 100644
> > index 0000000000..1537960374
> > --- /dev/null
> > +++ b/Documentation/git-history.adoc
> > @@ -0,0 +1,45 @@
> > +git-history(1)
> > +==============
> > +
> > +NAME
> > +----
> > +git-history - EXPERIMENTAL: Rewrite history of the current branch
> > +
> > +SYNOPSIS
> > +--------
> > +[synopsis]
> > +git history [<options>]
> > +
> > +DESCRIPTION
> > +-----------
> > +
> > +Rewrite history by rearranging or modifying specific commits in the
> > +history.
> > +
> > +This command is similar to linkgit:git-rebase[1] and uses the same
> > +underlying machinery. You should use rebases if you either want to
> > +reapply a range of commits onto a different base, or interactive rebases
> > +if you want to edit a range of commits.
> > +
> >
> 
> The either..or in the last sentence is a bit confusing; as it is not an
> either between 'want to reapply a range of commit onto a different base'
> & 'interactive rebases'.
> 
> Perhaps we can simply s/either//
Fair.
Show 29 quoted lines
> > diff --git a/builtin/history.c b/builtin/history.c
> > new file mode 100644
> > index 0000000000..f6fe32610b
> > --- /dev/null
> > +++ b/builtin/history.c
> > @@ -0,0 +1,22 @@
> > +#include "builtin.h"
> > +#include "gettext.h"
> > +#include "parse-options.h"
> > +
> > +int cmd_history(int argc,
> > +		const char **argv,
> > +		const char *prefix,
> > +		struct repository *repo UNUSED)
> > +{
> > +	const char * const usage[] = {
> > +		N_("git history [<options>]"),
> > +		NULL,
> > +	};
> 
> Nit: We have pointer alignment set to 'Right' in our styling guide and
> also mentioned in our 'Documentation/CodingGuidelines'
> 
>    When declaring pointers, the star sides with the variable
>    name, i.e. "char *string", not "char* string" or
>    "char * string".  This makes it easier to understand code
>    like "char *string, c;".
> 
> The rest of the patch looks good!
This is one of the common exceptions though:
    $ git grep 'const char \* const' | wc -l
    186
    $ git grep 'const char \*const' | wc -l
    108

So when there is another keyword following the asterisk we tend to have an additional space inbetween. We tend to only drop the space when the next token is the variable name.

Patrick
Junio C Hamano· Oct 22, 2025, 03:32 UTC · re: Karthik Nayak · lore

Re: [PATCH v4 05/12] builtin: add new "history" command

Karthik Nayak <karthik.188@gmail.com> writes:
Show 12 quoted lines
>> +	const char * const usage[] = {
>> +		N_("git history [<options>]"),
>> +		NULL,
>> +	};
>
> Nit: We have pointer alignment set to 'Right' in our styling guide and
> also mentioned in our 'Documentation/CodingGuidelines'
>
>    When declaring pointers, the star sides with the variable
>    name, i.e. "char *string", not "char* string" or
>    "char * string".  This makes it easier to understand code
>    like "char *string, c;".

But there is nothing specified for an asterisk that cannot side with variable name, like the one we see above. I _think_ the "space on both sides" is the prevalent style, but I do not know (although I suspect you do---as the person with most changes in it) what (y)our clang format configuration wants to do. Can you make sure the tool suggests the style that matches the prevailing style?

Thanks.
Karthik Nayak· Oct 22, 2025, 12:12 UTC · re: Junio C Hamano · lore

Re: [PATCH v4 05/12] builtin: add new "history" command

Junio C Hamano <gitster@pobox.com> writes:
Show 23 quoted lines
> Karthik Nayak <karthik.188@gmail.com> writes:
>
>>> +	const char * const usage[] = {
>>> +		N_("git history [<options>]"),
>>> +		NULL,
>>> +	};
>>
>> Nit: We have pointer alignment set to 'Right' in our styling guide and
>> also mentioned in our 'Documentation/CodingGuidelines'
>>
>>    When declaring pointers, the star sides with the variable
>>    name, i.e. "char *string", not "char* string" or
>>    "char * string".  This makes it easier to understand code
>>    like "char *string, c;".
>
> But there is nothing specified for an asterisk that cannot side with
> variable name, like the one we see above.  I _think_ the "space on
> both sides" is the prevalent style, but I do not know (although I
> suspect you do---as the person with most changes in it) what (y)our
> clang format configuration wants to do.  Can you make sure the tool
> suggests the style that matches the prevailing style?
>
> Thanks.

I looked into this, and unfortunately it [1] doesn't support such granularity.

So for something like `const char * const usage`, it only cares about the alignment of the pointer with respect to the tokens surrounding it.

With our current setting of `PointerAlignment: Right`, this means it would expect to have `const char *const usage` which is not the prevalent style.

[1]: https://clang.llvm.org/docs/ClangFormatStyleOptions.html#pointeralignment
Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 06/12] builtin/history: implement "reword" subcommand

Implement a new "reword" subcommand for git-history(1). This subcommand is essentially the same as if a user performed an interactive rebase with a single commit changed to use the "reword" verb.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |   7 +
 builtin/history.c              | 375 ++++++++++++++++++++++++++++++++++++++++-
 t/meson.build                  |   1 +
 t/t3450-history.sh             |   6 +-
 t/t3451-history-reword.sh      | 202 ++++++++++++++++++++++
 5 files changed, 584 insertions(+), 7 deletions(-)
Show changes to 5 files +584 −7

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3450-history.sh, t/t3451-history-reword.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 1537960374..b55babe206 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -9,6 +9,7 @@ SYNOPSIS
 --------
 [synopsis]
 git history [<options>]
+git history reword [<options>] <commit>
 
 DESCRIPTION
 -----------
@@ -33,6 +34,12 @@ COMMANDS
 This command requires a subcommand. Several subcommands are available to
 rewrite history in different ways:
 
+`reword <commit> [--message=<message>]`::
+	Rewrite the commit message of the specified commit. All the other
+	details of this commit remain unchanged. If no commit message is
+	provided, then this command will spawn an editor with the current
+	message of that commit.
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index f6fe32610b..7b2a0023e8 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,22 +1,389 @@
+#define USE_THE_REPOSITORY_VARIABLE
+
 #include "builtin.h"
+#include "commit-reach.h"
+#include "commit.h"
+#include "config.h"
+#include "editor.h"
+#include "environment.h"
 #include "gettext.h"
+#include "hex.h"
+#include "oidmap.h"
 #include "parse-options.h"
+#include "refs.h"
+#include "replay.h"
+#include "reset.h"
+#include "revision.h"
+#include "sequencer.h"
+#include "strvec.h"
+#include "tree.h"
+#include "wt-status.h"
+
+static int collect_commits(struct repository *repo,
+			   struct commit *old_commit,
+			   struct commit *new_commit,
+			   struct strvec *out)
+{
+	struct setup_revision_opt revision_opts = {
+		.assume_dashdash = 1,
+	};
+	struct strvec revisions = STRVEC_INIT;
+	struct commit_list *from_list = NULL;
+	struct commit *child;
+	struct rev_info rev = { 0 };
+	int ret;
+
+	/*
+	 * Check that the old commit actually is an ancestor of HEAD. If not
+	 * the whole request becomes nonsensical.
+	*/
+	if (old_commit) {
+		commit_list_insert(old_commit, &from_list);
+		if (!repo_is_descendant_of(repo, new_commit, from_list)) {
+			ret = error(_("commit must be reachable from current HEAD commit"));
+			goto out;
+		}
+	}
+
+	repo_init_revisions(repo, &rev, NULL);
+	strvec_push(&revisions, "");
+	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
+	if (old_commit)
+		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
+
+	setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
+	if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
+		ret = error(_("revision walk setup failed"));
+		goto out;
+	}
+
+	while ((child = get_revision(&rev))) {
+		if (old_commit && !child->parents)
+			BUG("revision walk did not find child commit");
+		if (child->parents && child->parents->next) {
+			ret = error(_("cannot rearrange commit history with merges"));
+			goto out;
+		}
+
+		strvec_push(out, oid_to_hex(&child->object.oid));
+
+		if (child->parents && old_commit &&
+		    commit_list_contains(old_commit, child->parents))
+			break;
+	}
+
+	/*
+	 * Revisions are in newest-order-first. We have to reverse the
+	 * array though so that we pick the oldest commits first.
+	 */
+	for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
+		SWAP(out->v[i], out->v[j]);
+
+	ret = 0;
+
+out:
+	free_commit_list(from_list);
+	strvec_clear(&revisions);
+	release_revisions(&rev);
+	reset_revision_walk();
+	return ret;
+}
+
+static void replace_commits(struct strvec *commits,
+			    const struct object_id *commit_to_replace,
+			    const struct object_id *replacements,
+			    size_t replacements_nr)
+{
+	char commit_to_replace_oid[GIT_MAX_HEXSZ + 1];
+	struct strvec replacement_oids = STRVEC_INIT;
+	bool found = false;
+
+	oid_to_hex_r(commit_to_replace_oid, commit_to_replace);
+	for (size_t i = 0; i < replacements_nr; i++)
+		strvec_push(&replacement_oids, oid_to_hex(&replacements[i]));
+
+	for (size_t i = 0; i < commits->nr; i++) {
+		if (strcmp(commits->v[i], commit_to_replace_oid))
+			continue;
+		strvec_splice(commits, i, 1, replacement_oids.v, replacement_oids.nr);
+		found = true;
+		break;
+	}
+	if (!found)
+		BUG("could not find commit to replace");
+
+	strvec_clear(&replacement_oids);
+}
+
+static int apply_commits(struct repository *repo,
+			 const struct strvec *commits,
+			 struct commit *onto,
+			 struct commit *orig_head,
+			 const char *action)
+{
+	struct reset_head_opts reset_opts = { 0 };
+	struct merge_options merge_opts = { 0 };
+	struct merge_result result = { 0 };
+	struct strbuf buf = STRBUF_INIT;
+	kh_oid_map_t *replayed_commits;
+	int ret;
+
+	replayed_commits = kh_init_oid_map();
+
+	init_basic_merge_options(&merge_opts, repo);
+	merge_opts.show_rename_progress = 0;
+
+	for (size_t i = 0; i < commits->nr; i++) {
+		struct object_id commit_id;
+		struct commit *commit;
+		const char *end;
+		int hash_result;
+		khint_t pos;
+
+		if (parse_oid_hex_algop(commits->v[i], &commit_id, &end,
+					repo->hash_algo)) {
+			ret = error(_("invalid object ID: %s"), commits->v[i]);
+			goto out;
+		}
+
+		commit = lookup_commit(repo, &commit_id);
+		if (!commit || repo_parse_commit(repo, commit)) {
+			ret = error(_("failed to look up commit: %s"), oid_to_hex(&commit_id));
+			goto out;
+		}
+
+		if (!onto) {
+			onto = commit;
+			result.clean = 1;
+			result.tree = repo_get_commit_tree(repo, commit);
+		} else {
+			onto = replay_pick_regular_commit(repo, commit, replayed_commits,
+							  onto, &merge_opts, &result);
+			if (!onto)
+				break;
+		}
+
+		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hash_result);
+		if (hash_result == 0) {
+			ret = error(_("duplicate rewritten commit: %s\n"),
+				    oid_to_hex(&commit->object.oid));
+			goto out;
+		}
+		kh_value(replayed_commits, pos) = onto;
+	}
+
+	if (!result.clean) {
+		ret = error(_("could not merge"));
+		goto out;
+	}
+
+	reset_opts.oid = &onto->object.oid;
+	strbuf_addf(&buf, "%s: switch to rewritten %s", action, oid_to_hex(reset_opts.oid));
+	reset_opts.flags = RESET_HEAD_REFS_ONLY | RESET_ORIG_HEAD;
+	reset_opts.orig_head = &orig_head->object.oid;
+	reset_opts.default_reflog_action = action;
+	if (reset_head(repo, &reset_opts) < 0) {
+		ret = error(_("could not switch to %s"), oid_to_hex(reset_opts.oid));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	kh_destroy_oid_map(replayed_commits);
+	merge_finalize(&merge_opts, &result);
+	strbuf_release(&buf);
+	return ret;
+}
+
+static void change_data_free(void *util, const char *str UNUSED)
+{
+	struct wt_status_change_data *d = util;
+	free(d->rename_source);
+	free(d);
+}
+
+static int fill_commit_message(struct repository *repo,
+			       const struct object_id *old_tree,
+			       const struct object_id *new_tree,
+			       const char *default_message,
+			       const char *provided_message,
+			       const char *action,
+			       struct strbuf *out)
+{
+	if (!provided_message) {
+		const char *path = git_path_commit_editmsg();
+		const char *hint =
+			_("Please enter the commit message for the %s changes. Lines starting\n"
+     "with '%s' will be kept; you may remove them yourself if you want to.\n");
+		struct wt_status s;
+
+		strbuf_addstr(out, default_message);
+		strbuf_addch(out, '\n');
+		strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
+		write_file_buf(path, out->buf, out->len);
+
+		wt_status_prepare(repo, &s);
+		FREE_AND_NULL(s.branch);
+		s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
+		s.commit_template = 1;
+		s.colopts = 0;
+		s.display_comment_prefix = 1;
+		s.hints = 0;
+		s.use_color = 0;
+		s.whence = FROM_COMMIT;
+		s.committable = 1;
+
+		s.fp = fopen(git_path_commit_editmsg(), "a");
+		if (!s.fp)
+			return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
+
+		wt_status_collect_changes_trees(&s, old_tree, new_tree);
+		wt_status_print(&s);
+		wt_status_collect_free_buffers(&s);
+		string_list_clear_func(&s.change, change_data_free);
+
+		strbuf_reset(out);
+		if (launch_editor(path, out, NULL)) {
+			fprintf(stderr, _("Please supply the message using the -m option.\n"));
+			return -1;
+		}
+		strbuf_stripspace(out, comment_line_str);
+	} else {
+		strbuf_addstr(out, provided_message);
+	}
+
+	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
+
+	if (!out->len) {
+		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
+		return -1;
+	}
+
+	return 0;
+}
+
+static int cmd_history_reword(int argc,
+			      const char **argv,
+			      const char *prefix,
+			      struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history reword [<options>] <commit>"),
+		NULL,
+	};
+	const char *commit_message = NULL;
+	struct option options[] = {
+		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
+		OPT_END(),
+	};
+	struct strbuf final_message = STRBUF_INIT;
+	struct commit *original_commit, *parent, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct object_id parent_tree_oid, original_commit_tree_oid;
+	struct object_id rewritten_commit;
+	const char *original_message, *original_body, *ptr;
+	char *original_author = NULL;
+	size_t len;
+	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);
+
+	original_commit = lookup_commit_reference_by_name(argv[0]);
+	if (!original_commit) {
+		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
+		goto out;
+	}
+	if (repo_parse_commit(repo, original_commit)) {
+		ret = error(_("unable to parse commit %s"),
+			    oid_to_hex(&original_commit->object.oid));
+		goto out;
+	}
+	original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
+
+	parent = original_commit->parents ? original_commit->parents->item : NULL;
+	if (parent) {
+		if (repo_parse_commit(repo, parent)) {
+			ret = error(_("unable to parse commit %s"),
+				    oid_to_hex(&parent->object.oid));
+			goto out;
+		}
+		parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
+	} else {
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	/*
+	 * Collect the list of commits that we'll have to reapply now already.
+	 * This ensures that we'll abort early on in case the range of commits
+	 * contains merges, which we do not yet handle.
+	 */
+	ret = collect_commits(repo, parent, head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+	find_commit_subject(original_message, &original_body);
+
+	ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
+				  original_body, commit_message, "reworded", &final_message);
+	if (ret < 0)
+		goto out;
+
+	ret = commit_tree(final_message.buf, final_message.len,
+			  &repo_get_commit_tree(repo, original_commit)->object.oid,
+			  original_commit->parents, &rewritten_commit, original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing reworded commit"));
+		goto out;
+	}
+
+	replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
+
+	ret = apply_commits(repo, &commits, parent, head, "reword");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	strbuf_release(&final_message);
+	strvec_clear(&commits);
+	free(original_author);
+	return ret;
+}
 
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
-		struct repository *repo UNUSED)
+		struct repository *repo)
 {
 	const char * const usage[] = {
 		N_("git history [<options>]"),
+		N_("git history reword [<options>] <commit>"),
 		NULL,
 	};
+	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
+		OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
 		OPT_END(),
 	};
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
-	if (argc)
-		usagef("unrecognized argument: %s", argv[0]);
-	return 0;
+	return fn(argc, argv, prefix, repo);
 }
diff --git a/t/meson.build b/t/meson.build
index 8b31eb0858..2a74243202 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -383,6 +383,7 @@ integration_tests = [
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
+  't3451-history-reword.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
index 417c343d43..f513463b92 100755
--- a/t/t3450-history.sh
+++ b/t/t3450-history.sh
@@ -5,13 +5,13 @@ test_description='tests for git-history command'
 . ./test-lib.sh
 
 test_expect_success 'does nothing without any arguments' '
-	git history >out 2>&1 &&
-	test_must_be_empty out
+	test_must_fail git history 2>err &&
+	test_grep "need a subcommand" err
 '
 
 test_expect_success 'raises an error with unknown argument' '
 	test_must_fail git history garbage 2>err &&
-	test_grep "unrecognized argument: garbage" err
+	test_grep "unknown subcommand: .garbage." err
 '
 
 test_done
diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
new file mode 100755
index 0000000000..84d203643e
--- /dev/null
+++ b/t/t3451-history-reword.sh
@@ -0,0 +1,202 @@
+#!/bin/sh
+
+test_description='tests for git-history reword subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history reword HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err &&
+		test_must_fail git history reword HEAD 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'can reword tip of a branch' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		git history reword -m "third reworded" HEAD &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		third reworded
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can reword commit in the middle' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		git history reword -m "second reworded" HEAD~ &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can reword root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history reword -m "first reworded" HEAD~2 &&
+
+		cat >expect <<-EOF &&
+		third
+		second
+		first reworded
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can use editor to rewrite commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		printf "\namend a comment\n" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+		git history reword HEAD &&
+
+		cat >expect <<-EOF &&
+		first
+
+		# Please enter the commit message for the reworded changes. Lines starting
+		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
+		# Changes to be committed:
+		#	new file:   first.t
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		cat >expect <<-EOF &&
+		first
+
+		amend a comment
+
+		EOF
+		git log --format=%B >actual &&
+		test_cmp expect actual
+	)
+'
+
+# For now, git-history(1) does not yet execute any hooks. This is subject to
+# change in the future, and if it does this test here is expected to start
+# failing. In other words, this test is not an endorsement of the current
+# status quo.
+test_expect_success 'hooks are executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		write_script .git/hooks/prepare-commit-msg <<-EOF &&
+		touch "$(pwd)/hooks.log
+		EOF
+		write_script .git/hooks/post-commit <<-EOF &&
+		touch "$(pwd)/hooks.log
+		EOF
+		write_script .git/hooks/post-rewrite <<-EOF &&
+		touch "$(pwd)/hooks.log
+		EOF
+
+		git history reword -m "second reworded" HEAD~ &&
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+
+		test_path_is_missing hooks.log
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		test_must_fail git history reword -m "" HEAD 2>err &&
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_expect_success 'retains changes in the worktree and index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch a b &&
+		git add . &&
+		git commit -m "initial commit" &&
+		echo foo >a &&
+		echo bar >b &&
+		git add b &&
+		git history reword HEAD -m message &&
+		cat >expect <<-\EOF &&
+		 M a
+		M  b
+		?? actual
+		?? expect
+		EOF
+		git status --porcelain >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.51.0.700.g236ee7b076.dirty
Karthik Nayak· Oct 14, 2025, 11:04 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 06/12] builtin/history: implement "reword" subcommand

Patrick Steinhardt <ps@pks.im> writes:
> Implement a new "reword" subcommand for git-history(1). This subcommand
> is essentially the same as if a user performed an interactive rebase
> with a single commit changed to use the "reword" verb.
>
[snip]
Show 16 quoted lines
> diff --git a/builtin/history.c b/builtin/history.c
> index f6fe32610b..7b2a0023e8 100644
> --- a/builtin/history.c
> +++ b/builtin/history.c
> @@ -1,22 +1,389 @@
> +#define USE_THE_REPOSITORY_VARIABLE
> +
>  #include "builtin.h"
> +#include "commit-reach.h"
> +#include "commit.h"
> +#include "config.h"
> +#include "editor.h"
> +#include "environment.h"
>  #include "gettext.h"
> +#include "hex.h"
> +#include "oidmap.h"
Nit: This can be dropped, perhaps needed in a future patch?
Show 28 quoted lines
>  #include "parse-options.h"
> +#include "refs.h"
> +#include "replay.h"
> +#include "reset.h"
> +#include "revision.h"
> +#include "sequencer.h"
> +#include "strvec.h"
> +#include "tree.h"
> +#include "wt-status.h"
> +
> +static int collect_commits(struct repository *repo,
> +			   struct commit *old_commit,
> +			   struct commit *new_commit,
> +			   struct strvec *out)
> +{
> +	struct setup_revision_opt revision_opts = {
> +		.assume_dashdash = 1,
> +	};
> +	struct strvec revisions = STRVEC_INIT;
> +	struct commit_list *from_list = NULL;
> +	struct commit *child;
> +	struct rev_info rev = { 0 };
> +	int ret;
> +
> +	/*
> +	 * Check that the old commit actually is an ancestor of HEAD. If not
> +	 * the whole request becomes nonsensical.
> +	*/
Missing space here
Show 7 quoted lines
> +	if (old_commit) {
> +		commit_list_insert(old_commit, &from_list);
> +		if (!repo_is_descendant_of(repo, new_commit, from_list)) {
> +			ret = error(_("commit must be reachable from current HEAD commit"));
> +			goto out;
> +		}
> +	}

Makes sense. There is an inherent assumption using the 'git history' command that you want to modify the history of the current reference.

One question, wouldn't it make sense to parse and check that the commit to be reworded should be checked to be a descendant of HEAD earlier on in `cmd_history_reword()`?

This would ensure this function `collect_commits()` doesn't worry about how it is meant to be used, and simply worries about collecting commits.

Show 27 quoted lines
> +	repo_init_revisions(repo, &rev, NULL);
> +	strvec_push(&revisions, "");
> +	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
> +	if (old_commit)
> +		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
> +
> +	setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
> +	if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
> +		ret = error(_("revision walk setup failed"));
> +		goto out;
> +	}
> +
> +	while ((child = get_revision(&rev))) {
> +		if (old_commit && !child->parents)
> +			BUG("revision walk did not find child commit");
> +		if (child->parents && child->parents->next) {
> +			ret = error(_("cannot rearrange commit history with merges"));
> +			goto out;
> +		}
> +
> +		strvec_push(out, oid_to_hex(&child->object.oid));
> +
> +		if (child->parents && old_commit &&
> +		    commit_list_contains(old_commit, child->parents))
> +			break;
> +	}
> +

Okay makes sense here, we collect all the commits we break as soon as we reach old_commit. Since we check for merges at the start of the loop, the history should be linear.

[snip]
Show 25 quoted lines
> +static void replace_commits(struct strvec *commits,
> +			    const struct object_id *commit_to_replace,
> +			    const struct object_id *replacements,
> +			    size_t replacements_nr)
> +{
> +	char commit_to_replace_oid[GIT_MAX_HEXSZ + 1];
> +	struct strvec replacement_oids = STRVEC_INIT;
> +	bool found = false;
> +
> +	oid_to_hex_r(commit_to_replace_oid, commit_to_replace);
> +	for (size_t i = 0; i < replacements_nr; i++)
> +		strvec_push(&replacement_oids, oid_to_hex(&replacements[i]));
> +
> +	for (size_t i = 0; i < commits->nr; i++) {
> +		if (strcmp(commits->v[i], commit_to_replace_oid))
> +			continue;
> +		strvec_splice(commits, i, 1, replacement_oids.v, replacement_oids.nr);
> +		found = true;
> +		break;
> +	}
> +	if (!found)
> +		BUG("could not find commit to replace");
> +
> +	strvec_clear(&replacement_oids);
> +}

So this basically goes over the commits that we pass and replaces a single commit with a set of commits. In our case that would be

  C0 C1 C2 ...  CN
   └─(reword)    └─(HEAD)
     ↓ (rewrites to)
  R0 R1 R2 ... RN C1 C2 ... CN
   └────────────┘            └─(HEAD)
    (new commits)

Makes sense. I assume we use a list of replacements here for future commands.

Show 15 quoted lines
> +static int apply_commits(struct repository *repo,
> +			 const struct strvec *commits,
> +			 struct commit *onto,
> +			 struct commit *orig_head,
> +			 const char *action)
> +{
> +	struct reset_head_opts reset_opts = { 0 };
> +	struct merge_options merge_opts = { 0 };
> +	struct merge_result result = { 0 };
> +	struct strbuf buf = STRBUF_INIT;
> +	kh_oid_map_t *replayed_commits;
> +	int ret;
> +
> +	replayed_commits = kh_init_oid_map();
> +

So this is used to the pass the replayed_commits list to the replay mechanism so that the appropriate base commit is selected.

Show 26 quoted lines
> +	init_basic_merge_options(&merge_opts, repo);
> +	merge_opts.show_rename_progress = 0;
> +
> +	for (size_t i = 0; i < commits->nr; i++) {
> +		struct object_id commit_id;
> +		struct commit *commit;
> +		const char *end;
> +		int hash_result;
> +		khint_t pos;
> +
> +		if (parse_oid_hex_algop(commits->v[i], &commit_id, &end,
> +					repo->hash_algo)) {
> +			ret = error(_("invalid object ID: %s"), commits->v[i]);
> +			goto out;
> +		}
> +
> +		commit = lookup_commit(repo, &commit_id);
> +		if (!commit || repo_parse_commit(repo, commit)) {
> +			ret = error(_("failed to look up commit: %s"), oid_to_hex(&commit_id));
> +			goto out;
> +		}
> +
> +		if (!onto) {
> +			onto = commit;
> +			result.clean = 1;
> +			result.tree = repo_get_commit_tree(repo, commit);

So if there is no onto, we're starting at the root commit and expect a clean merge.

Show 5 quoted lines
> +		} else {
> +			onto = replay_pick_regular_commit(repo, commit, replayed_commits,
> +							  onto, &merge_opts, &result);
> +			if (!onto)
> +				break;
Else we will replay the current commit onto the prev 'onto' commit.
Show 26 quoted lines
> +		}
> +
> +		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hash_result);
> +		if (hash_result == 0) {
> +			ret = error(_("duplicate rewritten commit: %s\n"),
> +				    oid_to_hex(&commit->object.oid));
> +			goto out;
> +		}
> +		kh_value(replayed_commits, pos) = onto;
> +	}
> +
> +	if (!result.clean) {
> +		ret = error(_("could not merge"));
> +		goto out;
> +	}
> +
> +	reset_opts.oid = &onto->object.oid;
> +	strbuf_addf(&buf, "%s: switch to rewritten %s", action, oid_to_hex(reset_opts.oid));
> +	reset_opts.flags = RESET_HEAD_REFS_ONLY | RESET_ORIG_HEAD;
> +	reset_opts.orig_head = &orig_head->object.oid;
> +	reset_opts.default_reflog_action = action;
> +	if (reset_head(repo, &reset_opts) < 0) {
> +		ret = error(_("could not switch to %s"), oid_to_hex(reset_opts.oid));
> +		goto out;
> +	}
> +
We finally update the HEAD reference also.
Show 29 quoted lines
> +	ret = 0;
> +
> +out:
> +	kh_destroy_oid_map(replayed_commits);
> +	merge_finalize(&merge_opts, &result);
> +	strbuf_release(&buf);
> +	return ret;
> +}
> +
> +static void change_data_free(void *util, const char *str UNUSED)
> +{
> +	struct wt_status_change_data *d = util;
> +	free(d->rename_source);
> +	free(d);
> +}
> +
> +static int fill_commit_message(struct repository *repo,
> +			       const struct object_id *old_tree,
> +			       const struct object_id *new_tree,
> +			       const char *default_message,
> +			       const char *provided_message,
> +			       const char *action,
> +			       struct strbuf *out)
> +{
> +	if (!provided_message) {
> +		const char *path = git_path_commit_editmsg();
> +		const char *hint =
> +			_("Please enter the commit message for the %s changes. Lines starting\n"
> +     "with '%s' will be kept; you may remove them yourself if you want to.\n");
Shouldn't this be s/kept/removed? Also this line needs to be aligned.
Show 27 quoted lines
> +		struct wt_status s;
> +
> +		strbuf_addstr(out, default_message);
> +		strbuf_addch(out, '\n');
> +		strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
> +		write_file_buf(path, out->buf, out->len);
> +
> +		wt_status_prepare(repo, &s);
> +		FREE_AND_NULL(s.branch);
> +		s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
> +		s.commit_template = 1;
> +		s.colopts = 0;
> +		s.display_comment_prefix = 1;
> +		s.hints = 0;
> +		s.use_color = 0;
> +		s.whence = FROM_COMMIT;
> +		s.committable = 1;
> +
> +		s.fp = fopen(git_path_commit_editmsg(), "a");
> +		if (!s.fp)
> +			return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
> +
> +		wt_status_collect_changes_trees(&s, old_tree, new_tree);
> +		wt_status_print(&s);
> +		wt_status_collect_free_buffers(&s);
> +		string_list_clear_func(&s.change, change_data_free);
> +

Nice, so we show the user the diff tree and then ask them to modify the existing message as they want.

Show 26 quoted lines
> +		strbuf_reset(out);
> +		if (launch_editor(path, out, NULL)) {
> +			fprintf(stderr, _("Please supply the message using the -m option.\n"));
> +			return -1;
> +		}
> +		strbuf_stripspace(out, comment_line_str);
> +	} else {
> +		strbuf_addstr(out, provided_message);
> +	}
> +
> +	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
> +
> +	if (!out->len) {
> +		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
> +		return -1;
> +	}
> +
> +	return 0;
> +}
> +
> +static int cmd_history_reword(int argc,
> +			      const char **argv,
> +			      const char *prefix,
> +			      struct repository *repo)
> +{
> +	const char * const usage[] = {
Same as the prev commit, this should be '*const'.
Show 25 quoted lines
> +		N_("git history reword [<options>] <commit>"),
> +		NULL,
> +	};
> +	const char *commit_message = NULL;
> +	struct option options[] = {
> +		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
> +		OPT_END(),
> +	};
> +	struct strbuf final_message = STRBUF_INIT;
> +	struct commit *original_commit, *parent, *head;
> +	struct strvec commits = STRVEC_INIT;
> +	struct object_id parent_tree_oid, original_commit_tree_oid;
> +	struct object_id rewritten_commit;
> +	const char *original_message, *original_body, *ptr;
> +	char *original_author = NULL;
> +	size_t len;
> +	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);
> +

Right. We only expect something like 'git history reword @~10', so we allow only one commit to be re-worded at a given time.

Show 5 quoted lines
> +	original_commit = lookup_commit_reference_by_name(argv[0]);
> +	if (!original_commit) {
> +		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
> +		goto out;
> +	}
So here we want to parse the commit-ish name to find the commit.
Show 5 quoted lines
> +	if (repo_parse_commit(repo, original_commit)) {
> +		ret = error(_("unable to parse commit %s"),
> +			    oid_to_hex(&original_commit->object.oid));
> +		goto out;
> +	}

Isn't this already done as part of `lookup_commit_reference_by_name_gently()` which is called by `lookup_commit_reference_by_name()` ?

> +	original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
> +

Okay we get the tree of the commit, I assume this is used to create the new commit and replay the children on top of it.

Show 27 quoted lines
> +	parent = original_commit->parents ? original_commit->parents->item : NULL;
> +	if (parent) {
> +		if (repo_parse_commit(repo, parent)) {
> +			ret = error(_("unable to parse commit %s"),
> +				    oid_to_hex(&parent->object.oid));
> +			goto out;
> +		}
> +		parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
> +	} else {
> +		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
> +	}
> +
> +	head = lookup_commit_reference_by_name("HEAD");
> +	if (!head) {
> +		ret = error(_("could not resolve HEAD to a commit"));
> +		goto out;
> +	}
> +
> +	/*
> +	 * Collect the list of commits that we'll have to reapply now already.
> +	 * This ensures that we'll abort early on in case the range of commits
> +	 * contains merges, which we do not yet handle.
> +	 */
> +	ret = collect_commits(repo, parent, head, &commits);
> +	if (ret < 0)
> +		goto out;
> +
The user is currently at HEAD and wishes to reword a commit.
  PN ... P2 P1 C0 C1 ... CN
               └─(reword) └─(HEAD)
So we want to re-word C0, so we need to collect P1..CN.
Show 11 quoted lines
> +	/* We retain authorship of the original commit. */
> +	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
> +	ptr = find_commit_header(original_message, "author", &len);
> +	if (ptr)
> +		original_author = xmemdupz(ptr, len);
> +	find_commit_subject(original_message, &original_body);
> +
> +	ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
> +				  original_body, commit_message, "reworded", &final_message);
> +	if (ret < 0)
> +		goto out;
We obtain the new message from the user.
> +
> +	ret = commit_tree(final_message.buf, final_message.len,
> +			  &repo_get_commit_tree(repo, original_commit)->object.oid,
Can't we use original_commit_tree_oid here?
Show 8 quoted lines
> +			  original_commit->parents, &rewritten_commit, original_author, NULL);
> +	if (ret < 0) {
> +		ret = error(_("failed writing reworded commit"));
> +		goto out;
> +	}
> +
> +	replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
> +
Okay we replace the commits we obtained with the new rewritten commit.
Show 6 quoted lines
> +	ret = apply_commits(repo, &commits, parent, head, "reword");
> +	if (ret < 0)
> +		goto out;
> +
> +	ret = 0;
> +

So we're now asking to replay the commits onto parent and update HEAD. Looks good.

Show 16 quoted lines
> +out:
> +	strbuf_release(&final_message);
> +	strvec_clear(&commits);
> +	free(original_author);
> +	return ret;
> +}
>
>  int cmd_history(int argc,
>  		const char **argv,
>  		const char *prefix,
> -		struct repository *repo UNUSED)
> +		struct repository *repo)
>  {
>  	const char * const usage[] = {
>  		N_("git history [<options>]"),
> +		N_("git history reword [<options>] <commit>"),
This string is used twice, perhaps we move it to a macro?
[snip]
The tests looked good too!
Patrick Steinhardt· Oct 21, 2025, 11:43 UTC · re: Karthik Nayak · lore

Re: [PATCH v4 06/12] builtin/history: implement "reword" subcommand

On Tue, Oct 14, 2025 at 07:04:06AM -0400, Karthik Nayak wrote:
Show 19 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
> > diff --git a/builtin/history.c b/builtin/history.c
> > index f6fe32610b..7b2a0023e8 100644
> > --- a/builtin/history.c
> > +++ b/builtin/history.c
> > @@ -1,22 +1,389 @@
> > +#define USE_THE_REPOSITORY_VARIABLE
> > +
> >  #include "builtin.h"
> > +#include "commit-reach.h"
> > +#include "commit.h"
> > +#include "config.h"
> > +#include "editor.h"
> > +#include "environment.h"
> >  #include "gettext.h"
> > +#include "hex.h"
> > +#include "oidmap.h"
> 
> Nit: This can be dropped, perhaps needed in a future patch?

Yeah, it's indeed needed in a subsequent patch. Let me move the import around.

Show 30 quoted lines
> >  #include "parse-options.h"
> > +#include "refs.h"
> > +#include "replay.h"
> > +#include "reset.h"
> > +#include "revision.h"
> > +#include "sequencer.h"
> > +#include "strvec.h"
> > +#include "tree.h"
> > +#include "wt-status.h"
> > +
> > +static int collect_commits(struct repository *repo,
> > +			   struct commit *old_commit,
> > +			   struct commit *new_commit,
> > +			   struct strvec *out)
> > +{
> > +	struct setup_revision_opt revision_opts = {
> > +		.assume_dashdash = 1,
> > +	};
> > +	struct strvec revisions = STRVEC_INIT;
> > +	struct commit_list *from_list = NULL;
> > +	struct commit *child;
> > +	struct rev_info rev = { 0 };
> > +	int ret;
> > +
> > +	/*
> > +	 * Check that the old commit actually is an ancestor of HEAD. If not
> > +	 * the whole request becomes nonsensical.
> > +	*/
> 
> Missing space here
Good eyes.
Show 17 quoted lines
> > +	if (old_commit) {
> > +		commit_list_insert(old_commit, &from_list);
> > +		if (!repo_is_descendant_of(repo, new_commit, from_list)) {
> > +			ret = error(_("commit must be reachable from current HEAD commit"));
> > +			goto out;
> > +		}
> > +	}
> 
> Makes sense. There is an inherent assumption using the 'git history'
> command that you want to modify the history of the current reference.
> 
> One question, wouldn't it make sense to parse and check that the commit
> to be reworded should be checked to be a descendant of HEAD earlier on
> in `cmd_history_reword()`?
> 
> This would ensure this function `collect_commits()` doesn't worry about
> how it is meant to be used, and simply worries about collecting commits.

The reason why I opted to move this into `collect_commits()` is so that we don't have to reimplement that check for every single subcommand, as they also have the same restriction.

[snip]
Show 15 quoted lines
> > +static int fill_commit_message(struct repository *repo,
> > +			       const struct object_id *old_tree,
> > +			       const struct object_id *new_tree,
> > +			       const char *default_message,
> > +			       const char *provided_message,
> > +			       const char *action,
> > +			       struct strbuf *out)
> > +{
> > +	if (!provided_message) {
> > +		const char *path = git_path_commit_editmsg();
> > +		const char *hint =
> > +			_("Please enter the commit message for the %s changes. Lines starting\n"
> > +     "with '%s' will be kept; you may remove them yourself if you want to.\n");
> 
> Shouldn't this be s/kept/removed? Also this line needs to be aligned.
Huh, yes, indeed.
[snip]
Show 9 quoted lines
> > +	if (repo_parse_commit(repo, original_commit)) {
> > +		ret = error(_("unable to parse commit %s"),
> > +			    oid_to_hex(&original_commit->object.oid));
> > +		goto out;
> > +	}
> 
> Isn't this already done as part of
> `lookup_commit_reference_by_name_gently()` which is called by
> `lookup_commit_reference_by_name()` ?
Yes, you're right.
[snip]
> > +	ret = commit_tree(final_message.buf, final_message.len,
> > +			  &repo_get_commit_tree(repo, original_commit)->object.oid,
> 
> Can't we use original_commit_tree_oid here?
Yup, indeed.
Show 10 quoted lines
> >  		const char **argv,
> >  		const char *prefix,
> > -		struct repository *repo UNUSED)
> > +		struct repository *repo)
> >  {
> >  	const char * const usage[] = {
> >  		N_("git history [<options>]"),
> > +		N_("git history reword [<options>] <commit>"),
> 
> This string is used twice, perhaps we move it to a macro?
Yeah, why not.
Patrick
Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 07/12] add-patch: split out header from "add-interactive.h"

While we have a "add-patch.c" code file, its declarations are part of "add-interactive.h". This makes it somewhat harder than necessary to find relevant code and to identify clear boundaries between the two subsystems.

Split up concerns and move declarations that relate to "add-patch.c" into a new "add-patch.h" header.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.h | 23 +++--------------------
 add-patch.c       |  1 +
 add-patch.h       | 26 ++++++++++++++++++++++++++
 3 files changed, 30 insertions(+), 20 deletions(-)
Show changes to 3 files +30 −20

add-interactive.h, add-patch.c, add-patch.h

diff --git a/add-interactive.h b/add-interactive.h
index da49502b76..2e3d1d871d 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -1,14 +1,11 @@
 #ifndef ADD_INTERACTIVE_H
 #define ADD_INTERACTIVE_H
 
+#include "add-patch.h"
 #include "color.h"
 
-struct add_p_opt {
-	int context;
-	int interhunkcontext;
-};
-
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+struct pathspec;
+struct repository;
 
 struct add_i_state {
 	struct repository *r;
@@ -35,21 +32,7 @@ void init_add_i_state(struct add_i_state *s, struct repository *r,
 		      struct add_p_opt *add_p_opt);
 void clear_add_i_state(struct add_i_state *s);
 
-struct repository;
-struct pathspec;
 int run_add_i(struct repository *r, const struct pathspec *ps,
 	      struct add_p_opt *add_p_opt);
 
-enum add_p_mode {
-	ADD_P_ADD,
-	ADD_P_STASH,
-	ADD_P_RESET,
-	ADD_P_CHECKOUT,
-	ADD_P_WORKTREE,
-};
-
-int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
-	      const struct pathspec *ps);
-
 #endif
diff --git a/add-patch.c b/add-patch.c
index b0389c5d5b..9d0890fc49 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -3,6 +3,7 @@
 
 #include "git-compat-util.h"
 #include "add-interactive.h"
+#include "add-patch.h"
 #include "advice.h"
 #include "editor.h"
 #include "environment.h"
diff --git a/add-patch.h b/add-patch.h
new file mode 100644
index 0000000000..4394c74107
--- /dev/null
+++ b/add-patch.h
@@ -0,0 +1,26 @@
+#ifndef ADD_PATCH_H
+#define ADD_PATCH_H
+
+struct pathspec;
+struct repository;
+
+struct add_p_opt {
+	int context;
+	int interhunkcontext;
+};
+
+#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+
+enum add_p_mode {
+	ADD_P_ADD,
+	ADD_P_STASH,
+	ADD_P_RESET,
+	ADD_P_CHECKOUT,
+	ADD_P_WORKTREE,
+};
+
+int run_add_p(struct repository *r, enum add_p_mode mode,
+	      struct add_p_opt *o, const char *revision,
+	      const struct pathspec *ps);
+
+#endif
-- 
2.51.0.700.g236ee7b076.dirty
Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 08/12] add-patch: split out `struct interactive_options`

The `struct add_p_opt` is reused both by our the infra for "git add -p" and "git add -i". Users of `run_add_i()` for example are expected to pass `struct add_p_opt`. This is somewhat confusing and raises the question which options apply to what part of the stack.

But things are even more confusing than that: while callers are expected to pass in `struct add_p_opt`, these options ultimately get used to initialize a `struct add_i_state` that is used by both subsystems. So we are basically going full circle here.

Refactor the code and split out a new `struct interactive_options` that hosts common options used by both. These options are then applied to a `struct interactive_config` that hosts common configuration.

This refactoring doesn't yet fully detangle the two subsystems from one another, as we still end up calling `init_add_i_state()` in the "git add -p" subsystem. This will be fixed in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.c  | 174 +++++++++++------------------------------------------
 add-interactive.h  |  23 +------
 add-patch.c        | 170 +++++++++++++++++++++++++++++++++++++++++++--------
 add-patch.h        |  36 ++++++++++-
 builtin/add.c      |  22 +++----
 builtin/checkout.c |   4 +-
 builtin/commit.c   |  16 ++---
 builtin/reset.c    |  16 ++---
 builtin/stash.c    |  46 +++++++-------
 commit.h           |   2 +-
 10 files changed, 270 insertions(+), 239 deletions(-)
Show changes to 10 files +270 −239

add-interactive.c, add-interactive.h, add-patch.c, add-patch.h, builtin/add.c, builtin/checkout.c, builtin/commit.c, builtin/reset.c, builtin/stash.c, commit.h

diff --git a/add-interactive.c b/add-interactive.c
index 6ffe64c38d..f2d971818e 100644
--- a/add-interactive.c
+++ b/add-interactive.c
@@ -3,7 +3,6 @@
 #include "git-compat-util.h"
 #include "add-interactive.h"
 #include "color.h"
-#include "config.h"
 #include "diffcore.h"
 #include "gettext.h"
 #include "hash.h"
@@ -20,119 +19,18 @@
 #include "prompt.h"
 #include "tree.h"
 
-static void init_color(struct repository *r, enum git_colorbool use_color,
-		       const char *section_and_slot, char *dst,
-		       const char *default_color)
-{
-	char *key = xstrfmt("color.%s", section_and_slot);
-	const char *value;
-
-	if (!want_color(use_color))
-		dst[0] = '\0';
-	else if (repo_config_get_value(r, key, &value) ||
-		 color_parse(value, dst))
-		strlcpy(dst, default_color, COLOR_MAXLEN);
-
-	free(key);
-}
-
-static enum git_colorbool check_color_config(struct repository *r, const char *var)
-{
-	const char *value;
-	enum git_colorbool ret;
-
-	if (repo_config_get_value(r, var, &value))
-		ret = GIT_COLOR_UNKNOWN;
-	else
-		ret = git_config_colorbool(var, value);
-
-	/*
-	 * Do not rely on want_color() to fall back to color.ui for us. It uses
-	 * the value parsed by git_color_config(), which may not have been
-	 * called by the main command.
-	 */
-	if (ret == GIT_COLOR_UNKNOWN &&
-	    !repo_config_get_value(r, "color.ui", &value))
-		ret = git_config_colorbool("color.ui", value);
-
-	return ret;
-}
-
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt)
+		      struct interactive_options *opts)
 {
 	s->r = r;
-	s->context = -1;
-	s->interhunkcontext = -1;
-
-	s->use_color_interactive = check_color_config(r, "color.interactive");
-
-	init_color(r, s->use_color_interactive, "interactive.header",
-		   s->header_color, GIT_COLOR_BOLD);
-	init_color(r, s->use_color_interactive, "interactive.help",
-		   s->help_color, GIT_COLOR_BOLD_RED);
-	init_color(r, s->use_color_interactive, "interactive.prompt",
-		   s->prompt_color, GIT_COLOR_BOLD_BLUE);
-	init_color(r, s->use_color_interactive, "interactive.error",
-		   s->error_color, GIT_COLOR_BOLD_RED);
-	strlcpy(s->reset_color_interactive,
-		want_color(s->use_color_interactive) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
-
-	s->use_color_diff = check_color_config(r, "color.diff");
-
-	init_color(r, s->use_color_diff, "diff.frag", s->fraginfo_color,
-		   diff_get_color(s->use_color_diff, DIFF_FRAGINFO));
-	init_color(r, s->use_color_diff, "diff.context", s->context_color,
-		   "fall back");
-	if (!strcmp(s->context_color, "fall back"))
-		init_color(r, s->use_color_diff, "diff.plain",
-			   s->context_color,
-			   diff_get_color(s->use_color_diff, DIFF_CONTEXT));
-	init_color(r, s->use_color_diff, "diff.old", s->file_old_color,
-		   diff_get_color(s->use_color_diff, DIFF_FILE_OLD));
-	init_color(r, s->use_color_diff, "diff.new", s->file_new_color,
-		   diff_get_color(s->use_color_diff, DIFF_FILE_NEW));
-	strlcpy(s->reset_color_diff,
-		want_color(s->use_color_diff) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
-
-	FREE_AND_NULL(s->interactive_diff_filter);
-	repo_config_get_string(r, "interactive.difffilter",
-			       &s->interactive_diff_filter);
-
-	FREE_AND_NULL(s->interactive_diff_algorithm);
-	repo_config_get_string(r, "diff.algorithm",
-			       &s->interactive_diff_algorithm);
-
-	if (!repo_config_get_int(r, "diff.context", &s->context))
-		if (s->context < 0)
-			die(_("%s cannot be negative"), "diff.context");
-	if (!repo_config_get_int(r, "diff.interHunkContext", &s->interhunkcontext))
-		if (s->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "diff.interHunkContext");
-
-	repo_config_get_bool(r, "interactive.singlekey", &s->use_single_key);
-	if (s->use_single_key)
-		setbuf(stdin, NULL);
-
-	if (add_p_opt->context != -1) {
-		if (add_p_opt->context < 0)
-			die(_("%s cannot be negative"), "--unified");
-		s->context = add_p_opt->context;
-	}
-	if (add_p_opt->interhunkcontext != -1) {
-		if (add_p_opt->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "--inter-hunk-context");
-		s->interhunkcontext = add_p_opt->interhunkcontext;
-	}
+	interactive_config_init(&s->cfg, r, opts);
 }
 
 void clear_add_i_state(struct add_i_state *s)
 {
-	FREE_AND_NULL(s->interactive_diff_filter);
-	FREE_AND_NULL(s->interactive_diff_algorithm);
+	interactive_config_clear(&s->cfg);
 	memset(s, 0, sizeof(*s));
-	s->use_color_interactive = GIT_COLOR_UNKNOWN;
-	s->use_color_diff = GIT_COLOR_UNKNOWN;
+	interactive_config_clear(&s->cfg);
 }
 
 /*
@@ -285,7 +183,7 @@ static void list(struct add_i_state *s, struct string_list *list, int *selected,
 		return;
 
 	if (opts->header)
-		color_fprintf_ln(stdout, s->header_color,
+		color_fprintf_ln(stdout, s->cfg.header_color,
 				 "%s", opts->header);
 
 	for (i = 0; i < list->nr; i++) {
@@ -353,7 +251,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 		list(s, &items->items, items->selected, &opts->list_opts);
 
-		color_fprintf(stdout, s->prompt_color, "%s", opts->prompt);
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", opts->prompt);
 		fputs(singleton ? "> " : ">> ", stdout);
 		fflush(stdout);
 
@@ -431,7 +329,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 			if (from < 0 || from >= items->items.nr ||
 			    (singleton && from + 1 != to)) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("Huh (%s)?"), p);
 				break;
 			} else if (singleton) {
@@ -991,7 +889,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 				free(files->items.items[i].string);
 			} else if (item->index.unmerged ||
 				 item->worktree.unmerged) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("ignoring unmerged: %s"),
 						 files->items.items[i].string);
 				free(item);
@@ -1013,9 +911,9 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 	opts->prompt = N_("Patch update");
 	count = list_and_choose(s, files, opts);
 	if (count > 0) {
-		struct add_p_opt add_p_opt = {
-			.context = s->context,
-			.interhunkcontext = s->interhunkcontext,
+		struct interactive_options opts = {
+			.context = s->cfg.context,
+			.interhunkcontext = s->cfg.interhunkcontext,
 		};
 		struct strvec args = STRVEC_INIT;
 		struct pathspec ps_selected = { 0 };
@@ -1027,7 +925,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 		parse_pathspec(&ps_selected,
 			       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,
 			       PATHSPEC_LITERAL_PATH, "", args.v);
-		res = run_add_p(s->r, ADD_P_ADD, &add_p_opt, NULL, &ps_selected);
+		res = run_add_p(s->r, ADD_P_ADD, &opts, NULL, &ps_selected);
 		strvec_clear(&args);
 		clear_pathspec(&ps_selected);
 	}
@@ -1063,10 +961,10 @@ static int run_diff(struct add_i_state *s, const struct pathspec *ps,
 		struct child_process cmd = CHILD_PROCESS_INIT;
 
 		strvec_pushl(&cmd.args, "git", "diff", "-p", "--cached", NULL);
-		if (s->context != -1)
-			strvec_pushf(&cmd.args, "--unified=%i", s->context);
-		if (s->interhunkcontext != -1)
-			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->interhunkcontext);
+		if (s->cfg.context != -1)
+			strvec_pushf(&cmd.args, "--unified=%i", s->cfg.context);
+		if (s->cfg.interhunkcontext != -1)
+			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
 		strvec_pushl(&cmd.args, oid_to_hex(!is_initial ? &oid :
 			     s->r->hash_algo->empty_tree), "--", NULL);
 		for (i = 0; i < files->items.nr; i++)
@@ -1084,17 +982,17 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 		    struct prefix_item_list *files UNUSED,
 		    struct list_and_choose_options *opts UNUSED)
 {
-	color_fprintf_ln(stdout, s->help_color, "status        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "status        - %s",
 			 _("show paths with changes"));
-	color_fprintf_ln(stdout, s->help_color, "update        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "update        - %s",
 			 _("add working tree state to the staged set of changes"));
-	color_fprintf_ln(stdout, s->help_color, "revert        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "revert        - %s",
 			 _("revert staged set of changes back to the HEAD version"));
-	color_fprintf_ln(stdout, s->help_color, "patch         - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "patch         - %s",
 			 _("pick hunks and update selectively"));
-	color_fprintf_ln(stdout, s->help_color, "diff          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "diff          - %s",
 			 _("view diff between HEAD and index"));
-	color_fprintf_ln(stdout, s->help_color, "add untracked - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "add untracked - %s",
 			 _("add contents of untracked files to the staged set of changes"));
 
 	return 0;
@@ -1102,21 +1000,21 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 
 static void choose_prompt_help(struct add_i_state *s)
 {
-	color_fprintf_ln(stdout, s->help_color, "%s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "%s",
 			 _("Prompt help:"));
-	color_fprintf_ln(stdout, s->help_color, "1          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "1          - %s",
 			 _("select a single item"));
-	color_fprintf_ln(stdout, s->help_color, "3-5        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "3-5        - %s",
 			 _("select a range of items"));
-	color_fprintf_ln(stdout, s->help_color, "2-3,6-9    - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "2-3,6-9    - %s",
 			 _("select multiple ranges"));
-	color_fprintf_ln(stdout, s->help_color, "foo        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "foo        - %s",
 			 _("select item based on unique prefix"));
-	color_fprintf_ln(stdout, s->help_color, "-...       - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "-...       - %s",
 			 _("unselect specified items"));
-	color_fprintf_ln(stdout, s->help_color, "*          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "*          - %s",
 			 _("choose all items"));
-	color_fprintf_ln(stdout, s->help_color, "           - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "           - %s",
 			 _("(empty) finish selecting"));
 }
 
@@ -1151,7 +1049,7 @@ static void print_command_item(int i, int selected UNUSED,
 
 static void command_prompt_help(struct add_i_state *s)
 {
-	const char *help_color = s->help_color;
+	const char *help_color = s->cfg.help_color;
 	color_fprintf_ln(stdout, help_color, "%s", _("Prompt help:"));
 	color_fprintf_ln(stdout, help_color, "1          - %s",
 			 _("select a numbered item"));
@@ -1162,7 +1060,7 @@ static void command_prompt_help(struct add_i_state *s)
 }
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt)
+	      struct interactive_options *interactive_opts)
 {
 	struct add_i_state s = { NULL };
 	struct print_command_item_data data = { "[", "]" };
@@ -1205,15 +1103,15 @@ int run_add_i(struct repository *r, const struct pathspec *ps,
 			->util = util;
 	}
 
-	init_add_i_state(&s, r, add_p_opt);
+	init_add_i_state(&s, r, interactive_opts);
 
 	/*
 	 * When color was asked for, use the prompt color for
 	 * highlighting, otherwise use square brackets.
 	 */
-	if (want_color(s.use_color_interactive)) {
-		data.color = s.prompt_color;
-		data.reset = s.reset_color_interactive;
+	if (want_color(s.cfg.use_color_interactive)) {
+		data.color = s.cfg.prompt_color;
+		data.reset = s.cfg.reset_color_interactive;
 	}
 	print_file_item_data.color = data.color;
 	print_file_item_data.reset = data.reset;
diff --git a/add-interactive.h b/add-interactive.h
index 2e3d1d871d..eefa2edc7c 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -2,37 +2,20 @@
 #define ADD_INTERACTIVE_H
 
 #include "add-patch.h"
-#include "color.h"
 
 struct pathspec;
 struct repository;
 
 struct add_i_state {
 	struct repository *r;
-	enum git_colorbool use_color_interactive;
-	enum git_colorbool use_color_diff;
-	char header_color[COLOR_MAXLEN];
-	char help_color[COLOR_MAXLEN];
-	char prompt_color[COLOR_MAXLEN];
-	char error_color[COLOR_MAXLEN];
-	char reset_color_interactive[COLOR_MAXLEN];
-
-	char fraginfo_color[COLOR_MAXLEN];
-	char context_color[COLOR_MAXLEN];
-	char file_old_color[COLOR_MAXLEN];
-	char file_new_color[COLOR_MAXLEN];
-	char reset_color_diff[COLOR_MAXLEN];
-
-	int use_single_key;
-	char *interactive_diff_filter, *interactive_diff_algorithm;
-	int context, interhunkcontext;
+	struct interactive_config cfg;
 };
 
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt);
+		      struct interactive_options *opts);
 void clear_add_i_state(struct add_i_state *s);
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt);
+	      struct interactive_options *opts);
 
 #endif
diff --git a/add-patch.c b/add-patch.c
index 9d0890fc49..29c15695dd 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -5,6 +5,8 @@
 #include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "config.h"
+#include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
@@ -279,6 +281,122 @@ struct add_p_state {
 	const char *revision;
 };
 
+static void init_color(struct repository *r,
+		       enum git_colorbool use_color,
+		       const char *section_and_slot, char *dst,
+		       const char *default_color)
+{
+	char *key = xstrfmt("color.%s", section_and_slot);
+	const char *value;
+
+	if (!want_color(use_color))
+		dst[0] = '\0';
+	else if (repo_config_get_value(r, key, &value) ||
+		 color_parse(value, dst))
+		strlcpy(dst, default_color, COLOR_MAXLEN);
+
+	free(key);
+}
+
+static enum git_colorbool check_color_config(struct repository *r, const char *var)
+{
+	const char *value;
+	enum git_colorbool ret;
+
+	if (repo_config_get_value(r, var, &value))
+		ret = GIT_COLOR_UNKNOWN;
+	else
+		ret = git_config_colorbool(var, value);
+
+	/*
+	 * Do not rely on want_color() to fall back to color.ui for us. It uses
+	 * the value parsed by git_color_config(), which may not have been
+	 * called by the main command.
+	 */
+	if (ret == GIT_COLOR_UNKNOWN &&
+	    !repo_config_get_value(r, "color.ui", &value))
+		ret = git_config_colorbool("color.ui", value);
+
+	return ret;
+}
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts)
+{
+	cfg->context = -1;
+	cfg->interhunkcontext = -1;
+
+	cfg->use_color_interactive = check_color_config(r, "color.interactive");
+
+	init_color(r, cfg->use_color_interactive, "interactive.header",
+		   cfg->header_color, GIT_COLOR_BOLD);
+	init_color(r, cfg->use_color_interactive, "interactive.help",
+		   cfg->help_color, GIT_COLOR_BOLD_RED);
+	init_color(r, cfg->use_color_interactive, "interactive.prompt",
+		   cfg->prompt_color, GIT_COLOR_BOLD_BLUE);
+	init_color(r, cfg->use_color_interactive, "interactive.error",
+		   cfg->error_color, GIT_COLOR_BOLD_RED);
+	strlcpy(cfg->reset_color_interactive,
+		want_color(cfg->use_color_interactive) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
+
+	cfg->use_color_diff = check_color_config(r, "color.diff");
+
+	init_color(r, cfg->use_color_diff, "diff.frag", cfg->fraginfo_color,
+		   diff_get_color(cfg->use_color_diff, DIFF_FRAGINFO));
+	init_color(r, cfg->use_color_diff, "diff.context", cfg->context_color,
+		   "fall back");
+	if (!strcmp(cfg->context_color, "fall back"))
+		init_color(r, cfg->use_color_diff, "diff.plain",
+			   cfg->context_color,
+			   diff_get_color(cfg->use_color_diff, DIFF_CONTEXT));
+	init_color(r, cfg->use_color_diff, "diff.old", cfg->file_old_color,
+		diff_get_color(cfg->use_color_diff, DIFF_FILE_OLD));
+	init_color(r, cfg->use_color_diff, "diff.new", cfg->file_new_color,
+		diff_get_color(cfg->use_color_diff, DIFF_FILE_NEW));
+	strlcpy(cfg->reset_color_diff,
+		want_color(cfg->use_color_diff) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
+
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	repo_config_get_string(r, "interactive.difffilter",
+			       &cfg->interactive_diff_filter);
+
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	repo_config_get_string(r, "diff.algorithm",
+			       &cfg->interactive_diff_algorithm);
+
+	if (!repo_config_get_int(r, "diff.context", &cfg->context))
+		if (cfg->context < 0)
+			die(_("%s cannot be negative"), "diff.context");
+	if (!repo_config_get_int(r, "diff.interHunkContext", &cfg->interhunkcontext))
+		if (cfg->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "diff.interHunkContext");
+
+	repo_config_get_bool(r, "interactive.singlekey", &cfg->use_single_key);
+	if (cfg->use_single_key)
+		setbuf(stdin, NULL);
+
+	if (opts->context != -1) {
+		if (opts->context < 0)
+			die(_("%s cannot be negative"), "--unified");
+		cfg->context = opts->context;
+	}
+	if (opts->interhunkcontext != -1) {
+		if (opts->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "--inter-hunk-context");
+		cfg->interhunkcontext = opts->interhunkcontext;
+	}
+}
+
+void interactive_config_clear(struct interactive_config *cfg)
+{
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	memset(cfg, 0, sizeof(*cfg));
+	cfg->use_color_interactive = GIT_COLOR_UNKNOWN;
+	cfg->use_color_diff = GIT_COLOR_UNKNOWN;
+}
+
 static void add_p_state_clear(struct add_p_state *s)
 {
 	size_t i;
@@ -299,9 +417,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.error_color, stdout);
+	fputs(s->s.cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.reset_color_interactive);
+	puts(s->s.cfg.reset_color_interactive);
 	va_end(args);
 }
 
@@ -424,12 +542,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.context);
-	if (s->s.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.interhunkcontext);
-	if (s->s.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.interactive_diff_algorithm);
+	if (s->s.cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
+	if (s->s.cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
+	if (s->s.cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -458,9 +576,9 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	}
 	strbuf_complete_line(plain);
 
-	if (want_color_fd(1, s->s.use_color_diff)) {
+	if (want_color_fd(1, s->s.cfg.use_color_diff)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.interactive_diff_filter;
+		const char *diff_filter = s->s.cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -693,7 +811,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.fraginfo_color);
+			strbuf_addstr(out, s->s.cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -715,7 +833,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.reset_color_diff);
+			strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1103,12 +1221,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.file_old_color :
+			      s->s.cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.file_new_color :
-			      s->s.context_color);
+			      s->s.cfg.file_new_color :
+			      s->s.cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.reset_color_diff);
+		strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1227,7 +1345,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.use_single_key) {
+	if (s->s.cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1241,7 +1359,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1522,15 +1640,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.reset_color_interactive)
-			fputs(s->s.reset_color_interactive, stdout);
+		if (*s->s.cfg.reset_color_interactive)
+			fputs(s->s.cfg.reset_color_interactive, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			break;
@@ -1687,7 +1805,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.header_color,
+				color_fprintf_ln(stdout, s->s.cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1705,7 +1823,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.help_color, "%s",
+			color_fprintf(stdout, s->s.cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1723,7 +1841,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.help_color,
+				color_fprintf_ln(stdout, s->s.cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1764,7 +1882,7 @@ static int patch_update_file(struct add_p_state *s,
 }
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
@@ -1772,7 +1890,7 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, o);
+	init_add_i_state(&s.s, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
diff --git a/add-patch.h b/add-patch.h
index 4394c74107..a4a05d9d14 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -1,15 +1,45 @@
 #ifndef ADD_PATCH_H
 #define ADD_PATCH_H
 
+#include "color.h"
+
 struct pathspec;
 struct repository;
 
-struct add_p_opt {
+struct interactive_options {
 	int context;
 	int interhunkcontext;
 };
 
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+#define INTERACTIVE_OPTIONS_INIT { \
+	.context = -1, \
+	.interhunkcontext = -1, \
+}
+
+struct interactive_config {
+	enum git_colorbool use_color_interactive;
+	enum git_colorbool use_color_diff;
+	char header_color[COLOR_MAXLEN];
+	char help_color[COLOR_MAXLEN];
+	char prompt_color[COLOR_MAXLEN];
+	char error_color[COLOR_MAXLEN];
+	char reset_color_interactive[COLOR_MAXLEN];
+
+	char fraginfo_color[COLOR_MAXLEN];
+	char context_color[COLOR_MAXLEN];
+	char file_old_color[COLOR_MAXLEN];
+	char file_new_color[COLOR_MAXLEN];
+	char reset_color_diff[COLOR_MAXLEN];
+
+	int use_single_key;
+	char *interactive_diff_filter, *interactive_diff_algorithm;
+	int context, interhunkcontext;
+};
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts);
+void interactive_config_clear(struct interactive_config *cfg);
 
 enum add_p_mode {
 	ADD_P_ADD,
@@ -20,7 +50,7 @@ enum add_p_mode {
 };
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
 #endif
diff --git a/builtin/add.c b/builtin/add.c
index 4cd3d183f9..08e2ef98b0 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -30,7 +30,7 @@ static const char * const builtin_add_usage[] = {
 	NULL
 };
 static int patch_interactive, add_interactive, edit_interactive;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int take_worktree_changes;
 static int add_renormalize;
 static int pathspec_file_nul;
@@ -159,7 +159,7 @@ static int refresh(struct repository *repo, int verbose, const struct pathspec *
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt)
+		    int patch, struct interactive_options *interactive_opts)
 {
 	struct pathspec pathspec;
 	int ret;
@@ -171,9 +171,9 @@ int interactive_add(struct repository *repo,
 		       prefix, argv);
 
 	if (patch)
-		ret = !!run_add_p(repo, ADD_P_ADD, add_p_opt, NULL, &pathspec);
+		ret = !!run_add_p(repo, ADD_P_ADD, interactive_opts, NULL, &pathspec);
 	else
-		ret = !!run_add_i(repo, &pathspec, add_p_opt);
+		ret = !!run_add_i(repo, &pathspec, interactive_opts);
 
 	clear_pathspec(&pathspec);
 	return ret;
@@ -255,8 +255,8 @@ static struct option builtin_add_options[] = {
 	OPT_GROUP(""),
 	OPT_BOOL('i', "interactive", &add_interactive, N_("interactive picking")),
 	OPT_BOOL('p', "patch", &patch_interactive, N_("select hunks interactively")),
-	OPT_DIFF_UNIFIED(&add_p_opt.context),
-	OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+	OPT_DIFF_UNIFIED(&interactive_opts.context),
+	OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 	OPT_BOOL('e', "edit", &edit_interactive, N_("edit current diff and apply")),
 	OPT__FORCE(&ignored_too, N_("allow adding otherwise ignored files"), 0),
 	OPT_BOOL('u', "update", &take_worktree_changes, N_("update tracked files")),
@@ -399,9 +399,9 @@ int cmd_add(int argc,
 	prepare_repo_settings(repo);
 	repo->settings.command_requires_full_index = 0;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (patch_interactive)
@@ -411,11 +411,11 @@ int cmd_add(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--dry-run", "--interactive/--patch");
 		if (pathspec_from_file)
 			die(_("options '%s' and '%s' cannot be used together"), "--pathspec-from-file", "--interactive/--patch");
-		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &add_p_opt));
+		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &interactive_opts));
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
diff --git a/builtin/checkout.c b/builtin/checkout.c
index f9453473fe..d230b1f899 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -546,7 +546,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 
 	if (opts->patch_mode) {
 		enum add_p_mode patch_mode;
-		struct add_p_opt add_p_opt = {
+		struct interactive_options interactive_opts = {
 			.context = opts->patch_context,
 			.interhunkcontext = opts->patch_interhunk_context,
 		};
@@ -575,7 +575,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 		else
 			BUG("either flag must have been set, worktree=%d, index=%d",
 			    opts->checkout_worktree, opts->checkout_index);
-		return !!run_add_p(the_repository, patch_mode, &add_p_opt,
+		return !!run_add_p(the_repository, patch_mode, &interactive_opts,
 				   rev, &opts->pathspec);
 	}
 
diff --git a/builtin/commit.c b/builtin/commit.c
index 0243f17d53..640495cc57 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -123,7 +123,7 @@ static const char *edit_message, *use_message;
 static char *fixup_message, *fixup_commit, *squash_message;
 static const char *fixup_prefix;
 static int all, also, interactive, patch_interactive, only, amend, signoff;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int edit_flag = -1; /* unspecified */
 static int quiet, verbose, no_verify, allow_empty, dry_run, renew_authorship;
 static int config_commit_verbose = -1; /* unspecified */
@@ -356,9 +356,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 	const char *ret;
 	char *path = NULL;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (is_status)
@@ -407,7 +407,7 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 		setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-		if (interactive_add(the_repository, argv, prefix, patch_interactive, &add_p_opt) != 0)
+		if (interactive_add(the_repository, argv, prefix, patch_interactive, &interactive_opts) != 0)
 			die(_("interactive add failed"));
 
 		the_repository->index_file = old_repo_index_file;
@@ -432,9 +432,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		ret = get_lock_file_path(&index_lock);
 		goto out;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
@@ -1742,8 +1742,8 @@ int cmd_commit(int argc,
 		OPT_BOOL('i', "include", &also, N_("add specified files to index for commit")),
 		OPT_BOOL(0, "interactive", &interactive, N_("interactively add files")),
 		OPT_BOOL('p', "patch", &patch_interactive, N_("interactively add changes")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('o', "only", &only, N_("commit only specified files")),
 		OPT_BOOL('n', "no-verify", &no_verify, N_("bypass pre-commit and commit-msg hooks")),
 		OPT_BOOL(0, "dry-run", &dry_run, N_("show what would be committed")),
diff --git a/builtin/reset.c b/builtin/reset.c
index ed35802af1..088449e120 100644
--- a/builtin/reset.c
+++ b/builtin/reset.c
@@ -346,7 +346,7 @@ int cmd_reset(int argc,
 	struct object_id oid;
 	struct pathspec pathspec;
 	int intent_to_add = 0;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	const struct option options[] = {
 		OPT__QUIET(&quiet, N_("be quiet, only report errors")),
 		OPT_BOOL(0, "no-refresh", &no_refresh,
@@ -371,8 +371,8 @@ int cmd_reset(int argc,
 			       PARSE_OPT_OPTARG,
 			       option_parse_recurse_submodules_worktree_updater),
 		OPT_BOOL('p', "patch", &patch_mode, N_("select hunks interactively")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('N', "intent-to-add", &intent_to_add,
 				N_("record only the fact that removed paths will be added later")),
 		OPT_PATHSPEC_FROM_FILE(&pathspec_from_file),
@@ -423,9 +423,9 @@ int cmd_reset(int argc,
 		oidcpy(&oid, &tree->object.oid);
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	prepare_repo_settings(the_repository);
@@ -436,12 +436,12 @@ int cmd_reset(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--patch", "--{hard,mixed,soft}");
 		trace2_cmd_mode("patch-interactive");
 		update_ref_status = !!run_add_p(the_repository, ADD_P_RESET,
-						&add_p_opt, rev, &pathspec);
+						&interactive_opts, rev, &pathspec);
 		goto cleanup;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
diff --git a/builtin/stash.c b/builtin/stash.c
index 948eba06fb..3b50905233 100644
--- a/builtin/stash.c
+++ b/builtin/stash.c
@@ -1306,7 +1306,7 @@ static int stash_staged(struct stash_info *info, struct strbuf *out_patch,
 
 static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 		       struct strbuf *out_patch, int quiet,
-		       struct add_p_opt *add_p_opt)
+		       struct interactive_options *interactive_opts)
 {
 	int ret = 0;
 	struct child_process cp_read_tree = CHILD_PROCESS_INIT;
@@ -1331,7 +1331,7 @@ static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 	old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 	setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-	ret = !!run_add_p(the_repository, ADD_P_STASH, add_p_opt, NULL, ps);
+	ret = !!run_add_p(the_repository, ADD_P_STASH, interactive_opts, NULL, ps);
 
 	the_repository->index_file = old_repo_index_file;
 	if (old_index_env && *old_index_env)
@@ -1427,7 +1427,8 @@ static int stash_working_tree(struct stash_info *info, const struct pathspec *ps
 }
 
 static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_buf,
-			   int include_untracked, int patch_mode, struct add_p_opt *add_p_opt,
+			   int include_untracked, int patch_mode,
+			   struct interactive_options *interactive_opts,
 			   int only_staged, struct stash_info *info, struct strbuf *patch,
 			   int quiet)
 {
@@ -1509,7 +1510,7 @@ static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_b
 		untracked_commit_option = 1;
 	}
 	if (patch_mode) {
-		ret = stash_patch(info, ps, patch, quiet, add_p_opt);
+		ret = stash_patch(info, ps, patch, quiet, interactive_opts);
 		if (ret < 0) {
 			if (!quiet)
 				fprintf_ln(stderr, _("Cannot save the current "
@@ -1595,7 +1596,8 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED,
 }
 
 static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int quiet,
-			 int keep_index, int patch_mode, struct add_p_opt *add_p_opt,
+			 int keep_index, int patch_mode,
+			 struct interactive_options *interactive_opts,
 			 int include_untracked, int only_staged)
 {
 	int ret = 0;
@@ -1667,7 +1669,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q
 	if (stash_msg)
 		strbuf_addstr(&stash_msg_buf, stash_msg);
 	if (do_create_stash(ps, &stash_msg_buf, include_untracked, patch_mode,
-			    add_p_opt, only_staged, &info, &patch, quiet)) {
+			    interactive_opts, only_staged, &info, &patch, quiet)) {
 		ret = -1;
 		goto done;
 	}
@@ -1841,7 +1843,7 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	char *pathspec_from_file = NULL;
 	struct pathspec ps;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1849,8 +1851,8 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1907,19 +1909,19 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	}
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index, patch_mode,
-			    &add_p_opt, include_untracked, only_staged);
+			    &interactive_opts, include_untracked, only_staged);
 
 	clear_pathspec(&ps);
 	free(pathspec_from_file);
@@ -1944,7 +1946,7 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	struct pathspec ps;
 	struct strbuf stash_msg_buf = STRBUF_INIT;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1952,8 +1954,8 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1973,20 +1975,20 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 
 	memset(&ps, 0, sizeof(ps));
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index,
-			    patch_mode, &add_p_opt, include_untracked,
+			    patch_mode, &interactive_opts, include_untracked,
 			    only_staged);
 
 	strbuf_release(&stash_msg_buf);
diff --git a/commit.h b/commit.h
index 1d6e0c7518..7b6e59d6c1 100644
--- a/commit.h
+++ b/commit.h
@@ -258,7 +258,7 @@ int for_each_commit_graft(each_commit_graft_fn, void *);
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt);
+		    int patch, struct interactive_options *opts);
 
 struct commit_extra_header {
 	struct commit_extra_header *next;
-- 
2.51.0.700.g236ee7b076.dirty
Kristoffer Haugsbakk· Oct 2, 2025, 09:25 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 08/12] add-patch: split out `struct interactive_options`

On Wed, Oct 1, 2025, at 17:57, Patrick Steinhardt wrote:
> The `struct add_p_opt` is reused both by our the infra for "git add -p"
“both by our the infra”

I think an edit to this sentence got mixed up/jumbled with the old version.

> and "git add -i". Users of `run_add_i()` for example are expected to
> pass `struct add_p_opt`. This is somewhat confusing and raises the
> question which options apply to what part of the stack.
s/question which/question of which/ ?
Or: s/question which options apply to what/question of what options apply to what/
>
>[snip]
Karthik Nayak· Oct 14, 2025, 12:35 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 08/12] add-patch: split out `struct interactive_options`

Patrick Steinhardt <ps@pks.im> writes:
Show 5 quoted lines
> The `struct add_p_opt` is reused both by our the infra for "git add -p"
> and "git add -i". Users of `run_add_i()` for example are expected to
> pass `struct add_p_opt`. This is somewhat confusing and raises the
> question which options apply to what part of the stack.
>

Okay. So seems like `struct add_p_opt` is defined in 'add-patch.h' and `struct add_i_state` in 'add-interactive.h'.

Show 13 quoted lines
> But things are even more confusing than that: while callers are expected
> to pass in `struct add_p_opt`, these options ultimately get used to
> initialize a `struct add_i_state` that is used by both subsystems. So we
> are basically going full circle here.
>
> Refactor the code and split out a new `struct interactive_options` that
> hosts common options used by both. These options are then applied to a
> `struct interactive_config` that hosts common configuration.
>
> This refactoring doesn't yet fully detangle the two subsystems from one
> another, as we still end up calling `init_add_i_state()` in the "git add
> -p" subsystem. This will be fixed in a subsequent commit.
>
[snip]
Show 50 quoted lines
> diff --git a/add-patch.h b/add-patch.h
> index 4394c74107..a4a05d9d14 100644
> --- a/add-patch.h
> +++ b/add-patch.h
> @@ -1,15 +1,45 @@
>  #ifndef ADD_PATCH_H
>  #define ADD_PATCH_H
>
> +#include "color.h"
> +
>  struct pathspec;
>  struct repository;
>
> -struct add_p_opt {
> +struct interactive_options {
>  	int context;
>  	int interhunkcontext;
>  };
>
> -#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
> +#define INTERACTIVE_OPTIONS_INIT { \
> +	.context = -1, \
> +	.interhunkcontext = -1, \
> +}
> +
> +struct interactive_config {
> +	enum git_colorbool use_color_interactive;
> +	enum git_colorbool use_color_diff;
> +	char header_color[COLOR_MAXLEN];
> +	char help_color[COLOR_MAXLEN];
> +	char prompt_color[COLOR_MAXLEN];
> +	char error_color[COLOR_MAXLEN];
> +	char reset_color_interactive[COLOR_MAXLEN];
> +
> +	char fraginfo_color[COLOR_MAXLEN];
> +	char context_color[COLOR_MAXLEN];
> +	char file_old_color[COLOR_MAXLEN];
> +	char file_new_color[COLOR_MAXLEN];
> +	char reset_color_diff[COLOR_MAXLEN];
> +
> +	int use_single_key;
> +	char *interactive_diff_filter, *interactive_diff_algorithm;
> +	int context, interhunkcontext;
> +};
> +
> +void interactive_config_init(struct interactive_config *cfg,
> +			     struct repository *r,
> +			     struct interactive_options *opts);
> +void interactive_config_clear(struct interactive_config *cfg);
>

It feels a little odd that the `interactive_*` code lies in the 'add-patch.h' and not in the 'add-interactive.h'.

Should we also consider moving this or renaming the structs?
Nit: might be nice to make add the 'add_' prefix to them while we're
here.
Show 11 quoted lines
>  enum add_p_mode {
>  	ADD_P_ADD,
> @@ -20,7 +50,7 @@ enum add_p_mode {
>  };
>
>  int run_add_p(struct repository *r, enum add_p_mode mode,
> -	      struct add_p_opt *o, const char *revision,
> +	      struct interactive_options *opts, const char *revision,
>  	      const struct pathspec *ps);
>
>  #endif
[snip]
Patrick Steinhardt· Oct 21, 2025, 11:44 UTC · re: Karthik Nayak · lore

Re: [PATCH v4 08/12] add-patch: split out `struct interactive_options`

On Tue, Oct 14, 2025 at 08:35:39AM -0400, Karthik Nayak wrote:
Show 59 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
> > diff --git a/add-patch.h b/add-patch.h
> > index 4394c74107..a4a05d9d14 100644
> > --- a/add-patch.h
> > +++ b/add-patch.h
> > @@ -1,15 +1,45 @@
> >  #ifndef ADD_PATCH_H
> >  #define ADD_PATCH_H
> >
> > +#include "color.h"
> > +
> >  struct pathspec;
> >  struct repository;
> >
> > -struct add_p_opt {
> > +struct interactive_options {
> >  	int context;
> >  	int interhunkcontext;
> >  };
> >
> > -#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
> > +#define INTERACTIVE_OPTIONS_INIT { \
> > +	.context = -1, \
> > +	.interhunkcontext = -1, \
> > +}
> > +
> > +struct interactive_config {
> > +	enum git_colorbool use_color_interactive;
> > +	enum git_colorbool use_color_diff;
> > +	char header_color[COLOR_MAXLEN];
> > +	char help_color[COLOR_MAXLEN];
> > +	char prompt_color[COLOR_MAXLEN];
> > +	char error_color[COLOR_MAXLEN];
> > +	char reset_color_interactive[COLOR_MAXLEN];
> > +
> > +	char fraginfo_color[COLOR_MAXLEN];
> > +	char context_color[COLOR_MAXLEN];
> > +	char file_old_color[COLOR_MAXLEN];
> > +	char file_new_color[COLOR_MAXLEN];
> > +	char reset_color_diff[COLOR_MAXLEN];
> > +
> > +	int use_single_key;
> > +	char *interactive_diff_filter, *interactive_diff_algorithm;
> > +	int context, interhunkcontext;
> > +};
> > +
> > +void interactive_config_init(struct interactive_config *cfg,
> > +			     struct repository *r,
> > +			     struct interactive_options *opts);
> > +void interactive_config_clear(struct interactive_config *cfg);
> >
> 
> It feels a little odd that the `interactive_*` code lies in the
> 'add-patch.h' and not in the 'add-interactive.h'.
> 
> Should we also consider moving this or renaming the structs?
> 
> Nit: might be nice to make add the 'add_' prefix to them while we're
> here.

The proper name for this struct would be `add_patch_interactive_config`, but I decided against that name for now as it feels like a mouthful. I think these two patches improve the status quo regardless of that, so I'd prefer to just keep those as-is if you don't mind?

Patrick
Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 09/12] add-patch: remove dependency on "add-interactive" subsystem

With the preceding commit we have split out interactive configuration that is used by both "git add -p" and "git add -i". But we still initialize that configuration in the "add -p" subsystem by calling `init_add_i_state()`, even though we only do so to initialize the interactive configuration as well as a repository pointer.

Stop doing so and instead store and initialize the interactive configuration in `struct add_p_state` directly.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 70 ++++++++++++++++++++++++++++++++-----------------------------
 1 file changed, 37 insertions(+), 33 deletions(-)
Show changes to add-patch.c +37 −33
diff --git a/add-patch.c b/add-patch.c
index 29c15695dd..9c0f3b23ef 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -2,7 +2,6 @@
 #define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
-#include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
 #include "config.h"
@@ -263,7 +262,8 @@ struct hunk {
 };
 
 struct add_p_state {
-	struct add_i_state s;
+	struct repository *r;
+	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
 	/* parsed diff */
@@ -408,7 +408,7 @@ static void add_p_state_clear(struct add_p_state *s)
 	for (i = 0; i < s->file_diff_nr; i++)
 		free(s->file_diff[i].hunk);
 	free(s->file_diff);
-	clear_add_i_state(&s->s);
+	interactive_config_clear(&s->cfg);
 }
 
 __attribute__((format (printf, 2, 3)))
@@ -417,9 +417,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.cfg.error_color, stdout);
+	fputs(s->cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.cfg.reset_color_interactive);
+	puts(s->cfg.reset_color_interactive);
 	va_end(args);
 }
 
@@ -437,7 +437,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->s.r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
 }
 
 static int parse_range(const char **p,
@@ -542,12 +542,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.cfg.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
-	if (s->s.cfg.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
-	if (s->s.cfg.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
+	if (s->cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->cfg.context);
+	if (s->cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
+	if (s->cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -576,9 +576,9 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	}
 	strbuf_complete_line(plain);
 
-	if (want_color_fd(1, s->s.cfg.use_color_diff)) {
+	if (want_color_fd(1, s->cfg.use_color_diff)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.cfg.interactive_diff_filter;
+		const char *diff_filter = s->cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -811,7 +811,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.cfg.fraginfo_color);
+			strbuf_addstr(out, s->cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -833,7 +833,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
+			strbuf_addf(out, "%s\n", s->cfg.reset_color_diff);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1221,12 +1221,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.cfg.file_old_color :
+			      s->cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.cfg.file_new_color :
-			      s->s.cfg.context_color);
+			      s->cfg.file_new_color :
+			      s->cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
+		strbuf_addstr(&s->colored, s->cfg.reset_color_diff);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1345,7 +1345,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.cfg.use_single_key) {
+	if (s->cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1359,7 +1359,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1640,15 +1640,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.cfg.reset_color_interactive)
-			fputs(s->s.cfg.reset_color_interactive, stdout);
+		if (*s->cfg.reset_color_interactive)
+			fputs(s->cfg.reset_color_interactive, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			break;
@@ -1805,7 +1805,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.cfg.header_color,
+				color_fprintf_ln(stdout, s->cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1823,7 +1823,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.cfg.help_color, "%s",
+			color_fprintf(stdout, s->cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1841,7 +1841,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.cfg.help_color,
+				color_fprintf_ln(stdout, s->cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1861,7 +1861,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->s.r->index);
+		discard_index(s->r->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1872,8 +1872,8 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->s.r) >= 0)
-			repo_refresh_and_write_index(s->s.r, REFRESH_QUIET, 0,
+		if (repo_read_index(s->r) >= 0)
+			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
 	}
 
@@ -1886,11 +1886,15 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
-		{ r }, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT
+		.r = r,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, opts);
+	interactive_config_init(&s.cfg, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
-- 
2.51.0.700.g236ee7b076.dirty
Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 10/12] add-patch: add support for in-memory index patching

With `run_add_p()` callers have the ability to apply changes from a specific revision to a repository's index. This infra supports several different modes, like for example applying changes to the index, worktree or both.

One feature that is missing though is the ability to apply changes to an in-memory index different from the repository's index. Add a new function `run_add_p_index()` to plug this gap.

This new function will be used in a subsequent commit.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 add-patch.h |   8 +++++
 2 files changed, 115 insertions(+), 3 deletions(-)
Show changes to 2 files +115 −3

add-patch.c, add-patch.h

diff --git a/add-patch.c b/add-patch.c
index 9c0f3b23ef..fac82c3886 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -4,11 +4,13 @@
 #include "git-compat-util.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "commit.h"
 #include "config.h"
 #include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "object-name.h"
 #include "pager.h"
 #include "read-cache-ll.h"
@@ -263,6 +265,8 @@ struct hunk {
 
 struct add_p_state {
 	struct repository *r;
+	struct index_state *index;
+	const char *index_file;
 	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
@@ -437,7 +441,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->index_file);
 }
 
 static int parse_range(const char **p,
@@ -1861,7 +1865,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->r->index);
+		discard_index(s->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1872,9 +1876,11 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->r) >= 0)
+		if (read_index_from(s->index, s->index_file, s->r->gitdir) >= 0 &&
+		    s->index == s->r->index) {
 			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
+		}
 	}
 
 	putchar('\n');
@@ -1887,6 +1893,8 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 {
 	struct add_p_state s = {
 		.r = r,
+		.index = r->index,
+		.index_file = r->index_file,
 		.answer = STRBUF_INIT,
 		.buf = STRBUF_INIT,
 		.plain = STRBUF_INIT,
@@ -1945,3 +1953,99 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	add_p_state_clear(&s);
 	return 0;
 }
+
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps)
+{
+	struct patch_mode mode = {
+		.apply_args = { "--cached", NULL },
+		.apply_check_args = { "--cached", NULL },
+		.prompt_mode = {
+			N_("Stage mode change [y,n,q,a,d%s,?]? "),
+			N_("Stage deletion [y,n,q,a,d%s,?]? "),
+			N_("Stage addition [y,n,q,a,d%s,?]? "),
+			N_("Stage this hunk [y,n,q,a,d%s,?]? ")
+		},
+		.edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
+				     "will immediately be marked for staging."),
+		.help_patch_text =
+			N_("y - stage this hunk\n"
+			   "n - do not stage this hunk\n"
+			   "q - quit; do not stage this hunk or any of the remaining "
+				"ones\n"
+			   "a - stage this hunk and all later hunks in the file\n"
+			   "d - do not stage this hunk or any of the later hunks in "
+				"the file\n"),
+		.index_only = 1,
+	};
+	struct add_p_state s = {
+		.r = r,
+		.index = index,
+		.index_file = index_file,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
+		.mode = &mode,
+		.revision = revision,
+	};
+	struct strbuf parent_revision = STRBUF_INIT;
+	char parent_tree_oid[GIT_MAX_HEXSZ + 1];
+	size_t binary_count = 0;
+	struct commit *commit;
+	int ret;
+
+	commit = lookup_commit_reference_by_name(revision);
+	if (!commit) {
+		err(&s, _("Revision does not refer to a commit"));
+		ret = -1;
+		goto out;
+	}
+
+	if (commit->parents)
+		oid_to_hex_r(parent_tree_oid, get_commit_tree_oid(commit->parents->item));
+	else
+		oid_to_hex_r(parent_tree_oid, r->hash_algo->empty_tree);
+
+	strbuf_addf(&parent_revision, "%s~", revision);
+	mode.diff_cmd[0] = "diff-tree";
+	mode.diff_cmd[1] = "-r";
+	mode.diff_cmd[2] = parent_tree_oid;
+
+	interactive_config_init(&s.cfg, r, opts);
+
+	if (parse_diff(&s, ps) < 0) {
+		ret = -1;
+		goto out;
+	}
+
+	for (size_t i = 0; i < s.file_diff_nr; i++) {
+		if (s.file_diff[i].binary && !s.file_diff[i].hunk_nr)
+			binary_count++;
+		else if (patch_update_file(&s, s.file_diff + i))
+			break;
+	}
+
+	if (s.file_diff_nr == 0) {
+		err(&s, _("No changes."));
+		ret = -1;
+		goto out;
+	}
+
+	if (binary_count == s.file_diff_nr) {
+		err(&s, _("Only binary files changed."));
+		ret = -1;
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	strbuf_release(&parent_revision);
+	add_p_state_clear(&s);
+	return ret;
+}
diff --git a/add-patch.h b/add-patch.h
index a4a05d9d14..901c42fd7b 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -3,6 +3,7 @@
 
 #include "color.h"
 
+struct index_state;
 struct pathspec;
 struct repository;
 
@@ -53,4 +54,11 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps);
+
 #endif
-- 
2.51.0.700.g236ee7b076.dirty
Kristoffer Haugsbakk· Oct 2, 2025, 09:28 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 10/12] add-patch: add support for in-memory index patching

On Wed, Oct 1, 2025, at 17:57, Patrick Steinhardt wrote:
> With `run_add_p()` callers have the ability to apply changes from a
> specific revision to a repository's index. This infra supports several
> different modes, like for example applying changes to the index,
> worktree or both.
s/worktree/working tree/ ?
>
>[snip]
Patrick Steinhardt· Oct 2, 2025, 10:24 UTC · re: Kristoffer Haugsbakk · lore

Re: [PATCH v4 10/12] add-patch: add support for in-memory index patching

On Thu, Oct 02, 2025 at 11:28:23AM +0200, Kristoffer Haugsbakk wrote:
Show 7 quoted lines
> On Wed, Oct 1, 2025, at 17:57, Patrick Steinhardt wrote:
> > With `run_add_p()` callers have the ability to apply changes from a
> > specific revision to a repository's index. This infra supports several
> > different modes, like for example applying changes to the index,
> > worktree or both.
> 
> s/worktree/working tree/ ?

Yup. I've applied all of your feedback locally and will send it out with the next version. Thanks!

Patrick
Karthik Nayak· Oct 14, 2025, 13:08 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 10/12] add-patch: add support for in-memory index patching

Patrick Steinhardt <ps@pks.im> writes:
[snip]
Show 20 quoted lines
> @@ -1945,3 +1953,99 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
>  	add_p_state_clear(&s);
>  	return 0;
>  }
> +
> +int run_add_p_index(struct repository *r,
> +		    struct index_state *index,
> +		    const char *index_file,
> +		    struct interactive_options *opts,
> +		    const char *revision,
> +		    const struct pathspec *ps)
> +{
> +	struct patch_mode mode = {
> +		.apply_args = { "--cached", NULL },
> +		.apply_check_args = { "--cached", NULL },
> +		.prompt_mode = {
> +			N_("Stage mode change [y,n,q,a,d%s,?]? "),
> +			N_("Stage deletion [y,n,q,a,d%s,?]? "),
> +			N_("Stage addition [y,n,q,a,d%s,?]? "),
> +			N_("Stage this hunk [y,n,q,a,d%s,?]? ")
Missing trailing comma.
Rest of the patch looks good.
[snip]
Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 11/12] cache-tree: allow writing in-memory index as tree

The function `write_in_core_index_as_tree()` takes a repository and writes its index into a tree object. What this function cannot do though is to take an _arbitrary_ in-memory index.

Introduce a new `struct index_state` parameter so that the caller can pass a different index than the one belonging to the repository. This will be used in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/checkout.c | 3 ++-
 cache-tree.c       | 5 ++---
 cache-tree.h       | 3 ++-
 3 files changed, 6 insertions(+), 5 deletions(-)
Show changes to 3 files +6 −5

builtin/checkout.c, cache-tree.c, cache-tree.h

diff --git a/builtin/checkout.c b/builtin/checkout.c
index d230b1f8995..0b90f398feb 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -902,7 +902,8 @@ static int merge_working_tree(const struct checkout_opts *opts,
 					   0);
 			init_ui_merge_options(&o, the_repository);
 			o.verbosity = 0;
-			work = write_in_core_index_as_tree(the_repository);
+			work = write_in_core_index_as_tree(the_repository,
+							   the_repository->index);
 
 			ret = reset_tree(new_tree,
 					 opts, 1,
diff --git a/cache-tree.c b/cache-tree.c
index d225554eedd..1fe03605225 100644
--- a/cache-tree.c
+++ b/cache-tree.c
@@ -700,11 +700,11 @@ static int write_index_as_tree_internal(struct object_id *oid,
 	return 0;
 }
 
-struct tree* write_in_core_index_as_tree(struct repository *repo) {
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state) {
 	struct object_id o;
 	int was_valid, ret;
 
-	struct index_state *index_state	= repo->index;
 	was_valid = index_state->cache_tree &&
 		    cache_tree_fully_valid(index_state->cache_tree);
 
@@ -724,7 +724,6 @@ struct tree* write_in_core_index_as_tree(struct repository *repo) {
 	return lookup_tree(repo, &index_state->cache_tree->oid);
 }
 
-
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix)
 {
 	int entries, was_valid;
diff --git a/cache-tree.h b/cache-tree.h
index b82c4963e7c..f8bddae5235 100644
--- a/cache-tree.h
+++ b/cache-tree.h
@@ -47,7 +47,8 @@ int cache_tree_verify(struct repository *, struct index_state *);
 #define WRITE_TREE_UNMERGED_INDEX (-2)
 #define WRITE_TREE_PREFIX_ERROR (-3)
 
-struct tree* write_in_core_index_as_tree(struct repository *repo);
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state);
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix);
 void prime_cache_tree(struct repository *, struct index_state *, struct tree *);
 
-- 
2.51.0.700.g236ee7b076.dirty
Patrick Steinhardt· Oct 1, 2025, 15:57 UTC · re: Patrick Steinhardt · lore

[PATCH v4 12/12] builtin/history: implement "split" subcommand

It is quite a common use case that one wants to split up one commit into multiple commits by moving parts of the changes of the original commit out into a separate commit. This is quite an involved operation though:

  1. Identify the commit in question that is to be dropped.
  2. Perform an interactive rebase on top of that commit's parent.
  3. Modify the instruction sheet to "edit" the commit that is to be
     split up.
  4. Drop the commit via "git reset HEAD~".
  5. Stage changes that should go into the first commit and commit it.
  6. Stage changes that should go into the second commit and commit it.
  7. Finalize the rebase.

This is quite complex, and overall I would claim that most people who are not experts in Git would struggle with this flow.

Introduce a new "split" subcommand for git-history(1) to make this way easier. All the user needs to do is to say `git history split $COMMIT`. From hereon, Git asks the user which parts of the commit shall be moved out into a separate commit and, once done, asks the user for the commit message. Git then creates that split-out commit and applies the original commit on top of it.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  62 ++++++
 builtin/history.c              | 225 +++++++++++++++++++++
 t/meson.build                  |   1 +
 t/t3452-history-split.sh       | 432 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 720 insertions(+)
Show changes to 4 files +718 −0

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3452-history-split.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index b55babe206..83d675afea 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -10,6 +10,7 @@ SYNOPSIS
 [synopsis]
 git history [<options>]
 git history reword [<options>] <commit>
+git history split [<options>] <commit> [--] [<pathspec>...]
 
 DESCRIPTION
 -----------
@@ -40,6 +41,26 @@ rewrite history in different ways:
 	provided, then this command will spawn an editor with the current
 	message of that commit.
 
+`split [--message=<message>] <commit> [--] [<pathspec>...]`::
+	Interactively split up <commit> into two commits by choosing
+	hunks introduced by it that will be moved into the new split-out
+	commit. These hunks will then be written into a new commit that
+	becomes the parent of the previous commit. The original commit
+	stays intact, except that its parent will be the newly split-out
+	commit.
++
+The commit message of the new commit will be asked for by launching the
+configured editor, unless it has been specified with the `-m` option.
+Authorship of the commit will be the same as for the original commit.
++
+If passed, _<pathspec>_ can be used to limit which changes shall be split out
+of the original commit. Files not matching any of the pathspecs will remain
+part of the original commit. For more details, see the 'pathspec' entry in
+linkgit:gitglossary[7].
++
+It is invalid to select either all or no hunks, as that would lead to
+one of the commits becoming empty.
+
 CONFIGURATION
 -------------
 
@@ -47,6 +68,47 @@ include::includes/cmd-config-section-all.adoc[]
 
 include::config/sequencer.adoc[]
 
+EXAMPLES
+--------
+
+Split a commit
+~~~~~~~~~~~~~~
+
+----------
+$ git log --stat --oneline
+3f81232 (HEAD -> main) original
+ bar | 1 +
+ foo | 1 +
+ 2 files changed, 2 insertions(+)
+
+$ git history split HEAD --message="split-out commit"
+diff --git a/bar b/bar
+new file mode 100644
+index 0000000..5716ca5
+--- /dev/null
++++ b/bar
+@@ -0,0 +1 @@
++bar
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? y
+
+diff --git a/foo b/foo
+new file mode 100644
+index 0000000..257cc56
+--- /dev/null
++++ b/foo
+@@ -0,0 +1 @@
++foo
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? n
+
+$ git log --stat --oneline
+7cebe64 (HEAD -> main) original
+ foo | 1 +
+ 1 file changed, 1 insertion(+)
+d1582f3 split-out commit
+ bar | 1 +
+ 1 file changed, 1 insertion(+)
+----------
+
 GIT
 ---
 Part of the linkgit:git[1] suite
diff --git a/builtin/history.c b/builtin/history.c
index 7b2a0023e8..8851a2945e 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,6 +1,7 @@
 #define USE_THE_REPOSITORY_VARIABLE
 
 #include "builtin.h"
+#include "cache-tree.h"
 #include "commit-reach.h"
 #include "commit.h"
 #include "config.h"
@@ -10,10 +11,13 @@
 #include "hex.h"
 #include "oidmap.h"
 #include "parse-options.h"
+#include "path.h"
+#include "read-cache.h"
 #include "refs.h"
 #include "replay.h"
 #include "reset.h"
 #include "revision.h"
+#include "run-command.h"
 #include "sequencer.h"
 #include "strvec.h"
 #include "tree.h"
@@ -368,6 +372,225 @@ static int cmd_history_reword(int argc,
 	return ret;
 }
 
+static int split_commit(struct repository *repo,
+			struct commit *original_commit,
+			struct pathspec *pathspec,
+			const char *commit_message,
+			struct object_id *out)
+{
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
+	struct strbuf index_file = STRBUF_INIT, split_message = STRBUF_INIT;
+	struct child_process read_tree_cmd = CHILD_PROCESS_INIT;
+	struct index_state index = INDEX_STATE_INIT(repo);
+	struct object_id original_commit_tree_oid, parent_tree_oid;
+	const char *original_message, *original_body, *ptr;
+	char original_commit_oid[GIT_MAX_HEXSZ + 1];
+	char *original_author = NULL;
+	struct commit_list *parents = NULL;
+	struct commit *first_commit;
+	struct tree *split_tree;
+	size_t len;
+	int ret;
+
+	if (original_commit->parents)
+		parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
+	else
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
+
+	/*
+	* Construct the first commit. This is done by taking the original
+	* commit parent's tree and selectively patching changes from the diff
+	* between that parent and its child.
+	*/
+	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
+
+	read_tree_cmd.git_cmd = 1;
+	strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
+	strvec_push(&read_tree_cmd.args, "read-tree");
+	strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
+	ret = run_command(&read_tree_cmd);
+	if (ret < 0)
+		goto out;
+
+	ret = read_index_from(&index, index_file.buf, repo->gitdir);
+	if (ret < 0) {
+		ret = error(_("failed reading temporary index"));
+		goto out;
+	}
+
+	oid_to_hex_r(original_commit_oid, &original_commit->object.oid);
+	ret = run_add_p_index(repo, &index, index_file.buf, &interactive_opts,
+			      original_commit_oid, pathspec);
+	if (ret < 0)
+		goto out;
+
+	split_tree = write_in_core_index_as_tree(repo, &index);
+	if (!split_tree) {
+		ret = error(_("failed split tree"));
+		goto out;
+	}
+
+	unlink(index_file.buf);
+
+	/*
+	* We disallow the cases where either the split-out commit or the
+	* original commit would become empty. Consequently, if we see that the
+	* new tree ID matches either of those trees we abort.
+	*/
+	if (oideq(&split_tree->object.oid, &parent_tree_oid)) {
+		ret = error(_("split commit is empty"));
+		goto out;
+	} else if (oideq(&split_tree->object.oid, &original_commit_tree_oid)) {
+		ret = error(_("split commit tree matches original commit"));
+		goto out;
+	}
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+
+	ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
+				  "", commit_message, "split-out", &split_message);
+	if (ret < 0)
+		goto out;
+
+	ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
+			  original_commit->parents, &out[0], original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing split-out commit"));
+		goto out;
+	}
+
+	/*
+	* The second commit is much simpler to construct, as we can simply use
+	* the original commit details, except that we adjust its parent to be
+	* the newly split-out commit.
+	*/
+	find_commit_subject(original_message, &original_body);
+	first_commit = lookup_commit_reference(repo, &out[0]);
+	commit_list_append(first_commit, &parents);
+
+	ret = commit_tree(original_body, strlen(original_body), &original_commit_tree_oid,
+			  parents, &out[1], original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing second commit"));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	if (index_file.len)
+		unlink(index_file.buf);
+	strbuf_release(&split_message);
+	strbuf_release(&index_file);
+	free_commit_list(parents);
+	free(original_author);
+	release_index(&index);
+	return ret;
+}
+
+static int cmd_history_split(int argc,
+			     const char **argv,
+			     const char *prefix,
+			     struct repository *repo)
+{
+	const char * const usage[] = {
+		N_("git history split [<options>] <commit>"),
+		NULL,
+	};
+	const char *commit_message = NULL;
+	struct option options[] = {
+		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
+		OPT_END(),
+	};
+	struct oidmap rewritten_commits = OIDMAP_INIT;
+	struct commit *original_commit, *parent, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct commit_list *list = NULL;
+	struct object_id split_commits[2];
+	struct pathspec pathspec = { 0 };
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc < 1) {
+		ret = error(_("command expects a revision"));
+		goto out;
+	}
+	repo_config(repo, git_default_config, NULL);
+
+	original_commit = lookup_commit_reference_by_name(argv[0]);
+	if (!original_commit) {
+		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
+		goto out;
+	}
+
+	if (original_commit->parents && original_commit->parents->next) {
+		ret = error(_("commit to be split must not be a merge commit"));
+		goto out;
+	}
+
+	parent = original_commit->parents ? original_commit->parents->item : NULL;
+	if (parent && repo_parse_commit(repo, parent)) {
+		ret = error(_("unable to parse commit %s"),
+			    oid_to_hex(&parent->object.oid));
+		goto out;
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	commit_list_append(original_commit, &list);
+	if (!repo_is_descendant_of(repo, original_commit, list)) {
+		ret = error (_("split commit must be reachable from current HEAD commit"));
+		goto out;
+	}
+
+	parse_pathspec(&pathspec, 0,
+		PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
+		prefix, argv + 1);
+
+	/*
+	* Collect the list of commits that we'll have to reapply now already.
+	* This ensures that we'll abort early on in case the range of commits
+	* contains merges, which we do not yet handle.
+	*/
+	ret = collect_commits(repo, parent, head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/*
+	 * Then we split up the commit and replace the original commit with the
+	 * new new ones.
+	 */
+	ret = split_commit(repo, original_commit, &pathspec,
+			   commit_message, split_commits);
+	if (ret < 0)
+		goto out;
+
+	replace_commits(&commits, &original_commit->object.oid,
+			split_commits, ARRAY_SIZE(split_commits));
+
+	ret = apply_commits(repo, &commits, parent, head, "split");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	oidmap_clear(&rewritten_commits, 0);
+	clear_pathspec(&pathspec);
+	strvec_clear(&commits);
+	free_commit_list(list);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -376,11 +599,13 @@ int cmd_history(int argc,
 	const char * const usage[] = {
 		N_("git history [<options>]"),
 		N_("git history reword [<options>] <commit>"),
+		N_("git history split [<options>] <commit> [--] [<pathspec>...]"),
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
 		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 2a74243202..fb05be16ae 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -384,6 +384,7 @@ integration_tests = [
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
   't3451-history-reword.sh',
+  't3452-history-split.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh
new file mode 100755
index 0000000000..45d3b32ebf
--- /dev/null
+++ b/t/t3452-history-split.sh
@@ -0,0 +1,432 @@
+#!/bin/sh
+
+test_description='tests for git-history split subcommand'
+
+. ./test-lib.sh
+
+set_fake_editor () {
+	write_script fake-editor.sh <<-\EOF &&
+	echo "split-out commit" >"$1"
+	EOF
+	test_set_editor "$(pwd)"/fake-editor.sh
+}
+
+expect_log () {
+	git log --format="%s" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+expect_tree_entries () {
+	git ls-tree --name-only "$1" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history split HEAD 2>err &&
+		test_grep "commit to be split must not be a merge commit" err &&
+		test_must_fail git history split HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'can split up tip commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git symbolic-ref HEAD >expect &&
+		set_fake_editor &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		initial
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m root &&
+		test_commit tip &&
+
+		set_fake_editor &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		root
+		split-out commit
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up in-between commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+		test_commit tip &&
+
+		set_fake_editor &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		split-me
+		split-out commit
+		initial
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can pick multiple hunks' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar baz foo qux &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "split-out commit" <<-EOF &&
+		y
+		n
+		y
+		n
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		baz
+		foo
+		qux
+		EOF
+	)
+'
+
+
+test_expect_success 'can use only last hunk' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "split-out commit" <<-EOF &&
+		n
+		y
+		EOF
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD -m "" <<-EOF 2>err &&
+		y
+		n
+		EOF
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_expect_success 'can specify message via option' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "message option" <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF
+		split-me
+		message option
+		EOF
+	)
+'
+
+test_expect_success 'commit message editor sees split-out changes' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		echo "some commit message" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		cat >expect <<-EOF &&
+
+		# Please enter the commit message for the split-out changes. Lines starting
+		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
+		# Changes to be committed:
+		#	new file:   bar
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		expect_log <<-EOF
+		split-me
+		some commit message
+		EOF
+	)
+'
+
+test_expect_success 'can use pathspec to limit what gets split' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "message option" -- foo <<-EOF &&
+		y
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'refuses to create empty split-out commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		n
+		n
+		EOF
+		test_grep "split commit is empty" err
+	)
+'
+
+test_expect_success 'hooks are executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+		old_head=$(git rev-parse HEAD) &&
+
+		write_script .git/hooks/prepare-commit-msg <<-EOF &&
+		touch "$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-commit <<-EOF &&
+		touch "$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-rewrite <<-EOF &&
+		touch "$(pwd)/hooks.log"
+		EOF
+
+		set_fake_editor &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		EOF
+
+		test_path_is_missing hooks.log
+	)
+'
+
+test_expect_success 'refuses to create empty original commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		y
+		y
+		EOF
+		test_grep "split commit tree matches original commit" err
+	)
+'
+
+test_expect_success 'retains changes in the worktree and index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		echo a >a &&
+		echo b >b &&
+		git add . &&
+		git commit -m "initial commit" &&
+		echo a-modified >a &&
+		echo b-modified >b &&
+		git add b &&
+		git history split HEAD -m a-only <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		a
+		EOF
+		expect_tree_entries HEAD <<-EOF &&
+		a
+		b
+		EOF
+
+		cat >expect <<-\EOF &&
+		 M a
+		M  b
+		?? actual
+		?? expect
+		EOF
+		git status --porcelain >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.51.0.700.g236ee7b076.dirty
Karthik Nayak· Oct 14, 2025, 13:38 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 12/12] builtin/history: implement "split" subcommand

Patrick Steinhardt <ps@pks.im> writes:
Show 61 quoted lines
> It is quite a common use case that one wants to split up one commit into
> multiple commits by moving parts of the changes of the original commit
> out into a separate commit. This is quite an involved operation though:
>
>   1. Identify the commit in question that is to be dropped.
>
>   2. Perform an interactive rebase on top of that commit's parent.
>
>   3. Modify the instruction sheet to "edit" the commit that is to be
>      split up.
>
>   4. Drop the commit via "git reset HEAD~".
>
>   5. Stage changes that should go into the first commit and commit it.
>
>   6. Stage changes that should go into the second commit and commit it.
>
>   7. Finalize the rebase.
>
> This is quite complex, and overall I would claim that most people who
> are not experts in Git would struggle with this flow.
>
> Introduce a new "split" subcommand for git-history(1) to make this way
> easier. All the user needs to do is to say `git history split $COMMIT`.
> From hereon, Git asks the user which parts of the commit shall be moved
> out into a separate commit and, once done, asks the user for the commit
> message. Git then creates that split-out commit and applies the original
> commit on top of it.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  Documentation/git-history.adoc |  62 ++++++
>  builtin/history.c              | 225 +++++++++++++++++++++
>  t/meson.build                  |   1 +
>  t/t3452-history-split.sh       | 432 +++++++++++++++++++++++++++++++++++++++++
>  4 files changed, 720 insertions(+)
>
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> index b55babe206..83d675afea 100644
> --- a/Documentation/git-history.adoc
> +++ b/Documentation/git-history.adoc
> @@ -10,6 +10,7 @@ SYNOPSIS
>  [synopsis]
>  git history [<options>]
>  git history reword [<options>] <commit>
> +git history split [<options>] <commit> [--] [<pathspec>...]
>
>  DESCRIPTION
>  -----------
> @@ -40,6 +41,26 @@ rewrite history in different ways:
>  	provided, then this command will spawn an editor with the current
>  	message of that commit.
>
> +`split [--message=<message>] <commit> [--] [<pathspec>...]`::
> +	Interactively split up <commit> into two commits by choosing
> +	hunks introduced by it that will be moved into the new split-out
> +	commit. These hunks will then be written into a new commit that
> +	becomes the parent of the previous commit. The original commit
> +	stays intact, except that its parent will be the newly split-out
> +	commit.
>
So in essence we do this:
  Before split:
  P1 ── C0 ── C1 ── ... ── CN
        └─(target)         └─(HEAD)
  After split:
  P1 ── S0 ── C0' ── C1 ── ...... ── CN
        │     └─(modified original)  └─(HEAD)
        └─(split-out hunks)

I do wonder if S0 should contain the existing message and the new message should go to C0'. So perhaps more like

  After split:
  P1 ── C0' ── S0 ── C1 ── ..... ── CN
        │      └─(split-out hunks)  └─(HEAD)
        └─(modified original)

Mostly because when you say split, I would assume we keep the original as is and add on top of it. I don't really have a strong argument though :)

[snip]
Show 41 quoted lines
> +EXAMPLES
> +--------
> +
> +Split a commit
> +~~~~~~~~~~~~~~
> +
> +----------
> +$ git log --stat --oneline
> +3f81232 (HEAD -> main) original
> + bar | 1 +
> + foo | 1 +
> + 2 files changed, 2 insertions(+)
> +
> +$ git history split HEAD --message="split-out commit"
> +diff --git a/bar b/bar
> +new file mode 100644
> +index 0000000..5716ca5
> +--- /dev/null
> ++++ b/bar
> +@@ -0,0 +1 @@
> ++bar
> +(1/1) Stage addition [y,n,q,a,d,e,p,?]? y
> +
> +diff --git a/foo b/foo
> +new file mode 100644
> +index 0000000..257cc56
> +--- /dev/null
> ++++ b/foo
> +@@ -0,0 +1 @@
> ++foo
> +(1/1) Stage addition [y,n,q,a,d,e,p,?]? n
> +
> +$ git log --stat --oneline
> +7cebe64 (HEAD -> main) original
> + foo | 1 +
> + 1 file changed, 1 insertion(+)
> +d1582f3 split-out commit
> + bar | 1 +
> + 1 file changed, 1 insertion(+)
> +----------
> +
It's really nice to have examples.
[snip]
Show 10 quoted lines
> +
> +static int cmd_history_split(int argc,
> +			     const char **argv,
> +			     const char *prefix,
> +			     struct repository *repo)
> +{
> +	const char * const usage[] = {
> +		N_("git history split [<options>] <commit>"),
> +		NULL,
> +	};
should be '*const' here.
Show 30 quoted lines
> +	const char *commit_message = NULL;
> +	struct option options[] = {
> +		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
> +		OPT_END(),
> +	};
> +	struct oidmap rewritten_commits = OIDMAP_INIT;
> +	struct commit *original_commit, *parent, *head;
> +	struct strvec commits = STRVEC_INIT;
> +	struct commit_list *list = NULL;
> +	struct object_id split_commits[2];
> +	struct pathspec pathspec = { 0 };
> +	int ret;
> +
> +	argc = parse_options(argc, argv, prefix, options, usage, 0);
> +	if (argc < 1) {
> +		ret = error(_("command expects a revision"));
> +		goto out;
> +	}
> +	repo_config(repo, git_default_config, NULL);
> +
> +	original_commit = lookup_commit_reference_by_name(argv[0]);
> +	if (!original_commit) {
> +		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
> +		goto out;
> +	}
> +
> +	if (original_commit->parents && original_commit->parents->next) {
> +		ret = error(_("commit to be split must not be a merge commit"));
> +		goto out;
> +	}
Do we need this? Since we also check for merges in `collect_commits()` below.
Show 16 quoted lines
> +	parent = original_commit->parents ? original_commit->parents->item : NULL;
> +	if (parent && repo_parse_commit(repo, parent)) {
> +		ret = error(_("unable to parse commit %s"),
> +			    oid_to_hex(&parent->object.oid));
> +		goto out;
> +	}
> +
> +	head = lookup_commit_reference_by_name("HEAD");
> +	if (!head) {
> +		ret = error(_("could not resolve HEAD to a commit"));
> +		goto out;
> +	}
> +
> +	commit_list_append(original_commit, &list);
> +	if (!repo_is_descendant_of(repo, original_commit, list)) {
> +		ret = error (_("split commit must be reachable from current HEAD commit"));
s/error /error/
> +		goto out;
> +	}
> +
This is also checked within collect_commits(), no?
Show 9 quoted lines
> +	parse_pathspec(&pathspec, 0,
> +		PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
> +		prefix, argv + 1);
> +
> +	/*
> +	* Collect the list of commits that we'll have to reapply now already.
> +	* This ensures that we'll abort early on in case the range of commits
> +	* contains merges, which we do not yet handle.
> +	*/
Comment spacing is off here.
Show 8 quoted lines
> +	ret = collect_commits(repo, parent, head, &commits);
> +	if (ret < 0)
> +		goto out;
> +
> +	/*
> +	 * Then we split up the commit and replace the original commit with the
> +	 * new new ones.
> +	 */
s/new/new
Show 5 quoted lines
> +	ret = split_commit(repo, original_commit, &pathspec,
> +			   commit_message, split_commits);
> +	if (ret < 0)
> +		goto out;
> +

This one was straight forward, it handles adding the hunks to the index creating the two commits and linking them.

> +	replace_commits(&commits, &original_commit->object.oid,
> +			split_commits, ARRAY_SIZE(split_commits));
> +

Nice. We use the function introduced earlier to replace the replace the commits with the new two commits.

Show 13 quoted lines
> +	ret = apply_commits(repo, &commits, parent, head, "split");
> +	if (ret < 0)
> +		goto out;
> +
> +	ret = 0;
> +
> +out:
> +	oidmap_clear(&rewritten_commits, 0);
> +	clear_pathspec(&pathspec);
> +	strvec_clear(&commits);
> +	free_commit_list(list);
> +	return ret;
> +}
[snip]
The tests look good. Nothing to add there.
Patrick Steinhardt· Oct 21, 2025, 11:44 UTC · re: Karthik Nayak · lore

Re: [PATCH v4 12/12] builtin/history: implement "split" subcommand

On Tue, Oct 14, 2025 at 09:38:51AM -0400, Karthik Nayak wrote:
Show 40 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
> > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > index b55babe206..83d675afea 100644
> > --- a/Documentation/git-history.adoc
> > +++ b/Documentation/git-history.adoc
> > @@ -40,6 +41,26 @@ rewrite history in different ways:
> >  	provided, then this command will spawn an editor with the current
> >  	message of that commit.
> >
> > +`split [--message=<message>] <commit> [--] [<pathspec>...]`::
> > +	Interactively split up <commit> into two commits by choosing
> > +	hunks introduced by it that will be moved into the new split-out
> > +	commit. These hunks will then be written into a new commit that
> > +	becomes the parent of the previous commit. The original commit
> > +	stays intact, except that its parent will be the newly split-out
> > +	commit.
> >
> 
> So in essence we do this:
> 
>   Before split:
>   P1 ── C0 ── C1 ── ... ── CN
>         └─(target)         └─(HEAD)
> 
>   After split:
>   P1 ── S0 ── C0' ── C1 ── ...... ── CN
>         │     └─(modified original)  └─(HEAD)
>         └─(split-out hunks)
> 
> I do wonder if S0 should contain the existing message and the new
> message should go to C0'. So perhaps more like
> 
>   After split:
>   P1 ── C0' ── S0 ── C1 ── ..... ── CN
>         │      └─(split-out hunks)  └─(HEAD)
>         └─(modified original)
> 
> Mostly because when you say split, I would assume we keep the original
> as is and add on top of it. I don't really have a strong argument though
> :)

Yeah, this has already caused some discussion beforehand. I guess you can argue either way, and the suggestion from others was to simply allow the user to edit both commit messages.

I don't at all mind going into that direction, but I wonder how to call the "--message" switch in that case. We could of course just call these "--first-message" and "--second-message", but that feels somewhat awkward.

Also, I already have it in my mind that it would be cool to extend this command so that you can split into arbitrary many commits. That is, after you have split out the first commit we simply go back into interactive mode to create a second commit tree. Rinse and repeat until we have no chunks left anymore. But if we had such a mode though, then numbered parameters don't make much sense anymore.

An alternative could be to just accept multiple "-m" arguments, and we then apply the messages to the respective commits? Dunno.

Show 32 quoted lines
> > +	const char *commit_message = NULL;
> > +	struct option options[] = {
> > +		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
> > +		OPT_END(),
> > +	};
> > +	struct oidmap rewritten_commits = OIDMAP_INIT;
> > +	struct commit *original_commit, *parent, *head;
> > +	struct strvec commits = STRVEC_INIT;
> > +	struct commit_list *list = NULL;
> > +	struct object_id split_commits[2];
> > +	struct pathspec pathspec = { 0 };
> > +	int ret;
> > +
> > +	argc = parse_options(argc, argv, prefix, options, usage, 0);
> > +	if (argc < 1) {
> > +		ret = error(_("command expects a revision"));
> > +		goto out;
> > +	}
> > +	repo_config(repo, git_default_config, NULL);
> > +
> > +	original_commit = lookup_commit_reference_by_name(argv[0]);
> > +	if (!original_commit) {
> > +		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
> > +		goto out;
> > +	}
> > +
> > +	if (original_commit->parents && original_commit->parents->next) {
> > +		ret = error(_("commit to be split must not be a merge commit"));
> > +		goto out;
> > +	}
> 
> Do we need this? Since we also check for merges in `collect_commits()` below.
Yeah, we don't indeed.
Show 24 quoted lines
> > +	parent = original_commit->parents ? original_commit->parents->item : NULL;
> > +	if (parent && repo_parse_commit(repo, parent)) {
> > +		ret = error(_("unable to parse commit %s"),
> > +			    oid_to_hex(&parent->object.oid));
> > +		goto out;
> > +	}
> > +
> > +	head = lookup_commit_reference_by_name("HEAD");
> > +	if (!head) {
> > +		ret = error(_("could not resolve HEAD to a commit"));
> > +		goto out;
> > +	}
> > +
> > +	commit_list_append(original_commit, &list);
> > +	if (!repo_is_descendant_of(repo, original_commit, list)) {
> > +		ret = error (_("split commit must be reachable from current HEAD commit"));
> 
> s/error /error/
> 
> > +		goto out;
> > +	}
> > +
> 
> This is also checked within collect_commits(), no?

The problem is rather that the check is wrong: we check whether the original commit is a descendent of itself, which is always true. What we actually want to check though is whether HEAD is a descendant of the original commit.

The check in `collect_commits()` is slightly different, as we verify that the range provided to us is actually the same. But that check shouldn't ever hit if the above check actually triggers.

I'll fix the check here and drop the one in `collect_commits()`.
Patrick
D. Ben Knoble· Oct 21, 2025, 21:19 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 12/12] builtin/history: implement "split" subcommand

On Tue, Oct 21, 2025 at 7:44 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 61 quoted lines
>
> On Tue, Oct 14, 2025 at 09:38:51AM -0400, Karthik Nayak wrote:
> > Patrick Steinhardt <ps@pks.im> writes:
> > > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > > index b55babe206..83d675afea 100644
> > > --- a/Documentation/git-history.adoc
> > > +++ b/Documentation/git-history.adoc
> > > @@ -40,6 +41,26 @@ rewrite history in different ways:
> > >     provided, then this command will spawn an editor with the current
> > >     message of that commit.
> > >
> > > +`split [--message=<message>] <commit> [--] [<pathspec>...]`::
> > > +   Interactively split up <commit> into two commits by choosing
> > > +   hunks introduced by it that will be moved into the new split-out
> > > +   commit. These hunks will then be written into a new commit that
> > > +   becomes the parent of the previous commit. The original commit
> > > +   stays intact, except that its parent will be the newly split-out
> > > +   commit.
> > >
> >
> > So in essence we do this:
> >
> >   Before split:
> >   P1 ── C0 ── C1 ── ... ── CN
> >         └─(target)         └─(HEAD)
> >
> >   After split:
> >   P1 ── S0 ── C0' ── C1 ── ...... ── CN
> >         │     └─(modified original)  └─(HEAD)
> >         └─(split-out hunks)
> >
> > I do wonder if S0 should contain the existing message and the new
> > message should go to C0'. So perhaps more like
> >
> >   After split:
> >   P1 ── C0' ── S0 ── C1 ── ..... ── CN
> >         │      └─(split-out hunks)  └─(HEAD)
> >         └─(modified original)
> >
> > Mostly because when you say split, I would assume we keep the original
> > as is and add on top of it. I don't really have a strong argument though
> > :)
>
> Yeah, this has already caused some discussion beforehand. I guess you
> can argue either way, and the suggestion from others was to simply allow
> the user to edit both commit messages.
>
> I don't at all mind going into that direction, but I wonder how to call
> the "--message" switch in that case. We could of course just call these
> "--first-message" and "--second-message", but that feels somewhat
> awkward.
>
> Also, I already have it in my mind that it would be cool to extend this
> command so that you can split into arbitrary many commits. That is,
> after you have split out the first commit we simply go back into
> interactive mode to create a second commit tree. Rinse and repeat until
> we have no chunks left anymore. But if we had such a mode though, then
> numbered parameters don't make much sense anymore.
>
> An alternative could be to just accept multiple "-m" arguments, and we
> then apply the messages to the respective commits? Dunno.

Or *gasp* not support "-m" at all, and require the user to put _something_ in an editor? 🤔

-- 
D. Ben Knoble
Patrick Steinhardt· Oct 27, 2025, 09:58 UTC · re: D. Ben Knoble · lore

Re: [PATCH v4 12/12] builtin/history: implement "split" subcommand

On Tue, Oct 21, 2025 at 05:19:19PM -0400, D. Ben Knoble wrote:
Show 64 quoted lines
> On Tue, Oct 21, 2025 at 7:44 AM Patrick Steinhardt <ps@pks.im> wrote:
> > On Tue, Oct 14, 2025 at 09:38:51AM -0400, Karthik Nayak wrote:
> > > Patrick Steinhardt <ps@pks.im> writes:
> > > > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > > > index b55babe206..83d675afea 100644
> > > > --- a/Documentation/git-history.adoc
> > > > +++ b/Documentation/git-history.adoc
> > > > @@ -40,6 +41,26 @@ rewrite history in different ways:
> > > >     provided, then this command will spawn an editor with the current
> > > >     message of that commit.
> > > >
> > > > +`split [--message=<message>] <commit> [--] [<pathspec>...]`::
> > > > +   Interactively split up <commit> into two commits by choosing
> > > > +   hunks introduced by it that will be moved into the new split-out
> > > > +   commit. These hunks will then be written into a new commit that
> > > > +   becomes the parent of the previous commit. The original commit
> > > > +   stays intact, except that its parent will be the newly split-out
> > > > +   commit.
> > > >
> > >
> > > So in essence we do this:
> > >
> > >   Before split:
> > >   P1 ── C0 ── C1 ── ... ── CN
> > >         └─(target)         └─(HEAD)
> > >
> > >   After split:
> > >   P1 ── S0 ── C0' ── C1 ── ...... ── CN
> > >         │     └─(modified original)  └─(HEAD)
> > >         └─(split-out hunks)
> > >
> > > I do wonder if S0 should contain the existing message and the new
> > > message should go to C0'. So perhaps more like
> > >
> > >   After split:
> > >   P1 ── C0' ── S0 ── C1 ── ..... ── CN
> > >         │      └─(split-out hunks)  └─(HEAD)
> > >         └─(modified original)
> > >
> > > Mostly because when you say split, I would assume we keep the original
> > > as is and add on top of it. I don't really have a strong argument though
> > > :)
> >
> > Yeah, this has already caused some discussion beforehand. I guess you
> > can argue either way, and the suggestion from others was to simply allow
> > the user to edit both commit messages.
> >
> > I don't at all mind going into that direction, but I wonder how to call
> > the "--message" switch in that case. We could of course just call these
> > "--first-message" and "--second-message", but that feels somewhat
> > awkward.
> >
> > Also, I already have it in my mind that it would be cool to extend this
> > command so that you can split into arbitrary many commits. That is,
> > after you have split out the first commit we simply go back into
> > interactive mode to create a second commit tree. Rinse and repeat until
> > we have no chunks left anymore. But if we had such a mode though, then
> > numbered parameters don't make much sense anymore.
> >
> > An alternative could be to just accept multiple "-m" arguments, and we
> > then apply the messages to the respective commits? Dunno.
> 
> Or *gasp* not support "-m" at all, and require the user to put
> _something_ in an editor? 🤔

Let's do that for now and discuss in more detail in follow-up patch series.

Thanks!
Patrick
Karthik Nayak· Oct 14, 2025, 13:41 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 00/12] Introduce git-history(1) command for easy history editing

Patrick Steinhardt <ps@pks.im> writes:
Show 29 quoted lines
> Hi,
>
> over recent months I've been playing around with Jujutsu quite
> frequently. While I still prefer using Git, there's been a couple
> features in it that I really like and that I'd like to have in Git, as
> well.
>
> A copule of these features relate to history editing. Most importantly,
> I really dig the following commands:
>
>   - jj-abandon(1) to drop a specific commit from your history.
>
>   - jj-absorb(1) to take some changes and automatically apply them to
>     commits in your history that last modified the respective hunks.
>
>   - jj-split(1) to split a commit into two.
>
>   - jj-new(1) to insert a new commit after or before a specific other
>     commit.
>
> Not all of these commands can be ported directly into Git. jj-new(1) for
> example doesn't really make a ton of sense for us, I'd claim. But some
> of these commands _do_ make sense.
>
> This patch series is a starting point for such a command. I've
> significantly slimmed it down from the first couple revisions now
> following the discussions at the Contributor's Summit yesterday. This
> was my intent anyway, as I already mentioned on the last iteration.
>
Hello,

I'm jumping in directly to review the fourth version of the patch series. As such I might have missed discussions in the prev versions.

Apart from small comments and questions, it looks to be in a good shape. I think the addition of changes would perhaps warrant a re-roll but we should be close :)

Thanks, Karthik

[snip]
Junio C Hamano· Oct 14, 2025, 16:47 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v4 00/12] Introduce git-history(1) command for easy history editing

Patrick Steinhardt <ps@pks.im> writes:
Show 22 quoted lines
> over recent months I've been playing around with Jujutsu quite
> frequently. While I still prefer using Git, there's been a couple
> features in it that I really like and that I'd like to have in Git, as
> well.
> ...
> Changes in v4:
>   - I've rebuilt the patch series. It is now based on 821f583da6 (The
>     thirteenth batcn, 2025-09-29) with sa/replay-atomic-ref-updates
>     at 665c66a743 (replay: make atomic ref updates the default behavior,
>     2025-09-27) merged into it. This should fix all conflicts with seen.
>   - I've reworked this patch series to use the same infra as
>     git-replay(1), as discussed during the Contributor's Summit.
>   - I've slimmed down the patch series to only tackle those commands
>     that cannot result in a conflict to keep it simple. I also learned
>     that Elijah has been working on a "git replay edit" command, so I
>     dropped that command so that we can instead use his version.
>   - During the Contributor's Summit we have agreed that for now, we
>     won't care about hook execution just yet. This may be backfilled at
>     a later point in time.
>   - I dropped "commit.verbose" handling for now, as my understanding of
>     it was wrong at first. This is something we should backfill.
>   - Link to v3: https://lore.kernel.org/r/20250904-b4-pks-history-builtin-v3-0-509053514755@pks.im

What is queued near the tip of 'seen' is v4 but rebased on the updated version of sa/replay-atomic-ref-updates that came from <20251013183311.33329-1-siddharthasthana31@gmail.com>. The rebase only had a slight conflict at [PATCH v4 02/12].

The result based on the same 821f583da6 (The thirteenth batcn, 2025-09-29) with sa/replay-atomic-ref-updates at a07d37b3 (replay: add replay.defaultAction config option, 2025-10-14).

Patrick Steinhardt· Oct 21, 2025, 14:15 UTC · re: Patrick Steinhardt · lore

[PATCH v5 00/12] Introduce git-history(1) command for easy history editing

Hi,

over recent months I've been playing around with Jujutsu quite frequently. While I still prefer using Git, there's been a couple features in it that I really like and that I'd like to have in Git, as well.

A copule of these features relate to history editing. Most importantly, I really dig the following commands:

  - jj-abandon(1) to drop a specific commit from your history.
  - jj-absorb(1) to take some changes and automatically apply them to
    commits in your history that last modified the respective hunks.
  - jj-split(1) to split a commit into two.
  - jj-new(1) to insert a new commit after or before a specific other
    commit.

Not all of these commands can be ported directly into Git. jj-new(1) for example doesn't really make a ton of sense for us, I'd claim. But some of these commands _do_ make sense.

This patch series is a starting point for such a command. I've significantly slimmed it down from the first couple revisions now following the discussions at the Contributor's Summit yesterday. This was my intent anyway, as I already mentioned on the last iteration.

Changes in v5:
  - I've changed the patch series to be based on top of 133d151831 (The
    twenty-first batch, 2025-10-20) with sa/replay-atomic-ref-updates at
    a1c22e627e (SQAUASH??? t0450 band-aid, 2025-10-14) merged into it.
    This is one the one hand to fix a conflict, but also to get some of
    the CI updates to make GitLab CI work again.
  - Some slight commit message improvements.
  - Deduplicate subcommand usage strings by using defines.
  - Fix the desendancy checks to properly verify that HEAD is a
    descendant of the commit to be rewritten. Also add some tests for
    this.
  - Fix the hint that mentions that lines starting with the comment
    character will be tripped after having written the commit message.
  - Move an include to the correct commit.
  - Link to v4: https://lore.kernel.org/r/20251001-b4-pks-history-builtin-v4-0-8e61ddb86317@pks.im
Changes in v4:
  - I've rebuilt the patch series. It is now based on 821f583da6 (The
    thirteenth batcn, 2025-09-29) with sa/replay-atomic-ref-updates
    at 665c66a743 (replay: make atomic ref updates the default behavior,
    2025-09-27) merged into it. This should fix all conflicts with seen.
  - I've reworked this patch series to use the same infra as
    git-replay(1), as discussed during the Contributor's Summit.
  - I've slimmed down the patch series to only tackle those commands
    that cannot result in a conflict to keep it simple. I also learned
    that Elijah has been working on a "git replay edit" command, so I
    dropped that command so that we can instead use his version.
  - During the Contributor's Summit we have agreed that for now, we
    won't care about hook execution just yet. This may be backfilled at
    a later point in time.
  - I dropped "commit.verbose" handling for now, as my understanding of
    it was wrong at first. This is something we should backfill.
  - Link to v3: https://lore.kernel.org/r/20250904-b4-pks-history-builtin-v3-0-509053514755@pks.im
Changes in v3:
  - Add logic to drive the "post-rewrite" hook and add tests to verify
    that all hooks are executed as expected.
  - Deduplicate logic to turn a replay action into a todo command.
  - Move the addition of tests for the top-level git-history(1) command
    to the correct commit.
  - Some smaller commit message fixes.
  - Honor "commit.verbose".
  - Fix copy-paste error with an error message.
  - Link to v2: https://lore.kernel.org/r/20250824-b4-pks-history-builtin-v2-0-964ac12f65bd@pks.im
Changes in v2:
  - Add a new "reword" subcommand.
  - List git-history(1) in "command-list.txt".
  - Add some missing error handling.
  - Simplify calling convention of `apply_commits()` to handle root
    commits internally instead of requiring every caller to do so.
  - Add tests to verify that git-history(1) refuses to work with changes
    in the worktree or index.
  - Mark git-history(1) as experimental.
  - Introduce commands to manage interrupted history edits.
  - A bunch of improvements to the manpage.
  - Link to v1: https://lore.kernel.org/r/20250819-b4-pks-history-builtin-v1-0-9b77c32688fe@pks.im
Thanks!
Patrick
---
Patrick Steinhardt (12):
      wt-status: provide function to expose status for trees
      replay: extract logic to pick commits
      replay: stop using `the_repository`
      replay: parse commits before dereferencing them
      builtin: add new "history" command
      builtin/history: implement "reword" subcommand
      add-patch: split out header from "add-interactive.h"
      add-patch: split out `struct interactive_options`
      add-patch: remove dependency on "add-interactive" subsystem
      add-patch: add support for in-memory index patching
      cache-tree: allow writing in-memory index as tree
      builtin/history: implement "split" subcommand
 .gitignore                     |   1 +
 Documentation/git-history.adoc | 114 ++++++++
 Documentation/meson.build      |   1 +
 Makefile                       |   2 +
 add-interactive.c              | 174 +++---------
 add-interactive.h              |  46 +---
 add-patch.c                    | 297 +++++++++++++++++---
 add-patch.h                    |  64 +++++
 builtin.h                      |   1 +
 builtin/add.c                  |  22 +-
 builtin/checkout.c             |   7 +-
 builtin/commit.c               |  16 +-
 builtin/history.c              | 600 +++++++++++++++++++++++++++++++++++++++++
 builtin/replay.c               | 110 +-------
 builtin/reset.c                |  16 +-
 builtin/stash.c                |  46 ++--
 cache-tree.c                   |   5 +-
 cache-tree.h                   |   3 +-
 command-list.txt               |   1 +
 commit.h                       |   2 +-
 git.c                          |   1 +
 meson.build                    |   2 +
 replay.c                       | 118 ++++++++
 replay.h                       |  18 ++
 t/meson.build                  |   3 +
 t/t3450-history.sh             |  17 ++
 t/t3451-history-reword.sh      | 217 +++++++++++++++
 t/t3452-history-split.sh       | 447 ++++++++++++++++++++++++++++++
 wt-status.c                    |  24 ++
 wt-status.h                    |   3 +
 30 files changed, 2001 insertions(+), 377 deletions(-)
Range-diff versus v4:
 1:  5073f973555 !  1:  74adad5ad51 wt-status: provide function to expose status for trees
    @@ Commit message
         includes information around whether the working tree or the index have
         any changes.
     
    -    We're about to introduce a new command though where the changes in
    -    neither of them are actually relevant to us. Instead, what we want is to
    -    format the changes between two different trees. While it is a little bit
    -    of a stretch to add this as functionality to _working tree_ status, it
    +    We're about to introduce a new command where the changes in neither of
    +    them are actually relevant to us. Instead, what we want is to format the
    +    changes between two different trees. While it is a little bit of a
    +    stretch to add this as functionality to _working tree_ status, it
         doesn't make any sense to open-code this functionality, either.
     
         Implement a new function `wt_status_collect_changes_trees()` that diffs
 2:  b21049120fa !  2:  e92d28e9033 replay: extract logic to pick commits
    @@ Commit message
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
     
      ## Makefile ##
    -@@ Makefile: LIB_OBJS += refs/ref-cache.o
    - LIB_OBJS += refspec.o
    +@@ Makefile: LIB_OBJS += reftable/tree.o
    + LIB_OBJS += reftable/writer.o
      LIB_OBJS += remote.o
      LIB_OBJS += replace-object.o
     +LIB_OBJS += replay.o
    @@ builtin/replay.c: static void determine_replay_mode(struct repository *repo,
     -	return create_commit(repo, result->tree, pickme, replayed_base);
     -}
     -
    - static int add_ref_to_transaction(struct ref_transaction *transaction,
    - 				  const char *refname,
    - 				  const struct object_id *new_oid,
    + static int handle_ref_update(const char *mode,
    + 			     struct ref_transaction *transaction,
    + 			     const char *refname,
     @@ builtin/replay.c: int cmd_replay(int argc,
      		if (commit->parents->next)
      			die(_("replaying merge commits is not supported yet!"));
 3:  be13cd322df =  3:  652fbddc10e replay: stop using `the_repository`
 4:  7af626c0fbc =  4:  0c9a409eea7 replay: parse commits before dereferencing them
 5:  ac152bca162 !  5:  d568ffc1f65 builtin: add new "history" command
    @@ Commit message
     
           - Multiple commits should be squashed into one.
     
    -    While these operations are all doable, it often feels needlessly cludgy
    +    While these operations are all doable, it often feels needlessly kludgey
         to do so by doing an interactive rebase, using the editor to say what
         one wants, and then perform the actions. Furthermore, some operations
         like splitting up a commit into two are way more involved than that and
    @@ Commit message
     
         Add a new "history" command to plug this gap. This command will have
         several different subcommands to imperatively rewrite history for common
    -    use cases like the above. These commands will be implemented in
    +    use cases like the above. These subcommands will be implemented in
         subsequent commits.
     
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
    @@ Documentation/git-history.adoc (new)
     +history.
     +
     +This command is similar to linkgit:git-rebase[1] and uses the same
    -+underlying machinery. You should use rebases if you either want to
    -+reapply a range of commits onto a different base, or interactive rebases
    -+if you want to edit a range of commits.
    ++underlying machinery. You should use rebases if you want to reapply a range of
    ++commits onto a different base, or interactive rebases if you want to edit a
    ++range of commits.
     +
     +Note that this command does not (yet) work with histories that contain
     +merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
 6:  8519861bfb0 !  6:  39acd4734c3 builtin/history: implement "reword" subcommand
    @@ builtin/history.c
     +#include "environment.h"
      #include "gettext.h"
     +#include "hex.h"
    -+#include "oidmap.h"
      #include "parse-options.h"
     +#include "refs.h"
     +#include "replay.h"
    @@ builtin/history.c
     +#include "tree.h"
     +#include "wt-status.h"
     +
    ++#define GIT_HISTORY_REWORD_USAGE N_("git history reword [<options>] <commit>")
    ++
     +static int collect_commits(struct repository *repo,
     +			   struct commit *old_commit,
     +			   struct commit *new_commit,
    @@ builtin/history.c
     +		.assume_dashdash = 1,
     +	};
     +	struct strvec revisions = STRVEC_INIT;
    -+	struct commit_list *from_list = NULL;
     +	struct commit *child;
     +	struct rev_info rev = { 0 };
     +	int ret;
     +
    -+	/*
    -+	 * Check that the old commit actually is an ancestor of HEAD. If not
    -+	 * the whole request becomes nonsensical.
    -+	*/
    -+	if (old_commit) {
    -+		commit_list_insert(old_commit, &from_list);
    -+		if (!repo_is_descendant_of(repo, new_commit, from_list)) {
    -+			ret = error(_("commit must be reachable from current HEAD commit"));
    -+			goto out;
    -+		}
    -+	}
    -+
     +	repo_init_revisions(repo, &rev, NULL);
     +	strvec_push(&revisions, "");
     +	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
    @@ builtin/history.c
     +	ret = 0;
     +
     +out:
    -+	free_commit_list(from_list);
     +	strvec_clear(&revisions);
     +	release_revisions(&rev);
     +	reset_revision_walk();
    @@ builtin/history.c
     +	if (!provided_message) {
     +		const char *path = git_path_commit_editmsg();
     +		const char *hint =
    -+			_("Please enter the commit message for the %s changes. Lines starting\n"
    -+     "with '%s' will be kept; you may remove them yourself if you want to.\n");
    ++			_("Please enter the commit message for the %s changes."
    ++			  " Lines starting\nwith '%s' will be ignored.\n");
     +		struct wt_status s;
     +
     +		strbuf_addstr(out, default_message);
    @@ builtin/history.c
     +			      struct repository *repo)
     +{
     +	const char * const usage[] = {
    -+		N_("git history reword [<options>] <commit>"),
    ++		GIT_HISTORY_REWORD_USAGE,
     +		NULL,
     +	};
     +	const char *commit_message = NULL;
    @@ builtin/history.c
     +	struct strvec commits = STRVEC_INIT;
     +	struct object_id parent_tree_oid, original_commit_tree_oid;
     +	struct object_id rewritten_commit;
    ++	struct commit_list *from_list = NULL;
     +	const char *original_message, *original_body, *ptr;
     +	char *original_author = NULL;
     +	size_t len;
    @@ builtin/history.c
     +		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
     +		goto out;
     +	}
    -+	if (repo_parse_commit(repo, original_commit)) {
    -+		ret = error(_("unable to parse commit %s"),
    -+			    oid_to_hex(&original_commit->object.oid));
    -+		goto out;
    -+	}
     +	original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
     +
     +	parent = original_commit->parents ? original_commit->parents->item : NULL;
    @@ builtin/history.c
     +		goto out;
     +	}
     +
    ++	commit_list_append(original_commit, &from_list);
    ++	if (!repo_is_descendant_of(repo, head, from_list)) {
    ++		ret = error (_("split commit must be reachable from current HEAD commit"));
    ++		goto out;
    ++	}
    ++
     +	/*
     +	 * Collect the list of commits that we'll have to reapply now already.
     +	 * This ensures that we'll abort early on in case the range of commits
    @@ builtin/history.c
     +	if (ret < 0)
     +		goto out;
     +
    -+	ret = commit_tree(final_message.buf, final_message.len,
    -+			  &repo_get_commit_tree(repo, original_commit)->object.oid,
    ++	ret = commit_tree(final_message.buf, final_message.len, &original_commit_tree_oid,
     +			  original_commit->parents, &rewritten_commit, original_author, NULL);
     +	if (ret < 0) {
     +		ret = error(_("failed writing reworded commit"));
    @@ builtin/history.c
     +
     +out:
     +	strbuf_release(&final_message);
    ++	free_commit_list(from_list);
     +	strvec_clear(&commits);
     +	free(original_author);
     +	return ret;
    @@ builtin/history.c
      {
      	const char * const usage[] = {
      		N_("git history [<options>]"),
    -+		N_("git history reword [<options>] <commit>"),
    ++		GIT_HISTORY_REWORD_USAGE,
      		NULL,
      	};
     +	parse_opt_subcommand_fn *fn = NULL;
    @@ t/t3451-history-reword.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'refuses to work with unrelated commits' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		test_commit base &&
    ++		git branch branch &&
    ++		test_commit ours &&
    ++		git switch branch &&
    ++		test_commit theirs &&
    ++		test_must_fail git history reword ours 2>err &&
    ++		test_grep "split commit must be reachable from current HEAD commit" err
    ++	)
    ++'
    ++
     +test_expect_success 'can reword tip of a branch' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3451-history-reword.sh (new)
     +		first
     +
     +		# Please enter the commit message for the reworded changes. Lines starting
    -+		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
    ++		# with ${SQ}#${SQ} will be ignored.
     +		# Changes to be committed:
     +		#	new file:   first.t
     +		#
 7:  89883c85fa3 =  7:  2e4291a3b87 add-patch: split out header from "add-interactive.h"
 8:  f1602b8513c !  8:  715f3a32b82 add-patch: split out `struct interactive_options`
    @@ Metadata
      ## Commit message ##
         add-patch: split out `struct interactive_options`
     
    -    The `struct add_p_opt` is reused both by our the infra for "git add -p"
    -    and "git add -i". Users of `run_add_i()` for example are expected to
    -    pass `struct add_p_opt`. This is somewhat confusing and raises the
    -    question which options apply to what part of the stack.
    +    The `struct add_p_opt` is reused both by our infra for "git add -p" and
    +    "git add -i". Users of `run_add_i()` for example are expected to pass
    +    `struct add_p_opt`. This is somewhat confusing and raises the question
    +    of which options apply to what part of the stack.
     
         But things are even more confusing than that: while callers are expected
         to pass in `struct add_p_opt`, these options ultimately get used to
 9:  8ef83293ae5 =  9:  94ea67db00c add-patch: remove dependency on "add-interactive" subsystem
10:  385c39356dc ! 10:  19c02eb16de add-patch: add support for in-memory index patching
    @@ Commit message
         With `run_add_p()` callers have the ability to apply changes from a
         specific revision to a repository's index. This infra supports several
         different modes, like for example applying changes to the index,
    -    worktree or both.
    +    working tree or both.
     
         One feature that is missing though is the ability to apply changes to an
         in-memory index different from the repository's index. Add a new
    @@ add-patch.c
      #include "object-name.h"
      #include "pager.h"
      #include "read-cache-ll.h"
    +@@ add-patch.c: static struct patch_mode patch_mode_add = {
    + 		N_("Stage mode change [y,n,q,a,d%s,?]? "),
    + 		N_("Stage deletion [y,n,q,a,d%s,?]? "),
    + 		N_("Stage addition [y,n,q,a,d%s,?]? "),
    +-		N_("Stage this hunk [y,n,q,a,d%s,?]? ")
    ++		N_("Stage this hunk [y,n,q,a,d%s,?]? "),
    + 	},
    + 	.edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
    + 			     "will immediately be marked for staging."),
     @@ add-patch.c: struct hunk {
      
      struct add_p_state {
11:  d26e225c854 = 11:  3161d4bad3f cache-tree: allow writing in-memory index as tree
12:  fa878dc362c ! 12:  e7a6537ecfe builtin/history: implement "split" subcommand
    @@ builtin/history.c
      #include "commit.h"
      #include "config.h"
     @@
    + #include "environment.h"
    + #include "gettext.h"
      #include "hex.h"
    - #include "oidmap.h"
    ++#include "oidmap.h"
      #include "parse-options.h"
     +#include "path.h"
     +#include "read-cache.h"
    @@ builtin/history.c
      #include "sequencer.h"
      #include "strvec.h"
      #include "tree.h"
    + #include "wt-status.h"
    + 
    + #define GIT_HISTORY_REWORD_USAGE N_("git history reword [<options>] <commit>")
    ++#define GIT_HISTORY_SPLIT_USAGE  N_("git history split [<options>] <commit> [--] [<pathspec>...]")
    + 
    + static int collect_commits(struct repository *repo,
    + 			   struct commit *old_commit,
     @@ builtin/history.c: static int cmd_history_reword(int argc,
      	return ret;
      }
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +			     struct repository *repo)
     +{
     +	const char * const usage[] = {
    -+		N_("git history split [<options>] <commit>"),
    ++		GIT_HISTORY_SPLIT_USAGE,
     +		NULL,
     +	};
     +	const char *commit_message = NULL;
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +	struct oidmap rewritten_commits = OIDMAP_INIT;
     +	struct commit *original_commit, *parent, *head;
     +	struct strvec commits = STRVEC_INIT;
    -+	struct commit_list *list = NULL;
    ++	struct commit_list *from_list = NULL;
     +	struct object_id split_commits[2];
     +	struct pathspec pathspec = { 0 };
     +	int ret;
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +		goto out;
     +	}
     +
    -+	if (original_commit->parents && original_commit->parents->next) {
    -+		ret = error(_("commit to be split must not be a merge commit"));
    -+		goto out;
    -+	}
    -+
     +	parent = original_commit->parents ? original_commit->parents->item : NULL;
     +	if (parent && repo_parse_commit(repo, parent)) {
     +		ret = error(_("unable to parse commit %s"),
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +		goto out;
     +	}
     +
    -+	commit_list_append(original_commit, &list);
    -+	if (!repo_is_descendant_of(repo, original_commit, list)) {
    -+		ret = error (_("split commit must be reachable from current HEAD commit"));
    ++	commit_list_append(original_commit, &from_list);
    ++	if (!repo_is_descendant_of(repo, head, from_list)) {
    ++		ret = error(_("split commit must be reachable from current HEAD commit"));
     +		goto out;
     +	}
     +
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +		prefix, argv + 1);
     +
     +	/*
    -+	* Collect the list of commits that we'll have to reapply now already.
    -+	* This ensures that we'll abort early on in case the range of commits
    -+	* contains merges, which we do not yet handle.
    -+	*/
    ++	 * Collect the list of commits that we'll have to reapply now already.
    ++	 * This ensures that we'll abort early on in case the range of commits
    ++	 * contains merges, which we do not yet handle.
    ++	 */
     +	ret = collect_commits(repo, parent, head, &commits);
     +	if (ret < 0)
     +		goto out;
     +
     +	/*
     +	 * Then we split up the commit and replace the original commit with the
    -+	 * new new ones.
    ++	 * new ones.
     +	 */
     +	ret = split_commit(repo, original_commit, &pathspec,
     +			   commit_message, split_commits);
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +
     +out:
     +	oidmap_clear(&rewritten_commits, 0);
    ++	free_commit_list(from_list);
     +	clear_pathspec(&pathspec);
     +	strvec_clear(&commits);
    -+	free_commit_list(list);
     +	return ret;
     +}
     +
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     @@ builtin/history.c: int cmd_history(int argc,
      	const char * const usage[] = {
      		N_("git history [<options>]"),
    - 		N_("git history reword [<options>] <commit>"),
    -+		N_("git history split [<options>] <commit> [--] [<pathspec>...]"),
    + 		GIT_HISTORY_REWORD_USAGE,
    ++		GIT_HISTORY_SPLIT_USAGE,
      		NULL,
      	};
      	parse_opt_subcommand_fn *fn = NULL;
    @@ t/t3452-history-split.sh (new)
     +		git switch - &&
     +		git merge theirs &&
     +		test_must_fail git history split HEAD 2>err &&
    -+		test_grep "commit to be split must not be a merge commit" err &&
    ++		test_grep "cannot rearrange commit history with merges" err &&
     +		test_must_fail git history split HEAD~ 2>err &&
     +		test_grep "cannot rearrange commit history with merges" err
     +	)
     +'
     +
    ++test_expect_success 'refuses to work with unrelated commits' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		test_commit base &&
    ++		git branch branch &&
    ++		test_commit ours &&
    ++		git switch branch &&
    ++		test_commit theirs &&
    ++		test_must_fail git history split ours 2>err &&
    ++		test_grep "split commit must be reachable from current HEAD commit" err
    ++	)
    ++'
    ++
     +test_expect_success 'can split up tip commit' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3452-history-split.sh (new)
     +		cat >expect <<-EOF &&
     +
     +		# Please enter the commit message for the split-out changes. Lines starting
    -+		# with ${SQ}#${SQ} will be kept; you may remove them yourself if you want to.
    ++		# with ${SQ}#${SQ} will be ignored.
     +		# Changes to be committed:
     +		#	new file:   bar
     +		#

--- base-commit: eef20e55db9c6590670e245245e207271daea61f change-id: 20250819-b4-pks-history-builtin-83398f9a05f0

Patrick Steinhardt· Oct 21, 2025, 14:15 UTC · re: Patrick Steinhardt · lore

[PATCH v5 01/12] wt-status: provide function to expose status for trees

The "wt-status" subsystem is responsible for printing status information around the current state of the working tree. This most importantly includes information around whether the working tree or the index have any changes.

We're about to introduce a new command where the changes in neither of them are actually relevant to us. Instead, what we want is to format the changes between two different trees. While it is a little bit of a stretch to add this as functionality to _working tree_ status, it doesn't make any sense to open-code this functionality, either.

Implement a new function `wt_status_collect_changes_trees()` that diffs two trees and formats the status accordingly. This function is not yet used, but will be in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 wt-status.c | 24 ++++++++++++++++++++++++
 wt-status.h |  3 +++
 2 files changed, 27 insertions(+)
Show changes to 2 files +27 −0

wt-status.c, wt-status.h

diff --git a/wt-status.c b/wt-status.c
index 8ffe6d3988f..b66edbfca6c 100644
--- a/wt-status.c
+++ b/wt-status.c
@@ -612,6 +612,30 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q,
 	}
 }
 
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish)
+{
+	struct diff_options opts = { 0 };
+
+	repo_diff_setup(s->repo, &opts);
+	opts.output_format = DIFF_FORMAT_CALLBACK;
+	opts.format_callback = wt_status_collect_updated_cb;
+	opts.format_callback_data = s;
+	opts.detect_rename = s->detect_rename >= 0 ? s->detect_rename : opts.detect_rename;
+	opts.rename_limit = s->rename_limit >= 0 ? s->rename_limit : opts.rename_limit;
+	opts.rename_score = s->rename_score >= 0 ? s->rename_score : opts.rename_score;
+	opts.flags.recursive = 1;
+	diff_setup_done(&opts);
+
+	diff_tree_oid(old_treeish, new_treeish, "", &opts);
+	diffcore_std(&opts);
+	diff_flush(&opts);
+	wt_status_get_state(s->repo, &s->state, 0);
+
+	diff_free(&opts);
+}
+
 static void wt_status_collect_changes_worktree(struct wt_status *s)
 {
 	struct rev_info rev;
diff --git a/wt-status.h b/wt-status.h
index e40a27214a7..924d7a5fa99 100644
--- a/wt-status.h
+++ b/wt-status.h
@@ -153,6 +153,9 @@ void wt_status_add_cut_line(struct wt_status *s);
 void wt_status_prepare(struct repository *r, struct wt_status *s);
 void wt_status_print(struct wt_status *s);
 void wt_status_collect(struct wt_status *s);
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish);
 /*
  * Frees the buffers allocated by wt_status_collect.
  */
-- 
2.51.1.851.g4ebd6896fd.dirty
Junio C Hamano· Oct 21, 2025, 20:38 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v5 01/12] wt-status: provide function to expose status for trees

Patrick Steinhardt <ps@pks.im> writes:
Show 23 quoted lines
> diff --git a/wt-status.c b/wt-status.c
> index 8ffe6d3988f..b66edbfca6c 100644
> --- a/wt-status.c
> +++ b/wt-status.c
> @@ -612,6 +612,30 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q,
>  	}
>  }
>  
> +void wt_status_collect_changes_trees(struct wt_status *s,
> +				     const struct object_id *old_treeish,
> +				     const struct object_id *new_treeish)
> +{
> +	struct diff_options opts = { 0 };
> +
> +	repo_diff_setup(s->repo, &opts);
> +	opts.output_format = DIFF_FORMAT_CALLBACK;
> +	opts.format_callback = wt_status_collect_updated_cb;
> +	opts.format_callback_data = s;
> +	opts.detect_rename = s->detect_rename >= 0 ? s->detect_rename : opts.detect_rename;
> +	opts.rename_limit = s->rename_limit >= 0 ? s->rename_limit : opts.rename_limit;
> +	opts.rename_score = s->rename_score >= 0 ? s->rename_score : opts.rename_score;
> +	opts.flags.recursive = 1;
> +	diff_setup_done(&opts);

This is obviously modelled after collect_changes_index(), whose callback this function reuses, except that the set up to prepare for running a diff is different from their way to compare between HEAD (or void, if root) with the index. We also do not have to worry about sparse checkout. We do not have to worry about ignore-submodule argument, either.

Show 17 quoted lines
> +	diff_tree_oid(old_treeish, new_treeish, "", &opts);
> +	diffcore_std(&opts);
> +	diff_flush(&opts);
> +	wt_status_get_state(s->repo, &s->state, 0);
> +
> +	diff_free(&opts);
> +}
> +
>  static void wt_status_collect_changes_worktree(struct wt_status *s)
>  {
>  	struct rev_info rev;
> diff --git a/wt-status.h b/wt-status.h
> index e40a27214a7..924d7a5fa99 100644
> --- a/wt-status.h
> +++ b/wt-status.h
> @@ -153,6 +153,9 @@ void wt_status_add_cut_line(struct wt_status *s);
> ...

Let's add a comment to help callers of this function that the changes going from "old" to "new" are recorded as if they are "staged" changes and recorded on the "index" side. It can only be inferred from the use of collect_updated_cb, which was written to be used by wt_status_collect_changes_index(), and probably it is a bit brutal to expect for casual readers to realize on their own.

> +void wt_status_collect_changes_trees(struct wt_status *s,
> +				     const struct object_id *old_treeish,
> +				     const struct object_id *new_treeish);
Patrick Steinhardt· Oct 21, 2025, 14:15 UTC · re: Patrick Steinhardt · lore

[PATCH v5 02/12] replay: extract logic to pick commits

We're about to add a new git-history(1) command that will reuse some of the same infrastructure as git-replay(1). To prepare for this, extract the logic to pick a commit into a new "replay.c" file so that it can be shared between both commands.

Rename the function to have a "replay_" prefix to clearly indicate its subsystem.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Makefile         |   1 +
 builtin/replay.c | 110 ++--------------------------------------------------
 meson.build      |   1 +
 replay.c         | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 replay.h         |  18 +++++++++
 5 files changed, 138 insertions(+), 107 deletions(-)
Show changes to 5 files +138 −107

Makefile, builtin/replay.c, meson.build, replay.c, replay.h

diff --git a/Makefile b/Makefile
index 1919d35bf3f..01c171b4f03 100644
--- a/Makefile
+++ b/Makefile
@@ -1261,6 +1261,7 @@ LIB_OBJS += reftable/tree.o
 LIB_OBJS += reftable/writer.o
 LIB_OBJS += remote.o
 LIB_OBJS += replace-object.o
+LIB_OBJS += replay.o
 LIB_OBJS += repo-settings.o
 LIB_OBJS += repository.o
 LIB_OBJS += rerere.o
diff --git a/builtin/replay.c b/builtin/replay.c
index d0f04927908..aeb7cc88fe5 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -2,7 +2,6 @@
  * "git replay" builtin command
  */
 
-#define USE_THE_REPOSITORY_VARIABLE
 #define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
@@ -16,18 +15,12 @@
 #include "object-name.h"
 #include "parse-options.h"
 #include "refs.h"
+#include "replay.h"
 #include "revision.h"
 #include "strmap.h"
 #include <oidset.h>
 #include <tree.h>
 
-static const char *short_commit_name(struct repository *repo,
-				     struct commit *commit)
-{
-	return repo_find_unique_abbrev(repo, &commit->object.oid,
-				       DEFAULT_ABBREV);
-}
-
 static struct commit *peel_committish(struct repository *repo, const char *name)
 {
 	struct object *obj;
@@ -40,59 +33,6 @@ static struct commit *peel_committish(struct repository *repo, const char *name)
 						  OBJ_COMMIT);
 }
 
-static char *get_author(const char *message)
-{
-	size_t len;
-	const char *a;
-
-	a = find_commit_header(message, "author", &len);
-	if (a)
-		return xmemdupz(a, len);
-
-	return NULL;
-}
-
-static struct commit *create_commit(struct repository *repo,
-				    struct tree *tree,
-				    struct commit *based_on,
-				    struct commit *parent)
-{
-	struct object_id ret;
-	struct object *obj = NULL;
-	struct commit_list *parents = NULL;
-	char *author;
-	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
-	struct commit_extra_header *extra = NULL;
-	struct strbuf msg = STRBUF_INIT;
-	const char *out_enc = get_commit_output_encoding();
-	const char *message = repo_logmsg_reencode(repo, based_on,
-						   NULL, out_enc);
-	const char *orig_message = NULL;
-	const char *exclude_gpgsig[] = { "gpgsig", NULL };
-
-	commit_list_insert(parent, &parents);
-	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
-	find_commit_subject(message, &orig_message);
-	strbuf_addstr(&msg, orig_message);
-	author = get_author(message);
-	reset_ident_date();
-	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
-				 &ret, author, NULL, sign_commit, extra)) {
-		error(_("failed to write commit object"));
-		goto out;
-	}
-
-	obj = parse_object(repo, &ret);
-
-out:
-	repo_unuse_commit_buffer(the_repository, based_on, message);
-	free_commit_extra_headers(extra);
-	free_commit_list(parents);
-	strbuf_release(&msg);
-	free(author);
-	return (struct commit *)obj;
-}
-
 struct ref_info {
 	struct commit *onto;
 	struct strset positive_refs;
@@ -241,50 +181,6 @@ static void determine_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
-{
-	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
-	if (pos == kh_end(replayed_commits))
-		return fallback;
-	return kh_value(replayed_commits, pos);
-}
-
-static struct commit *pick_regular_commit(struct repository *repo,
-					  struct commit *pickme,
-					  kh_oid_map_t *replayed_commits,
-					  struct commit *onto,
-					  struct merge_options *merge_opt,
-					  struct merge_result *result)
-{
-	struct commit *base, *replayed_base;
-	struct tree *pickme_tree, *base_tree;
-
-	base = pickme->parents->item;
-	replayed_base = mapped_commit(replayed_commits, base, onto);
-
-	result->tree = repo_get_commit_tree(repo, replayed_base);
-	pickme_tree = repo_get_commit_tree(repo, pickme);
-	base_tree = repo_get_commit_tree(repo, base);
-
-	merge_opt->branch1 = short_commit_name(repo, replayed_base);
-	merge_opt->branch2 = short_commit_name(repo, pickme);
-	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
-
-	merge_incore_nonrecursive(merge_opt,
-				  base_tree,
-				  result->tree,
-				  pickme_tree,
-				  result);
-
-	free((char*)merge_opt->ancestor);
-	merge_opt->ancestor = NULL;
-	if (!result->clean)
-		return NULL;
-	return create_commit(repo, result->tree, pickme, replayed_base);
-}
-
 static int handle_ref_update(const char *mode,
 			     struct ref_transaction *transaction,
 			     const char *refname,
@@ -472,8 +368,8 @@ int cmd_replay(int argc,
 		if (commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
 
-		last_commit = pick_regular_commit(repo, commit, replayed_commits,
-						  onto, &merge_opt, &result);
+		last_commit = replay_pick_regular_commit(repo, commit, replayed_commits,
+							 onto, &merge_opt, &result);
 		if (!last_commit)
 			break;
 
diff --git a/meson.build b/meson.build
index cee94244759..ae8d4fef059 100644
--- a/meson.build
+++ b/meson.build
@@ -464,6 +464,7 @@ libgit_sources = [
   'reftable/writer.c',
   'remote.c',
   'replace-object.c',
+  'replay.c',
   'repo-settings.c',
   'repository.c',
   'rerere.c',
diff --git a/replay.c b/replay.c
new file mode 100644
index 00000000000..e22ce399406
--- /dev/null
+++ b/replay.c
@@ -0,0 +1,115 @@
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "git-compat-util.h"
+#include "commit.h"
+#include "environment.h"
+#include "gettext.h"
+#include "ident.h"
+#include "object.h"
+#include "object-name.h"
+#include "replay.h"
+#include "tree.h"
+
+static const char *short_commit_name(struct repository *repo,
+				     struct commit *commit)
+{
+	return repo_find_unique_abbrev(repo, &commit->object.oid,
+				       DEFAULT_ABBREV);
+}
+
+static char *get_author(const char *message)
+{
+	size_t len;
+	const char *a;
+
+	a = find_commit_header(message, "author", &len);
+	if (a)
+		return xmemdupz(a, len);
+
+	return NULL;
+}
+
+static struct commit *create_commit(struct repository *repo,
+				    struct tree *tree,
+				    struct commit *based_on,
+				    struct commit *parent)
+{
+	struct object_id ret;
+	struct object *obj = NULL;
+	struct commit_list *parents = NULL;
+	char *author;
+	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
+	struct commit_extra_header *extra = NULL;
+	struct strbuf msg = STRBUF_INIT;
+	const char *out_enc = get_commit_output_encoding();
+	const char *message = repo_logmsg_reencode(repo, based_on,
+						   NULL, out_enc);
+	const char *orig_message = NULL;
+	const char *exclude_gpgsig[] = { "gpgsig", NULL };
+
+	commit_list_insert(parent, &parents);
+	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
+	find_commit_subject(message, &orig_message);
+	strbuf_addstr(&msg, orig_message);
+	author = get_author(message);
+	reset_ident_date();
+	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
+				 &ret, author, NULL, sign_commit, extra)) {
+		error(_("failed to write commit object"));
+		goto out;
+	}
+
+	obj = parse_object(repo, &ret);
+
+out:
+	repo_unuse_commit_buffer(the_repository, based_on, message);
+	free_commit_extra_headers(extra);
+	free_commit_list(parents);
+	strbuf_release(&msg);
+	free(author);
+	return (struct commit *)obj;
+}
+
+static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
+				    struct commit *commit,
+				    struct commit *fallback)
+{
+	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
+	if (pos == kh_end(replayed_commits))
+		return fallback;
+	return kh_value(replayed_commits, pos);
+}
+
+struct commit *replay_pick_regular_commit(struct repository *repo,
+					  struct commit *pickme,
+					  kh_oid_map_t *replayed_commits,
+					  struct commit *onto,
+					  struct merge_options *merge_opt,
+					  struct merge_result *result)
+{
+	struct commit *base, *replayed_base;
+	struct tree *pickme_tree, *base_tree;
+
+	base = pickme->parents->item;
+	replayed_base = mapped_commit(replayed_commits, base, onto);
+
+	result->tree = repo_get_commit_tree(repo, replayed_base);
+	pickme_tree = repo_get_commit_tree(repo, pickme);
+	base_tree = repo_get_commit_tree(repo, base);
+
+	merge_opt->branch1 = short_commit_name(repo, replayed_base);
+	merge_opt->branch2 = short_commit_name(repo, pickme);
+	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
+
+	merge_incore_nonrecursive(merge_opt,
+				  base_tree,
+				  result->tree,
+				  pickme_tree,
+				  result);
+
+	free((char*)merge_opt->ancestor);
+	merge_opt->ancestor = NULL;
+	if (!result->clean)
+		return NULL;
+	return create_commit(repo, result->tree, pickme, replayed_base);
+}
diff --git a/replay.h b/replay.h
new file mode 100644
index 00000000000..a461b5c2341
--- /dev/null
+++ b/replay.h
@@ -0,0 +1,18 @@
+#ifndef REPLAY_H
+#define REPLAY_H
+
+#include "khash.h"
+#include "merge-ort.h"
+#include "repository.h"
+
+struct commit;
+struct tree;
+
+struct commit *replay_pick_regular_commit(struct repository *repo,
+					  struct commit *pickme,
+					  kh_oid_map_t *replayed_commits,
+					  struct commit *onto,
+					  struct merge_options *merge_opt,
+					  struct merge_result *result);
+
+#endif
-- 
2.51.1.851.g4ebd6896fd.dirty
Junio C Hamano· Oct 21, 2025, 20:41 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v5 02/12] replay: extract logic to pick commits

Patrick Steinhardt <ps@pks.im> writes:
Show 16 quoted lines
> We're about to add a new git-history(1) command that will reuse some of
> the same infrastructure as git-replay(1). To prepare for this, extract
> the logic to pick a commit into a new "replay.c" file so that it can be
> shared between both commands.
>
> Rename the function to have a "replay_" prefix to clearly indicate its
> subsystem.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  Makefile         |   1 +
>  builtin/replay.c | 110 ++--------------------------------------------------
>  meson.build      |   1 +
>  replay.c         | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
>  replay.h         |  18 +++++++++
>  5 files changed, 138 insertions(+), 107 deletions(-)

A clean refactoring that --color-moved helps vastly in reading. I wish all our patches are this easy ;-)

Patrick Steinhardt· Oct 21, 2025, 14:15 UTC · re: Patrick Steinhardt · lore

[PATCH v5 03/12] replay: stop using `the_repository`

In `create_commit()` we're using `the_repository` even though we already have a repository passed to use as an argument. Fix this.

Note that we still cannot get rid of `USE_THE_REPOSITORY_VARIABLE`. This is because we use `DEFAULT_ABBREV and `get_commit_output_encoding()`, both of which are stored as global variables that can be modified via the Git configuration.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 replay.c | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
Show changes to replay.c +1 −1
diff --git a/replay.c b/replay.c
index e22ce399406..13d75d80543 100644
--- a/replay.c
+++ b/replay.c
@@ -62,7 +62,7 @@ static struct commit *create_commit(struct repository *repo,
 	obj = parse_object(repo, &ret);
 
 out:
-	repo_unuse_commit_buffer(the_repository, based_on, message);
+	repo_unuse_commit_buffer(repo, based_on, message);
 	free_commit_extra_headers(extra);
 	free_commit_list(parents);
 	strbuf_release(&msg);
-- 
2.51.1.851.g4ebd6896fd.dirty
Junio C Hamano· Oct 21, 2025, 20:48 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v5 03/12] replay: stop using `the_repository`

Patrick Steinhardt <ps@pks.im> writes:
Show 12 quoted lines
> In `create_commit()` we're using `the_repository` even though we already
> have a repository passed to use as an argument. Fix this.
>
> Note that we still cannot get rid of `USE_THE_REPOSITORY_VARIABLE`. This
> is because we use `DEFAULT_ABBREV and `get_commit_output_encoding()`,
> both of which are stored as global variables that can be modified via
> the Git configuration.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  replay.c | 2 +-
>  1 file changed, 1 insertion(+), 1 deletion(-)
Obviously correct.
Show 14 quoted lines
>
> diff --git a/replay.c b/replay.c
> index e22ce399406..13d75d80543 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -62,7 +62,7 @@ static struct commit *create_commit(struct repository *repo,
>  	obj = parse_object(repo, &ret);
>  
>  out:
> -	repo_unuse_commit_buffer(the_repository, based_on, message);
> +	repo_unuse_commit_buffer(repo, based_on, message);
>  	free_commit_extra_headers(extra);
>  	free_commit_list(parents);
>  	strbuf_release(&msg);
Patrick Steinhardt· Oct 21, 2025, 14:15 UTC · re: Patrick Steinhardt · lore

[PATCH v5 04/12] replay: parse commits before dereferencing them

When looking up a commit it may not be parsed yet. Callers that wish to access the fields of `struct commit` have to call `repo_parse_commit()` first so that it is guaranteed to be populated.

We didn't yet care about doing so, because code paths that lead to `pick_regular_commit()` in "builtin/replay.c" already implicitly parsed the commits. But now that the function is exposed to outside callers it's quite easy to get this wrong.

Make the function easier to use by calling `repo_parse_commit()`.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 replay.c | 3 +++
 1 file changed, 3 insertions(+)
Show changes to replay.c +3 −0
diff --git a/replay.c b/replay.c
index 13d75d80543..c3628d2488b 100644
--- a/replay.c
+++ b/replay.c
@@ -90,6 +90,9 @@ struct commit *replay_pick_regular_commit(struct repository *repo,
 	struct commit *base, *replayed_base;
 	struct tree *pickme_tree, *base_tree;
 
+	if (repo_parse_commit(repo, pickme))
+		return NULL;
+
 	base = pickme->parents->item;
 	replayed_base = mapped_commit(replayed_commits, base, onto);
 
-- 
2.51.1.851.g4ebd6896fd.dirty
Junio C Hamano· Oct 21, 2025, 20:57 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v5 04/12] replay: parse commits before dereferencing them

Patrick Steinhardt <ps@pks.im> writes:
Show 10 quoted lines
> When looking up a commit it may not be parsed yet. Callers that wish to
> access the fields of `struct commit` have to call `repo_parse_commit()`
> first so that it is guaranteed to be populated.
>
> We didn't yet care about doing so, because code paths that lead to
> `pick_regular_commit()` in "builtin/replay.c" already implicitly parsed
> the commits. But now that the function is exposed to outside callers
> it's quite easy to get this wrong.
>
> Make the function easier to use by calling `repo_parse_commit()`.
Two-and-half obvious questions.
 * With this change, can we lose the parse-commit call(s) from
   existing callers, or do the need to look at the in-core commit
   object themselves before calling this function so they need to
   have their parse-commit call(s) anyway?
 * Can new callers you plan to add decide without having an already
   parsed "pickme" commit object if they want to call this function,
   iow, can they decide to call or not to call this function without
   looking at the members of the commit structure?
 * Are existing callers prepared to see NULL returned from this
   function to signal an error?
Show 18 quoted lines
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  replay.c | 3 +++
>  1 file changed, 3 insertions(+)
>
> diff --git a/replay.c b/replay.c
> index 13d75d80543..c3628d2488b 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -90,6 +90,9 @@ struct commit *replay_pick_regular_commit(struct repository *repo,
>  	struct commit *base, *replayed_base;
>  	struct tree *pickme_tree, *base_tree;
>  
> +	if (repo_parse_commit(repo, pickme))
> +		return NULL;
> +
>  	base = pickme->parents->item;
>  	replayed_base = mapped_commit(replayed_commits, base, onto);
Patrick Steinhardt· Oct 27, 2025, 09:57 UTC · re: Junio C Hamano · lore

Re: [PATCH v5 04/12] replay: parse commits before dereferencing them

On Tue, Oct 21, 2025 at 01:57:54PM -0700, Junio C Hamano wrote:
Show 19 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
> 
> > When looking up a commit it may not be parsed yet. Callers that wish to
> > access the fields of `struct commit` have to call `repo_parse_commit()`
> > first so that it is guaranteed to be populated.
> >
> > We didn't yet care about doing so, because code paths that lead to
> > `pick_regular_commit()` in "builtin/replay.c" already implicitly parsed
> > the commits. But now that the function is exposed to outside callers
> > it's quite easy to get this wrong.
> >
> > Make the function easier to use by calling `repo_parse_commit()`.
> 
> Two-and-half obvious questions.
> 
>  * With this change, can we lose the parse-commit call(s) from
>    existing callers, or do the need to look at the in-core commit
>    object themselves before calling this function so they need to
>    have their parse-commit call(s) anyway?

There's only a single caller in "builtin/replay.c", and that caller parses commits deep inside the callstack via `prepare_revision_walk()`. So we cannot easily get rid of any calls.

>  * Can new callers you plan to add decide without having an already
>    parsed "pickme" commit object if they want to call this function,
>    iow, can they decide to call or not to call this function without
>    looking at the members of the commit structure?

Not quite sure I understand this question. In any case, I was hitting segfaults in tests when I didn't have this call. But your questions made me double-check this now, and I cannot see any of these failures anymore. And we do use the same infra to pick commits as "builtin/replay.c" does, so things should work alright.

Let me drop this commit for now. Things work without it, and in theory they should. And now that I'm revamping the infra to not use the merge machinery in the first place we don't even hit this code path anymore.

Thanks!
Patrick
Patrick Steinhardt· Oct 21, 2025, 14:15 UTC · re: Patrick Steinhardt · lore

[PATCH v5 05/12] builtin: add new "history" command

When rewriting history via git-rebase(1) there are a couple of very common use cases:

  - The ordering of two commits should be reversed.
  - A commit should be split up into two commits.
  - A commit should be dropped from the history completely.
  - Multiple commits should be squashed into one.

While these operations are all doable, it often feels needlessly kludgey to do so by doing an interactive rebase, using the editor to say what one wants, and then perform the actions. Furthermore, some operations like splitting up a commit into two are way more involved than that and require a whole series of commands.

Add a new "history" command to plug this gap. This command will have several different subcommands to imperatively rewrite history for common use cases like the above. These subcommands will be implemented in subsequent commits.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 .gitignore                     |  1 +
 Documentation/git-history.adoc | 45 ++++++++++++++++++++++++++++++++++++++++++
 Documentation/meson.build      |  1 +
 Makefile                       |  1 +
 builtin.h                      |  1 +
 builtin/history.c              | 22 +++++++++++++++++++++
 command-list.txt               |  1 +
 git.c                          |  1 +
 meson.build                    |  1 +
 t/meson.build                  |  1 +
 t/t3450-history.sh             | 17 ++++++++++++++++
 11 files changed, 92 insertions(+)
Show changes to 11 files +92 −0

.gitignore, Documentation/git-history.adoc, Documentation/meson.build, Makefile, builtin.h, builtin/history.c, command-list.txt, git.c, meson.build, t/meson.build, t/t3450-history.sh

diff --git a/.gitignore b/.gitignore
index 78a45cb5bec..24635cf2d6f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,6 +79,7 @@
 /git-grep
 /git-hash-object
 /git-help
+/git-history
 /git-hook
 /git-http-backend
 /git-http-fetch
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
new file mode 100644
index 00000000000..57560525a70
--- /dev/null
+++ b/Documentation/git-history.adoc
@@ -0,0 +1,45 @@
+git-history(1)
+==============
+
+NAME
+----
+git-history - EXPERIMENTAL: Rewrite history of the current branch
+
+SYNOPSIS
+--------
+[synopsis]
+git history [<options>]
+
+DESCRIPTION
+-----------
+
+Rewrite history by rearranging or modifying specific commits in the
+history.
+
+This command is similar to linkgit:git-rebase[1] and uses the same
+underlying machinery. You should use rebases if you want to reapply a range of
+commits onto a different base, or interactive rebases if you want to edit a
+range of commits.
+
+Note that this command does not (yet) work with histories that contain
+merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
+flag instead.
+
+THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
+
+COMMANDS
+--------
+
+This command requires a subcommand. Several subcommands are available to
+rewrite history in different ways:
+
+CONFIGURATION
+-------------
+
+include::includes/cmd-config-section-all.adoc[]
+
+include::config/sequencer.adoc[]
+
+GIT
+---
+Part of the linkgit:git[1] suite
diff --git a/Documentation/meson.build b/Documentation/meson.build
index 44f94cdb7ba..cf551a28ae7 100644
--- a/Documentation/meson.build
+++ b/Documentation/meson.build
@@ -64,6 +64,7 @@ manpages = {
   'git-gui.adoc' : 1,
   'git-hash-object.adoc' : 1,
   'git-help.adoc' : 1,
+  'git-history.adoc' : 1,
   'git-hook.adoc' : 1,
   'git-http-backend.adoc' : 1,
   'git-http-fetch.adoc' : 1,
diff --git a/Makefile b/Makefile
index 01c171b4f03..1380ee1e196 100644
--- a/Makefile
+++ b/Makefile
@@ -1395,6 +1395,7 @@ BUILTIN_OBJS += builtin/get-tar-commit-id.o
 BUILTIN_OBJS += builtin/grep.o
 BUILTIN_OBJS += builtin/hash-object.o
 BUILTIN_OBJS += builtin/help.o
+BUILTIN_OBJS += builtin/history.o
 BUILTIN_OBJS += builtin/hook.o
 BUILTIN_OBJS += builtin/index-pack.o
 BUILTIN_OBJS += builtin/init-db.o
diff --git a/builtin.h b/builtin.h
index 1b35565fbd9..93c91d07d4b 100644
--- a/builtin.h
+++ b/builtin.h
@@ -172,6 +172,7 @@ int cmd_get_tar_commit_id(int argc, const char **argv, const char *prefix, struc
 int cmd_grep(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hash_object(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_help(int argc, const char **argv, const char *prefix, struct repository *repo);
+int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hook(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);
diff --git a/builtin/history.c b/builtin/history.c
new file mode 100644
index 00000000000..f6fe32610b0
--- /dev/null
+++ b/builtin/history.c
@@ -0,0 +1,22 @@
+#include "builtin.h"
+#include "gettext.h"
+#include "parse-options.h"
+
+int cmd_history(int argc,
+		const char **argv,
+		const char *prefix,
+		struct repository *repo UNUSED)
+{
+	const char * const usage[] = {
+		N_("git history [<options>]"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc)
+		usagef("unrecognized argument: %s", argv[0]);
+	return 0;
+}
diff --git a/command-list.txt b/command-list.txt
index accd3d0c4b5..f9005cf4597 100644
--- a/command-list.txt
+++ b/command-list.txt
@@ -115,6 +115,7 @@ git-grep                                mainporcelain           info
 git-gui                                 mainporcelain
 git-hash-object                         plumbingmanipulators
 git-help                                ancillaryinterrogators          complete
+git-history                             mainporcelain           history
 git-hook                                purehelpers
 git-http-backend                        synchingrepositories
 git-http-fetch                          synchelpers
diff --git a/git.c b/git.c
index c5fad56813f..744cb6527e0 100644
--- a/git.c
+++ b/git.c
@@ -586,6 +586,7 @@ static struct cmd_struct commands[] = {
 	{ "grep", cmd_grep, RUN_SETUP_GENTLY },
 	{ "hash-object", cmd_hash_object },
 	{ "help", cmd_help },
+	{ "history", cmd_history, RUN_SETUP },
 	{ "hook", cmd_hook, RUN_SETUP },
 	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
 	{ "init", cmd_init_db },
diff --git a/meson.build b/meson.build
index ae8d4fef059..2d789612a01 100644
--- a/meson.build
+++ b/meson.build
@@ -604,6 +604,7 @@ builtin_sources = [
   'builtin/grep.c',
   'builtin/hash-object.c',
   'builtin/help.c',
+  'builtin/history.c',
   'builtin/hook.c',
   'builtin/index-pack.c',
   'builtin/init-db.c',
diff --git a/t/meson.build b/t/meson.build
index 401b24e50e0..019435918fa 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -384,6 +384,7 @@ integration_tests = [
   't3436-rebase-more-options.sh',
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
+  't3450-history.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
new file mode 100755
index 00000000000..417c343d43b
--- /dev/null
+++ b/t/t3450-history.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+test_description='tests for git-history command'
+
+. ./test-lib.sh
+
+test_expect_success 'does nothing without any arguments' '
+	git history >out 2>&1 &&
+	test_must_be_empty out
+'
+
+test_expect_success 'raises an error with unknown argument' '
+	test_must_fail git history garbage 2>err &&
+	test_grep "unrecognized argument: garbage" err
+'
+
+test_done
-- 
2.51.1.851.g4ebd6896fd.dirty
Junio C Hamano· Oct 21, 2025, 21:15 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v5 05/12] builtin: add new "history" command

Patrick Steinhardt <ps@pks.im> writes:
Show 21 quoted lines
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> new file mode 100644
> index 00000000000..57560525a70
> --- /dev/null
> +++ b/Documentation/git-history.adoc
> @@ -0,0 +1,45 @@
> +git-history(1)
> +==============
> +
> +NAME
> +----
> +git-history - EXPERIMENTAL: Rewrite history of the current branch
>
>
> +SYNOPSIS
> +--------
> +[synopsis]
> +git history [<options>]
> +
> +DESCRIPTION
> +-----------

We would want to make sure that all experimental things identify themselves in a similar way.

The way how replay identifies itself as experimental, which this patch is modeled after, is somewhat different from what is done by backfill, for-each-repo, last-modified, and sparse-checkout commands.

Show 19 quoted lines
> +Rewrite history by rearranging or modifying specific commits in the
> +history.
> +
> +This command is similar to linkgit:git-rebase[1] and uses the same
> +underlying machinery. You should use rebases if you want to reapply a range of
> +commits onto a different base, or interactive rebases if you want to edit a
> +range of commits.
> +
> +Note that this command does not (yet) work with histories that contain
> +merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
> +flag instead.
> +
> +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
> +
> +COMMANDS
> +--------
> +
> +This command requires a subcommand. Several subcommands are available to
> +rewrite history in different ways:

Looking at "refs", "repo" and "sparse-checkout", none of them say "requires a subcommand", even though they do. It would probably be obvious from the syntax, so drop the first sentence, perhaps?

And "subcommand" -> "command" to match the section title.

The remainder of this step seems a bog standard "here is how you add an empty shell for a new command" and I didn't see anything fishy in it.

Patrick Steinhardt· Oct 27, 2025, 09:57 UTC · re: Junio C Hamano · lore

Re: [PATCH v5 05/12] builtin: add new "history" command

On Tue, Oct 21, 2025 at 02:15:10PM -0700, Junio C Hamano wrote:
Show 31 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
> 
> > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > new file mode 100644
> > index 00000000000..57560525a70
> > --- /dev/null
> > +++ b/Documentation/git-history.adoc
> > @@ -0,0 +1,45 @@
> > +git-history(1)
> > +==============
> > +
> > +NAME
> > +----
> > +git-history - EXPERIMENTAL: Rewrite history of the current branch
> >
> >
> > +SYNOPSIS
> > +--------
> > +[synopsis]
> > +git history [<options>]
> > +
> > +DESCRIPTION
> > +-----------
> 
> We would want to make sure that all experimental things identify
> themselves in a similar way.
> 
> The way how replay identifies itself as experimental, which this
> patch is modeled after, is somewhat different from what is done by
> backfill, for-each-repo, last-modified, and sparse-checkout
> commands.

I guess the only thing that's different with git-replay(1) is that we also have the `(EXPERIMENTAL!)` tag in the synopsis. No other man page does that as far as I can see.

But yeah, I agree that things should be consistent here. I think the most sensible thing to do is to:

  - Have the "EXPERIMENTAL:" tag in the NAME section.
  - Have "THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE." in the
    DESCRIPTION section, ideally after the first summarizing sentence.
Show 25 quoted lines
> > +Rewrite history by rearranging or modifying specific commits in the
> > +history.
> > +
> > +This command is similar to linkgit:git-rebase[1] and uses the same
> > +underlying machinery. You should use rebases if you want to reapply a range of
> > +commits onto a different base, or interactive rebases if you want to edit a
> > +range of commits.
> > +
> > +Note that this command does not (yet) work with histories that contain
> > +merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
> > +flag instead.
> > +
> > +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
> > +
> > +COMMANDS
> > +--------
> > +
> > +This command requires a subcommand. Several subcommands are available to
> > +rewrite history in different ways:
> 
> Looking at "refs", "repo" and "sparse-checkout", none of them say
> "requires a subcommand", even though they do.  It would probably be
> obvious from the syntax, so drop the first sentence, perhaps?
> 
> And "subcommand" -> "command" to match the section title.
Makes sense.
Thanks!
Patrick
Patrick Steinhardt· Oct 21, 2025, 14:15 UTC · re: Patrick Steinhardt · lore

[PATCH v5 06/12] builtin/history: implement "reword" subcommand

Implement a new "reword" subcommand for git-history(1). This subcommand is essentially the same as if a user performed an interactive rebase with a single commit changed to use the "reword" verb.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |   7 +
 builtin/history.c              | 364 ++++++++++++++++++++++++++++++++++++++++-
 t/meson.build                  |   1 +
 t/t3450-history.sh             |   6 +-
 t/t3451-history-reword.sh      | 217 ++++++++++++++++++++++++
 5 files changed, 588 insertions(+), 7 deletions(-)
Show changes to 5 files +588 −7

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3450-history.sh, t/t3451-history-reword.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 57560525a70..6f016801936 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -9,6 +9,7 @@ SYNOPSIS
 --------
 [synopsis]
 git history [<options>]
+git history reword [<options>] <commit>
 
 DESCRIPTION
 -----------
@@ -33,6 +34,12 @@ COMMANDS
 This command requires a subcommand. Several subcommands are available to
 rewrite history in different ways:
 
+`reword <commit> [--message=<message>]`::
+	Rewrite the commit message of the specified commit. All the other
+	details of this commit remain unchanged. If no commit message is
+	provided, then this command will spawn an editor with the current
+	message of that commit.
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index f6fe32610b0..f14e88c9bf4 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,22 +1,378 @@
+#define USE_THE_REPOSITORY_VARIABLE
+
 #include "builtin.h"
+#include "commit-reach.h"
+#include "commit.h"
+#include "config.h"
+#include "editor.h"
+#include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "parse-options.h"
+#include "refs.h"
+#include "replay.h"
+#include "reset.h"
+#include "revision.h"
+#include "sequencer.h"
+#include "strvec.h"
+#include "tree.h"
+#include "wt-status.h"
+
+#define GIT_HISTORY_REWORD_USAGE N_("git history reword [<options>] <commit>")
+
+static int collect_commits(struct repository *repo,
+			   struct commit *old_commit,
+			   struct commit *new_commit,
+			   struct strvec *out)
+{
+	struct setup_revision_opt revision_opts = {
+		.assume_dashdash = 1,
+	};
+	struct strvec revisions = STRVEC_INIT;
+	struct commit *child;
+	struct rev_info rev = { 0 };
+	int ret;
+
+	repo_init_revisions(repo, &rev, NULL);
+	strvec_push(&revisions, "");
+	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
+	if (old_commit)
+		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
+
+	setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
+	if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
+		ret = error(_("revision walk setup failed"));
+		goto out;
+	}
+
+	while ((child = get_revision(&rev))) {
+		if (old_commit && !child->parents)
+			BUG("revision walk did not find child commit");
+		if (child->parents && child->parents->next) {
+			ret = error(_("cannot rearrange commit history with merges"));
+			goto out;
+		}
+
+		strvec_push(out, oid_to_hex(&child->object.oid));
+
+		if (child->parents && old_commit &&
+		    commit_list_contains(old_commit, child->parents))
+			break;
+	}
+
+	/*
+	 * Revisions are in newest-order-first. We have to reverse the
+	 * array though so that we pick the oldest commits first.
+	 */
+	for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
+		SWAP(out->v[i], out->v[j]);
+
+	ret = 0;
+
+out:
+	strvec_clear(&revisions);
+	release_revisions(&rev);
+	reset_revision_walk();
+	return ret;
+}
+
+static void replace_commits(struct strvec *commits,
+			    const struct object_id *commit_to_replace,
+			    const struct object_id *replacements,
+			    size_t replacements_nr)
+{
+	char commit_to_replace_oid[GIT_MAX_HEXSZ + 1];
+	struct strvec replacement_oids = STRVEC_INIT;
+	bool found = false;
+
+	oid_to_hex_r(commit_to_replace_oid, commit_to_replace);
+	for (size_t i = 0; i < replacements_nr; i++)
+		strvec_push(&replacement_oids, oid_to_hex(&replacements[i]));
+
+	for (size_t i = 0; i < commits->nr; i++) {
+		if (strcmp(commits->v[i], commit_to_replace_oid))
+			continue;
+		strvec_splice(commits, i, 1, replacement_oids.v, replacement_oids.nr);
+		found = true;
+		break;
+	}
+	if (!found)
+		BUG("could not find commit to replace");
+
+	strvec_clear(&replacement_oids);
+}
+
+static int apply_commits(struct repository *repo,
+			 const struct strvec *commits,
+			 struct commit *onto,
+			 struct commit *orig_head,
+			 const char *action)
+{
+	struct reset_head_opts reset_opts = { 0 };
+	struct merge_options merge_opts = { 0 };
+	struct merge_result result = { 0 };
+	struct strbuf buf = STRBUF_INIT;
+	kh_oid_map_t *replayed_commits;
+	int ret;
+
+	replayed_commits = kh_init_oid_map();
+
+	init_basic_merge_options(&merge_opts, repo);
+	merge_opts.show_rename_progress = 0;
+
+	for (size_t i = 0; i < commits->nr; i++) {
+		struct object_id commit_id;
+		struct commit *commit;
+		const char *end;
+		int hash_result;
+		khint_t pos;
+
+		if (parse_oid_hex_algop(commits->v[i], &commit_id, &end,
+					repo->hash_algo)) {
+			ret = error(_("invalid object ID: %s"), commits->v[i]);
+			goto out;
+		}
+
+		commit = lookup_commit(repo, &commit_id);
+		if (!commit || repo_parse_commit(repo, commit)) {
+			ret = error(_("failed to look up commit: %s"), oid_to_hex(&commit_id));
+			goto out;
+		}
+
+		if (!onto) {
+			onto = commit;
+			result.clean = 1;
+			result.tree = repo_get_commit_tree(repo, commit);
+		} else {
+			onto = replay_pick_regular_commit(repo, commit, replayed_commits,
+							  onto, &merge_opts, &result);
+			if (!onto)
+				break;
+		}
+
+		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hash_result);
+		if (hash_result == 0) {
+			ret = error(_("duplicate rewritten commit: %s\n"),
+				    oid_to_hex(&commit->object.oid));
+			goto out;
+		}
+		kh_value(replayed_commits, pos) = onto;
+	}
+
+	if (!result.clean) {
+		ret = error(_("could not merge"));
+		goto out;
+	}
+
+	reset_opts.oid = &onto->object.oid;
+	strbuf_addf(&buf, "%s: switch to rewritten %s", action, oid_to_hex(reset_opts.oid));
+	reset_opts.flags = RESET_HEAD_REFS_ONLY | RESET_ORIG_HEAD;
+	reset_opts.orig_head = &orig_head->object.oid;
+	reset_opts.default_reflog_action = action;
+	if (reset_head(repo, &reset_opts) < 0) {
+		ret = error(_("could not switch to %s"), oid_to_hex(reset_opts.oid));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	kh_destroy_oid_map(replayed_commits);
+	merge_finalize(&merge_opts, &result);
+	strbuf_release(&buf);
+	return ret;
+}
+
+static void change_data_free(void *util, const char *str UNUSED)
+{
+	struct wt_status_change_data *d = util;
+	free(d->rename_source);
+	free(d);
+}
+
+static int fill_commit_message(struct repository *repo,
+			       const struct object_id *old_tree,
+			       const struct object_id *new_tree,
+			       const char *default_message,
+			       const char *provided_message,
+			       const char *action,
+			       struct strbuf *out)
+{
+	if (!provided_message) {
+		const char *path = git_path_commit_editmsg();
+		const char *hint =
+			_("Please enter the commit message for the %s changes."
+			  " Lines starting\nwith '%s' will be ignored.\n");
+		struct wt_status s;
+
+		strbuf_addstr(out, default_message);
+		strbuf_addch(out, '\n');
+		strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
+		write_file_buf(path, out->buf, out->len);
+
+		wt_status_prepare(repo, &s);
+		FREE_AND_NULL(s.branch);
+		s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
+		s.commit_template = 1;
+		s.colopts = 0;
+		s.display_comment_prefix = 1;
+		s.hints = 0;
+		s.use_color = 0;
+		s.whence = FROM_COMMIT;
+		s.committable = 1;
+
+		s.fp = fopen(git_path_commit_editmsg(), "a");
+		if (!s.fp)
+			return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
+
+		wt_status_collect_changes_trees(&s, old_tree, new_tree);
+		wt_status_print(&s);
+		wt_status_collect_free_buffers(&s);
+		string_list_clear_func(&s.change, change_data_free);
+
+		strbuf_reset(out);
+		if (launch_editor(path, out, NULL)) {
+			fprintf(stderr, _("Please supply the message using the -m option.\n"));
+			return -1;
+		}
+		strbuf_stripspace(out, comment_line_str);
+	} else {
+		strbuf_addstr(out, provided_message);
+	}
+
+	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
+
+	if (!out->len) {
+		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
+		return -1;
+	}
+
+	return 0;
+}
+
+static int cmd_history_reword(int argc,
+			      const char **argv,
+			      const char *prefix,
+			      struct repository *repo)
+{
+	const char * const usage[] = {
+		GIT_HISTORY_REWORD_USAGE,
+		NULL,
+	};
+	const char *commit_message = NULL;
+	struct option options[] = {
+		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
+		OPT_END(),
+	};
+	struct strbuf final_message = STRBUF_INIT;
+	struct commit *original_commit, *parent, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct object_id parent_tree_oid, original_commit_tree_oid;
+	struct object_id rewritten_commit;
+	struct commit_list *from_list = NULL;
+	const char *original_message, *original_body, *ptr;
+	char *original_author = NULL;
+	size_t len;
+	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);
+
+	original_commit = lookup_commit_reference_by_name(argv[0]);
+	if (!original_commit) {
+		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
+		goto out;
+	}
+	original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
+
+	parent = original_commit->parents ? original_commit->parents->item : NULL;
+	if (parent) {
+		if (repo_parse_commit(repo, parent)) {
+			ret = error(_("unable to parse commit %s"),
+				    oid_to_hex(&parent->object.oid));
+			goto out;
+		}
+		parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
+	} else {
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	commit_list_append(original_commit, &from_list);
+	if (!repo_is_descendant_of(repo, head, from_list)) {
+		ret = error (_("split commit must be reachable from current HEAD commit"));
+		goto out;
+	}
+
+	/*
+	 * Collect the list of commits that we'll have to reapply now already.
+	 * This ensures that we'll abort early on in case the range of commits
+	 * contains merges, which we do not yet handle.
+	 */
+	ret = collect_commits(repo, parent, head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+	find_commit_subject(original_message, &original_body);
+
+	ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
+				  original_body, commit_message, "reworded", &final_message);
+	if (ret < 0)
+		goto out;
+
+	ret = commit_tree(final_message.buf, final_message.len, &original_commit_tree_oid,
+			  original_commit->parents, &rewritten_commit, original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing reworded commit"));
+		goto out;
+	}
+
+	replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
+
+	ret = apply_commits(repo, &commits, parent, head, "reword");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	strbuf_release(&final_message);
+	free_commit_list(from_list);
+	strvec_clear(&commits);
+	free(original_author);
+	return ret;
+}
 
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
-		struct repository *repo UNUSED)
+		struct repository *repo)
 {
 	const char * const usage[] = {
 		N_("git history [<options>]"),
+		GIT_HISTORY_REWORD_USAGE,
 		NULL,
 	};
+	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
+		OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
 		OPT_END(),
 	};
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
-	if (argc)
-		usagef("unrecognized argument: %s", argv[0]);
-	return 0;
+	return fn(argc, argv, prefix, repo);
 }
diff --git a/t/meson.build b/t/meson.build
index 019435918fa..a3ec9199947 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -385,6 +385,7 @@ integration_tests = [
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
+  't3451-history-reword.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
index 417c343d43b..f513463b92b 100755
--- a/t/t3450-history.sh
+++ b/t/t3450-history.sh
@@ -5,13 +5,13 @@ test_description='tests for git-history command'
 . ./test-lib.sh
 
 test_expect_success 'does nothing without any arguments' '
-	git history >out 2>&1 &&
-	test_must_be_empty out
+	test_must_fail git history 2>err &&
+	test_grep "need a subcommand" err
 '
 
 test_expect_success 'raises an error with unknown argument' '
 	test_must_fail git history garbage 2>err &&
-	test_grep "unrecognized argument: garbage" err
+	test_grep "unknown subcommand: .garbage." err
 '
 
 test_done
diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
new file mode 100755
index 00000000000..b73371fb51c
--- /dev/null
+++ b/t/t3451-history-reword.sh
@@ -0,0 +1,217 @@
+#!/bin/sh
+
+test_description='tests for git-history reword subcommand'
+
+. ./test-lib.sh
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history reword HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err &&
+		test_must_fail git history reword HEAD 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work with unrelated commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		test_must_fail git history reword ours 2>err &&
+		test_grep "split commit must be reachable from current HEAD commit" err
+	)
+'
+
+test_expect_success 'can reword tip of a branch' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		git history reword -m "third reworded" HEAD &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		third reworded
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can reword commit in the middle' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		git history reword -m "second reworded" HEAD~ &&
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can reword root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		git history reword -m "first reworded" HEAD~2 &&
+
+		cat >expect <<-EOF &&
+		third
+		second
+		first reworded
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can use editor to rewrite commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		printf "\namend a comment\n" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+		git history reword HEAD &&
+
+		cat >expect <<-EOF &&
+		first
+
+		# Please enter the commit message for the reworded changes. Lines starting
+		# with ${SQ}#${SQ} will be ignored.
+		# Changes to be committed:
+		#	new file:   first.t
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		cat >expect <<-EOF &&
+		first
+
+		amend a comment
+
+		EOF
+		git log --format=%B >actual &&
+		test_cmp expect actual
+	)
+'
+
+# For now, git-history(1) does not yet execute any hooks. This is subject to
+# change in the future, and if it does this test here is expected to start
+# failing. In other words, this test is not an endorsement of the current
+# status quo.
+test_expect_success 'hooks are executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		write_script .git/hooks/prepare-commit-msg <<-EOF &&
+		touch "$(pwd)/hooks.log
+		EOF
+		write_script .git/hooks/post-commit <<-EOF &&
+		touch "$(pwd)/hooks.log
+		EOF
+		write_script .git/hooks/post-rewrite <<-EOF &&
+		touch "$(pwd)/hooks.log
+		EOF
+
+		git history reword -m "second reworded" HEAD~ &&
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+
+		test_path_is_missing hooks.log
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		test_must_fail git history reword -m "" HEAD 2>err &&
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_expect_success 'retains changes in the worktree and index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch a b &&
+		git add . &&
+		git commit -m "initial commit" &&
+		echo foo >a &&
+		echo bar >b &&
+		git add b &&
+		git history reword HEAD -m message &&
+		cat >expect <<-\EOF &&
+		 M a
+		M  b
+		?? actual
+		?? expect
+		EOF
+		git status --porcelain >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.51.1.851.g4ebd6896fd.dirty
Junio C Hamano· Oct 21, 2025, 21:34 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v5 06/12] builtin/history: implement "reword" subcommand

Patrick Steinhardt <ps@pks.im> writes:
> Implement a new "reword" subcommand for git-history(1). This subcommand
> is essentially the same as if a user performed an interactive rebase
> with a single commit changed to use the "reword" verb.
Oy.  I've always wanted something like this in "rebase -i" myself.

It is a bit sad that I have to learn a new command to do something obvious and trivial like this, but that's life ;-)

Maybe "git history" becomes powerful enough and can replace my every-day use of "rebase -i".

Show 5 quoted lines
> @@ -9,6 +9,7 @@ SYNOPSIS
>  --------
>  [synopsis]
>  git history [<options>]
> +git history reword [<options>] <commit>

It is curious that a command-less form is still listed here, especially since this command "requires" a subcommand. I would have expected that there will be a single line here after implementing a single subcommand.

> +`reword <commit> [--message=<message>]`::
That should be `reword [--message=<message>] <commit>` no?
> +	Rewrite the commit message of the specified commit. All the other
> +	details of this commit remain unchanged. If no commit message is
> +	provided, then this command will spawn an editor with the current
> +	message of that commit.

As long as it takes more than one -m and concatenates them just like "git commit -m <message1> -m <message2>" does, I would not complain too much that a command line option to give message encourages sloppy log messages.

Show 10 quoted lines
> +		if (!onto) {
> +			onto = commit;
> +			result.clean = 1;
> +			result.tree = repo_get_commit_tree(repo, commit);
> +		} else {
> +			onto = replay_pick_regular_commit(repo, commit, replayed_commits,
> +							  onto, &merge_opts, &result);
> +			if (!onto)
> +				break;
> +		}

Hmph, I would have expected that the overall flow of this command would be

 * find the commits above and including the <commit> in question,
   making sure there is no merge.
 * read metadata of <commit> like the parent (as we do not allow
   merges), tree, author ident & time.
 * create a new commit object that has the same metadata as <commit>
   on top of the parent of <commit>, but with the updated message
   and new committer ident & time.
 * initialize a variable Current to point at the rewritten <commit>
 * loop for each commit C in <commit>..HEAD range in reverse order
   (we know we have a single strand of pearls):
   - read metadata of C
   - create a new commit object C' that has the same metadata and
     message as C on top of the Current commit, with new committer
     ident & time.
   - make Current point at the resulting C'
 * Point the Current with HEAD.

without having to touch any "pick" machinery. Why do we need to go down to the merge machinery for a mere "reword" operation?

D. Ben Knoble· Oct 21, 2025, 21:43 UTC · re: Junio C Hamano · lore

Re: [PATCH v5 06/12] builtin/history: implement "reword" subcommand

On Tue, Oct 21, 2025 at 5:34 PM Junio C Hamano <gitster@pobox.com> wrote:
Show 56 quoted lines
>
> Patrick Steinhardt <ps@pks.im> writes:
>
> > Implement a new "reword" subcommand for git-history(1). This subcommand
> > is essentially the same as if a user performed an interactive rebase
> > with a single commit changed to use the "reword" verb.
>
> Oy.  I've always wanted something like this in "rebase -i" myself.
>
> It is a bit sad that I have to learn a new command to do something
> obvious and trivial like this, but that's life ;-)
>
> Maybe "git history" becomes powerful enough and can replace my
> every-day use of "rebase -i".
>
> > @@ -9,6 +9,7 @@ SYNOPSIS
> >  --------
> >  [synopsis]
> >  git history [<options>]
> > +git history reword [<options>] <commit>
>
> It is curious that a command-less form is still listed here,
> especially since this command "requires" a subcommand.  I would have
> expected that there will be a single line here after implementing a
> single subcommand.
>
> > +`reword <commit> [--message=<message>]`::
>
> That should be `reword [--message=<message>] <commit>` no?
>
> > +     Rewrite the commit message of the specified commit. All the other
> > +     details of this commit remain unchanged. If no commit message is
> > +     provided, then this command will spawn an editor with the current
> > +     message of that commit.
>
> As long as it takes more than one -m and concatenates them just like
> "git commit -m <message1> -m <message2>" does, I would not complain
> too much that a command line option to give message encourages sloppy
> log messages.
>
> > +             if (!onto) {
> > +                     onto = commit;
> > +                     result.clean = 1;
> > +                     result.tree = repo_get_commit_tree(repo, commit);
> > +             } else {
> > +                     onto = replay_pick_regular_commit(repo, commit, replayed_commits,
> > +                                                       onto, &merge_opts, &result);
> > +                     if (!onto)
> > +                             break;
> > +             }
>
> Hmph, I would have expected that the overall flow of this command
> would be
>
>  * find the commits above and including the <commit> in question,
>    making sure there is no merge.

I don't remember offhand if the implementation supports merges, so this might not answer the question…

> without having to touch any "pick" machinery.  Why do we need to go
> down to the merge machinery for a mere "reword" operation?

…but it would be nice to not overly restrict the commits that can be reworded (IIRC, jj permits the equivalent).

-- 
D. Ben Knoble
Patrick Steinhardt· Oct 27, 2025, 09:58 UTC · re: D. Ben Knoble · lore

Re: [PATCH v5 06/12] builtin/history: implement "reword" subcommand

On Tue, Oct 21, 2025 at 05:43:16PM -0400, D. Ben Knoble wrote:
Show 15 quoted lines
> On Tue, Oct 21, 2025 at 5:34 PM Junio C Hamano <gitster@pobox.com> wrote:
> > Hmph, I would have expected that the overall flow of this command
> > would be
> >
> >  * find the commits above and including the <commit> in question,
> >    making sure there is no merge.
> 
> I don't remember offhand if the implementation supports merges, so
> this might not answer the question…
> 
> > without having to touch any "pick" machinery.  Why do we need to go
> > down to the merge machinery for a mere "reword" operation?
> 
> …but it would be nice to not overly restrict the commits that can be
> reworded (IIRC, jj permits the equivalent).

I agree that it would be nice, but I'd defer that to the future. Let's focus on the easy cases for now and then extend going forward.

Patrick
Patrick Steinhardt· Oct 27, 2025, 09:58 UTC · re: Junio C Hamano · lore

Re: [PATCH v5 06/12] builtin/history: implement "reword" subcommand

On Tue, Oct 21, 2025 at 02:34:31PM -0700, Junio C Hamano wrote:
Show 11 quoted lines
> Patrick Steinhardt <ps@pks.im> writes:
> > @@ -9,6 +9,7 @@ SYNOPSIS
> >  --------
> >  [synopsis]
> >  git history [<options>]
> > +git history reword [<options>] <commit>
> 
> It is curious that a command-less form is still listed here,
> especially since this command "requires" a subcommand.  I would have
> expected that there will be a single line here after implementing a
> single subcommand.

True. You can execute `git history` alone, but it doesn't do anything useful except for giving you an error and all the potential subcommands. That's in contrast to e.g. git-reflog(1), which supports a command-less mode that does something useful.

> > +`reword <commit> [--message=<message>]`::
> 
> That should be `reword [--message=<message>] <commit>` no?
Indeed.
Show 9 quoted lines
> > +	Rewrite the commit message of the specified commit. All the other
> > +	details of this commit remain unchanged. If no commit message is
> > +	provided, then this command will spawn an editor with the current
> > +	message of that commit.
> 
> As long as it takes more than one -m and concatenates them just like
> "git commit -m <message1> -m <message2>" does, I would not complain
> too much that a command line option to give message encourages sloppy
> log messages.

I'll for now defer the discussion around "-m" completely, as the design isn't entirely clear yet in the first place. So I'll just drop the option in the next iteration.

Show 41 quoted lines
> > +		if (!onto) {
> > +			onto = commit;
> > +			result.clean = 1;
> > +			result.tree = repo_get_commit_tree(repo, commit);
> > +		} else {
> > +			onto = replay_pick_regular_commit(repo, commit, replayed_commits,
> > +							  onto, &merge_opts, &result);
> > +			if (!onto)
> > +				break;
> > +		}
> 
> Hmph, I would have expected that the overall flow of this command
> would be
> 
>  * find the commits above and including the <commit> in question,
>    making sure there is no merge.
> 
>  * read metadata of <commit> like the parent (as we do not allow
>    merges), tree, author ident & time.
> 
>  * create a new commit object that has the same metadata as <commit>
>    on top of the parent of <commit>, but with the updated message
>    and new committer ident & time.
> 
>  * initialize a variable Current to point at the rewritten <commit>
> 
>  * loop for each commit C in <commit>..HEAD range in reverse order
>    (we know we have a single strand of pearls):
> 
>    - read metadata of C
> 
>    - create a new commit object C' that has the same metadata and
>      message as C on top of the Current commit, with new committer
>      ident & time.
> 
>    - make Current point at the resulting C'
> 
>  * Point the Current with HEAD.
> 
> without having to touch any "pick" machinery.  Why do we need to go
> down to the merge machinery for a mere "reword" operation?

You're exactly right, we don't need the "pick" machinery at all right now. I think this is still a leftover from previous iterations, where I was also driving things like an "edit" command that _do_ require merges. But neither splitting a commit nor rewording it does require a merge at all.

Will simplify. We can reintroduce the heavier machinery at a later point in time as needed.

Thanks!
Patrick
Patrick Steinhardt· Oct 21, 2025, 14:15 UTC · re: Patrick Steinhardt · lore

[PATCH v5 07/12] add-patch: split out header from "add-interactive.h"

While we have a "add-patch.c" code file, its declarations are part of "add-interactive.h". This makes it somewhat harder than necessary to find relevant code and to identify clear boundaries between the two subsystems.

Split up concerns and move declarations that relate to "add-patch.c" into a new "add-patch.h" header.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.h | 23 +++--------------------
 add-patch.c       |  1 +
 add-patch.h       | 26 ++++++++++++++++++++++++++
 3 files changed, 30 insertions(+), 20 deletions(-)
Show changes to 3 files +30 −20

add-interactive.h, add-patch.c, add-patch.h

diff --git a/add-interactive.h b/add-interactive.h
index da49502b765..2e3d1d871d2 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -1,14 +1,11 @@
 #ifndef ADD_INTERACTIVE_H
 #define ADD_INTERACTIVE_H
 
+#include "add-patch.h"
 #include "color.h"
 
-struct add_p_opt {
-	int context;
-	int interhunkcontext;
-};
-
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+struct pathspec;
+struct repository;
 
 struct add_i_state {
 	struct repository *r;
@@ -35,21 +32,7 @@ void init_add_i_state(struct add_i_state *s, struct repository *r,
 		      struct add_p_opt *add_p_opt);
 void clear_add_i_state(struct add_i_state *s);
 
-struct repository;
-struct pathspec;
 int run_add_i(struct repository *r, const struct pathspec *ps,
 	      struct add_p_opt *add_p_opt);
 
-enum add_p_mode {
-	ADD_P_ADD,
-	ADD_P_STASH,
-	ADD_P_RESET,
-	ADD_P_CHECKOUT,
-	ADD_P_WORKTREE,
-};
-
-int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
-	      const struct pathspec *ps);
-
 #endif
diff --git a/add-patch.c b/add-patch.c
index 9402dc71bc6..3bb7bcf3d26 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -3,6 +3,7 @@
 
 #include "git-compat-util.h"
 #include "add-interactive.h"
+#include "add-patch.h"
 #include "advice.h"
 #include "editor.h"
 #include "environment.h"
diff --git a/add-patch.h b/add-patch.h
new file mode 100644
index 00000000000..4394c741076
--- /dev/null
+++ b/add-patch.h
@@ -0,0 +1,26 @@
+#ifndef ADD_PATCH_H
+#define ADD_PATCH_H
+
+struct pathspec;
+struct repository;
+
+struct add_p_opt {
+	int context;
+	int interhunkcontext;
+};
+
+#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+
+enum add_p_mode {
+	ADD_P_ADD,
+	ADD_P_STASH,
+	ADD_P_RESET,
+	ADD_P_CHECKOUT,
+	ADD_P_WORKTREE,
+};
+
+int run_add_p(struct repository *r, enum add_p_mode mode,
+	      struct add_p_opt *o, const char *revision,
+	      const struct pathspec *ps);
+
+#endif
-- 
2.51.1.851.g4ebd6896fd.dirty
Patrick Steinhardt· Oct 21, 2025, 14:15 UTC · re: Patrick Steinhardt · lore

[PATCH v5 08/12] add-patch: split out `struct interactive_options`

The `struct add_p_opt` is reused both by our infra for "git add -p" and "git add -i". Users of `run_add_i()` for example are expected to pass `struct add_p_opt`. This is somewhat confusing and raises the question of which options apply to what part of the stack.

But things are even more confusing than that: while callers are expected to pass in `struct add_p_opt`, these options ultimately get used to initialize a `struct add_i_state` that is used by both subsystems. So we are basically going full circle here.

Refactor the code and split out a new `struct interactive_options` that hosts common options used by both. These options are then applied to a `struct interactive_config` that hosts common configuration.

This refactoring doesn't yet fully detangle the two subsystems from one another, as we still end up calling `init_add_i_state()` in the "git add -p" subsystem. This will be fixed in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.c  | 174 +++++++++++------------------------------------------
 add-interactive.h  |  23 +------
 add-patch.c        | 170 +++++++++++++++++++++++++++++++++++++++++++--------
 add-patch.h        |  36 ++++++++++-
 builtin/add.c      |  22 +++----
 builtin/checkout.c |   4 +-
 builtin/commit.c   |  16 ++---
 builtin/reset.c    |  16 ++---
 builtin/stash.c    |  46 +++++++-------
 commit.h           |   2 +-
 10 files changed, 270 insertions(+), 239 deletions(-)
Show changes to 10 files +270 −239

add-interactive.c, add-interactive.h, add-patch.c, add-patch.h, builtin/add.c, builtin/checkout.c, builtin/commit.c, builtin/reset.c, builtin/stash.c, commit.h

diff --git a/add-interactive.c b/add-interactive.c
index 68fc09547dd..05d2e7eefe3 100644
--- a/add-interactive.c
+++ b/add-interactive.c
@@ -3,7 +3,6 @@
 #include "git-compat-util.h"
 #include "add-interactive.h"
 #include "color.h"
-#include "config.h"
 #include "diffcore.h"
 #include "gettext.h"
 #include "hash.h"
@@ -20,119 +19,18 @@
 #include "prompt.h"
 #include "tree.h"
 
-static void init_color(struct repository *r, enum git_colorbool use_color,
-		       const char *section_and_slot, char *dst,
-		       const char *default_color)
-{
-	char *key = xstrfmt("color.%s", section_and_slot);
-	const char *value;
-
-	if (!want_color(use_color))
-		dst[0] = '\0';
-	else if (repo_config_get_value(r, key, &value) ||
-		 color_parse(value, dst))
-		strlcpy(dst, default_color, COLOR_MAXLEN);
-
-	free(key);
-}
-
-static enum git_colorbool check_color_config(struct repository *r, const char *var)
-{
-	const char *value;
-	enum git_colorbool ret;
-
-	if (repo_config_get_value(r, var, &value))
-		ret = GIT_COLOR_UNKNOWN;
-	else
-		ret = git_config_colorbool(var, value);
-
-	/*
-	 * Do not rely on want_color() to fall back to color.ui for us. It uses
-	 * the value parsed by git_color_config(), which may not have been
-	 * called by the main command.
-	 */
-	if (ret == GIT_COLOR_UNKNOWN &&
-	    !repo_config_get_value(r, "color.ui", &value))
-		ret = git_config_colorbool("color.ui", value);
-
-	return ret;
-}
-
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt)
+		      struct interactive_options *opts)
 {
 	s->r = r;
-	s->context = -1;
-	s->interhunkcontext = -1;
-
-	s->use_color_interactive = check_color_config(r, "color.interactive");
-
-	init_color(r, s->use_color_interactive, "interactive.header",
-		   s->header_color, GIT_COLOR_BOLD);
-	init_color(r, s->use_color_interactive, "interactive.help",
-		   s->help_color, GIT_COLOR_BOLD_RED);
-	init_color(r, s->use_color_interactive, "interactive.prompt",
-		   s->prompt_color, GIT_COLOR_BOLD_BLUE);
-	init_color(r, s->use_color_interactive, "interactive.error",
-		   s->error_color, GIT_COLOR_BOLD_RED);
-	strlcpy(s->reset_color_interactive,
-		want_color(s->use_color_interactive) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
-
-	s->use_color_diff = check_color_config(r, "color.diff");
-
-	init_color(r, s->use_color_diff, "diff.frag", s->fraginfo_color,
-		   diff_get_color(s->use_color_diff, DIFF_FRAGINFO));
-	init_color(r, s->use_color_diff, "diff.context", s->context_color,
-		   "fall back");
-	if (!strcmp(s->context_color, "fall back"))
-		init_color(r, s->use_color_diff, "diff.plain",
-			   s->context_color,
-			   diff_get_color(s->use_color_diff, DIFF_CONTEXT));
-	init_color(r, s->use_color_diff, "diff.old", s->file_old_color,
-		   diff_get_color(s->use_color_diff, DIFF_FILE_OLD));
-	init_color(r, s->use_color_diff, "diff.new", s->file_new_color,
-		   diff_get_color(s->use_color_diff, DIFF_FILE_NEW));
-	strlcpy(s->reset_color_diff,
-		want_color(s->use_color_diff) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
-
-	FREE_AND_NULL(s->interactive_diff_filter);
-	repo_config_get_string(r, "interactive.difffilter",
-			       &s->interactive_diff_filter);
-
-	FREE_AND_NULL(s->interactive_diff_algorithm);
-	repo_config_get_string(r, "diff.algorithm",
-			       &s->interactive_diff_algorithm);
-
-	if (!repo_config_get_int(r, "diff.context", &s->context))
-		if (s->context < 0)
-			die(_("%s cannot be negative"), "diff.context");
-	if (!repo_config_get_int(r, "diff.interHunkContext", &s->interhunkcontext))
-		if (s->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "diff.interHunkContext");
-
-	repo_config_get_bool(r, "interactive.singlekey", &s->use_single_key);
-	if (s->use_single_key)
-		setbuf(stdin, NULL);
-
-	if (add_p_opt->context != -1) {
-		if (add_p_opt->context < 0)
-			die(_("%s cannot be negative"), "--unified");
-		s->context = add_p_opt->context;
-	}
-	if (add_p_opt->interhunkcontext != -1) {
-		if (add_p_opt->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "--inter-hunk-context");
-		s->interhunkcontext = add_p_opt->interhunkcontext;
-	}
+	interactive_config_init(&s->cfg, r, opts);
 }
 
 void clear_add_i_state(struct add_i_state *s)
 {
-	FREE_AND_NULL(s->interactive_diff_filter);
-	FREE_AND_NULL(s->interactive_diff_algorithm);
+	interactive_config_clear(&s->cfg);
 	memset(s, 0, sizeof(*s));
-	s->use_color_interactive = GIT_COLOR_UNKNOWN;
-	s->use_color_diff = GIT_COLOR_UNKNOWN;
+	interactive_config_clear(&s->cfg);
 }
 
 /*
@@ -286,7 +184,7 @@ static void list(struct add_i_state *s, struct string_list *list, int *selected,
 		return;
 
 	if (opts->header)
-		color_fprintf_ln(stdout, s->header_color,
+		color_fprintf_ln(stdout, s->cfg.header_color,
 				 "%s", opts->header);
 
 	for (i = 0; i < list->nr; i++) {
@@ -354,7 +252,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 		list(s, &items->items, items->selected, &opts->list_opts);
 
-		color_fprintf(stdout, s->prompt_color, "%s", opts->prompt);
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", opts->prompt);
 		fputs(singleton ? "> " : ">> ", stdout);
 		fflush(stdout);
 
@@ -432,7 +330,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 			if (from < 0 || from >= items->items.nr ||
 			    (singleton && from + 1 != to)) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("Huh (%s)?"), p);
 				break;
 			} else if (singleton) {
@@ -992,7 +890,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 				free(files->items.items[i].string);
 			} else if (item->index.unmerged ||
 				 item->worktree.unmerged) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("ignoring unmerged: %s"),
 						 files->items.items[i].string);
 				free(item);
@@ -1014,9 +912,9 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 	opts->prompt = N_("Patch update");
 	count = list_and_choose(s, files, opts);
 	if (count > 0) {
-		struct add_p_opt add_p_opt = {
-			.context = s->context,
-			.interhunkcontext = s->interhunkcontext,
+		struct interactive_options opts = {
+			.context = s->cfg.context,
+			.interhunkcontext = s->cfg.interhunkcontext,
 		};
 		struct strvec args = STRVEC_INIT;
 		struct pathspec ps_selected = { 0 };
@@ -1028,7 +926,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 		parse_pathspec(&ps_selected,
 			       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,
 			       PATHSPEC_LITERAL_PATH, "", args.v);
-		res = run_add_p(s->r, ADD_P_ADD, &add_p_opt, NULL, &ps_selected);
+		res = run_add_p(s->r, ADD_P_ADD, &opts, NULL, &ps_selected);
 		strvec_clear(&args);
 		clear_pathspec(&ps_selected);
 	}
@@ -1064,10 +962,10 @@ static int run_diff(struct add_i_state *s, const struct pathspec *ps,
 		struct child_process cmd = CHILD_PROCESS_INIT;
 
 		strvec_pushl(&cmd.args, "git", "diff", "-p", "--cached", NULL);
-		if (s->context != -1)
-			strvec_pushf(&cmd.args, "--unified=%i", s->context);
-		if (s->interhunkcontext != -1)
-			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->interhunkcontext);
+		if (s->cfg.context != -1)
+			strvec_pushf(&cmd.args, "--unified=%i", s->cfg.context);
+		if (s->cfg.interhunkcontext != -1)
+			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
 		strvec_pushl(&cmd.args, oid_to_hex(!is_initial ? &oid :
 			     s->r->hash_algo->empty_tree), "--", NULL);
 		for (i = 0; i < files->items.nr; i++)
@@ -1085,17 +983,17 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 		    struct prefix_item_list *files UNUSED,
 		    struct list_and_choose_options *opts UNUSED)
 {
-	color_fprintf_ln(stdout, s->help_color, "status        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "status        - %s",
 			 _("show paths with changes"));
-	color_fprintf_ln(stdout, s->help_color, "update        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "update        - %s",
 			 _("add working tree state to the staged set of changes"));
-	color_fprintf_ln(stdout, s->help_color, "revert        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "revert        - %s",
 			 _("revert staged set of changes back to the HEAD version"));
-	color_fprintf_ln(stdout, s->help_color, "patch         - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "patch         - %s",
 			 _("pick hunks and update selectively"));
-	color_fprintf_ln(stdout, s->help_color, "diff          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "diff          - %s",
 			 _("view diff between HEAD and index"));
-	color_fprintf_ln(stdout, s->help_color, "add untracked - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "add untracked - %s",
 			 _("add contents of untracked files to the staged set of changes"));
 
 	return 0;
@@ -1103,21 +1001,21 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 
 static void choose_prompt_help(struct add_i_state *s)
 {
-	color_fprintf_ln(stdout, s->help_color, "%s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "%s",
 			 _("Prompt help:"));
-	color_fprintf_ln(stdout, s->help_color, "1          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "1          - %s",
 			 _("select a single item"));
-	color_fprintf_ln(stdout, s->help_color, "3-5        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "3-5        - %s",
 			 _("select a range of items"));
-	color_fprintf_ln(stdout, s->help_color, "2-3,6-9    - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "2-3,6-9    - %s",
 			 _("select multiple ranges"));
-	color_fprintf_ln(stdout, s->help_color, "foo        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "foo        - %s",
 			 _("select item based on unique prefix"));
-	color_fprintf_ln(stdout, s->help_color, "-...       - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "-...       - %s",
 			 _("unselect specified items"));
-	color_fprintf_ln(stdout, s->help_color, "*          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "*          - %s",
 			 _("choose all items"));
-	color_fprintf_ln(stdout, s->help_color, "           - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "           - %s",
 			 _("(empty) finish selecting"));
 }
 
@@ -1152,7 +1050,7 @@ static void print_command_item(int i, int selected UNUSED,
 
 static void command_prompt_help(struct add_i_state *s)
 {
-	const char *help_color = s->help_color;
+	const char *help_color = s->cfg.help_color;
 	color_fprintf_ln(stdout, help_color, "%s", _("Prompt help:"));
 	color_fprintf_ln(stdout, help_color, "1          - %s",
 			 _("select a numbered item"));
@@ -1163,7 +1061,7 @@ static void command_prompt_help(struct add_i_state *s)
 }
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt)
+	      struct interactive_options *interactive_opts)
 {
 	struct add_i_state s = { NULL };
 	struct print_command_item_data data = { "[", "]" };
@@ -1206,15 +1104,15 @@ int run_add_i(struct repository *r, const struct pathspec *ps,
 			->util = util;
 	}
 
-	init_add_i_state(&s, r, add_p_opt);
+	init_add_i_state(&s, r, interactive_opts);
 
 	/*
 	 * When color was asked for, use the prompt color for
 	 * highlighting, otherwise use square brackets.
 	 */
-	if (want_color(s.use_color_interactive)) {
-		data.color = s.prompt_color;
-		data.reset = s.reset_color_interactive;
+	if (want_color(s.cfg.use_color_interactive)) {
+		data.color = s.cfg.prompt_color;
+		data.reset = s.cfg.reset_color_interactive;
 	}
 	print_file_item_data.color = data.color;
 	print_file_item_data.reset = data.reset;
diff --git a/add-interactive.h b/add-interactive.h
index 2e3d1d871d2..eefa2edc7c1 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -2,37 +2,20 @@
 #define ADD_INTERACTIVE_H
 
 #include "add-patch.h"
-#include "color.h"
 
 struct pathspec;
 struct repository;
 
 struct add_i_state {
 	struct repository *r;
-	enum git_colorbool use_color_interactive;
-	enum git_colorbool use_color_diff;
-	char header_color[COLOR_MAXLEN];
-	char help_color[COLOR_MAXLEN];
-	char prompt_color[COLOR_MAXLEN];
-	char error_color[COLOR_MAXLEN];
-	char reset_color_interactive[COLOR_MAXLEN];
-
-	char fraginfo_color[COLOR_MAXLEN];
-	char context_color[COLOR_MAXLEN];
-	char file_old_color[COLOR_MAXLEN];
-	char file_new_color[COLOR_MAXLEN];
-	char reset_color_diff[COLOR_MAXLEN];
-
-	int use_single_key;
-	char *interactive_diff_filter, *interactive_diff_algorithm;
-	int context, interhunkcontext;
+	struct interactive_config cfg;
 };
 
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt);
+		      struct interactive_options *opts);
 void clear_add_i_state(struct add_i_state *s);
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt);
+	      struct interactive_options *opts);
 
 #endif
diff --git a/add-patch.c b/add-patch.c
index 3bb7bcf3d26..1c625a7d7a7 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -5,6 +5,8 @@
 #include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "config.h"
+#include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
@@ -279,6 +281,122 @@ struct add_p_state {
 	const char *revision;
 };
 
+static void init_color(struct repository *r,
+		       enum git_colorbool use_color,
+		       const char *section_and_slot, char *dst,
+		       const char *default_color)
+{
+	char *key = xstrfmt("color.%s", section_and_slot);
+	const char *value;
+
+	if (!want_color(use_color))
+		dst[0] = '\0';
+	else if (repo_config_get_value(r, key, &value) ||
+		 color_parse(value, dst))
+		strlcpy(dst, default_color, COLOR_MAXLEN);
+
+	free(key);
+}
+
+static enum git_colorbool check_color_config(struct repository *r, const char *var)
+{
+	const char *value;
+	enum git_colorbool ret;
+
+	if (repo_config_get_value(r, var, &value))
+		ret = GIT_COLOR_UNKNOWN;
+	else
+		ret = git_config_colorbool(var, value);
+
+	/*
+	 * Do not rely on want_color() to fall back to color.ui for us. It uses
+	 * the value parsed by git_color_config(), which may not have been
+	 * called by the main command.
+	 */
+	if (ret == GIT_COLOR_UNKNOWN &&
+	    !repo_config_get_value(r, "color.ui", &value))
+		ret = git_config_colorbool("color.ui", value);
+
+	return ret;
+}
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts)
+{
+	cfg->context = -1;
+	cfg->interhunkcontext = -1;
+
+	cfg->use_color_interactive = check_color_config(r, "color.interactive");
+
+	init_color(r, cfg->use_color_interactive, "interactive.header",
+		   cfg->header_color, GIT_COLOR_BOLD);
+	init_color(r, cfg->use_color_interactive, "interactive.help",
+		   cfg->help_color, GIT_COLOR_BOLD_RED);
+	init_color(r, cfg->use_color_interactive, "interactive.prompt",
+		   cfg->prompt_color, GIT_COLOR_BOLD_BLUE);
+	init_color(r, cfg->use_color_interactive, "interactive.error",
+		   cfg->error_color, GIT_COLOR_BOLD_RED);
+	strlcpy(cfg->reset_color_interactive,
+		want_color(cfg->use_color_interactive) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
+
+	cfg->use_color_diff = check_color_config(r, "color.diff");
+
+	init_color(r, cfg->use_color_diff, "diff.frag", cfg->fraginfo_color,
+		   diff_get_color(cfg->use_color_diff, DIFF_FRAGINFO));
+	init_color(r, cfg->use_color_diff, "diff.context", cfg->context_color,
+		   "fall back");
+	if (!strcmp(cfg->context_color, "fall back"))
+		init_color(r, cfg->use_color_diff, "diff.plain",
+			   cfg->context_color,
+			   diff_get_color(cfg->use_color_diff, DIFF_CONTEXT));
+	init_color(r, cfg->use_color_diff, "diff.old", cfg->file_old_color,
+		diff_get_color(cfg->use_color_diff, DIFF_FILE_OLD));
+	init_color(r, cfg->use_color_diff, "diff.new", cfg->file_new_color,
+		diff_get_color(cfg->use_color_diff, DIFF_FILE_NEW));
+	strlcpy(cfg->reset_color_diff,
+		want_color(cfg->use_color_diff) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
+
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	repo_config_get_string(r, "interactive.difffilter",
+			       &cfg->interactive_diff_filter);
+
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	repo_config_get_string(r, "diff.algorithm",
+			       &cfg->interactive_diff_algorithm);
+
+	if (!repo_config_get_int(r, "diff.context", &cfg->context))
+		if (cfg->context < 0)
+			die(_("%s cannot be negative"), "diff.context");
+	if (!repo_config_get_int(r, "diff.interHunkContext", &cfg->interhunkcontext))
+		if (cfg->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "diff.interHunkContext");
+
+	repo_config_get_bool(r, "interactive.singlekey", &cfg->use_single_key);
+	if (cfg->use_single_key)
+		setbuf(stdin, NULL);
+
+	if (opts->context != -1) {
+		if (opts->context < 0)
+			die(_("%s cannot be negative"), "--unified");
+		cfg->context = opts->context;
+	}
+	if (opts->interhunkcontext != -1) {
+		if (opts->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "--inter-hunk-context");
+		cfg->interhunkcontext = opts->interhunkcontext;
+	}
+}
+
+void interactive_config_clear(struct interactive_config *cfg)
+{
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	memset(cfg, 0, sizeof(*cfg));
+	cfg->use_color_interactive = GIT_COLOR_UNKNOWN;
+	cfg->use_color_diff = GIT_COLOR_UNKNOWN;
+}
+
 static void add_p_state_clear(struct add_p_state *s)
 {
 	size_t i;
@@ -299,9 +417,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.error_color, stdout);
+	fputs(s->s.cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.reset_color_interactive);
+	puts(s->s.cfg.reset_color_interactive);
 	va_end(args);
 }
 
@@ -424,12 +542,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.context);
-	if (s->s.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.interhunkcontext);
-	if (s->s.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.interactive_diff_algorithm);
+	if (s->s.cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
+	if (s->s.cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
+	if (s->s.cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -458,9 +576,9 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	}
 	strbuf_complete_line(plain);
 
-	if (want_color_fd(1, s->s.use_color_diff)) {
+	if (want_color_fd(1, s->s.cfg.use_color_diff)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.interactive_diff_filter;
+		const char *diff_filter = s->s.cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -693,7 +811,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.fraginfo_color);
+			strbuf_addstr(out, s->s.cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -715,7 +833,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.reset_color_diff);
+			strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1104,12 +1222,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.file_old_color :
+			      s->s.cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.file_new_color :
-			      s->s.context_color);
+			      s->s.cfg.file_new_color :
+			      s->s.cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.reset_color_diff);
+		strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1238,7 +1356,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.use_single_key) {
+	if (s->s.cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1252,7 +1370,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1559,15 +1677,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.reset_color_interactive)
-			fputs(s->s.reset_color_interactive, stdout);
+		if (*s->s.cfg.reset_color_interactive)
+			fputs(s->s.cfg.reset_color_interactive, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			break;
@@ -1729,7 +1847,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.header_color,
+				color_fprintf_ln(stdout, s->s.cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1747,7 +1865,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.help_color, "%s",
+			color_fprintf(stdout, s->s.cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1765,7 +1883,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.help_color,
+				color_fprintf_ln(stdout, s->s.cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1806,7 +1924,7 @@ static int patch_update_file(struct add_p_state *s,
 }
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
@@ -1814,7 +1932,7 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, o);
+	init_add_i_state(&s.s, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
diff --git a/add-patch.h b/add-patch.h
index 4394c741076..a4a05d9d145 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -1,15 +1,45 @@
 #ifndef ADD_PATCH_H
 #define ADD_PATCH_H
 
+#include "color.h"
+
 struct pathspec;
 struct repository;
 
-struct add_p_opt {
+struct interactive_options {
 	int context;
 	int interhunkcontext;
 };
 
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+#define INTERACTIVE_OPTIONS_INIT { \
+	.context = -1, \
+	.interhunkcontext = -1, \
+}
+
+struct interactive_config {
+	enum git_colorbool use_color_interactive;
+	enum git_colorbool use_color_diff;
+	char header_color[COLOR_MAXLEN];
+	char help_color[COLOR_MAXLEN];
+	char prompt_color[COLOR_MAXLEN];
+	char error_color[COLOR_MAXLEN];
+	char reset_color_interactive[COLOR_MAXLEN];
+
+	char fraginfo_color[COLOR_MAXLEN];
+	char context_color[COLOR_MAXLEN];
+	char file_old_color[COLOR_MAXLEN];
+	char file_new_color[COLOR_MAXLEN];
+	char reset_color_diff[COLOR_MAXLEN];
+
+	int use_single_key;
+	char *interactive_diff_filter, *interactive_diff_algorithm;
+	int context, interhunkcontext;
+};
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts);
+void interactive_config_clear(struct interactive_config *cfg);
 
 enum add_p_mode {
 	ADD_P_ADD,
@@ -20,7 +50,7 @@ enum add_p_mode {
 };
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
 #endif
diff --git a/builtin/add.c b/builtin/add.c
index 32709794b38..6f1e2130528 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -31,7 +31,7 @@ static const char * const builtin_add_usage[] = {
 	NULL
 };
 static int patch_interactive, add_interactive, edit_interactive;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int take_worktree_changes;
 static int add_renormalize;
 static int pathspec_file_nul;
@@ -160,7 +160,7 @@ static int refresh(struct repository *repo, int verbose, const struct pathspec *
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt)
+		    int patch, struct interactive_options *interactive_opts)
 {
 	struct pathspec pathspec;
 	int ret;
@@ -172,9 +172,9 @@ int interactive_add(struct repository *repo,
 		       prefix, argv);
 
 	if (patch)
-		ret = !!run_add_p(repo, ADD_P_ADD, add_p_opt, NULL, &pathspec);
+		ret = !!run_add_p(repo, ADD_P_ADD, interactive_opts, NULL, &pathspec);
 	else
-		ret = !!run_add_i(repo, &pathspec, add_p_opt);
+		ret = !!run_add_i(repo, &pathspec, interactive_opts);
 
 	clear_pathspec(&pathspec);
 	return ret;
@@ -256,8 +256,8 @@ static struct option builtin_add_options[] = {
 	OPT_GROUP(""),
 	OPT_BOOL('i', "interactive", &add_interactive, N_("interactive picking")),
 	OPT_BOOL('p', "patch", &patch_interactive, N_("select hunks interactively")),
-	OPT_DIFF_UNIFIED(&add_p_opt.context),
-	OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+	OPT_DIFF_UNIFIED(&interactive_opts.context),
+	OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 	OPT_BOOL('e', "edit", &edit_interactive, N_("edit current diff and apply")),
 	OPT__FORCE(&ignored_too, N_("allow adding otherwise ignored files"), 0),
 	OPT_BOOL('u', "update", &take_worktree_changes, N_("update tracked files")),
@@ -400,9 +400,9 @@ int cmd_add(int argc,
 	prepare_repo_settings(repo);
 	repo->settings.command_requires_full_index = 0;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (patch_interactive)
@@ -412,11 +412,11 @@ int cmd_add(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--dry-run", "--interactive/--patch");
 		if (pathspec_from_file)
 			die(_("options '%s' and '%s' cannot be used together"), "--pathspec-from-file", "--interactive/--patch");
-		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &add_p_opt));
+		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &interactive_opts));
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
diff --git a/builtin/checkout.c b/builtin/checkout.c
index f9453473fe2..d230b1f8995 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -546,7 +546,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 
 	if (opts->patch_mode) {
 		enum add_p_mode patch_mode;
-		struct add_p_opt add_p_opt = {
+		struct interactive_options interactive_opts = {
 			.context = opts->patch_context,
 			.interhunkcontext = opts->patch_interhunk_context,
 		};
@@ -575,7 +575,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 		else
 			BUG("either flag must have been set, worktree=%d, index=%d",
 			    opts->checkout_worktree, opts->checkout_index);
-		return !!run_add_p(the_repository, patch_mode, &add_p_opt,
+		return !!run_add_p(the_repository, patch_mode, &interactive_opts,
 				   rev, &opts->pathspec);
 	}
 
diff --git a/builtin/commit.c b/builtin/commit.c
index 0243f17d53c..640495cc57e 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -123,7 +123,7 @@ static const char *edit_message, *use_message;
 static char *fixup_message, *fixup_commit, *squash_message;
 static const char *fixup_prefix;
 static int all, also, interactive, patch_interactive, only, amend, signoff;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int edit_flag = -1; /* unspecified */
 static int quiet, verbose, no_verify, allow_empty, dry_run, renew_authorship;
 static int config_commit_verbose = -1; /* unspecified */
@@ -356,9 +356,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 	const char *ret;
 	char *path = NULL;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (is_status)
@@ -407,7 +407,7 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 		setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-		if (interactive_add(the_repository, argv, prefix, patch_interactive, &add_p_opt) != 0)
+		if (interactive_add(the_repository, argv, prefix, patch_interactive, &interactive_opts) != 0)
 			die(_("interactive add failed"));
 
 		the_repository->index_file = old_repo_index_file;
@@ -432,9 +432,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		ret = get_lock_file_path(&index_lock);
 		goto out;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
@@ -1742,8 +1742,8 @@ int cmd_commit(int argc,
 		OPT_BOOL('i', "include", &also, N_("add specified files to index for commit")),
 		OPT_BOOL(0, "interactive", &interactive, N_("interactively add files")),
 		OPT_BOOL('p', "patch", &patch_interactive, N_("interactively add changes")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('o', "only", &only, N_("commit only specified files")),
 		OPT_BOOL('n', "no-verify", &no_verify, N_("bypass pre-commit and commit-msg hooks")),
 		OPT_BOOL(0, "dry-run", &dry_run, N_("show what would be committed")),
diff --git a/builtin/reset.c b/builtin/reset.c
index ed35802af15..088449e1209 100644
--- a/builtin/reset.c
+++ b/builtin/reset.c
@@ -346,7 +346,7 @@ int cmd_reset(int argc,
 	struct object_id oid;
 	struct pathspec pathspec;
 	int intent_to_add = 0;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	const struct option options[] = {
 		OPT__QUIET(&quiet, N_("be quiet, only report errors")),
 		OPT_BOOL(0, "no-refresh", &no_refresh,
@@ -371,8 +371,8 @@ int cmd_reset(int argc,
 			       PARSE_OPT_OPTARG,
 			       option_parse_recurse_submodules_worktree_updater),
 		OPT_BOOL('p', "patch", &patch_mode, N_("select hunks interactively")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('N', "intent-to-add", &intent_to_add,
 				N_("record only the fact that removed paths will be added later")),
 		OPT_PATHSPEC_FROM_FILE(&pathspec_from_file),
@@ -423,9 +423,9 @@ int cmd_reset(int argc,
 		oidcpy(&oid, &tree->object.oid);
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	prepare_repo_settings(the_repository);
@@ -436,12 +436,12 @@ int cmd_reset(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--patch", "--{hard,mixed,soft}");
 		trace2_cmd_mode("patch-interactive");
 		update_ref_status = !!run_add_p(the_repository, ADD_P_RESET,
-						&add_p_opt, rev, &pathspec);
+						&interactive_opts, rev, &pathspec);
 		goto cleanup;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
diff --git a/builtin/stash.c b/builtin/stash.c
index 948eba06fbc..3b509052338 100644
--- a/builtin/stash.c
+++ b/builtin/stash.c
@@ -1306,7 +1306,7 @@ static int stash_staged(struct stash_info *info, struct strbuf *out_patch,
 
 static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 		       struct strbuf *out_patch, int quiet,
-		       struct add_p_opt *add_p_opt)
+		       struct interactive_options *interactive_opts)
 {
 	int ret = 0;
 	struct child_process cp_read_tree = CHILD_PROCESS_INIT;
@@ -1331,7 +1331,7 @@ static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 	old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 	setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-	ret = !!run_add_p(the_repository, ADD_P_STASH, add_p_opt, NULL, ps);
+	ret = !!run_add_p(the_repository, ADD_P_STASH, interactive_opts, NULL, ps);
 
 	the_repository->index_file = old_repo_index_file;
 	if (old_index_env && *old_index_env)
@@ -1427,7 +1427,8 @@ static int stash_working_tree(struct stash_info *info, const struct pathspec *ps
 }
 
 static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_buf,
-			   int include_untracked, int patch_mode, struct add_p_opt *add_p_opt,
+			   int include_untracked, int patch_mode,
+			   struct interactive_options *interactive_opts,
 			   int only_staged, struct stash_info *info, struct strbuf *patch,
 			   int quiet)
 {
@@ -1509,7 +1510,7 @@ static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_b
 		untracked_commit_option = 1;
 	}
 	if (patch_mode) {
-		ret = stash_patch(info, ps, patch, quiet, add_p_opt);
+		ret = stash_patch(info, ps, patch, quiet, interactive_opts);
 		if (ret < 0) {
 			if (!quiet)
 				fprintf_ln(stderr, _("Cannot save the current "
@@ -1595,7 +1596,8 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED,
 }
 
 static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int quiet,
-			 int keep_index, int patch_mode, struct add_p_opt *add_p_opt,
+			 int keep_index, int patch_mode,
+			 struct interactive_options *interactive_opts,
 			 int include_untracked, int only_staged)
 {
 	int ret = 0;
@@ -1667,7 +1669,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q
 	if (stash_msg)
 		strbuf_addstr(&stash_msg_buf, stash_msg);
 	if (do_create_stash(ps, &stash_msg_buf, include_untracked, patch_mode,
-			    add_p_opt, only_staged, &info, &patch, quiet)) {
+			    interactive_opts, only_staged, &info, &patch, quiet)) {
 		ret = -1;
 		goto done;
 	}
@@ -1841,7 +1843,7 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	char *pathspec_from_file = NULL;
 	struct pathspec ps;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1849,8 +1851,8 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1907,19 +1909,19 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	}
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index, patch_mode,
-			    &add_p_opt, include_untracked, only_staged);
+			    &interactive_opts, include_untracked, only_staged);
 
 	clear_pathspec(&ps);
 	free(pathspec_from_file);
@@ -1944,7 +1946,7 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	struct pathspec ps;
 	struct strbuf stash_msg_buf = STRBUF_INIT;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1952,8 +1954,8 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1973,20 +1975,20 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 
 	memset(&ps, 0, sizeof(ps));
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index,
-			    patch_mode, &add_p_opt, include_untracked,
+			    patch_mode, &interactive_opts, include_untracked,
 			    only_staged);
 
 	strbuf_release(&stash_msg_buf);
diff --git a/commit.h b/commit.h
index 1d6e0c7518b..7b6e59d6c19 100644
--- a/commit.h
+++ b/commit.h
@@ -258,7 +258,7 @@ int for_each_commit_graft(each_commit_graft_fn, void *);
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt);
+		    int patch, struct interactive_options *opts);
 
 struct commit_extra_header {
 	struct commit_extra_header *next;
-- 
2.51.1.851.g4ebd6896fd.dirty
Patrick Steinhardt· Oct 21, 2025, 14:15 UTC · re: Patrick Steinhardt · lore

[PATCH v5 09/12] add-patch: remove dependency on "add-interactive" subsystem

With the preceding commit we have split out interactive configuration that is used by both "git add -p" and "git add -i". But we still initialize that configuration in the "add -p" subsystem by calling `init_add_i_state()`, even though we only do so to initialize the interactive configuration as well as a repository pointer.

Stop doing so and instead store and initialize the interactive configuration in `struct add_p_state` directly.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 70 ++++++++++++++++++++++++++++++++-----------------------------
 1 file changed, 37 insertions(+), 33 deletions(-)
Show changes to add-patch.c +37 −33
diff --git a/add-patch.c b/add-patch.c
index 1c625a7d7a7..362726c962d 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -2,7 +2,6 @@
 #define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
-#include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
 #include "config.h"
@@ -263,7 +262,8 @@ struct hunk {
 };
 
 struct add_p_state {
-	struct add_i_state s;
+	struct repository *r;
+	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
 	/* parsed diff */
@@ -408,7 +408,7 @@ static void add_p_state_clear(struct add_p_state *s)
 	for (i = 0; i < s->file_diff_nr; i++)
 		free(s->file_diff[i].hunk);
 	free(s->file_diff);
-	clear_add_i_state(&s->s);
+	interactive_config_clear(&s->cfg);
 }
 
 __attribute__((format (printf, 2, 3)))
@@ -417,9 +417,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.cfg.error_color, stdout);
+	fputs(s->cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.cfg.reset_color_interactive);
+	puts(s->cfg.reset_color_interactive);
 	va_end(args);
 }
 
@@ -437,7 +437,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->s.r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
 }
 
 static int parse_range(const char **p,
@@ -542,12 +542,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.cfg.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
-	if (s->s.cfg.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
-	if (s->s.cfg.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
+	if (s->cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->cfg.context);
+	if (s->cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
+	if (s->cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -576,9 +576,9 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	}
 	strbuf_complete_line(plain);
 
-	if (want_color_fd(1, s->s.cfg.use_color_diff)) {
+	if (want_color_fd(1, s->cfg.use_color_diff)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.cfg.interactive_diff_filter;
+		const char *diff_filter = s->cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -811,7 +811,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.cfg.fraginfo_color);
+			strbuf_addstr(out, s->cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -833,7 +833,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
+			strbuf_addf(out, "%s\n", s->cfg.reset_color_diff);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1222,12 +1222,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.cfg.file_old_color :
+			      s->cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.cfg.file_new_color :
-			      s->s.cfg.context_color);
+			      s->cfg.file_new_color :
+			      s->cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
+		strbuf_addstr(&s->colored, s->cfg.reset_color_diff);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1356,7 +1356,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.cfg.use_single_key) {
+	if (s->cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1370,7 +1370,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1677,15 +1677,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.cfg.reset_color_interactive)
-			fputs(s->s.cfg.reset_color_interactive, stdout);
+		if (*s->cfg.reset_color_interactive)
+			fputs(s->cfg.reset_color_interactive, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			break;
@@ -1847,7 +1847,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.cfg.header_color,
+				color_fprintf_ln(stdout, s->cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1865,7 +1865,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.cfg.help_color, "%s",
+			color_fprintf(stdout, s->cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1883,7 +1883,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.cfg.help_color,
+				color_fprintf_ln(stdout, s->cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1903,7 +1903,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->s.r->index);
+		discard_index(s->r->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1914,8 +1914,8 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->s.r) >= 0)
-			repo_refresh_and_write_index(s->s.r, REFRESH_QUIET, 0,
+		if (repo_read_index(s->r) >= 0)
+			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
 	}
 
@@ -1928,11 +1928,15 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
-		{ r }, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT
+		.r = r,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, opts);
+	interactive_config_init(&s.cfg, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
-- 
2.51.1.851.g4ebd6896fd.dirty
Patrick Steinhardt· Oct 21, 2025, 14:15 UTC · re: Patrick Steinhardt · lore

[PATCH v5 10/12] add-patch: add support for in-memory index patching

With `run_add_p()` callers have the ability to apply changes from a specific revision to a repository's index. This infra supports several different modes, like for example applying changes to the index, working tree or both.

One feature that is missing though is the ability to apply changes to an in-memory index different from the repository's index. Add a new function `run_add_p_index()` to plug this gap.

This new function will be used in a subsequent commit.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 add-patch.h |   8 +++++
 2 files changed, 116 insertions(+), 4 deletions(-)
Show changes to 2 files +116 −4

add-patch.c, add-patch.h

diff --git a/add-patch.c b/add-patch.c
index 362726c962d..b8d46d54a2e 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -4,11 +4,13 @@
 #include "git-compat-util.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "commit.h"
 #include "config.h"
 #include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "object-name.h"
 #include "pager.h"
 #include "read-cache-ll.h"
@@ -47,7 +49,7 @@ static struct patch_mode patch_mode_add = {
 		N_("Stage mode change [y,n,q,a,d%s,?]? "),
 		N_("Stage deletion [y,n,q,a,d%s,?]? "),
 		N_("Stage addition [y,n,q,a,d%s,?]? "),
-		N_("Stage this hunk [y,n,q,a,d%s,?]? ")
+		N_("Stage this hunk [y,n,q,a,d%s,?]? "),
 	},
 	.edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
 			     "will immediately be marked for staging."),
@@ -263,6 +265,8 @@ struct hunk {
 
 struct add_p_state {
 	struct repository *r;
+	struct index_state *index;
+	const char *index_file;
 	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
@@ -437,7 +441,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->index_file);
 }
 
 static int parse_range(const char **p,
@@ -1903,7 +1907,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->r->index);
+		discard_index(s->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1914,9 +1918,11 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->r) >= 0)
+		if (read_index_from(s->index, s->index_file, s->r->gitdir) >= 0 &&
+		    s->index == s->r->index) {
 			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
+		}
 	}
 
 	putchar('\n');
@@ -1929,6 +1935,8 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 {
 	struct add_p_state s = {
 		.r = r,
+		.index = r->index,
+		.index_file = r->index_file,
 		.answer = STRBUF_INIT,
 		.buf = STRBUF_INIT,
 		.plain = STRBUF_INIT,
@@ -1987,3 +1995,99 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	add_p_state_clear(&s);
 	return 0;
 }
+
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps)
+{
+	struct patch_mode mode = {
+		.apply_args = { "--cached", NULL },
+		.apply_check_args = { "--cached", NULL },
+		.prompt_mode = {
+			N_("Stage mode change [y,n,q,a,d%s,?]? "),
+			N_("Stage deletion [y,n,q,a,d%s,?]? "),
+			N_("Stage addition [y,n,q,a,d%s,?]? "),
+			N_("Stage this hunk [y,n,q,a,d%s,?]? ")
+		},
+		.edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
+				     "will immediately be marked for staging."),
+		.help_patch_text =
+			N_("y - stage this hunk\n"
+			   "n - do not stage this hunk\n"
+			   "q - quit; do not stage this hunk or any of the remaining "
+				"ones\n"
+			   "a - stage this hunk and all later hunks in the file\n"
+			   "d - do not stage this hunk or any of the later hunks in "
+				"the file\n"),
+		.index_only = 1,
+	};
+	struct add_p_state s = {
+		.r = r,
+		.index = index,
+		.index_file = index_file,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
+		.mode = &mode,
+		.revision = revision,
+	};
+	struct strbuf parent_revision = STRBUF_INIT;
+	char parent_tree_oid[GIT_MAX_HEXSZ + 1];
+	size_t binary_count = 0;
+	struct commit *commit;
+	int ret;
+
+	commit = lookup_commit_reference_by_name(revision);
+	if (!commit) {
+		err(&s, _("Revision does not refer to a commit"));
+		ret = -1;
+		goto out;
+	}
+
+	if (commit->parents)
+		oid_to_hex_r(parent_tree_oid, get_commit_tree_oid(commit->parents->item));
+	else
+		oid_to_hex_r(parent_tree_oid, r->hash_algo->empty_tree);
+
+	strbuf_addf(&parent_revision, "%s~", revision);
+	mode.diff_cmd[0] = "diff-tree";
+	mode.diff_cmd[1] = "-r";
+	mode.diff_cmd[2] = parent_tree_oid;
+
+	interactive_config_init(&s.cfg, r, opts);
+
+	if (parse_diff(&s, ps) < 0) {
+		ret = -1;
+		goto out;
+	}
+
+	for (size_t i = 0; i < s.file_diff_nr; i++) {
+		if (s.file_diff[i].binary && !s.file_diff[i].hunk_nr)
+			binary_count++;
+		else if (patch_update_file(&s, s.file_diff + i))
+			break;
+	}
+
+	if (s.file_diff_nr == 0) {
+		err(&s, _("No changes."));
+		ret = -1;
+		goto out;
+	}
+
+	if (binary_count == s.file_diff_nr) {
+		err(&s, _("Only binary files changed."));
+		ret = -1;
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	strbuf_release(&parent_revision);
+	add_p_state_clear(&s);
+	return ret;
+}
diff --git a/add-patch.h b/add-patch.h
index a4a05d9d145..901c42fd7b6 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -3,6 +3,7 @@
 
 #include "color.h"
 
+struct index_state;
 struct pathspec;
 struct repository;
 
@@ -53,4 +54,11 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps);
+
 #endif
-- 
2.51.1.851.g4ebd6896fd.dirty
Patrick Steinhardt· Oct 21, 2025, 14:15 UTC · re: Patrick Steinhardt · lore

[PATCH v5 11/12] cache-tree: allow writing in-memory index as tree

The function `write_in_core_index_as_tree()` takes a repository and writes its index into a tree object. What this function cannot do though is to take an _arbitrary_ in-memory index.

Introduce a new `struct index_state` parameter so that the caller can pass a different index than the one belonging to the repository. This will be used in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/checkout.c | 3 ++-
 cache-tree.c       | 5 ++---
 cache-tree.h       | 3 ++-
 3 files changed, 6 insertions(+), 5 deletions(-)
Show changes to 3 files +6 −5

builtin/checkout.c, cache-tree.c, cache-tree.h

diff --git a/builtin/checkout.c b/builtin/checkout.c
index d230b1f8995..0b90f398feb 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -902,7 +902,8 @@ static int merge_working_tree(const struct checkout_opts *opts,
 					   0);
 			init_ui_merge_options(&o, the_repository);
 			o.verbosity = 0;
-			work = write_in_core_index_as_tree(the_repository);
+			work = write_in_core_index_as_tree(the_repository,
+							   the_repository->index);
 
 			ret = reset_tree(new_tree,
 					 opts, 1,
diff --git a/cache-tree.c b/cache-tree.c
index 2aba47060e9..b67d0d703d2 100644
--- a/cache-tree.c
+++ b/cache-tree.c
@@ -699,11 +699,11 @@ static int write_index_as_tree_internal(struct object_id *oid,
 	return 0;
 }
 
-struct tree* write_in_core_index_as_tree(struct repository *repo) {
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state) {
 	struct object_id o;
 	int was_valid, ret;
 
-	struct index_state *index_state	= repo->index;
 	was_valid = index_state->cache_tree &&
 		    cache_tree_fully_valid(index_state->cache_tree);
 
@@ -723,7 +723,6 @@ struct tree* write_in_core_index_as_tree(struct repository *repo) {
 	return lookup_tree(repo, &index_state->cache_tree->oid);
 }
 
-
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix)
 {
 	int entries, was_valid;
diff --git a/cache-tree.h b/cache-tree.h
index b82c4963e7c..f8bddae5235 100644
--- a/cache-tree.h
+++ b/cache-tree.h
@@ -47,7 +47,8 @@ int cache_tree_verify(struct repository *, struct index_state *);
 #define WRITE_TREE_UNMERGED_INDEX (-2)
 #define WRITE_TREE_PREFIX_ERROR (-3)
 
-struct tree* write_in_core_index_as_tree(struct repository *repo);
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state);
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix);
 void prime_cache_tree(struct repository *, struct index_state *, struct tree *);
 
-- 
2.51.1.851.g4ebd6896fd.dirty
Patrick Steinhardt· Oct 21, 2025, 14:16 UTC · re: Patrick Steinhardt · lore

[PATCH v5 12/12] builtin/history: implement "split" subcommand

It is quite a common use case that one wants to split up one commit into multiple commits by moving parts of the changes of the original commit out into a separate commit. This is quite an involved operation though:

  1. Identify the commit in question that is to be dropped.
  2. Perform an interactive rebase on top of that commit's parent.
  3. Modify the instruction sheet to "edit" the commit that is to be
     split up.
  4. Drop the commit via "git reset HEAD~".
  5. Stage changes that should go into the first commit and commit it.
  6. Stage changes that should go into the second commit and commit it.
  7. Finalize the rebase.

This is quite complex, and overall I would claim that most people who are not experts in Git would struggle with this flow.

Introduce a new "split" subcommand for git-history(1) to make this way easier. All the user needs to do is to say `git history split $COMMIT`. From hereon, Git asks the user which parts of the commit shall be moved out into a separate commit and, once done, asks the user for the commit message. Git then creates that split-out commit and applies the original commit on top of it.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  62 ++++++
 builtin/history.c              | 222 ++++++++++++++++++++
 t/meson.build                  |   1 +
 t/t3452-history-split.sh       | 447 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 732 insertions(+)
Show changes to 4 files +730 −0

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3452-history-split.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 6f016801936..d741caf8347 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -10,6 +10,7 @@ SYNOPSIS
 [synopsis]
 git history [<options>]
 git history reword [<options>] <commit>
+git history split [<options>] <commit> [--] [<pathspec>...]
 
 DESCRIPTION
 -----------
@@ -40,6 +41,26 @@ rewrite history in different ways:
 	provided, then this command will spawn an editor with the current
 	message of that commit.
 
+`split [--message=<message>] <commit> [--] [<pathspec>...]`::
+	Interactively split up <commit> into two commits by choosing
+	hunks introduced by it that will be moved into the new split-out
+	commit. These hunks will then be written into a new commit that
+	becomes the parent of the previous commit. The original commit
+	stays intact, except that its parent will be the newly split-out
+	commit.
++
+The commit message of the new commit will be asked for by launching the
+configured editor, unless it has been specified with the `-m` option.
+Authorship of the commit will be the same as for the original commit.
++
+If passed, _<pathspec>_ can be used to limit which changes shall be split out
+of the original commit. Files not matching any of the pathspecs will remain
+part of the original commit. For more details, see the 'pathspec' entry in
+linkgit:gitglossary[7].
++
+It is invalid to select either all or no hunks, as that would lead to
+one of the commits becoming empty.
+
 CONFIGURATION
 -------------
 
@@ -47,6 +68,47 @@ include::includes/cmd-config-section-all.adoc[]
 
 include::config/sequencer.adoc[]
 
+EXAMPLES
+--------
+
+Split a commit
+~~~~~~~~~~~~~~
+
+----------
+$ git log --stat --oneline
+3f81232 (HEAD -> main) original
+ bar | 1 +
+ foo | 1 +
+ 2 files changed, 2 insertions(+)
+
+$ git history split HEAD --message="split-out commit"
+diff --git a/bar b/bar
+new file mode 100644
+index 0000000..5716ca5
+--- /dev/null
++++ b/bar
+@@ -0,0 +1 @@
++bar
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? y
+
+diff --git a/foo b/foo
+new file mode 100644
+index 0000000..257cc56
+--- /dev/null
++++ b/foo
+@@ -0,0 +1 @@
++foo
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? n
+
+$ git log --stat --oneline
+7cebe64 (HEAD -> main) original
+ foo | 1 +
+ 1 file changed, 1 insertion(+)
+d1582f3 split-out commit
+ bar | 1 +
+ 1 file changed, 1 insertion(+)
+----------
+
 GIT
 ---
 Part of the linkgit:git[1] suite
diff --git a/builtin/history.c b/builtin/history.c
index f14e88c9bf4..6ca4382c8e2 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,6 +1,7 @@
 #define USE_THE_REPOSITORY_VARIABLE
 
 #include "builtin.h"
+#include "cache-tree.h"
 #include "commit-reach.h"
 #include "commit.h"
 #include "config.h"
@@ -8,17 +9,22 @@
 #include "environment.h"
 #include "gettext.h"
 #include "hex.h"
+#include "oidmap.h"
 #include "parse-options.h"
+#include "path.h"
+#include "read-cache.h"
 #include "refs.h"
 #include "replay.h"
 #include "reset.h"
 #include "revision.h"
+#include "run-command.h"
 #include "sequencer.h"
 #include "strvec.h"
 #include "tree.h"
 #include "wt-status.h"
 
 #define GIT_HISTORY_REWORD_USAGE N_("git history reword [<options>] <commit>")
+#define GIT_HISTORY_SPLIT_USAGE  N_("git history split [<options>] <commit> [--] [<pathspec>...]")
 
 static int collect_commits(struct repository *repo,
 			   struct commit *old_commit,
@@ -357,6 +363,220 @@ static int cmd_history_reword(int argc,
 	return ret;
 }
 
+static int split_commit(struct repository *repo,
+			struct commit *original_commit,
+			struct pathspec *pathspec,
+			const char *commit_message,
+			struct object_id *out)
+{
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
+	struct strbuf index_file = STRBUF_INIT, split_message = STRBUF_INIT;
+	struct child_process read_tree_cmd = CHILD_PROCESS_INIT;
+	struct index_state index = INDEX_STATE_INIT(repo);
+	struct object_id original_commit_tree_oid, parent_tree_oid;
+	const char *original_message, *original_body, *ptr;
+	char original_commit_oid[GIT_MAX_HEXSZ + 1];
+	char *original_author = NULL;
+	struct commit_list *parents = NULL;
+	struct commit *first_commit;
+	struct tree *split_tree;
+	size_t len;
+	int ret;
+
+	if (original_commit->parents)
+		parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
+	else
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
+
+	/*
+	* Construct the first commit. This is done by taking the original
+	* commit parent's tree and selectively patching changes from the diff
+	* between that parent and its child.
+	*/
+	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
+
+	read_tree_cmd.git_cmd = 1;
+	strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
+	strvec_push(&read_tree_cmd.args, "read-tree");
+	strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
+	ret = run_command(&read_tree_cmd);
+	if (ret < 0)
+		goto out;
+
+	ret = read_index_from(&index, index_file.buf, repo->gitdir);
+	if (ret < 0) {
+		ret = error(_("failed reading temporary index"));
+		goto out;
+	}
+
+	oid_to_hex_r(original_commit_oid, &original_commit->object.oid);
+	ret = run_add_p_index(repo, &index, index_file.buf, &interactive_opts,
+			      original_commit_oid, pathspec);
+	if (ret < 0)
+		goto out;
+
+	split_tree = write_in_core_index_as_tree(repo, &index);
+	if (!split_tree) {
+		ret = error(_("failed split tree"));
+		goto out;
+	}
+
+	unlink(index_file.buf);
+
+	/*
+	* We disallow the cases where either the split-out commit or the
+	* original commit would become empty. Consequently, if we see that the
+	* new tree ID matches either of those trees we abort.
+	*/
+	if (oideq(&split_tree->object.oid, &parent_tree_oid)) {
+		ret = error(_("split commit is empty"));
+		goto out;
+	} else if (oideq(&split_tree->object.oid, &original_commit_tree_oid)) {
+		ret = error(_("split commit tree matches original commit"));
+		goto out;
+	}
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+
+	ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
+				  "", commit_message, "split-out", &split_message);
+	if (ret < 0)
+		goto out;
+
+	ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
+			  original_commit->parents, &out[0], original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing split-out commit"));
+		goto out;
+	}
+
+	/*
+	* The second commit is much simpler to construct, as we can simply use
+	* the original commit details, except that we adjust its parent to be
+	* the newly split-out commit.
+	*/
+	find_commit_subject(original_message, &original_body);
+	first_commit = lookup_commit_reference(repo, &out[0]);
+	commit_list_append(first_commit, &parents);
+
+	ret = commit_tree(original_body, strlen(original_body), &original_commit_tree_oid,
+			  parents, &out[1], original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing second commit"));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	if (index_file.len)
+		unlink(index_file.buf);
+	strbuf_release(&split_message);
+	strbuf_release(&index_file);
+	free_commit_list(parents);
+	free(original_author);
+	release_index(&index);
+	return ret;
+}
+
+static int cmd_history_split(int argc,
+			     const char **argv,
+			     const char *prefix,
+			     struct repository *repo)
+{
+	const char * const usage[] = {
+		GIT_HISTORY_SPLIT_USAGE,
+		NULL,
+	};
+	const char *commit_message = NULL;
+	struct option options[] = {
+		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
+		OPT_END(),
+	};
+	struct oidmap rewritten_commits = OIDMAP_INIT;
+	struct commit *original_commit, *parent, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct commit_list *from_list = NULL;
+	struct object_id split_commits[2];
+	struct pathspec pathspec = { 0 };
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc < 1) {
+		ret = error(_("command expects a revision"));
+		goto out;
+	}
+	repo_config(repo, git_default_config, NULL);
+
+	original_commit = lookup_commit_reference_by_name(argv[0]);
+	if (!original_commit) {
+		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
+		goto out;
+	}
+
+	parent = original_commit->parents ? original_commit->parents->item : NULL;
+	if (parent && repo_parse_commit(repo, parent)) {
+		ret = error(_("unable to parse commit %s"),
+			    oid_to_hex(&parent->object.oid));
+		goto out;
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	commit_list_append(original_commit, &from_list);
+	if (!repo_is_descendant_of(repo, head, from_list)) {
+		ret = error(_("split commit must be reachable from current HEAD commit"));
+		goto out;
+	}
+
+	parse_pathspec(&pathspec, 0,
+		PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
+		prefix, argv + 1);
+
+	/*
+	 * Collect the list of commits that we'll have to reapply now already.
+	 * This ensures that we'll abort early on in case the range of commits
+	 * contains merges, which we do not yet handle.
+	 */
+	ret = collect_commits(repo, parent, head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/*
+	 * Then we split up the commit and replace the original commit with the
+	 * new ones.
+	 */
+	ret = split_commit(repo, original_commit, &pathspec,
+			   commit_message, split_commits);
+	if (ret < 0)
+		goto out;
+
+	replace_commits(&commits, &original_commit->object.oid,
+			split_commits, ARRAY_SIZE(split_commits));
+
+	ret = apply_commits(repo, &commits, parent, head, "split");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	oidmap_clear(&rewritten_commits, 0);
+	free_commit_list(from_list);
+	clear_pathspec(&pathspec);
+	strvec_clear(&commits);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -365,11 +585,13 @@ int cmd_history(int argc,
 	const char * const usage[] = {
 		N_("git history [<options>]"),
 		GIT_HISTORY_REWORD_USAGE,
+		GIT_HISTORY_SPLIT_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
 		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 a3ec9199947..5d3014a768f 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -386,6 +386,7 @@ integration_tests = [
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
   't3451-history-reword.sh',
+  't3452-history-split.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh
new file mode 100755
index 00000000000..d9874c61f62
--- /dev/null
+++ b/t/t3452-history-split.sh
@@ -0,0 +1,447 @@
+#!/bin/sh
+
+test_description='tests for git-history split subcommand'
+
+. ./test-lib.sh
+
+set_fake_editor () {
+	write_script fake-editor.sh <<-\EOF &&
+	echo "split-out commit" >"$1"
+	EOF
+	test_set_editor "$(pwd)"/fake-editor.sh
+}
+
+expect_log () {
+	git log --format="%s" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+expect_tree_entries () {
+	git ls-tree --name-only "$1" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history split HEAD 2>err &&
+		test_grep "cannot rearrange commit history with merges" err &&
+		test_must_fail git history split HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work with unrelated commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		test_must_fail git history split ours 2>err &&
+		test_grep "split commit must be reachable from current HEAD commit" err
+	)
+'
+
+test_expect_success 'can split up tip commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git symbolic-ref HEAD >expect &&
+		set_fake_editor &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		initial
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m root &&
+		test_commit tip &&
+
+		set_fake_editor &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		root
+		split-out commit
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up in-between commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+		test_commit tip &&
+
+		set_fake_editor &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		split-me
+		split-out commit
+		initial
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can pick multiple hunks' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar baz foo qux &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "split-out commit" <<-EOF &&
+		y
+		n
+		y
+		n
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		baz
+		foo
+		qux
+		EOF
+	)
+'
+
+
+test_expect_success 'can use only last hunk' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "split-out commit" <<-EOF &&
+		n
+		y
+		EOF
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD -m "" <<-EOF 2>err &&
+		y
+		n
+		EOF
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_expect_success 'can specify message via option' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "message option" <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF
+		split-me
+		message option
+		EOF
+	)
+'
+
+test_expect_success 'commit message editor sees split-out changes' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		echo "some commit message" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		cat >expect <<-EOF &&
+
+		# Please enter the commit message for the split-out changes. Lines starting
+		# with ${SQ}#${SQ} will be ignored.
+		# Changes to be committed:
+		#	new file:   bar
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		expect_log <<-EOF
+		split-me
+		some commit message
+		EOF
+	)
+'
+
+test_expect_success 'can use pathspec to limit what gets split' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git history split HEAD -m "message option" -- foo <<-EOF &&
+		y
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'refuses to create empty split-out commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		n
+		n
+		EOF
+		test_grep "split commit is empty" err
+	)
+'
+
+test_expect_success 'hooks are executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+		old_head=$(git rev-parse HEAD) &&
+
+		write_script .git/hooks/prepare-commit-msg <<-EOF &&
+		touch "$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-commit <<-EOF &&
+		touch "$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-rewrite <<-EOF &&
+		touch "$(pwd)/hooks.log"
+		EOF
+
+		set_fake_editor &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		EOF
+
+		test_path_is_missing hooks.log
+	)
+'
+
+test_expect_success 'refuses to create empty original commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		y
+		y
+		EOF
+		test_grep "split commit tree matches original commit" err
+	)
+'
+
+test_expect_success 'retains changes in the worktree and index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		echo a >a &&
+		echo b >b &&
+		git add . &&
+		git commit -m "initial commit" &&
+		echo a-modified >a &&
+		echo b-modified >b &&
+		git add b &&
+		git history split HEAD -m a-only <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		a
+		EOF
+		expect_tree_entries HEAD <<-EOF &&
+		a
+		b
+		EOF
+
+		cat >expect <<-\EOF &&
+		 M a
+		M  b
+		?? actual
+		?? expect
+		EOF
+		git status --porcelain >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.51.1.851.g4ebd6896fd.dirty
Junio C Hamano· Oct 21, 2025, 18:53 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v5 00/12] Introduce git-history(1) command for easy history editing

Patrick Steinhardt <ps@pks.im> writes:
Show 21 quoted lines
> A copule of these features relate to history editing. Most importantly,
> I really dig the following commands:
>
>   - jj-abandon(1) to drop a specific commit from your history.
>
>   - jj-absorb(1) to take some changes and automatically apply them to
>     commits in your history that last modified the respective hunks.
>
>   - jj-split(1) to split a commit into two.
>
>   - jj-new(1) to insert a new commit after or before a specific other
>     commit.
>
> Not all of these commands can be ported directly into Git. jj-new(1) for
> example doesn't really make a ton of sense for us, I'd claim. But some
> of these commands _do_ make sense.
>
> This patch series is a starting point for such a command. I've
> significantly slimmed it down from the first couple revisions now
> following the discussions at the Contributor's Summit yesterday. This
> was my intent anyway, as I already mentioned on the last iteration.

Will replace. The other topic this depends on seems to be almost ready, hopefully, and the part this series depends on should not change much, even though what is remaining may be a bit more than finding where to squash the tip "SQUASH" fixup in.

Thanks.
Patrick Steinhardt· Oct 27, 2025, 11:33 UTC · re: Patrick Steinhardt · lore

[PATCH v6 00/11] Introduce git-history(1) command for easy history editing

Hi,

over recent months I've been playing around with Jujutsu quite frequently. While I still prefer using Git, there's been a couple features in it that I really like and that I'd like to have in Git, as well.

A copule of these features relate to history editing. Most importantly, I really dig the following commands:

  - jj-abandon(1) to drop a specific commit from your history.
  - jj-absorb(1) to take some changes and automatically apply them to
    commits in your history that last modified the respective hunks.
  - jj-split(1) to split a commit into two.
  - jj-new(1) to insert a new commit after or before a specific other
    commit.

Not all of these commands can be ported directly into Git. jj-new(1) for example doesn't really make a ton of sense for us, I'd claim. But some of these commands _do_ make sense.

This patch series is a starting point for such a command. I've significantly slimmed it down from the first couple revisions now following the discussions at the Contributor's Summit yesterday. This was my intent anyway, as I already mentioned on the last iteration.

Changes in v6:
  - I've rebased the patch series again to pull in the latest updates
    from sa/replay-atomic-ref-updates and fix conflicts. It is now based
    on 4e98b730f1 (The twenty-fourth batch, 2025-10-24) with ab661bb1bb
    (replay: add replay.refAction config option, 2025-10-23) merged into
    it.
  - I've dropped the "-m" options for now, so commit messages are always
    asked for via the editor. These can be introduced in a subsequent
    patch series once discussion around them has settled.
  - We don't use the merge machinery anymore to pick the commits.
  - Drop the commit to parse commits in the replay machinery. It didn't
    seem to be necessary in v5 anymore, and now that we don't use the
    merge machinery at all we don't ever take that code path in the
    first place.
  - Link to v5:
    https://lore.kernel.org/r/20251021-b4-pks-history-builtin-v5-0-78d23f578fe6@pks.im
Changes in v5:
  - I've changed the patch series to be based on top of 133d151831 (The
    twenty-first batch, 2025-10-20) with sa/replay-atomic-ref-updates at
    a1c22e627e (SQAUASH??? t0450 band-aid, 2025-10-14) merged into it.
    This is one the one hand to fix a conflict, but also to get some of
    the CI updates to make GitLab CI work again.
  - Some slight commit message improvements.
  - Deduplicate subcommand usage strings by using defines.
  - Fix the desendancy checks to properly verify that HEAD is a
    descendant of the commit to be rewritten. Also add some tests for
    this.
  - Fix the hint that mentions that lines starting with the comment
    character will be tripped after having written the commit message.
  - Move an include to the correct commit.
  - Link to v4: https://lore.kernel.org/r/20251001-b4-pks-history-builtin-v4-0-8e61ddb86317@pks.im
Changes in v4:
  - I've rebuilt the patch series. It is now based on 821f583da6 (The
    thirteenth batcn, 2025-09-29) with sa/replay-atomic-ref-updates
    at 665c66a743 (replay: make atomic ref updates the default behavior,
    2025-09-27) merged into it. This should fix all conflicts with seen.
  - I've reworked this patch series to use the same infra as
    git-replay(1), as discussed during the Contributor's Summit.
  - I've slimmed down the patch series to only tackle those commands
    that cannot result in a conflict to keep it simple. I also learned
    that Elijah has been working on a "git replay edit" command, so I
    dropped that command so that we can instead use his version.
  - During the Contributor's Summit we have agreed that for now, we
    won't care about hook execution just yet. This may be backfilled at
    a later point in time.
  - I dropped "commit.verbose" handling for now, as my understanding of
    it was wrong at first. This is something we should backfill.
  - Link to v3: https://lore.kernel.org/r/20250904-b4-pks-history-builtin-v3-0-509053514755@pks.im
Changes in v3:
  - Add logic to drive the "post-rewrite" hook and add tests to verify
    that all hooks are executed as expected.
  - Deduplicate logic to turn a replay action into a todo command.
  - Move the addition of tests for the top-level git-history(1) command
    to the correct commit.
  - Some smaller commit message fixes.
  - Honor "commit.verbose".
  - Fix copy-paste error with an error message.
  - Link to v2: https://lore.kernel.org/r/20250824-b4-pks-history-builtin-v2-0-964ac12f65bd@pks.im
Changes in v2:
  - Add a new "reword" subcommand.
  - List git-history(1) in "command-list.txt".
  - Add some missing error handling.
  - Simplify calling convention of `apply_commits()` to handle root
    commits internally instead of requiring every caller to do so.
  - Add tests to verify that git-history(1) refuses to work with changes
    in the worktree or index.
  - Mark git-history(1) as experimental.
  - Introduce commands to manage interrupted history edits.
  - A bunch of improvements to the manpage.
  - Link to v1: https://lore.kernel.org/r/20250819-b4-pks-history-builtin-v1-0-9b77c32688fe@pks.im
Thanks!
Patrick
---
Patrick Steinhardt (11):
      wt-status: provide function to expose status for trees
      replay: extract logic to pick commits
      replay: stop using `the_repository`
      builtin: add new "history" command
      builtin/history: implement "reword" subcommand
      add-patch: split out header from "add-interactive.h"
      add-patch: split out `struct interactive_options`
      add-patch: remove dependency on "add-interactive" subsystem
      add-patch: add support for in-memory index patching
      cache-tree: allow writing in-memory index as tree
      builtin/history: implement "split" subcommand
 .gitignore                     |   1 +
 Documentation/git-history.adoc | 111 ++++++++
 Documentation/meson.build      |   1 +
 Makefile                       |   2 +
 add-interactive.c              | 174 +++----------
 add-interactive.h              |  46 +---
 add-patch.c                    | 297 +++++++++++++++++++---
 add-patch.h                    |  64 +++++
 builtin.h                      |   1 +
 builtin/add.c                  |  22 +-
 builtin/checkout.c             |   7 +-
 builtin/commit.c               |  16 +-
 builtin/history.c              | 561 +++++++++++++++++++++++++++++++++++++++++
 builtin/replay.c               | 110 +-------
 builtin/reset.c                |  16 +-
 builtin/stash.c                |  46 ++--
 cache-tree.c                   |   5 +-
 cache-tree.h                   |   3 +-
 command-list.txt               |   1 +
 commit.h                       |   2 +-
 git.c                          |   1 +
 meson.build                    |   2 +
 replay.c                       | 115 +++++++++
 replay.h                       |  23 ++
 t/meson.build                  |   3 +
 t/t3450-history.sh             |  17 ++
 t/t3451-history-reword.sh      | 237 +++++++++++++++++
 t/t3452-history-split.sh       | 432 +++++++++++++++++++++++++++++++
 wt-status.c                    |  24 ++
 wt-status.h                    |   9 +
 30 files changed, 1972 insertions(+), 377 deletions(-)
Range-diff versus v5:
 1:  c351cd9fb34 !  1:  7e32877a47c wt-status: provide function to expose status for trees
    @@ wt-status.h: void wt_status_add_cut_line(struct wt_status *s);
      void wt_status_prepare(struct repository *r, struct wt_status *s);
      void wt_status_print(struct wt_status *s);
      void wt_status_collect(struct wt_status *s);
    ++
    ++/*
    ++ * Collect all changes between the two trees. Changes will be displayed as if
    ++ * they were staged into the index.
    ++ */
     +void wt_status_collect_changes_trees(struct wt_status *s,
     +				     const struct object_id *old_treeish,
     +				     const struct object_id *new_treeish);
    ++
      /*
       * Frees the buffers allocated by wt_status_collect.
       */
 2:  71266f8ed78 !  2:  3688a10ec38 replay: extract logic to pick commits
    @@ builtin/replay.c
      #include "revision.h"
      #include "strmap.h"
      #include <oidset.h>
    - #include <tree.h>
    +@@ builtin/replay.c: enum ref_action_mode {
    + 	REF_ACTION_PRINT
    + };
      
     -static const char *short_commit_name(struct repository *repo,
     -				     struct commit *commit)
    @@ builtin/replay.c: static void determine_replay_mode(struct repository *repo,
     -	return create_commit(repo, result->tree, pickme, replayed_base);
     -}
     -
    - static int handle_ref_update(const char *mode,
    + static int handle_ref_update(enum ref_action_mode mode,
      			     struct ref_transaction *transaction,
      			     const char *refname,
     @@ builtin/replay.c: int cmd_replay(int argc,
    @@ replay.c (new)
     +	return NULL;
     +}
     +
    -+static struct commit *create_commit(struct repository *repo,
    ++struct commit *replay_create_commit(struct repository *repo,
     +				    struct tree *tree,
     +				    struct commit *based_on,
     +				    struct commit *parent)
    @@ replay.c (new)
     +	merge_opt->ancestor = NULL;
     +	if (!result->clean)
     +		return NULL;
    -+	return create_commit(repo, result->tree, pickme, replayed_base);
    ++	return replay_create_commit(repo, result->tree, pickme, replayed_base);
     +}
     
      ## replay.h (new) ##
    @@ replay.h (new)
     +struct commit;
     +struct tree;
     +
    ++struct commit *replay_create_commit(struct repository *repo,
    ++				    struct tree *tree,
    ++				    struct commit *based_on,
    ++				    struct commit *parent);
    ++
     +struct commit *replay_pick_regular_commit(struct repository *repo,
     +					  struct commit *pickme,
     +					  kh_oid_map_t *replayed_commits,
 3:  703fc59d4e1 !  3:  7a5217797a0 replay: stop using `the_repository`
    @@ Commit message
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
     
      ## replay.c ##
    -@@ replay.c: static struct commit *create_commit(struct repository *repo,
    +@@ replay.c: struct commit *replay_create_commit(struct repository *repo,
      	obj = parse_object(repo, &ret);
      
      out:
 4:  f18a8ac67e0 <  -:  ----------- replay: parse commits before dereferencing them
 5:  65892f7da77 !  4:  bc435f62d87 builtin: add new "history" command
    @@ Documentation/git-history.adoc (new)
     +Rewrite history by rearranging or modifying specific commits in the
     +history.
     +
    ++THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
    ++
     +This command is similar to linkgit:git-rebase[1] and uses the same
     +underlying machinery. You should use rebases if you want to reapply a range of
     +commits onto a different base, or interactive rebases if you want to edit a
    @@ Documentation/git-history.adoc (new)
     +merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
     +flag instead.
     +
    -+THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
    -+
     +COMMANDS
     +--------
     +
    -+This command requires a subcommand. Several subcommands are available to
    -+rewrite history in different ways:
    ++Several commands are available to rewrite history in different ways:
     +
     +CONFIGURATION
     +-------------
 6:  cefe1dbb847 !  5:  39a21dc2bbd builtin/history: implement "reword" subcommand
    @@ Commit message
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
     
      ## Documentation/git-history.adoc ##
    -@@ Documentation/git-history.adoc: SYNOPSIS
    +@@ Documentation/git-history.adoc: git-history - EXPERIMENTAL: Rewrite history of the current branch
    + SYNOPSIS
      --------
      [synopsis]
    - git history [<options>]
    -+git history reword [<options>] <commit>
    +-git history [<options>]
    ++git history reword <commit>
      
      DESCRIPTION
      -----------
     @@ Documentation/git-history.adoc: COMMANDS
    - This command requires a subcommand. Several subcommands are available to
    - rewrite history in different ways:
      
    -+`reword <commit> [--message=<message>]`::
    + Several commands are available to rewrite history in different ways:
    + 
    ++`reword <commit>`::
     +	Rewrite the commit message of the specified commit. All the other
    -+	details of this commit remain unchanged. If no commit message is
    -+	provided, then this command will spawn an editor with the current
    -+	message of that commit.
    ++	details of this commit remain unchanged. This command will spawn an
    ++	editor with the current message of that commit.
     +
      CONFIGURATION
      -------------
    @@ builtin/history.c
     +#include "tree.h"
     +#include "wt-status.h"
     +
    -+#define GIT_HISTORY_REWORD_USAGE N_("git history reword [<options>] <commit>")
    ++#define GIT_HISTORY_REWORD_USAGE N_("git history reword <commit>")
     +
     +static int collect_commits(struct repository *repo,
     +			   struct commit *old_commit,
    @@ builtin/history.c
     +			 const char *action)
     +{
     +	struct reset_head_opts reset_opts = { 0 };
    -+	struct merge_options merge_opts = { 0 };
    -+	struct merge_result result = { 0 };
     +	struct strbuf buf = STRBUF_INIT;
    -+	kh_oid_map_t *replayed_commits;
     +	int ret;
     +
    -+	replayed_commits = kh_init_oid_map();
    -+
    -+	init_basic_merge_options(&merge_opts, repo);
    -+	merge_opts.show_rename_progress = 0;
    -+
     +	for (size_t i = 0; i < commits->nr; i++) {
     +		struct object_id commit_id;
     +		struct commit *commit;
     +		const char *end;
    -+		int hash_result;
    -+		khint_t pos;
     +
     +		if (parse_oid_hex_algop(commits->v[i], &commit_id, &end,
     +					repo->hash_algo)) {
    @@ builtin/history.c
     +
     +		if (!onto) {
     +			onto = commit;
    -+			result.clean = 1;
    -+			result.tree = repo_get_commit_tree(repo, commit);
     +		} else {
    -+			onto = replay_pick_regular_commit(repo, commit, replayed_commits,
    -+							  onto, &merge_opts, &result);
    ++			struct tree *tree = repo_get_commit_tree(repo, commit);
    ++			onto = replay_create_commit(repo, tree, commit, onto);
     +			if (!onto)
     +				break;
     +		}
    -+
    -+		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hash_result);
    -+		if (hash_result == 0) {
    -+			ret = error(_("duplicate rewritten commit: %s\n"),
    -+				    oid_to_hex(&commit->object.oid));
    -+			goto out;
    -+		}
    -+		kh_value(replayed_commits, pos) = onto;
    -+	}
    -+
    -+	if (!result.clean) {
    -+		ret = error(_("could not merge"));
    -+		goto out;
     +	}
     +
     +	reset_opts.oid = &onto->object.oid;
    @@ builtin/history.c
     +	ret = 0;
     +
     +out:
    -+	kh_destroy_oid_map(replayed_commits);
    -+	merge_finalize(&merge_opts, &result);
     +	strbuf_release(&buf);
     +	return ret;
     +}
    @@ builtin/history.c
     +			       const struct object_id *old_tree,
     +			       const struct object_id *new_tree,
     +			       const char *default_message,
    -+			       const char *provided_message,
     +			       const char *action,
     +			       struct strbuf *out)
     +{
    -+	if (!provided_message) {
    -+		const char *path = git_path_commit_editmsg();
    -+		const char *hint =
    -+			_("Please enter the commit message for the %s changes."
    -+			  " Lines starting\nwith '%s' will be ignored.\n");
    -+		struct wt_status s;
    -+
    -+		strbuf_addstr(out, default_message);
    -+		strbuf_addch(out, '\n');
    -+		strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
    -+		write_file_buf(path, out->buf, out->len);
    -+
    -+		wt_status_prepare(repo, &s);
    -+		FREE_AND_NULL(s.branch);
    -+		s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
    -+		s.commit_template = 1;
    -+		s.colopts = 0;
    -+		s.display_comment_prefix = 1;
    -+		s.hints = 0;
    -+		s.use_color = 0;
    -+		s.whence = FROM_COMMIT;
    -+		s.committable = 1;
    -+
    -+		s.fp = fopen(git_path_commit_editmsg(), "a");
    -+		if (!s.fp)
    -+			return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
    -+
    -+		wt_status_collect_changes_trees(&s, old_tree, new_tree);
    -+		wt_status_print(&s);
    -+		wt_status_collect_free_buffers(&s);
    -+		string_list_clear_func(&s.change, change_data_free);
    -+
    -+		strbuf_reset(out);
    -+		if (launch_editor(path, out, NULL)) {
    -+			fprintf(stderr, _("Please supply the message using the -m option.\n"));
    -+			return -1;
    -+		}
    -+		strbuf_stripspace(out, comment_line_str);
    -+	} else {
    -+		strbuf_addstr(out, provided_message);
    ++	const char *path = git_path_commit_editmsg();
    ++	const char *hint =
    ++		_("Please enter the commit message for the %s changes."
    ++		  " Lines starting\nwith '%s' will be ignored.\n");
    ++	struct wt_status s;
    ++
    ++	strbuf_addstr(out, default_message);
    ++	strbuf_addch(out, '\n');
    ++	strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
    ++	write_file_buf(path, out->buf, out->len);
    ++
    ++	wt_status_prepare(repo, &s);
    ++	FREE_AND_NULL(s.branch);
    ++	s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
    ++	s.commit_template = 1;
    ++	s.colopts = 0;
    ++	s.display_comment_prefix = 1;
    ++	s.hints = 0;
    ++	s.use_color = 0;
    ++	s.whence = FROM_COMMIT;
    ++	s.committable = 1;
    ++
    ++	s.fp = fopen(git_path_commit_editmsg(), "a");
    ++	if (!s.fp)
    ++		return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
    ++
    ++	wt_status_collect_changes_trees(&s, old_tree, new_tree);
    ++	wt_status_print(&s);
    ++	wt_status_collect_free_buffers(&s);
    ++	string_list_clear_func(&s.change, change_data_free);
    ++
    ++	strbuf_reset(out);
    ++	if (launch_editor(path, out, NULL)) {
    ++		fprintf(stderr, _("Please supply the message using the -m option.\n"));
    ++		return -1;
     +	}
    ++	strbuf_stripspace(out, comment_line_str);
     +
     +	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
     +
    @@ builtin/history.c
     +		GIT_HISTORY_REWORD_USAGE,
     +		NULL,
     +	};
    -+	const char *commit_message = NULL;
     +	struct option options[] = {
    -+		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
     +		OPT_END(),
     +	};
     +	struct strbuf final_message = STRBUF_INIT;
    @@ builtin/history.c
     +	find_commit_subject(original_message, &original_body);
     +
     +	ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
    -+				  original_body, commit_message, "reworded", &final_message);
    ++				  original_body, "reworded", &final_message);
     +	if (ret < 0)
     +		goto out;
     +
    @@ builtin/history.c
     +		struct repository *repo)
      {
      	const char * const usage[] = {
    - 		N_("git history [<options>]"),
    +-		N_("git history [<options>]"),
     +		GIT_HISTORY_REWORD_USAGE,
      		NULL,
      	};
    @@ t/t3451-history-reword.sh (new)
     +
     +. ./test-lib.sh
     +
    ++reword_with_message () {
    ++	cat >message &&
    ++	write_script fake-editor.sh <<-EOF &&
    ++	cp "$(pwd)/message" "\$1"
    ++	EOF
    ++	test_set_editor "$(pwd)"/fake-editor.sh &&
    ++	git history reword "$@" &&
    ++	rm fake-editor.sh message
    ++}
    ++
     +test_expect_success 'refuses to work with merge commits' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3451-history-reword.sh (new)
     +		test_commit third &&
     +
     +		git symbolic-ref HEAD >expect &&
    -+		git history reword -m "third reworded" HEAD &&
    ++		reword_with_message HEAD <<-EOF &&
    ++		third reworded
    ++		EOF
     +		git symbolic-ref HEAD >actual &&
     +		test_cmp expect actual &&
     +
    @@ t/t3451-history-reword.sh (new)
     +		test_commit third &&
     +
     +		git symbolic-ref HEAD >expect &&
    -+		git history reword -m "second reworded" HEAD~ &&
    ++		reword_with_message HEAD~ <<-EOF &&
    ++		second reworded
    ++		EOF
     +		git symbolic-ref HEAD >actual &&
     +		test_cmp expect actual &&
     +
    @@ t/t3451-history-reword.sh (new)
     +		test_commit first &&
     +		test_commit second &&
     +		test_commit third &&
    -+		git history reword -m "first reworded" HEAD~2 &&
    ++		reword_with_message HEAD~2 <<-EOF &&
    ++		first reworded
    ++		EOF
     +
     +		cat >expect <<-EOF &&
     +		third
    @@ t/t3451-history-reword.sh (new)
     +# change in the future, and if it does this test here is expected to start
     +# failing. In other words, this test is not an endorsement of the current
     +# status quo.
    -+test_expect_success 'hooks are executed for rewritten commits' '
    ++test_expect_success 'hooks are not executed for rewritten commits' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
     +	(
    @@ t/t3451-history-reword.sh (new)
     +		touch "$(pwd)/hooks.log
     +		EOF
     +
    -+		git history reword -m "second reworded" HEAD~ &&
    ++		reword_with_message HEAD~ <<-EOF &&
    ++		second reworded
    ++		EOF
     +
     +		cat >expect <<-EOF &&
     +		third
    @@ t/t3451-history-reword.sh (new)
     +		cd repo &&
     +		test_commit first &&
     +
    -+		test_must_fail git history reword -m "" HEAD 2>err &&
    ++		! reword_with_message HEAD 2>err </dev/null &&
     +		test_grep "Aborting commit due to empty commit message." err
     +	)
     +'
    @@ t/t3451-history-reword.sh (new)
     +		echo foo >a &&
     +		echo bar >b &&
     +		git add b &&
    -+		git history reword HEAD -m message &&
    ++		reword_with_message HEAD <<-EOF &&
    ++		message
    ++		EOF
     +		cat >expect <<-\EOF &&
     +		 M a
     +		M  b
 7:  a064a8c7eca =  6:  a1b1e5877bc add-patch: split out header from "add-interactive.h"
 8:  39c231e726f =  7:  4262663aa50 add-patch: split out `struct interactive_options`
 9:  104a42a552f =  8:  c285918281f add-patch: remove dependency on "add-interactive" subsystem
10:  2e60b2b6b48 =  9:  3f6e32241df add-patch: add support for in-memory index patching
11:  52bdbb2ac6a = 10:  c25564e1d16 cache-tree: allow writing in-memory index as tree
12:  052ee5bd58b ! 11:  221dd490af1 builtin/history: implement "split" subcommand
    @@ Commit message
     
      ## Documentation/git-history.adoc ##
     @@ Documentation/git-history.adoc: SYNOPSIS
    + --------
      [synopsis]
    - git history [<options>]
    - git history reword [<options>] <commit>
    -+git history split [<options>] <commit> [--] [<pathspec>...]
    + git history reword <commit>
    ++git history split <commit> [--] [<pathspec>...]
      
      DESCRIPTION
      -----------
    -@@ Documentation/git-history.adoc: rewrite history in different ways:
    - 	provided, then this command will spawn an editor with the current
    - 	message of that commit.
    +@@ Documentation/git-history.adoc: Several commands are available to rewrite history in different ways:
    + 	details of this commit remain unchanged. This command will spawn an
    + 	editor with the current message of that commit.
      
    -+`split [--message=<message>] <commit> [--] [<pathspec>...]`::
    ++`split <commit> [--] [<pathspec>...]`::
     +	Interactively split up <commit> into two commits by choosing
     +	hunks introduced by it that will be moved into the new split-out
     +	commit. These hunks will then be written into a new commit that
    @@ Documentation/git-history.adoc: rewrite history in different ways:
     +	commit.
     ++
     +The commit message of the new commit will be asked for by launching the
    -+configured editor, unless it has been specified with the `-m` option.
    -+Authorship of the commit will be the same as for the original commit.
    ++configured editor. Authorship of the commit will be the same as for the
    ++original commit.
     ++
     +If passed, _<pathspec>_ can be used to limit which changes shall be split out
     +of the original commit. Files not matching any of the pathspecs will remain
    @@ Documentation/git-history.adoc: include::includes/cmd-config-section-all.adoc[]
     + foo | 1 +
     + 2 files changed, 2 insertions(+)
     +
    -+$ git history split HEAD --message="split-out commit"
    ++$ git history split HEAD
     +diff --git a/bar b/bar
     +new file mode 100644
     +index 0000000..5716ca5
    @@ builtin/history.c
      #include "tree.h"
      #include "wt-status.h"
      
    - #define GIT_HISTORY_REWORD_USAGE N_("git history reword [<options>] <commit>")
    -+#define GIT_HISTORY_SPLIT_USAGE  N_("git history split [<options>] <commit> [--] [<pathspec>...]")
    + #define GIT_HISTORY_REWORD_USAGE N_("git history reword <commit>")
    ++#define GIT_HISTORY_SPLIT_USAGE  N_("git history split <commit> [--] [<pathspec>...]")
      
      static int collect_commits(struct repository *repo,
      			   struct commit *old_commit,
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +static int split_commit(struct repository *repo,
     +			struct commit *original_commit,
     +			struct pathspec *pathspec,
    -+			const char *commit_message,
     +			struct object_id *out)
     +{
     +	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +		original_author = xmemdupz(ptr, len);
     +
     +	ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
    -+				  "", commit_message, "split-out", &split_message);
    ++				  "", "split-out", &split_message);
     +	if (ret < 0)
     +		goto out;
     +
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +		GIT_HISTORY_SPLIT_USAGE,
     +		NULL,
     +	};
    -+	const char *commit_message = NULL;
     +	struct option options[] = {
    -+		OPT_STRING('m', "message", &commit_message, N_("message"), N_("commit message")),
     +		OPT_END(),
     +	};
     +	struct oidmap rewritten_commits = OIDMAP_INIT;
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +	 * Then we split up the commit and replace the original commit with the
     +	 * new ones.
     +	 */
    -+	ret = split_commit(repo, original_commit, &pathspec,
    -+			   commit_message, split_commits);
    ++	ret = split_commit(repo, original_commit, &pathspec, split_commits);
     +	if (ret < 0)
     +		goto out;
     +
    @@ builtin/history.c: static int cmd_history_reword(int argc,
      		const char **argv,
      		const char *prefix,
     @@ builtin/history.c: int cmd_history(int argc,
    + {
      	const char * const usage[] = {
    - 		N_("git history [<options>]"),
      		GIT_HISTORY_REWORD_USAGE,
     +		GIT_HISTORY_SPLIT_USAGE,
      		NULL,
    @@ t/t3452-history-split.sh (new)
     +. ./test-lib.sh
     +
     +set_fake_editor () {
    -+	write_script fake-editor.sh <<-\EOF &&
    -+	echo "split-out commit" >"$1"
    ++	write_script fake-editor.sh <<-EOF &&
    ++	echo "$@" >"\$1"
     +	EOF
     +	test_set_editor "$(pwd)"/fake-editor.sh
     +}
    @@ t/t3452-history-split.sh (new)
     +		git commit -m split-me &&
     +
     +		git symbolic-ref HEAD >expect &&
    -+		set_fake_editor &&
    ++		set_fake_editor "split-out commit" &&
     +		git history split HEAD <<-EOF &&
     +		y
     +		n
    @@ t/t3452-history-split.sh (new)
     +		git commit -m root &&
     +		test_commit tip &&
     +
    -+		set_fake_editor &&
    ++		set_fake_editor "split-out commit" &&
     +		git history split HEAD~ <<-EOF &&
     +		y
     +		n
    @@ t/t3452-history-split.sh (new)
     +		git commit -m split-me &&
     +		test_commit tip &&
     +
    -+		set_fake_editor &&
    ++		set_fake_editor "split-out commit" &&
     +		git history split HEAD~ <<-EOF &&
     +		y
     +		n
    @@ t/t3452-history-split.sh (new)
     +		git add . &&
     +		git commit -m split-me &&
     +
    -+		git history split HEAD -m "split-out commit" <<-EOF &&
    ++		set_fake_editor "split-out-commit" &&
    ++		git history split HEAD <<-EOF &&
     +		y
     +		n
     +		y
    @@ t/t3452-history-split.sh (new)
     +		git add . &&
     +		git commit -m split-me &&
     +
    -+		git history split HEAD -m "split-out commit" <<-EOF &&
    ++		set_fake_editor "split-out commit" &&
    ++		git history split HEAD <<-EOF &&
     +		n
     +		y
     +		EOF
    @@ t/t3452-history-split.sh (new)
     +		git add . &&
     +		git commit -m split-me &&
     +
    -+		test_must_fail git history split HEAD -m "" <<-EOF 2>err &&
    ++		set_fake_editor "" &&
    ++		test_must_fail git history split HEAD <<-EOF 2>err &&
     +		y
     +		n
     +		EOF
    @@ t/t3452-history-split.sh (new)
     +	)
     +'
     +
    -+test_expect_success 'can specify message via option' '
    -+	test_when_finished "rm -rf repo" &&
    -+	git init repo &&
    -+	(
    -+		cd repo &&
    -+		touch bar foo &&
    -+		git add . &&
    -+		git commit -m split-me &&
    -+
    -+		git history split HEAD -m "message option" <<-EOF &&
    -+		y
    -+		n
    -+		EOF
    -+
    -+		expect_log <<-EOF
    -+		split-me
    -+		message option
    -+		EOF
    -+	)
    -+'
    -+
     +test_expect_success 'commit message editor sees split-out changes' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
    @@ t/t3452-history-split.sh (new)
     +		git add . &&
     +		git commit -m split-me &&
     +
    -+		git history split HEAD -m "message option" -- foo <<-EOF &&
    ++		set_fake_editor "split-out commit" &&
    ++		git history split HEAD -- foo <<-EOF &&
     +		y
     +		EOF
     +
    @@ t/t3452-history-split.sh (new)
     +		touch "$(pwd)/hooks.log"
     +		EOF
     +
    -+		set_fake_editor &&
    ++		set_fake_editor "split-out commit" &&
     +		git history split HEAD <<-EOF &&
     +		y
     +		n
    @@ t/t3452-history-split.sh (new)
     +		echo a-modified >a &&
     +		echo b-modified >b &&
     +		git add b &&
    -+		git history split HEAD -m a-only <<-EOF &&
    ++		set_fake_editor "a-only" &&
    ++		git history split HEAD <<-EOF &&
     +		y
     +		n
     +		EOF
    @@ t/t3452-history-split.sh (new)
     +		M  b
     +		?? actual
     +		?? expect
    ++		?? fake-editor.sh
     +		EOF
     +		git status --porcelain >actual &&
     +		test_cmp expect actual

--- base-commit: a912335e972768e607159eb74a4c7b62f86ee38e change-id: 20250819-b4-pks-history-builtin-83398f9a05f0

Patrick Steinhardt· Oct 27, 2025, 11:33 UTC · re: Patrick Steinhardt · lore

[PATCH v6 01/11] wt-status: provide function to expose status for trees

The "wt-status" subsystem is responsible for printing status information around the current state of the working tree. This most importantly includes information around whether the working tree or the index have any changes.

We're about to introduce a new command where the changes in neither of them are actually relevant to us. Instead, what we want is to format the changes between two different trees. While it is a little bit of a stretch to add this as functionality to _working tree_ status, it doesn't make any sense to open-code this functionality, either.

Implement a new function `wt_status_collect_changes_trees()` that diffs two trees and formats the status accordingly. This function is not yet used, but will be in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 wt-status.c | 24 ++++++++++++++++++++++++
 wt-status.h |  9 +++++++++
 2 files changed, 33 insertions(+)
Show changes to 2 files +33 −0

wt-status.c, wt-status.h

diff --git a/wt-status.c b/wt-status.c
index e12adb26b9f..95942399f8c 100644
--- a/wt-status.c
+++ b/wt-status.c
@@ -612,6 +612,30 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q,
 	}
 }
 
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish)
+{
+	struct diff_options opts = { 0 };
+
+	repo_diff_setup(s->repo, &opts);
+	opts.output_format = DIFF_FORMAT_CALLBACK;
+	opts.format_callback = wt_status_collect_updated_cb;
+	opts.format_callback_data = s;
+	opts.detect_rename = s->detect_rename >= 0 ? s->detect_rename : opts.detect_rename;
+	opts.rename_limit = s->rename_limit >= 0 ? s->rename_limit : opts.rename_limit;
+	opts.rename_score = s->rename_score >= 0 ? s->rename_score : opts.rename_score;
+	opts.flags.recursive = 1;
+	diff_setup_done(&opts);
+
+	diff_tree_oid(old_treeish, new_treeish, "", &opts);
+	diffcore_std(&opts);
+	diff_flush(&opts);
+	wt_status_get_state(s->repo, &s->state, 0);
+
+	diff_free(&opts);
+}
+
 static void wt_status_collect_changes_worktree(struct wt_status *s)
 {
 	struct rev_info rev;
diff --git a/wt-status.h b/wt-status.h
index e40a27214a7..e9fe32e98cc 100644
--- a/wt-status.h
+++ b/wt-status.h
@@ -153,6 +153,15 @@ void wt_status_add_cut_line(struct wt_status *s);
 void wt_status_prepare(struct repository *r, struct wt_status *s);
 void wt_status_print(struct wt_status *s);
 void wt_status_collect(struct wt_status *s);
+
+/*
+ * Collect all changes between the two trees. Changes will be displayed as if
+ * they were staged into the index.
+ */
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish);
+
 /*
  * Frees the buffers allocated by wt_status_collect.
  */
-- 
2.51.1.930.gacf6e81ea2.dirty
Patrick Steinhardt· Oct 27, 2025, 11:33 UTC · re: Patrick Steinhardt · lore

[PATCH v6 02/11] replay: extract logic to pick commits

We're about to add a new git-history(1) command that will reuse some of the same infrastructure as git-replay(1). To prepare for this, extract the logic to pick a commit into a new "replay.c" file so that it can be shared between both commands.

Rename the function to have a "replay_" prefix to clearly indicate its subsystem.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Makefile         |   1 +
 builtin/replay.c | 110 ++--------------------------------------------------
 meson.build      |   1 +
 replay.c         | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 replay.h         |  23 +++++++++++
 5 files changed, 143 insertions(+), 107 deletions(-)
Show changes to 5 files +143 −107

Makefile, builtin/replay.c, meson.build, replay.c, replay.h

diff --git a/Makefile b/Makefile
index 1919d35bf3f..01c171b4f03 100644
--- a/Makefile
+++ b/Makefile
@@ -1261,6 +1261,7 @@ LIB_OBJS += reftable/tree.o
 LIB_OBJS += reftable/writer.o
 LIB_OBJS += remote.o
 LIB_OBJS += replace-object.o
+LIB_OBJS += replay.o
 LIB_OBJS += repo-settings.o
 LIB_OBJS += repository.o
 LIB_OBJS += rerere.o
diff --git a/builtin/replay.c b/builtin/replay.c
index bb0420dc992..e39824912cd 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -2,7 +2,6 @@
  * "git replay" builtin command
  */
 
-#define USE_THE_REPOSITORY_VARIABLE
 #define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
@@ -16,6 +15,7 @@
 #include "object-name.h"
 #include "parse-options.h"
 #include "refs.h"
+#include "replay.h"
 #include "revision.h"
 #include "strmap.h"
 #include <oidset.h>
@@ -26,13 +26,6 @@ enum ref_action_mode {
 	REF_ACTION_PRINT
 };
 
-static const char *short_commit_name(struct repository *repo,
-				     struct commit *commit)
-{
-	return repo_find_unique_abbrev(repo, &commit->object.oid,
-				       DEFAULT_ABBREV);
-}
-
 static struct commit *peel_committish(struct repository *repo, const char *name)
 {
 	struct object *obj;
@@ -45,59 +38,6 @@ static struct commit *peel_committish(struct repository *repo, const char *name)
 						  OBJ_COMMIT);
 }
 
-static char *get_author(const char *message)
-{
-	size_t len;
-	const char *a;
-
-	a = find_commit_header(message, "author", &len);
-	if (a)
-		return xmemdupz(a, len);
-
-	return NULL;
-}
-
-static struct commit *create_commit(struct repository *repo,
-				    struct tree *tree,
-				    struct commit *based_on,
-				    struct commit *parent)
-{
-	struct object_id ret;
-	struct object *obj = NULL;
-	struct commit_list *parents = NULL;
-	char *author;
-	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
-	struct commit_extra_header *extra = NULL;
-	struct strbuf msg = STRBUF_INIT;
-	const char *out_enc = get_commit_output_encoding();
-	const char *message = repo_logmsg_reencode(repo, based_on,
-						   NULL, out_enc);
-	const char *orig_message = NULL;
-	const char *exclude_gpgsig[] = { "gpgsig", NULL };
-
-	commit_list_insert(parent, &parents);
-	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
-	find_commit_subject(message, &orig_message);
-	strbuf_addstr(&msg, orig_message);
-	author = get_author(message);
-	reset_ident_date();
-	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
-				 &ret, author, NULL, sign_commit, extra)) {
-		error(_("failed to write commit object"));
-		goto out;
-	}
-
-	obj = parse_object(repo, &ret);
-
-out:
-	repo_unuse_commit_buffer(the_repository, based_on, message);
-	free_commit_extra_headers(extra);
-	free_commit_list(parents);
-	strbuf_release(&msg);
-	free(author);
-	return (struct commit *)obj;
-}
-
 struct ref_info {
 	struct commit *onto;
 	struct strset positive_refs;
@@ -246,50 +186,6 @@ static void determine_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
-{
-	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
-	if (pos == kh_end(replayed_commits))
-		return fallback;
-	return kh_value(replayed_commits, pos);
-}
-
-static struct commit *pick_regular_commit(struct repository *repo,
-					  struct commit *pickme,
-					  kh_oid_map_t *replayed_commits,
-					  struct commit *onto,
-					  struct merge_options *merge_opt,
-					  struct merge_result *result)
-{
-	struct commit *base, *replayed_base;
-	struct tree *pickme_tree, *base_tree;
-
-	base = pickme->parents->item;
-	replayed_base = mapped_commit(replayed_commits, base, onto);
-
-	result->tree = repo_get_commit_tree(repo, replayed_base);
-	pickme_tree = repo_get_commit_tree(repo, pickme);
-	base_tree = repo_get_commit_tree(repo, base);
-
-	merge_opt->branch1 = short_commit_name(repo, replayed_base);
-	merge_opt->branch2 = short_commit_name(repo, pickme);
-	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
-
-	merge_incore_nonrecursive(merge_opt,
-				  base_tree,
-				  result->tree,
-				  pickme_tree,
-				  result);
-
-	free((char*)merge_opt->ancestor);
-	merge_opt->ancestor = NULL;
-	if (!result->clean)
-		return NULL;
-	return create_commit(repo, result->tree, pickme, replayed_base);
-}
-
 static int handle_ref_update(enum ref_action_mode mode,
 			     struct ref_transaction *transaction,
 			     const char *refname,
@@ -483,8 +379,8 @@ int cmd_replay(int argc,
 		if (commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
 
-		last_commit = pick_regular_commit(repo, commit, replayed_commits,
-						  onto, &merge_opt, &result);
+		last_commit = replay_pick_regular_commit(repo, commit, replayed_commits,
+							 onto, &merge_opt, &result);
 		if (!last_commit)
 			break;
 
diff --git a/meson.build b/meson.build
index cee94244759..ae8d4fef059 100644
--- a/meson.build
+++ b/meson.build
@@ -464,6 +464,7 @@ libgit_sources = [
   'reftable/writer.c',
   'remote.c',
   'replace-object.c',
+  'replay.c',
   'repo-settings.c',
   'repository.c',
   'rerere.c',
diff --git a/replay.c b/replay.c
new file mode 100644
index 00000000000..98be33b8545
--- /dev/null
+++ b/replay.c
@@ -0,0 +1,115 @@
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "git-compat-util.h"
+#include "commit.h"
+#include "environment.h"
+#include "gettext.h"
+#include "ident.h"
+#include "object.h"
+#include "object-name.h"
+#include "replay.h"
+#include "tree.h"
+
+static const char *short_commit_name(struct repository *repo,
+				     struct commit *commit)
+{
+	return repo_find_unique_abbrev(repo, &commit->object.oid,
+				       DEFAULT_ABBREV);
+}
+
+static char *get_author(const char *message)
+{
+	size_t len;
+	const char *a;
+
+	a = find_commit_header(message, "author", &len);
+	if (a)
+		return xmemdupz(a, len);
+
+	return NULL;
+}
+
+struct commit *replay_create_commit(struct repository *repo,
+				    struct tree *tree,
+				    struct commit *based_on,
+				    struct commit *parent)
+{
+	struct object_id ret;
+	struct object *obj = NULL;
+	struct commit_list *parents = NULL;
+	char *author;
+	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
+	struct commit_extra_header *extra = NULL;
+	struct strbuf msg = STRBUF_INIT;
+	const char *out_enc = get_commit_output_encoding();
+	const char *message = repo_logmsg_reencode(repo, based_on,
+						   NULL, out_enc);
+	const char *orig_message = NULL;
+	const char *exclude_gpgsig[] = { "gpgsig", NULL };
+
+	commit_list_insert(parent, &parents);
+	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
+	find_commit_subject(message, &orig_message);
+	strbuf_addstr(&msg, orig_message);
+	author = get_author(message);
+	reset_ident_date();
+	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
+				 &ret, author, NULL, sign_commit, extra)) {
+		error(_("failed to write commit object"));
+		goto out;
+	}
+
+	obj = parse_object(repo, &ret);
+
+out:
+	repo_unuse_commit_buffer(the_repository, based_on, message);
+	free_commit_extra_headers(extra);
+	free_commit_list(parents);
+	strbuf_release(&msg);
+	free(author);
+	return (struct commit *)obj;
+}
+
+static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
+				    struct commit *commit,
+				    struct commit *fallback)
+{
+	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
+	if (pos == kh_end(replayed_commits))
+		return fallback;
+	return kh_value(replayed_commits, pos);
+}
+
+struct commit *replay_pick_regular_commit(struct repository *repo,
+					  struct commit *pickme,
+					  kh_oid_map_t *replayed_commits,
+					  struct commit *onto,
+					  struct merge_options *merge_opt,
+					  struct merge_result *result)
+{
+	struct commit *base, *replayed_base;
+	struct tree *pickme_tree, *base_tree;
+
+	base = pickme->parents->item;
+	replayed_base = mapped_commit(replayed_commits, base, onto);
+
+	result->tree = repo_get_commit_tree(repo, replayed_base);
+	pickme_tree = repo_get_commit_tree(repo, pickme);
+	base_tree = repo_get_commit_tree(repo, base);
+
+	merge_opt->branch1 = short_commit_name(repo, replayed_base);
+	merge_opt->branch2 = short_commit_name(repo, pickme);
+	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
+
+	merge_incore_nonrecursive(merge_opt,
+				  base_tree,
+				  result->tree,
+				  pickme_tree,
+				  result);
+
+	free((char*)merge_opt->ancestor);
+	merge_opt->ancestor = NULL;
+	if (!result->clean)
+		return NULL;
+	return replay_create_commit(repo, result->tree, pickme, replayed_base);
+}
diff --git a/replay.h b/replay.h
new file mode 100644
index 00000000000..d6535ee56c9
--- /dev/null
+++ b/replay.h
@@ -0,0 +1,23 @@
+#ifndef REPLAY_H
+#define REPLAY_H
+
+#include "khash.h"
+#include "merge-ort.h"
+#include "repository.h"
+
+struct commit;
+struct tree;
+
+struct commit *replay_create_commit(struct repository *repo,
+				    struct tree *tree,
+				    struct commit *based_on,
+				    struct commit *parent);
+
+struct commit *replay_pick_regular_commit(struct repository *repo,
+					  struct commit *pickme,
+					  kh_oid_map_t *replayed_commits,
+					  struct commit *onto,
+					  struct merge_options *merge_opt,
+					  struct merge_result *result);
+
+#endif
-- 
2.51.1.930.gacf6e81ea2.dirty
Phillip Wood· Nov 17, 2025, 16:27 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 02/11] replay: extract logic to pick commits

Hi Patrick
On 27/10/2025 11:33, Patrick Steinhardt wrote:
Show 7 quoted lines
> We're about to add a new git-history(1) command that will reuse some of
> the same infrastructure as git-replay(1). To prepare for this, extract
> the logic to pick a commit into a new "replay.c" file so that it can be
> shared between both commands.
> 
> Rename the function to have a "replay_" prefix to clearly indicate its
> subsystem.

I'm sorry it has taken me so long to get round to looking at this, I've been intending to read through this series ever since you re-rolled after the contributor summit.

This patch looks good, the only changes to the moved code are to namespace the function which become public. I'm very pleased to see us switching to using the replay machinery.

Thanks
Phillip
Show 340 quoted lines
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>   Makefile         |   1 +
>   builtin/replay.c | 110 ++--------------------------------------------------
>   meson.build      |   1 +
>   replay.c         | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
>   replay.h         |  23 +++++++++++
>   5 files changed, 143 insertions(+), 107 deletions(-)
> 
> diff --git a/Makefile b/Makefile
> index 1919d35bf3f..01c171b4f03 100644
> --- a/Makefile
> +++ b/Makefile
> @@ -1261,6 +1261,7 @@ LIB_OBJS += reftable/tree.o
>   LIB_OBJS += reftable/writer.o
>   LIB_OBJS += remote.o
>   LIB_OBJS += replace-object.o
> +LIB_OBJS += replay.o
>   LIB_OBJS += repo-settings.o
>   LIB_OBJS += repository.o
>   LIB_OBJS += rerere.o
> diff --git a/builtin/replay.c b/builtin/replay.c
> index bb0420dc992..e39824912cd 100644
> --- a/builtin/replay.c
> +++ b/builtin/replay.c
> @@ -2,7 +2,6 @@
>    * "git replay" builtin command
>    */
>   
> -#define USE_THE_REPOSITORY_VARIABLE
>   #define DISABLE_SIGN_COMPARE_WARNINGS
>   
>   #include "git-compat-util.h"
> @@ -16,6 +15,7 @@
>   #include "object-name.h"
>   #include "parse-options.h"
>   #include "refs.h"
> +#include "replay.h"
>   #include "revision.h"
>   #include "strmap.h"
>   #include <oidset.h>
> @@ -26,13 +26,6 @@ enum ref_action_mode {
>   	REF_ACTION_PRINT
>   };
>   
> -static const char *short_commit_name(struct repository *repo,
> -				     struct commit *commit)
> -{
> -	return repo_find_unique_abbrev(repo, &commit->object.oid,
> -				       DEFAULT_ABBREV);
> -}
> -
>   static struct commit *peel_committish(struct repository *repo, const char *name)
>   {
>   	struct object *obj;
> @@ -45,59 +38,6 @@ static struct commit *peel_committish(struct repository *repo, const char *name)
>   						  OBJ_COMMIT);
>   }
>   
> -static char *get_author(const char *message)
> -{
> -	size_t len;
> -	const char *a;
> -
> -	a = find_commit_header(message, "author", &len);
> -	if (a)
> -		return xmemdupz(a, len);
> -
> -	return NULL;
> -}
> -
> -static struct commit *create_commit(struct repository *repo,
> -				    struct tree *tree,
> -				    struct commit *based_on,
> -				    struct commit *parent)
> -{
> -	struct object_id ret;
> -	struct object *obj = NULL;
> -	struct commit_list *parents = NULL;
> -	char *author;
> -	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
> -	struct commit_extra_header *extra = NULL;
> -	struct strbuf msg = STRBUF_INIT;
> -	const char *out_enc = get_commit_output_encoding();
> -	const char *message = repo_logmsg_reencode(repo, based_on,
> -						   NULL, out_enc);
> -	const char *orig_message = NULL;
> -	const char *exclude_gpgsig[] = { "gpgsig", NULL };
> -
> -	commit_list_insert(parent, &parents);
> -	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
> -	find_commit_subject(message, &orig_message);
> -	strbuf_addstr(&msg, orig_message);
> -	author = get_author(message);
> -	reset_ident_date();
> -	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
> -				 &ret, author, NULL, sign_commit, extra)) {
> -		error(_("failed to write commit object"));
> -		goto out;
> -	}
> -
> -	obj = parse_object(repo, &ret);
> -
> -out:
> -	repo_unuse_commit_buffer(the_repository, based_on, message);
> -	free_commit_extra_headers(extra);
> -	free_commit_list(parents);
> -	strbuf_release(&msg);
> -	free(author);
> -	return (struct commit *)obj;
> -}
> -
>   struct ref_info {
>   	struct commit *onto;
>   	struct strset positive_refs;
> @@ -246,50 +186,6 @@ static void determine_replay_mode(struct repository *repo,
>   	strset_clear(&rinfo.positive_refs);
>   }
>   
> -static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
> -				    struct commit *commit,
> -				    struct commit *fallback)
> -{
> -	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
> -	if (pos == kh_end(replayed_commits))
> -		return fallback;
> -	return kh_value(replayed_commits, pos);
> -}
> -
> -static struct commit *pick_regular_commit(struct repository *repo,
> -					  struct commit *pickme,
> -					  kh_oid_map_t *replayed_commits,
> -					  struct commit *onto,
> -					  struct merge_options *merge_opt,
> -					  struct merge_result *result)
> -{
> -	struct commit *base, *replayed_base;
> -	struct tree *pickme_tree, *base_tree;
> -
> -	base = pickme->parents->item;
> -	replayed_base = mapped_commit(replayed_commits, base, onto);
> -
> -	result->tree = repo_get_commit_tree(repo, replayed_base);
> -	pickme_tree = repo_get_commit_tree(repo, pickme);
> -	base_tree = repo_get_commit_tree(repo, base);
> -
> -	merge_opt->branch1 = short_commit_name(repo, replayed_base);
> -	merge_opt->branch2 = short_commit_name(repo, pickme);
> -	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
> -
> -	merge_incore_nonrecursive(merge_opt,
> -				  base_tree,
> -				  result->tree,
> -				  pickme_tree,
> -				  result);
> -
> -	free((char*)merge_opt->ancestor);
> -	merge_opt->ancestor = NULL;
> -	if (!result->clean)
> -		return NULL;
> -	return create_commit(repo, result->tree, pickme, replayed_base);
> -}
> -
>   static int handle_ref_update(enum ref_action_mode mode,
>   			     struct ref_transaction *transaction,
>   			     const char *refname,
> @@ -483,8 +379,8 @@ int cmd_replay(int argc,
>   		if (commit->parents->next)
>   			die(_("replaying merge commits is not supported yet!"));
>   
> -		last_commit = pick_regular_commit(repo, commit, replayed_commits,
> -						  onto, &merge_opt, &result);
> +		last_commit = replay_pick_regular_commit(repo, commit, replayed_commits,
> +							 onto, &merge_opt, &result);
>   		if (!last_commit)
>   			break;
>   
> diff --git a/meson.build b/meson.build
> index cee94244759..ae8d4fef059 100644
> --- a/meson.build
> +++ b/meson.build
> @@ -464,6 +464,7 @@ libgit_sources = [
>     'reftable/writer.c',
>     'remote.c',
>     'replace-object.c',
> +  'replay.c',
>     'repo-settings.c',
>     'repository.c',
>     'rerere.c',
> diff --git a/replay.c b/replay.c
> new file mode 100644
> index 00000000000..98be33b8545
> --- /dev/null
> +++ b/replay.c
> @@ -0,0 +1,115 @@
> +#define USE_THE_REPOSITORY_VARIABLE
> +
> +#include "git-compat-util.h"
> +#include "commit.h"
> +#include "environment.h"
> +#include "gettext.h"
> +#include "ident.h"
> +#include "object.h"
> +#include "object-name.h"
> +#include "replay.h"
> +#include "tree.h"
> +
> +static const char *short_commit_name(struct repository *repo,
> +				     struct commit *commit)
> +{
> +	return repo_find_unique_abbrev(repo, &commit->object.oid,
> +				       DEFAULT_ABBREV);
> +}
> +
> +static char *get_author(const char *message)
> +{
> +	size_t len;
> +	const char *a;
> +
> +	a = find_commit_header(message, "author", &len);
> +	if (a)
> +		return xmemdupz(a, len);
> +
> +	return NULL;
> +}
> +
> +struct commit *replay_create_commit(struct repository *repo,
> +				    struct tree *tree,
> +				    struct commit *based_on,
> +				    struct commit *parent)
> +{
> +	struct object_id ret;
> +	struct object *obj = NULL;
> +	struct commit_list *parents = NULL;
> +	char *author;
> +	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
> +	struct commit_extra_header *extra = NULL;
> +	struct strbuf msg = STRBUF_INIT;
> +	const char *out_enc = get_commit_output_encoding();
> +	const char *message = repo_logmsg_reencode(repo, based_on,
> +						   NULL, out_enc);
> +	const char *orig_message = NULL;
> +	const char *exclude_gpgsig[] = { "gpgsig", NULL };
> +
> +	commit_list_insert(parent, &parents);
> +	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
> +	find_commit_subject(message, &orig_message);
> +	strbuf_addstr(&msg, orig_message);
> +	author = get_author(message);
> +	reset_ident_date();
> +	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
> +				 &ret, author, NULL, sign_commit, extra)) {
> +		error(_("failed to write commit object"));
> +		goto out;
> +	}
> +
> +	obj = parse_object(repo, &ret);
> +
> +out:
> +	repo_unuse_commit_buffer(the_repository, based_on, message);
> +	free_commit_extra_headers(extra);
> +	free_commit_list(parents);
> +	strbuf_release(&msg);
> +	free(author);
> +	return (struct commit *)obj;
> +}
> +
> +static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
> +				    struct commit *commit,
> +				    struct commit *fallback)
> +{
> +	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
> +	if (pos == kh_end(replayed_commits))
> +		return fallback;
> +	return kh_value(replayed_commits, pos);
> +}
> +
> +struct commit *replay_pick_regular_commit(struct repository *repo,
> +					  struct commit *pickme,
> +					  kh_oid_map_t *replayed_commits,
> +					  struct commit *onto,
> +					  struct merge_options *merge_opt,
> +					  struct merge_result *result)
> +{
> +	struct commit *base, *replayed_base;
> +	struct tree *pickme_tree, *base_tree;
> +
> +	base = pickme->parents->item;
> +	replayed_base = mapped_commit(replayed_commits, base, onto);
> +
> +	result->tree = repo_get_commit_tree(repo, replayed_base);
> +	pickme_tree = repo_get_commit_tree(repo, pickme);
> +	base_tree = repo_get_commit_tree(repo, base);
> +
> +	merge_opt->branch1 = short_commit_name(repo, replayed_base);
> +	merge_opt->branch2 = short_commit_name(repo, pickme);
> +	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
> +
> +	merge_incore_nonrecursive(merge_opt,
> +				  base_tree,
> +				  result->tree,
> +				  pickme_tree,
> +				  result);
> +
> +	free((char*)merge_opt->ancestor);
> +	merge_opt->ancestor = NULL;
> +	if (!result->clean)
> +		return NULL;
> +	return replay_create_commit(repo, result->tree, pickme, replayed_base);
> +}
> diff --git a/replay.h b/replay.h
> new file mode 100644
> index 00000000000..d6535ee56c9
> --- /dev/null
> +++ b/replay.h
> @@ -0,0 +1,23 @@
> +#ifndef REPLAY_H
> +#define REPLAY_H
> +
> +#include "khash.h"
> +#include "merge-ort.h"
> +#include "repository.h"
> +
> +struct commit;
> +struct tree;
> +
> +struct commit *replay_create_commit(struct repository *repo,
> +				    struct tree *tree,
> +				    struct commit *based_on,
> +				    struct commit *parent);
> +
> +struct commit *replay_pick_regular_commit(struct repository *repo,
> +					  struct commit *pickme,
> +					  kh_oid_map_t *replayed_commits,
> +					  struct commit *onto,
> +					  struct merge_options *merge_opt,
> +					  struct merge_result *result);
> +
> +#endif
> 
Elijah Newren· Nov 20, 2025, 07:01 UTC · re: Phillip Wood · lore

Re: [PATCH v6 02/11] replay: extract logic to pick commits

On Mon, Nov 17, 2025 at 8:27 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
Show 19 quoted lines
>
> Hi Patrick
>
> On 27/10/2025 11:33, Patrick Steinhardt wrote:
> > We're about to add a new git-history(1) command that will reuse some of
> > the same infrastructure as git-replay(1). To prepare for this, extract
> > the logic to pick a commit into a new "replay.c" file so that it can be
> > shared between both commands.
> >
> > Rename the function to have a "replay_" prefix to clearly indicate its
> > subsystem.
>
> I'm sorry it has taken me so long to get round to looking at this, I've
> been intending to read through this series ever since you re-rolled
> after the contributor summit.
>
> This patch looks good, the only changes to the moved code are to
> namespace the function which become public. I'm very pleased to see us
> switching to using the replay machinery.

The _two_ functions which become public; otherwise, agreed with Phillip's review comments.

Patrick Steinhardt· Oct 27, 2025, 11:33 UTC · re: Patrick Steinhardt · lore

[PATCH v6 03/11] replay: stop using `the_repository`

In `create_commit()` we're using `the_repository` even though we already have a repository passed to use as an argument. Fix this.

Note that we still cannot get rid of `USE_THE_REPOSITORY_VARIABLE`. This is because we use `DEFAULT_ABBREV and `get_commit_output_encoding()`, both of which are stored as global variables that can be modified via the Git configuration.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 replay.c | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
Show changes to replay.c +1 −1
diff --git a/replay.c b/replay.c
index 98be33b854..58fdc20140 100644
--- a/replay.c
+++ b/replay.c
@@ -62,7 +62,7 @@ struct commit *replay_create_commit(struct repository *repo,
 	obj = parse_object(repo, &ret);
 
 out:
-	repo_unuse_commit_buffer(the_repository, based_on, message);
+	repo_unuse_commit_buffer(repo, based_on, message);
 	free_commit_extra_headers(extra);
 	free_commit_list(parents);
 	strbuf_release(&msg);
-- 
2.51.1.930.gacf6e81ea2.dirty
Elijah Newren· Nov 20, 2025, 07:01 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 03/11] replay: stop using `the_repository`

On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
>
> In `create_commit()` we're using `the_repository` even though we already
> have a repository passed to use as an argument. Fix this.

I feel like I've fixed this multiple times, but it keeps coming back. In fact, I have this same fix locally in my replay-edit work. Thanks for fixing it.

Show 5 quoted lines
>
> Note that we still cannot get rid of `USE_THE_REPOSITORY_VARIABLE`. This
> is because we use `DEFAULT_ABBREV and `get_commit_output_encoding()`,
> both of which are stored as global variables that can be modified via
> the Git configuration.
Indeed.

Going on a tangent for a second...I feel like I've had to remove "the_repository" from builtin/replay.c multiple times. In my local replay-edit work, I actually added a "#define the_repository DO_NOT_USE_THE_REPOSITORY" in builtin/replay.c, after all the header includes, because the_repository isn't what builtin/replay.c is using, it's these other two things that are also only included if USE_THE_REPOSITORY_VARIABALE is defined. That obviously doesn't need to be part of your series, but what would you think if I were to submit that? Is it too ugly/weird of a way to avoid the_repository being added back to builtin/replay.c so we can stop having to remove it again?

Show 21 quoted lines
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  replay.c | 2 +-
>  1 file changed, 1 insertion(+), 1 deletion(-)
>
> diff --git a/replay.c b/replay.c
> index 98be33b854..58fdc20140 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -62,7 +62,7 @@ struct commit *replay_create_commit(struct repository *repo,
>         obj = parse_object(repo, &ret);
>
>  out:
> -       repo_unuse_commit_buffer(the_repository, based_on, message);
> +       repo_unuse_commit_buffer(repo, based_on, message);
>         free_commit_extra_headers(extra);
>         free_commit_list(parents);
>         strbuf_release(&msg);
>
> --
> 2.51.1.930.gacf6e81ea2.dirty
Patch looks good.
Patrick Steinhardt· Dec 2, 2025, 18:47 UTC · re: Elijah Newren · lore

Re: [PATCH v6 03/11] replay: stop using `the_repository`

On Wed, Nov 19, 2025 at 11:01:29PM -0800, Elijah Newren wrote:
Show 28 quoted lines
> On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
> >
> > In `create_commit()` we're using `the_repository` even though we already
> > have a repository passed to use as an argument. Fix this.
> 
> I feel like I've fixed this multiple times, but it keeps coming back.
> In fact, I have this same fix locally in my replay-edit work.  Thanks
> for fixing it.
> 
> >
> > Note that we still cannot get rid of `USE_THE_REPOSITORY_VARIABLE`. This
> > is because we use `DEFAULT_ABBREV and `get_commit_output_encoding()`,
> > both of which are stored as global variables that can be modified via
> > the Git configuration.
> 
> Indeed.
> 
> Going on a tangent for a second...I feel like I've had to remove
> "the_repository" from builtin/replay.c multiple times.  In my local
> replay-edit work, I actually added a "#define the_repository
> DO_NOT_USE_THE_REPOSITORY" in builtin/replay.c, after all the header
> includes, because the_repository isn't what builtin/replay.c is using,
> it's these other two things that are also only included if
> USE_THE_REPOSITORY_VARIABALE is defined.  That obviously doesn't need
> to be part of your series, but what would you think if I were to
> submit that?  Is it too ugly/weird of a way to avoid the_repository
> being added back to builtin/replay.c so we can stop having to remove
> it again?

It does feel somewhat ugly, and the better solution would of course be to refactor both `DEFAULT_ABBREV` and `get_commit_output_encoding()` to accept a repository as input. But if I remember correctly that was a nontrivial endeavour, so your proposed hack might be the next-best solution.

Patrick
Patrick Steinhardt· Oct 27, 2025, 11:33 UTC · re: Patrick Steinhardt · lore

[PATCH v6 04/11] builtin: add new "history" command

When rewriting history via git-rebase(1) there are a couple of very common use cases:

  - The ordering of two commits should be reversed.
  - A commit should be split up into two commits.
  - A commit should be dropped from the history completely.
  - Multiple commits should be squashed into one.

While these operations are all doable, it often feels needlessly kludgey to do so by doing an interactive rebase, using the editor to say what one wants, and then perform the actions. Furthermore, some operations like splitting up a commit into two are way more involved than that and require a whole series of commands.

Add a new "history" command to plug this gap. This command will have several different subcommands to imperatively rewrite history for common use cases like the above. These subcommands will be implemented in subsequent commits.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 .gitignore                     |  1 +
 Documentation/git-history.adoc | 44 ++++++++++++++++++++++++++++++++++++++++++
 Documentation/meson.build      |  1 +
 Makefile                       |  1 +
 builtin.h                      |  1 +
 builtin/history.c              | 22 +++++++++++++++++++++
 command-list.txt               |  1 +
 git.c                          |  1 +
 meson.build                    |  1 +
 t/meson.build                  |  1 +
 t/t3450-history.sh             | 17 ++++++++++++++++
 11 files changed, 91 insertions(+)
Show changes to 11 files +91 −0

.gitignore, Documentation/git-history.adoc, Documentation/meson.build, Makefile, builtin.h, builtin/history.c, command-list.txt, git.c, meson.build, t/meson.build, t/t3450-history.sh

diff --git a/.gitignore b/.gitignore
index 78a45cb5bec..24635cf2d6f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,6 +79,7 @@
 /git-grep
 /git-hash-object
 /git-help
+/git-history
 /git-hook
 /git-http-backend
 /git-http-fetch
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
new file mode 100644
index 00000000000..6bdfeb50e8b
--- /dev/null
+++ b/Documentation/git-history.adoc
@@ -0,0 +1,44 @@
+git-history(1)
+==============
+
+NAME
+----
+git-history - EXPERIMENTAL: Rewrite history of the current branch
+
+SYNOPSIS
+--------
+[synopsis]
+git history [<options>]
+
+DESCRIPTION
+-----------
+
+Rewrite history by rearranging or modifying specific commits in the
+history.
+
+THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
+
+This command is similar to linkgit:git-rebase[1] and uses the same
+underlying machinery. You should use rebases if you want to reapply a range of
+commits onto a different base, or interactive rebases if you want to edit a
+range of commits.
+
+Note that this command does not (yet) work with histories that contain
+merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
+flag instead.
+
+COMMANDS
+--------
+
+Several commands are available to rewrite history in different ways:
+
+CONFIGURATION
+-------------
+
+include::includes/cmd-config-section-all.adoc[]
+
+include::config/sequencer.adoc[]
+
+GIT
+---
+Part of the linkgit:git[1] suite
diff --git a/Documentation/meson.build b/Documentation/meson.build
index 9d24f2da544..d1f6bde7c16 100644
--- a/Documentation/meson.build
+++ b/Documentation/meson.build
@@ -64,6 +64,7 @@ manpages = {
   'git-gui.adoc' : 1,
   'git-hash-object.adoc' : 1,
   'git-help.adoc' : 1,
+  'git-history.adoc' : 1,
   'git-hook.adoc' : 1,
   'git-http-backend.adoc' : 1,
   'git-http-fetch.adoc' : 1,
diff --git a/Makefile b/Makefile
index 01c171b4f03..1380ee1e196 100644
--- a/Makefile
+++ b/Makefile
@@ -1395,6 +1395,7 @@ BUILTIN_OBJS += builtin/get-tar-commit-id.o
 BUILTIN_OBJS += builtin/grep.o
 BUILTIN_OBJS += builtin/hash-object.o
 BUILTIN_OBJS += builtin/help.o
+BUILTIN_OBJS += builtin/history.o
 BUILTIN_OBJS += builtin/hook.o
 BUILTIN_OBJS += builtin/index-pack.o
 BUILTIN_OBJS += builtin/init-db.o
diff --git a/builtin.h b/builtin.h
index 1b35565fbd9..93c91d07d4b 100644
--- a/builtin.h
+++ b/builtin.h
@@ -172,6 +172,7 @@ int cmd_get_tar_commit_id(int argc, const char **argv, const char *prefix, struc
 int cmd_grep(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hash_object(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_help(int argc, const char **argv, const char *prefix, struct repository *repo);
+int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hook(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);
diff --git a/builtin/history.c b/builtin/history.c
new file mode 100644
index 00000000000..f6fe32610b0
--- /dev/null
+++ b/builtin/history.c
@@ -0,0 +1,22 @@
+#include "builtin.h"
+#include "gettext.h"
+#include "parse-options.h"
+
+int cmd_history(int argc,
+		const char **argv,
+		const char *prefix,
+		struct repository *repo UNUSED)
+{
+	const char * const usage[] = {
+		N_("git history [<options>]"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc)
+		usagef("unrecognized argument: %s", argv[0]);
+	return 0;
+}
diff --git a/command-list.txt b/command-list.txt
index accd3d0c4b5..f9005cf4597 100644
--- a/command-list.txt
+++ b/command-list.txt
@@ -115,6 +115,7 @@ git-grep                                mainporcelain           info
 git-gui                                 mainporcelain
 git-hash-object                         plumbingmanipulators
 git-help                                ancillaryinterrogators          complete
+git-history                             mainporcelain           history
 git-hook                                purehelpers
 git-http-backend                        synchingrepositories
 git-http-fetch                          synchelpers
diff --git a/git.c b/git.c
index c5fad56813f..744cb6527e0 100644
--- a/git.c
+++ b/git.c
@@ -586,6 +586,7 @@ static struct cmd_struct commands[] = {
 	{ "grep", cmd_grep, RUN_SETUP_GENTLY },
 	{ "hash-object", cmd_hash_object },
 	{ "help", cmd_help },
+	{ "history", cmd_history, RUN_SETUP },
 	{ "hook", cmd_hook, RUN_SETUP },
 	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
 	{ "init", cmd_init_db },
diff --git a/meson.build b/meson.build
index ae8d4fef059..2d789612a01 100644
--- a/meson.build
+++ b/meson.build
@@ -604,6 +604,7 @@ builtin_sources = [
   'builtin/grep.c',
   'builtin/hash-object.c',
   'builtin/help.c',
+  'builtin/history.c',
   'builtin/hook.c',
   'builtin/index-pack.c',
   'builtin/init-db.c',
diff --git a/t/meson.build b/t/meson.build
index 401b24e50e0..019435918fa 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -384,6 +384,7 @@ integration_tests = [
   't3436-rebase-more-options.sh',
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
+  't3450-history.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
new file mode 100755
index 00000000000..417c343d43b
--- /dev/null
+++ b/t/t3450-history.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+test_description='tests for git-history command'
+
+. ./test-lib.sh
+
+test_expect_success 'does nothing without any arguments' '
+	git history >out 2>&1 &&
+	test_must_be_empty out
+'
+
+test_expect_success 'raises an error with unknown argument' '
+	test_must_fail git history garbage 2>err &&
+	test_grep "unrecognized argument: garbage" err
+'
+
+test_done
-- 
2.51.1.930.gacf6e81ea2.dirty
Phillip Wood· Nov 17, 2025, 16:28 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 04/11] builtin: add new "history" command

Hi Patrick
On 27/10/2025 11:33, Patrick Steinhardt wrote:
> When rewriting history via git-rebase(1) there are a couple of very
There's more than a couple of items in this list, s/couple of/few/?
Show 9 quoted lines
> common use cases:
> 
>    - The ordering of two commits should be reversed.
> 
>    - A commit should be split up into two commits.
> 
>    - A commit should be dropped from the history completely.
> 
>    - Multiple commits should be squashed into one.

I'd add editing an existing commit to this list, even if we don't implement it initially

Show 25 quoted lines
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> new file mode 100644
> index 00000000000..6bdfeb50e8b
> --- /dev/null
> +++ b/Documentation/git-history.adoc
> @@ -0,0 +1,44 @@
> +git-history(1)
> +==============
> +
> +NAME
> +----
> +git-history - EXPERIMENTAL: Rewrite history of the current branch
> +
> +SYNOPSIS
> +--------
> +[synopsis]
> +git history [<options>]
> +
> +DESCRIPTION
> +-----------
> +
> +Rewrite history by rearranging or modifying specific commits in the
> +history.
> +
> +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
Excellent - keeping our options open is a very good idea
> +This command is similar to linkgit:git-rebase[1] and uses the same
> +underlying machinery.

This isn't strictly true now that we're baisg "git history" on the replay machinery.

Show 19 quoted lines
> You should use rebases if you want to reapply a range of
> +commits onto a different base, or interactive rebases if you want to edit a
> +range of commits.
> +
> +Note that this command does not (yet) work with histories that contain
> +merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
> +flag instead.
> +
> +COMMANDS
> +--------
> +
> +Several commands are available to rewrite history in different ways:
> +
> +CONFIGURATION
> +-------------
> +
> +include::includes/cmd-config-section-all.adoc[]
> +
> +include::config/sequencer.adoc[]
This probably isn't relevant now we're not using the sequencer.
Thanks
Phillip
Show 140 quoted lines
> +
> +GIT
> +---
> +Part of the linkgit:git[1] suite
> diff --git a/Documentation/meson.build b/Documentation/meson.build
> index 9d24f2da544..d1f6bde7c16 100644
> --- a/Documentation/meson.build
> +++ b/Documentation/meson.build
> @@ -64,6 +64,7 @@ manpages = {
>     'git-gui.adoc' : 1,
>     'git-hash-object.adoc' : 1,
>     'git-help.adoc' : 1,
> +  'git-history.adoc' : 1,
>     'git-hook.adoc' : 1,
>     'git-http-backend.adoc' : 1,
>     'git-http-fetch.adoc' : 1,
> diff --git a/Makefile b/Makefile
> index 01c171b4f03..1380ee1e196 100644
> --- a/Makefile
> +++ b/Makefile
> @@ -1395,6 +1395,7 @@ BUILTIN_OBJS += builtin/get-tar-commit-id.o
>   BUILTIN_OBJS += builtin/grep.o
>   BUILTIN_OBJS += builtin/hash-object.o
>   BUILTIN_OBJS += builtin/help.o
> +BUILTIN_OBJS += builtin/history.o
>   BUILTIN_OBJS += builtin/hook.o
>   BUILTIN_OBJS += builtin/index-pack.o
>   BUILTIN_OBJS += builtin/init-db.o
> diff --git a/builtin.h b/builtin.h
> index 1b35565fbd9..93c91d07d4b 100644
> --- a/builtin.h
> +++ b/builtin.h
> @@ -172,6 +172,7 @@ int cmd_get_tar_commit_id(int argc, const char **argv, const char *prefix, struc
>   int cmd_grep(int argc, const char **argv, const char *prefix, struct repository *repo);
>   int cmd_hash_object(int argc, const char **argv, const char *prefix, struct repository *repo);
>   int cmd_help(int argc, const char **argv, const char *prefix, struct repository *repo);
> +int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo);
>   int cmd_hook(int argc, const char **argv, const char *prefix, struct repository *repo);
>   int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);
>   int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);
> diff --git a/builtin/history.c b/builtin/history.c
> new file mode 100644
> index 00000000000..f6fe32610b0
> --- /dev/null
> +++ b/builtin/history.c
> @@ -0,0 +1,22 @@
> +#include "builtin.h"
> +#include "gettext.h"
> +#include "parse-options.h"
> +
> +int cmd_history(int argc,
> +		const char **argv,
> +		const char *prefix,
> +		struct repository *repo UNUSED)
> +{
> +	const char * const usage[] = {
> +		N_("git history [<options>]"),
> +		NULL,
> +	};
> +	struct option options[] = {
> +		OPT_END(),
> +	};
> +
> +	argc = parse_options(argc, argv, prefix, options, usage, 0);
> +	if (argc)
> +		usagef("unrecognized argument: %s", argv[0]);
> +	return 0;
> +}
> diff --git a/command-list.txt b/command-list.txt
> index accd3d0c4b5..f9005cf4597 100644
> --- a/command-list.txt
> +++ b/command-list.txt
> @@ -115,6 +115,7 @@ git-grep                                mainporcelain           info
>   git-gui                                 mainporcelain
>   git-hash-object                         plumbingmanipulators
>   git-help                                ancillaryinterrogators          complete
> +git-history                             mainporcelain           history
>   git-hook                                purehelpers
>   git-http-backend                        synchingrepositories
>   git-http-fetch                          synchelpers
> diff --git a/git.c b/git.c
> index c5fad56813f..744cb6527e0 100644
> --- a/git.c
> +++ b/git.c
> @@ -586,6 +586,7 @@ static struct cmd_struct commands[] = {
>   	{ "grep", cmd_grep, RUN_SETUP_GENTLY },
>   	{ "hash-object", cmd_hash_object },
>   	{ "help", cmd_help },
> +	{ "history", cmd_history, RUN_SETUP },
>   	{ "hook", cmd_hook, RUN_SETUP },
>   	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
>   	{ "init", cmd_init_db },
> diff --git a/meson.build b/meson.build
> index ae8d4fef059..2d789612a01 100644
> --- a/meson.build
> +++ b/meson.build
> @@ -604,6 +604,7 @@ builtin_sources = [
>     'builtin/grep.c',
>     'builtin/hash-object.c',
>     'builtin/help.c',
> +  'builtin/history.c',
>     'builtin/hook.c',
>     'builtin/index-pack.c',
>     'builtin/init-db.c',
> diff --git a/t/meson.build b/t/meson.build
> index 401b24e50e0..019435918fa 100644
> --- a/t/meson.build
> +++ b/t/meson.build
> @@ -384,6 +384,7 @@ integration_tests = [
>     't3436-rebase-more-options.sh',
>     't3437-rebase-fixup-options.sh',
>     't3438-rebase-broken-files.sh',
> +  't3450-history.sh',
>     't3500-cherry.sh',
>     't3501-revert-cherry-pick.sh',
>     't3502-cherry-pick-merge.sh',
> diff --git a/t/t3450-history.sh b/t/t3450-history.sh
> new file mode 100755
> index 00000000000..417c343d43b
> --- /dev/null
> +++ b/t/t3450-history.sh
> @@ -0,0 +1,17 @@
> +#!/bin/sh
> +
> +test_description='tests for git-history command'
> +
> +. ./test-lib.sh
> +
> +test_expect_success 'does nothing without any arguments' '
> +	git history >out 2>&1 &&
> +	test_must_be_empty out
> +'
> +
> +test_expect_success 'raises an error with unknown argument' '
> +	test_must_fail git history garbage 2>err &&
> +	test_grep "unrecognized argument: garbage" err
> +'
> +
> +test_done
> 
Patrick Steinhardt· Dec 2, 2025, 18:48 UTC · re: Phillip Wood · lore

Re: [PATCH v6 04/11] builtin: add new "history" command

On Mon, Nov 17, 2025 at 04:28:06PM +0000, Phillip Wood wrote:
Show 19 quoted lines
> Hi Patrick
> 
> On 27/10/2025 11:33, Patrick Steinhardt wrote:
> > When rewriting history via git-rebase(1) there are a couple of very
> 
> There's more than a couple of items in this list, s/couple of/few/?
> 
> > common use cases:
> > 
> >    - The ordering of two commits should be reversed.
> > 
> >    - A commit should be split up into two commits.
> > 
> >    - A commit should be dropped from the history completely.
> > 
> >    - Multiple commits should be squashed into one.
> 
> I'd add editing an existing commit to this list, even if we don't implement
> it initially
Fair indeed.
Show 33 quoted lines
> > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > new file mode 100644
> > index 00000000000..6bdfeb50e8b
> > --- /dev/null
> > +++ b/Documentation/git-history.adoc
> > @@ -0,0 +1,44 @@
> > +git-history(1)
> > +==============
> > +
> > +NAME
> > +----
> > +git-history - EXPERIMENTAL: Rewrite history of the current branch
> > +
> > +SYNOPSIS
> > +--------
> > +[synopsis]
> > +git history [<options>]
> > +
> > +DESCRIPTION
> > +-----------
> > +
> > +Rewrite history by rearranging or modifying specific commits in the
> > +history.
> > +
> > +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
> 
> Excellent - keeping our options open is a very good idea
> 
> > +This command is similar to linkgit:git-rebase[1] and uses the same
> > +underlying machinery.
> 
> This isn't strictly true now that we're baisg "git history" on the replay
> machinery.
True, this is a historic leftover.
Show 21 quoted lines
> > You should use rebases if you want to reapply a range of
> > +commits onto a different base, or interactive rebases if you want to edit a
> > +range of commits.
> > +
> > +Note that this command does not (yet) work with histories that contain
> > +merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
> > +flag instead.
> > +
> > +COMMANDS
> > +--------
> > +
> > +Several commands are available to rewrite history in different ways:
> > +
> > +CONFIGURATION
> > +-------------
> > +
> > +include::includes/cmd-config-section-all.adoc[]
> > +
> > +include::config/sequencer.adoc[]
> 
> This probably isn't relevant now we're not using the sequencer.
And this, too.
Patrick
Elijah Newren· Nov 20, 2025, 07:02 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 04/11] builtin: add new "history" command

In addition to what Phillip commented on...
On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 22 quoted lines
>
> When rewriting history via git-rebase(1) there are a couple of very
> common use cases:
>
>   - The ordering of two commits should be reversed.
>
>   - A commit should be split up into two commits.
>
>   - A commit should be dropped from the history completely.
>
>   - Multiple commits should be squashed into one.
>
> While these operations are all doable, it often feels needlessly kludgey
> to do so by doing an interactive rebase, using the editor to say what
> one wants, and then perform the actions. Furthermore, some operations
> like splitting up a commit into two are way more involved than that and
> require a whole series of commands.
>
> Add a new "history" command to plug this gap. This command will have
> several different subcommands to imperatively rewrite history for common
> use cases like the above. These subcommands will be implemented in
> subsequent commits.

"...*Some of* these subcommands will be implemented...", right? You only implement two of them in this series, not all of them, or am I reading wrong?

Patrick Steinhardt· Dec 2, 2025, 18:48 UTC · re: Elijah Newren · lore

Re: [PATCH v6 04/11] builtin: add new "history" command

On Wed, Nov 19, 2025 at 11:02:20PM -0800, Elijah Newren wrote:
Show 29 quoted lines
> In addition to what Phillip commented on...
> 
> On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
> >
> > When rewriting history via git-rebase(1) there are a couple of very
> > common use cases:
> >
> >   - The ordering of two commits should be reversed.
> >
> >   - A commit should be split up into two commits.
> >
> >   - A commit should be dropped from the history completely.
> >
> >   - Multiple commits should be squashed into one.
> >
> > While these operations are all doable, it often feels needlessly kludgey
> > to do so by doing an interactive rebase, using the editor to say what
> > one wants, and then perform the actions. Furthermore, some operations
> > like splitting up a commit into two are way more involved than that and
> > require a whole series of commands.
> >
> > Add a new "history" command to plug this gap. This command will have
> > several different subcommands to imperatively rewrite history for common
> > use cases like the above. These subcommands will be implemented in
> > subsequent commits.
> 
> "...*Some of* these subcommands will be implemented...", right?  You
> only implement two of them in this series, not all of them, or am I
> reading wrong?

No, you're right. The initial versions of this series implemented more of the above commands, but at no point in time did we actually implement all of them.

Patrick
D. Ben Knoble· Dec 2, 2025, 22:44 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 04/11] builtin: add new "history" command

On Tue, Dec 2, 2025 at 1:48 PM Patrick Steinhardt <ps@pks.im> wrote:
Show 37 quoted lines
>
> On Wed, Nov 19, 2025 at 11:02:20PM -0800, Elijah Newren wrote:
> > In addition to what Phillip commented on...
> >
> > On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
> > >
> > > When rewriting history via git-rebase(1) there are a couple of very
> > > common use cases:
> > >
> > >   - The ordering of two commits should be reversed.
> > >
> > >   - A commit should be split up into two commits.
> > >
> > >   - A commit should be dropped from the history completely.
> > >
> > >   - Multiple commits should be squashed into one.
> > >
> > > While these operations are all doable, it often feels needlessly kludgey
> > > to do so by doing an interactive rebase, using the editor to say what
> > > one wants, and then perform the actions. Furthermore, some operations
> > > like splitting up a commit into two are way more involved than that and
> > > require a whole series of commands.
> > >
> > > Add a new "history" command to plug this gap. This command will have
> > > several different subcommands to imperatively rewrite history for common
> > > use cases like the above. These subcommands will be implemented in
> > > subsequent commits.
> >
> > "...*Some of* these subcommands will be implemented...", right?  You
> > only implement two of them in this series, not all of them, or am I
> > reading wrong?
>
> No, you're right. The initial versions of this series implemented more
> of the above commands, but at no point in time did we actually implement
> all of them.
>
> Patrick

While I'm thinking of it, at work today I had occasion to use "drop" and "reorder" (from an old version of this series whose binary I happened to still have laying around), and it was very convenient. Looking forward to it ;)

- drop: I had made some changes on my tree that needed to be in a
separate branch. I didn't want to mess with stashes for some reason,
so I did "commit; switch -c …; cherry-pick @@{1}" (or something
similar). Then when coming back (switch -), I could just do "drop @".
I'm sure there's a better way to do this slicing that wouldn't have
needed drop, but I couldn't think of it at the time.
- reorder: I have a series of mostly logical, separate wip commits
that need some more explanation and might need further tweaks as I go;
I'm working on uncommitted changes that logically belong to the tip,
but spot something else that will be a separate commit. I make that
commit, then "reorder @ --before-commit=@~", and voilà, I'm ready to
amend again when I get around to committing the rest of what I have.
-- 
D. Ben Knoble
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: D. Ben Knoble · lore

Re: [PATCH v6 04/11] builtin: add new "history" command

On Tue, Dec 02, 2025 at 05:44:15PM -0500, D. Ben Knoble wrote:
Show 57 quoted lines
> On Tue, Dec 2, 2025 at 1:48 PM Patrick Steinhardt <ps@pks.im> wrote:
> >
> > On Wed, Nov 19, 2025 at 11:02:20PM -0800, Elijah Newren wrote:
> > > In addition to what Phillip commented on...
> > >
> > > On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
> > > >
> > > > When rewriting history via git-rebase(1) there are a couple of very
> > > > common use cases:
> > > >
> > > >   - The ordering of two commits should be reversed.
> > > >
> > > >   - A commit should be split up into two commits.
> > > >
> > > >   - A commit should be dropped from the history completely.
> > > >
> > > >   - Multiple commits should be squashed into one.
> > > >
> > > > While these operations are all doable, it often feels needlessly kludgey
> > > > to do so by doing an interactive rebase, using the editor to say what
> > > > one wants, and then perform the actions. Furthermore, some operations
> > > > like splitting up a commit into two are way more involved than that and
> > > > require a whole series of commands.
> > > >
> > > > Add a new "history" command to plug this gap. This command will have
> > > > several different subcommands to imperatively rewrite history for common
> > > > use cases like the above. These subcommands will be implemented in
> > > > subsequent commits.
> > >
> > > "...*Some of* these subcommands will be implemented...", right?  You
> > > only implement two of them in this series, not all of them, or am I
> > > reading wrong?
> >
> > No, you're right. The initial versions of this series implemented more
> > of the above commands, but at no point in time did we actually implement
> > all of them.
> >
> > Patrick
> 
> While I'm thinking of it, at work today I had occasion to use "drop"
> and "reorder" (from an old version of this series whose binary I
> happened to still have laying around), and it was very convenient.
> Looking forward to it ;)
> 
> - drop: I had made some changes on my tree that needed to be in a
> separate branch. I didn't want to mess with stashes for some reason,
> so I did "commit; switch -c …; cherry-pick @@{1}" (or something
> similar). Then when coming back (switch -), I could just do "drop @".
> I'm sure there's a better way to do this slicing that wouldn't have
> needed drop, but I couldn't think of it at the time.
> 
> - reorder: I have a series of mostly logical, separate wip commits
> that need some more explanation and might need further tweaks as I go;
> I'm working on uncommitted changes that logically belong to the tip,
> but spot something else that will be a separate commit. I make that
> commit, then "reorder @ --before-commit=@~", and voilà, I'm ready to
> amend again when I get around to committing the rest of what I have.

Yeah, I definitely do wish to also upstream these two going forward. It's going to be a bit more complicated though now that we are building on top of the replay infrastructure, because it doesn't know to handle conflicts.

Maybe for an initial version we can get away with just bailing out on a conflict and then iterate.

Patrick
Patrick Steinhardt· Oct 27, 2025, 11:33 UTC · re: Patrick Steinhardt · lore

[PATCH v6 05/11] builtin/history: implement "reword" subcommand

Implement a new "reword" subcommand for git-history(1). This subcommand is essentially the same as if a user performed an interactive rebase with a single commit changed to use the "reword" verb.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |   7 +-
 builtin/history.c              | 331 ++++++++++++++++++++++++++++++++++++++++-
 t/meson.build                  |   1 +
 t/t3450-history.sh             |   6 +-
 t/t3451-history-reword.sh      | 237 +++++++++++++++++++++++++++++
 5 files changed, 573 insertions(+), 9 deletions(-)
Show changes to 5 files +573 −9

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3450-history.sh, t/t3451-history-reword.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 6bdfeb50e8b..bd903875120 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -8,7 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history of the current branch
 SYNOPSIS
 --------
 [synopsis]
-git history [<options>]
+git history reword <commit>
 
 DESCRIPTION
 -----------
@@ -32,6 +32,11 @@ COMMANDS
 
 Several commands are available to rewrite history in different ways:
 
+`reword <commit>`::
+	Rewrite the commit message of the specified commit. All the other
+	details of this commit remain unchanged. This command will spawn an
+	editor with the current message of that commit.
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index f6fe32610b0..cb251ae2e01 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,22 +1,343 @@
+#define USE_THE_REPOSITORY_VARIABLE
+
 #include "builtin.h"
+#include "commit-reach.h"
+#include "commit.h"
+#include "config.h"
+#include "editor.h"
+#include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "parse-options.h"
+#include "refs.h"
+#include "replay.h"
+#include "reset.h"
+#include "revision.h"
+#include "sequencer.h"
+#include "strvec.h"
+#include "tree.h"
+#include "wt-status.h"
+
+#define GIT_HISTORY_REWORD_USAGE N_("git history reword <commit>")
+
+static int collect_commits(struct repository *repo,
+			   struct commit *old_commit,
+			   struct commit *new_commit,
+			   struct strvec *out)
+{
+	struct setup_revision_opt revision_opts = {
+		.assume_dashdash = 1,
+	};
+	struct strvec revisions = STRVEC_INIT;
+	struct commit *child;
+	struct rev_info rev = { 0 };
+	int ret;
+
+	repo_init_revisions(repo, &rev, NULL);
+	strvec_push(&revisions, "");
+	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
+	if (old_commit)
+		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
+
+	setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
+	if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
+		ret = error(_("revision walk setup failed"));
+		goto out;
+	}
+
+	while ((child = get_revision(&rev))) {
+		if (old_commit && !child->parents)
+			BUG("revision walk did not find child commit");
+		if (child->parents && child->parents->next) {
+			ret = error(_("cannot rearrange commit history with merges"));
+			goto out;
+		}
+
+		strvec_push(out, oid_to_hex(&child->object.oid));
+
+		if (child->parents && old_commit &&
+		    commit_list_contains(old_commit, child->parents))
+			break;
+	}
+
+	/*
+	 * Revisions are in newest-order-first. We have to reverse the
+	 * array though so that we pick the oldest commits first.
+	 */
+	for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
+		SWAP(out->v[i], out->v[j]);
+
+	ret = 0;
+
+out:
+	strvec_clear(&revisions);
+	release_revisions(&rev);
+	reset_revision_walk();
+	return ret;
+}
+
+static void replace_commits(struct strvec *commits,
+			    const struct object_id *commit_to_replace,
+			    const struct object_id *replacements,
+			    size_t replacements_nr)
+{
+	char commit_to_replace_oid[GIT_MAX_HEXSZ + 1];
+	struct strvec replacement_oids = STRVEC_INIT;
+	bool found = false;
+
+	oid_to_hex_r(commit_to_replace_oid, commit_to_replace);
+	for (size_t i = 0; i < replacements_nr; i++)
+		strvec_push(&replacement_oids, oid_to_hex(&replacements[i]));
+
+	for (size_t i = 0; i < commits->nr; i++) {
+		if (strcmp(commits->v[i], commit_to_replace_oid))
+			continue;
+		strvec_splice(commits, i, 1, replacement_oids.v, replacement_oids.nr);
+		found = true;
+		break;
+	}
+	if (!found)
+		BUG("could not find commit to replace");
+
+	strvec_clear(&replacement_oids);
+}
+
+static int apply_commits(struct repository *repo,
+			 const struct strvec *commits,
+			 struct commit *onto,
+			 struct commit *orig_head,
+			 const char *action)
+{
+	struct reset_head_opts reset_opts = { 0 };
+	struct strbuf buf = STRBUF_INIT;
+	int ret;
+
+	for (size_t i = 0; i < commits->nr; i++) {
+		struct object_id commit_id;
+		struct commit *commit;
+		const char *end;
+
+		if (parse_oid_hex_algop(commits->v[i], &commit_id, &end,
+					repo->hash_algo)) {
+			ret = error(_("invalid object ID: %s"), commits->v[i]);
+			goto out;
+		}
+
+		commit = lookup_commit(repo, &commit_id);
+		if (!commit || repo_parse_commit(repo, commit)) {
+			ret = error(_("failed to look up commit: %s"), oid_to_hex(&commit_id));
+			goto out;
+		}
+
+		if (!onto) {
+			onto = commit;
+		} else {
+			struct tree *tree = repo_get_commit_tree(repo, commit);
+			onto = replay_create_commit(repo, tree, commit, onto);
+			if (!onto)
+				break;
+		}
+	}
+
+	reset_opts.oid = &onto->object.oid;
+	strbuf_addf(&buf, "%s: switch to rewritten %s", action, oid_to_hex(reset_opts.oid));
+	reset_opts.flags = RESET_HEAD_REFS_ONLY | RESET_ORIG_HEAD;
+	reset_opts.orig_head = &orig_head->object.oid;
+	reset_opts.default_reflog_action = action;
+	if (reset_head(repo, &reset_opts) < 0) {
+		ret = error(_("could not switch to %s"), oid_to_hex(reset_opts.oid));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	strbuf_release(&buf);
+	return ret;
+}
+
+static void change_data_free(void *util, const char *str UNUSED)
+{
+	struct wt_status_change_data *d = util;
+	free(d->rename_source);
+	free(d);
+}
+
+static int fill_commit_message(struct repository *repo,
+			       const struct object_id *old_tree,
+			       const struct object_id *new_tree,
+			       const char *default_message,
+			       const char *action,
+			       struct strbuf *out)
+{
+	const char *path = git_path_commit_editmsg();
+	const char *hint =
+		_("Please enter the commit message for the %s changes."
+		  " Lines starting\nwith '%s' will be ignored.\n");
+	struct wt_status s;
+
+	strbuf_addstr(out, default_message);
+	strbuf_addch(out, '\n');
+	strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
+	write_file_buf(path, out->buf, out->len);
+
+	wt_status_prepare(repo, &s);
+	FREE_AND_NULL(s.branch);
+	s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
+	s.commit_template = 1;
+	s.colopts = 0;
+	s.display_comment_prefix = 1;
+	s.hints = 0;
+	s.use_color = 0;
+	s.whence = FROM_COMMIT;
+	s.committable = 1;
+
+	s.fp = fopen(git_path_commit_editmsg(), "a");
+	if (!s.fp)
+		return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
+
+	wt_status_collect_changes_trees(&s, old_tree, new_tree);
+	wt_status_print(&s);
+	wt_status_collect_free_buffers(&s);
+	string_list_clear_func(&s.change, change_data_free);
+
+	strbuf_reset(out);
+	if (launch_editor(path, out, NULL)) {
+		fprintf(stderr, _("Please supply the message using the -m option.\n"));
+		return -1;
+	}
+	strbuf_stripspace(out, comment_line_str);
+
+	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
+
+	if (!out->len) {
+		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
+		return -1;
+	}
+
+	return 0;
+}
+
+static int cmd_history_reword(int argc,
+			      const char **argv,
+			      const char *prefix,
+			      struct repository *repo)
+{
+	const char * const usage[] = {
+		GIT_HISTORY_REWORD_USAGE,
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct strbuf final_message = STRBUF_INIT;
+	struct commit *original_commit, *parent, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct object_id parent_tree_oid, original_commit_tree_oid;
+	struct object_id rewritten_commit;
+	struct commit_list *from_list = NULL;
+	const char *original_message, *original_body, *ptr;
+	char *original_author = NULL;
+	size_t len;
+	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);
+
+	original_commit = lookup_commit_reference_by_name(argv[0]);
+	if (!original_commit) {
+		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
+		goto out;
+	}
+	original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
+
+	parent = original_commit->parents ? original_commit->parents->item : NULL;
+	if (parent) {
+		if (repo_parse_commit(repo, parent)) {
+			ret = error(_("unable to parse commit %s"),
+				    oid_to_hex(&parent->object.oid));
+			goto out;
+		}
+		parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
+	} else {
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	commit_list_append(original_commit, &from_list);
+	if (!repo_is_descendant_of(repo, head, from_list)) {
+		ret = error (_("split commit must be reachable from current HEAD commit"));
+		goto out;
+	}
+
+	/*
+	 * Collect the list of commits that we'll have to reapply now already.
+	 * This ensures that we'll abort early on in case the range of commits
+	 * contains merges, which we do not yet handle.
+	 */
+	ret = collect_commits(repo, parent, head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+	find_commit_subject(original_message, &original_body);
+
+	ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
+				  original_body, "reworded", &final_message);
+	if (ret < 0)
+		goto out;
+
+	ret = commit_tree(final_message.buf, final_message.len, &original_commit_tree_oid,
+			  original_commit->parents, &rewritten_commit, original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing reworded commit"));
+		goto out;
+	}
+
+	replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
+
+	ret = apply_commits(repo, &commits, parent, head, "reword");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	strbuf_release(&final_message);
+	free_commit_list(from_list);
+	strvec_clear(&commits);
+	free(original_author);
+	return ret;
+}
 
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
-		struct repository *repo UNUSED)
+		struct repository *repo)
 {
 	const char * const usage[] = {
-		N_("git history [<options>]"),
+		GIT_HISTORY_REWORD_USAGE,
 		NULL,
 	};
+	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
+		OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
 		OPT_END(),
 	};
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
-	if (argc)
-		usagef("unrecognized argument: %s", argv[0]);
-	return 0;
+	return fn(argc, argv, prefix, repo);
 }
diff --git a/t/meson.build b/t/meson.build
index 019435918fa..a3ec9199947 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -385,6 +385,7 @@ integration_tests = [
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
+  't3451-history-reword.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
index 417c343d43b..f513463b92b 100755
--- a/t/t3450-history.sh
+++ b/t/t3450-history.sh
@@ -5,13 +5,13 @@ test_description='tests for git-history command'
 . ./test-lib.sh
 
 test_expect_success 'does nothing without any arguments' '
-	git history >out 2>&1 &&
-	test_must_be_empty out
+	test_must_fail git history 2>err &&
+	test_grep "need a subcommand" err
 '
 
 test_expect_success 'raises an error with unknown argument' '
 	test_must_fail git history garbage 2>err &&
-	test_grep "unrecognized argument: garbage" err
+	test_grep "unknown subcommand: .garbage." err
 '
 
 test_done
diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
new file mode 100755
index 00000000000..09dbc463c59
--- /dev/null
+++ b/t/t3451-history-reword.sh
@@ -0,0 +1,237 @@
+#!/bin/sh
+
+test_description='tests for git-history reword subcommand'
+
+. ./test-lib.sh
+
+reword_with_message () {
+	cat >message &&
+	write_script fake-editor.sh <<-EOF &&
+	cp "$(pwd)/message" "\$1"
+	EOF
+	test_set_editor "$(pwd)"/fake-editor.sh &&
+	git history reword "$@" &&
+	rm fake-editor.sh message
+}
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history reword HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err &&
+		test_must_fail git history reword HEAD 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work with unrelated commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		test_must_fail git history reword ours 2>err &&
+		test_grep "split commit must be reachable from current HEAD commit" err
+	)
+'
+
+test_expect_success 'can reword tip of a branch' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		reword_with_message HEAD <<-EOF &&
+		third reworded
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		third reworded
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can reword commit in the middle' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		reword_with_message HEAD~ <<-EOF &&
+		second reworded
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can reword root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		reword_with_message HEAD~2 <<-EOF &&
+		first reworded
+		EOF
+
+		cat >expect <<-EOF &&
+		third
+		second
+		first reworded
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can use editor to rewrite commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		printf "\namend a comment\n" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+		git history reword HEAD &&
+
+		cat >expect <<-EOF &&
+		first
+
+		# Please enter the commit message for the reworded changes. Lines starting
+		# with ${SQ}#${SQ} will be ignored.
+		# Changes to be committed:
+		#	new file:   first.t
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		cat >expect <<-EOF &&
+		first
+
+		amend a comment
+
+		EOF
+		git log --format=%B >actual &&
+		test_cmp expect actual
+	)
+'
+
+# For now, git-history(1) does not yet execute any hooks. This is subject to
+# change in the future, and if it does this test here is expected to start
+# failing. In other words, this test is not an endorsement of the current
+# status quo.
+test_expect_success 'hooks are not executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		write_script .git/hooks/prepare-commit-msg <<-EOF &&
+		touch "$(pwd)/hooks.log
+		EOF
+		write_script .git/hooks/post-commit <<-EOF &&
+		touch "$(pwd)/hooks.log
+		EOF
+		write_script .git/hooks/post-rewrite <<-EOF &&
+		touch "$(pwd)/hooks.log
+		EOF
+
+		reword_with_message HEAD~ <<-EOF &&
+		second reworded
+		EOF
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+
+		test_path_is_missing hooks.log
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		! reword_with_message HEAD 2>err </dev/null &&
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_expect_success 'retains changes in the worktree and index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch a b &&
+		git add . &&
+		git commit -m "initial commit" &&
+		echo foo >a &&
+		echo bar >b &&
+		git add b &&
+		reword_with_message HEAD <<-EOF &&
+		message
+		EOF
+		cat >expect <<-\EOF &&
+		 M a
+		M  b
+		?? actual
+		?? expect
+		EOF
+		git status --porcelain >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.51.1.930.gacf6e81ea2.dirty
Phillip Wood· Nov 17, 2025, 16:27 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 05/11] builtin/history: implement "reword" subcommand

Hi Patrick On 27/10/2025 11:33, Patrick Steinhardt wrote:

> +static int collect_commits(struct repository *repo,
> +			   struct commit *old_commit,
> +			   struct commit *new_commit,
> +			   struct strvec *out)
Now that we're not using the sequencer it would be nice to stop messing 
about converting object ids to and from strings and return an array of 
"struct commit" instead of "struct strvec"
  > +{
Show 16 quoted lines
> +	struct setup_revision_opt revision_opts = {
> +		.assume_dashdash = 1,
> +	};
> +	struct strvec revisions = STRVEC_INIT;
> +	struct commit *child;
> +	struct rev_info rev = { 0 };
> +	int ret;
> +
> +	repo_init_revisions(repo, &rev, NULL);
> +	strvec_push(&revisions, "");
> +	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
> +	if (old_commit)
> +		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
> +
> +	setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
> +	if (revisions.nr != 1 || prepare_revision_walk(&rev)) {

I'm not that familiar with the revision walking api, what 'revisions.nr != 1' check for here?

Show 25 quoted lines
> +static int apply_commits(struct repository *repo,
> +			 const struct strvec *commits,
> +			 struct commit *onto,
> +			 struct commit *orig_head,
> +			 const char *action)
> +{
> +	struct reset_head_opts reset_opts = { 0 };
> +	struct strbuf buf = STRBUF_INIT;
> +	int ret;
> +
> +	for (size_t i = 0; i < commits->nr; i++) {
> +		struct object_id commit_id;
> +		struct commit *commit;
> +		const char *end;
> +
> +		if (parse_oid_hex_algop(commits->v[i], &commit_id, &end,
> +					repo->hash_algo)) {
> +			ret = error(_("invalid object ID: %s"), commits->v[i]);
> +			goto out;
> +		}
> +		commit = lookup_commit(repo, &commit_id);
> +		if (!commit || repo_parse_commit(repo, commit)) {
> +			ret = error(_("failed to look up commit: %s"), oid_to_hex(&commit_id));
> +			goto out;
> +		}

Using an array of "struct commit" rather than "struct strvec" would mean we could delete everything in the loop body up to here.

Show 7 quoted lines
> +		if (!onto) {
> +			onto = commit;
> +		} else {
> +			struct tree *tree = repo_get_commit_tree(repo, commit);
> +			onto = replay_create_commit(repo, tree, commit, onto);
> +			if (!onto)
> +				break;
Don't we want to avoid updating HEAD if replay_create_commit() fails?
Show 5 quoted lines
> +		}
> +	}
> +
> +	reset_opts.oid = &onto->object.oid;
> +	strbuf_addf(&buf, "%s: switch to rewritten %s", action, oid_to_hex(reset_opts.oid));

We're not switching branches so I wonder if saying "history: <action> <oid> <commit subject>" might be a more useful reflog entry

Show 10 quoted lines
> +static int fill_commit_message(struct repository *repo,
> +			       const struct object_id *old_tree,
> +			       const struct object_id *new_tree,
> +			       const char *default_message,
> +			       const char *action,
> +			       struct strbuf *out)
> +{
> +	const char *path = git_path_commit_editmsg();
> +	const char *hint =
> +		_("Please enter the commit message for the %s changes."

Maybe "Please edit the commit message"? Also do we want to tell the user they can abort by clearing the commit message?

Show 18 quoted lines
> +		  " Lines starting\nwith '%s' will be ignored.\n");
> +	struct wt_status s;
> +
> +	strbuf_addstr(out, default_message);
> +	strbuf_addch(out, '\n');
> +	strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
> +	write_file_buf(path, out->buf, out->len);
> +
> +	wt_status_prepare(repo, &s);
> +	FREE_AND_NULL(s.branch);
> +	s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
> +	s.commit_template = 1;
> +	s.colopts = 0;
> +	s.display_comment_prefix = 1;
> +	s.hints = 0;
> +	s.use_color = 0;
> +	s.whence = FROM_COMMIT;
> +	s.committable = 1;

"git commit" reads a load of status related config settings, is any of that relevant here?

Show 12 quoted lines
> +	s.fp = fopen(git_path_commit_editmsg(), "a");
> +	if (!s.fp)
> +		return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
> +
> +	wt_status_collect_changes_trees(&s, old_tree, new_tree);
> +	wt_status_print(&s);
> +	wt_status_collect_free_buffers(&s);
> +	string_list_clear_func(&s.change, change_data_free);
> +
> +	strbuf_reset(out);
> +	if (launch_editor(path, out, NULL)) {
> +		fprintf(stderr, _("Please supply the message using the -m option.\n"));

I'm not sure that's a very helpful suggestion as we don't support "-m" (it's not very helpful when "git commit --amend" suggests it either). We should just give up if the editor fails.

Show 36 quoted lines
> +static int cmd_history_reword(int argc,
> +			      const char **argv,
> +			      const char *prefix,
> +			      struct repository *repo)
> +{
> +	const char * const usage[] = {
> +		GIT_HISTORY_REWORD_USAGE,
> +		NULL,
> +	};
> +	struct option options[] = {
> +		OPT_END(),
> +	};
> +	struct strbuf final_message = STRBUF_INIT;
> +	struct commit *original_commit, *parent, *head;
> +	struct strvec commits = STRVEC_INIT;
> +	struct object_id parent_tree_oid, original_commit_tree_oid;
> +	struct object_id rewritten_commit;
> +	struct commit_list *from_list = NULL;
> +	const char *original_message, *original_body, *ptr;
> +	char *original_author = NULL;
> +	size_t len;
> +	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);
> +
> +	original_commit = lookup_commit_reference_by_name(argv[0]);
> +	if (!original_commit) {
> +		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
> +		goto out;
> +	}
> +	original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
Looking at the implementation of repo_get_commit_tree() it can return NULL
Show 22 quoted lines
> +
> +	parent = original_commit->parents ? original_commit->parents->item : NULL;
> +	if (parent) {
> +		if (repo_parse_commit(repo, parent)) {
> +			ret = error(_("unable to parse commit %s"),
> +				    oid_to_hex(&parent->object.oid));
> +			goto out;
> +		}
> +		parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
> +	} else {
> +		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
> +	}
> +
> +	head = lookup_commit_reference_by_name("HEAD");
> +	if (!head) {
> +		ret = error(_("could not resolve HEAD to a commit"));
> +		goto out;
> +	}
> +
> +	commit_list_append(original_commit, &from_list);
> +	if (!repo_is_descendant_of(repo, head, from_list)) {
> +		ret = error (_("split commit must be reachable from current HEAD commit"));
s/split/reword/
Show 8 quoted lines
> +		goto out;
> +	}
> +
> +	/*
> +	 * Collect the list of commits that we'll have to reapply now already.
> +	 * This ensures that we'll abort early on in case the range of commits
> +	 * contains merges, which we do not yet handle.
> +	 */

Ah, I wondered if we should be checking that the reworded commit wasn't a merge when we look at the parents above but we don't need to because we will error out here if it is.

Show 21 quoted lines
> diff --git a/t/t3450-history.sh b/t/t3450-history.sh
> index 417c343d43b..f513463b92b 100755
> --- a/t/t3450-history.sh
> +++ b/t/t3450-history.sh
> @@ -5,13 +5,13 @@ test_description='tests for git-history command'
>   . ./test-lib.sh
>   
>   test_expect_success 'does nothing without any arguments' '
> -	git history >out 2>&1 &&
> -	test_must_be_empty out
> +	test_must_fail git history 2>err &&
> +	test_grep "need a subcommand" err
>   '
>   
>   test_expect_success 'raises an error with unknown argument' '
>   	test_must_fail git history garbage 2>err &&
> -	test_grep "unrecognized argument: garbage" err
> +	test_grep "unknown subcommand: .garbage." err
>   '
>   
>   test_done

Do we really need a separate test file just for a couple of tests. I can see that having a separate test file for each subcommand makes sense but can't we just add these two tests to one of those?

Show 16 quoted lines
> diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
> new file mode 100755
> index 00000000000..09dbc463c59
> --- /dev/null
> +++ b/t/t3451-history-reword.sh
> @@ -0,0 +1,237 @@
> +#!/bin/sh
> +
> +test_description='tests for git-history reword subcommand'
> +
> +. ./test-lib.sh
> +
> +reword_with_message () {
> +	cat >message &&
> +	write_script fake-editor.sh <<-EOF &&
> +	cp "$(pwd)/message" "\$1"

Let's hope $(pwd) doesn't contain any dollar signs, backticks, backslashes or double quotes. Doing

	export MSG_PATH="$(pwd)/message"
	write_script fake-editor.sh <<-\EOF &&
	cp "$MSG_PATH" "$1"
	EOF
would be safer
> +test_expect_success 'refuses to work with merge commits' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(

Do we really need to set up a separate repo for each test? The test suite is slow enough already without running "git init" followed by a bunch calls to test_commit() in each test. Can we instead run "git reset --hard <known-starting-point> at the beginning of each test? That removes any interdependence between tests but saves a bunch of processes.

> +test_expect_success 'can reword root commit' '

It's nice that unlike "git rebase" this works without much effort from the implementation.

> +test_expect_success 'can use editor to rewrite commit message' '

Don't all the other tests check that? This test is checking what's presented to the user which is a good idea but I wouldn't have guessed that from the test name.

Show 32 quoted lines
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit first &&
> +
> +		write_script fake-editor.sh <<-\EOF &&
> +		cp "$1" . &&
> +		printf "\namend a comment\n" >>"$1"
> +		EOF
> +		test_set_editor "$(pwd)"/fake-editor.sh &&
> +		git history reword HEAD &&
> +
> +		cat >expect <<-EOF &&
> +		first
> +
> +		# Please enter the commit message for the reworded changes. Lines starting
> +		# with ${SQ}#${SQ} will be ignored.
> +		# Changes to be committed:
> +		#	new file:   first.t
> +		#
> +		EOF
> +		test_cmp expect COMMIT_EDITMSG &&
> +
> +		cat >expect <<-EOF &&
> +		first
> +
> +		amend a comment
> +
> +		EOF
> +		git log --format=%B >actual &&
> +		test_cmp expect actual

We have test_commit_message() to do this which will accept the expected message on stdin.

Show 18 quoted lines
> +	)
> +'
> +
> +# For now, git-history(1) does not yet execute any hooks. This is subject to
> +# change in the future, and if it does this test here is expected to start
> +# failing. In other words, this test is not an endorsement of the current
> +# status quo.
> +test_expect_success 'hooks are not executed for rewritten commits' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit first &&
> +		test_commit second &&
> +		test_commit third &&
> +
> +		write_script .git/hooks/prepare-commit-msg <<-EOF &&
> +		touch "$(pwd)/hooks.log

This has the same problem of expanding $(pwd) as fake-editor.sh. For debugging it would be nicer if the hook scripts did

	echo "$hook_name" >>hooks.log
so we can easily see which hooks are causing the test to fail.
Show 7 quoted lines
> +		EOF
> +		write_script .git/hooks/post-commit <<-EOF &&
> +		touch "$(pwd)/hooks.log
> +		EOF
> +		write_script .git/hooks/post-rewrite <<-EOF &&
> +		touch "$(pwd)/hooks.log
> +		EOF

This is good idea. We should add tests for the "pre-commit" and "commit-msg" hooks as well.

Overall the test coverage looks good, the only thing we might want to add is a check for the reflog message. Thanks for working on this, I'll try and look at the rest of the patches sometime this week.

Phillip
Patrick Steinhardt· Dec 2, 2025, 18:50 UTC · re: Phillip Wood · lore

Re: [PATCH v6 05/11] builtin/history: implement "reword" subcommand

On Mon, Nov 17, 2025 at 04:27:59PM +0000, Phillip Wood wrote:
Show 11 quoted lines
> Hi Patrick
> On 27/10/2025 11:33, Patrick Steinhardt wrote:
> 
> > +static int collect_commits(struct repository *repo,
> > +			   struct commit *old_commit,
> > +			   struct commit *new_commit,
> > +			   struct strvec *out)
> 
> Now that we're not using the sequencer it would be nice to stop messing
> about converting object ids to and from strings and return an array of
> "struct commit" instead of "struct strvec"

I was trying to avoid using a strvec, but honestly that turned out to be more pain than it is worth. We don't have functions like `strvec_splice()` for simple arrays, and there is no commit array struct that provides similar wrappers, either.

Show 20 quoted lines
>  > +{
> > +	struct setup_revision_opt revision_opts = {
> > +		.assume_dashdash = 1,
> > +	};
> > +	struct strvec revisions = STRVEC_INIT;
> > +	struct commit *child;
> > +	struct rev_info rev = { 0 };
> > +	int ret;
> > +
> > +	repo_init_revisions(repo, &rev, NULL);
> > +	strvec_push(&revisions, "");
> > +	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
> > +	if (old_commit)
> > +		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
> > +
> > +	setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
> > +	if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
> 
> I'm not that familiar with the revision walking api, what 'revisions.nr !=
> 1' check for here?

It's basically a check that the revision arguments have all been consumed, except for the initial empty argument. The interface is a bit weird.

[snip]
Show 9 quoted lines
> > +		if (!onto) {
> > +			onto = commit;
> > +		} else {
> > +			struct tree *tree = repo_get_commit_tree(repo, commit);
> > +			onto = replay_create_commit(repo, tree, commit, onto);
> > +			if (!onto)
> > +				break;
> 
> Don't we want to avoid updating HEAD if replay_create_commit() fails?
Good point, yes.
Show 8 quoted lines
> > +		}
> > +	}
> > +
> > +	reset_opts.oid = &onto->object.oid;
> > +	strbuf_addf(&buf, "%s: switch to rewritten %s", action, oid_to_hex(reset_opts.oid));
> 
> We're not switching branches so I wonder if saying "history: <action> <oid>
> <commit subject>" might be a more useful reflog entry

We're not switching branches, true, but we do switch to the rewritten commit. Also I'm not sure that printing the commit subject here would make sense, as the question becomes which subject to print: the one we're moving to, which is the new tip of the branch but may not be the commit we have rewritten? Or do we print the subject of the rewritten commit?

Show 13 quoted lines
> > +static int fill_commit_message(struct repository *repo,
> > +			       const struct object_id *old_tree,
> > +			       const struct object_id *new_tree,
> > +			       const char *default_message,
> > +			       const char *action,
> > +			       struct strbuf *out)
> > +{
> > +	const char *path = git_path_commit_editmsg();
> > +	const char *hint =
> > +		_("Please enter the commit message for the %s changes."
> 
> Maybe "Please edit the commit message"? Also do we want to tell the user
> they can abort by clearing the commit message?

The "Please edit the commit message" thing is taken from other commands that phrase it similarly. But it certainly does make sense to note that clearing the commit message aborts, will add.

Show 21 quoted lines
> > +		  " Lines starting\nwith '%s' will be ignored.\n");
> > +	struct wt_status s;
> > +
> > +	strbuf_addstr(out, default_message);
> > +	strbuf_addch(out, '\n');
> > +	strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
> > +	write_file_buf(path, out->buf, out->len);
> > +
> > +	wt_status_prepare(repo, &s);
> > +	FREE_AND_NULL(s.branch);
> > +	s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
> > +	s.commit_template = 1;
> > +	s.colopts = 0;
> > +	s.display_comment_prefix = 1;
> > +	s.hints = 0;
> > +	s.use_color = 0;
> > +	s.whence = FROM_COMMIT;
> > +	s.committable = 1;
> 
> "git commit" reads a load of status related config settings, is any of that
> relevant here?

Yeah, some of it is. We don't handle them all yet, but this will be backfilled in the future.

Show 16 quoted lines
> > +	s.fp = fopen(git_path_commit_editmsg(), "a");
> > +	if (!s.fp)
> > +		return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
> > +
> > +	wt_status_collect_changes_trees(&s, old_tree, new_tree);
> > +	wt_status_print(&s);
> > +	wt_status_collect_free_buffers(&s);
> > +	string_list_clear_func(&s.change, change_data_free);
> > +
> > +	strbuf_reset(out);
> > +	if (launch_editor(path, out, NULL)) {
> > +		fprintf(stderr, _("Please supply the message using the -m option.\n"));
> 
> I'm not sure that's a very helpful suggestion as we don't support "-m" (it's
> not very helpful when "git commit --amend" suggests it either). We should
> just give up if the editor fails.
Ah, this is a leftover error message from previous iterations.
Show 38 quoted lines
> > +static int cmd_history_reword(int argc,
> > +			      const char **argv,
> > +			      const char *prefix,
> > +			      struct repository *repo)
> > +{
> > +	const char * const usage[] = {
> > +		GIT_HISTORY_REWORD_USAGE,
> > +		NULL,
> > +	};
> > +	struct option options[] = {
> > +		OPT_END(),
> > +	};
> > +	struct strbuf final_message = STRBUF_INIT;
> > +	struct commit *original_commit, *parent, *head;
> > +	struct strvec commits = STRVEC_INIT;
> > +	struct object_id parent_tree_oid, original_commit_tree_oid;
> > +	struct object_id rewritten_commit;
> > +	struct commit_list *from_list = NULL;
> > +	const char *original_message, *original_body, *ptr;
> > +	char *original_author = NULL;
> > +	size_t len;
> > +	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);
> > +
> > +	original_commit = lookup_commit_reference_by_name(argv[0]);
> > +	if (!original_commit) {
> > +		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
> > +		goto out;
> > +	}
> > +	original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
> 
> Looking at the implementation of repo_get_commit_tree() it can return NULL
Show 22 quoted lines
> > diff --git a/t/t3450-history.sh b/t/t3450-history.sh
> > index 417c343d43b..f513463b92b 100755
> > --- a/t/t3450-history.sh
> > +++ b/t/t3450-history.sh
> > @@ -5,13 +5,13 @@ test_description='tests for git-history command'
> >   . ./test-lib.sh
> >   test_expect_success 'does nothing without any arguments' '
> > -	git history >out 2>&1 &&
> > -	test_must_be_empty out
> > +	test_must_fail git history 2>err &&
> > +	test_grep "need a subcommand" err
> >   '
> >   test_expect_success 'raises an error with unknown argument' '
> >   	test_must_fail git history garbage 2>err &&
> > -	test_grep "unrecognized argument: garbage" err
> > +	test_grep "unknown subcommand: .garbage." err
> >   '
> >   test_done
> 
> Do we really need a separate test file just for a couple of tests. I can see
> that having a separate test file for each subcommand makes sense but can't
> we just add these two tests to one of those?

I felt it was dirty to randomly add it to any of the other test suites, so I decided to instead have it in its own standalone file. It may also become relevant in the future if we ever needed commands like for example `git history --continue`, same as the sequencer-based commands have.

Show 26 quoted lines
> > diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
> > new file mode 100755
> > index 00000000000..09dbc463c59
> > --- /dev/null
> > +++ b/t/t3451-history-reword.sh
> > @@ -0,0 +1,237 @@
> > +#!/bin/sh
> > +
> > +test_description='tests for git-history reword subcommand'
> > +
> > +. ./test-lib.sh
> > +
> > +reword_with_message () {
> > +	cat >message &&
> > +	write_script fake-editor.sh <<-EOF &&
> > +	cp "$(pwd)/message" "\$1"
> 
> Let's hope $(pwd) doesn't contain any dollar signs, backticks, backslashes
> or double quotes. Doing
> 
> 	export MSG_PATH="$(pwd)/message"
> 	write_script fake-editor.sh <<-\EOF &&
> 	cp "$MSG_PATH" "$1"
> 	EOF
> 
> would be safer
True. Will use this:
Show changes to t/t3451-history-reword.sh +3 −3
diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
index 8b353e74dc..4c87953176 100755
--- a/t/t3451-history-reword.sh
+++ b/t/t3451-history-reword.sh
@@ -6,11 +6,11 @@ test_description='tests for git-history reword subcommand'
 
 reword_with_message () {
 	cat >message &&
-	write_script fake-editor.sh <<-EOF &&
-	cp "$(pwd)/message" "\$1"
+	write_script fake-editor.sh <<-\EOF &&
+	cp "$ORIG_PATH/message" "$1"
 	EOF
 	test_set_editor "$(pwd)"/fake-editor.sh &&
-	git history reword "$@" &&
+	ORIG_PATH="$(pwd)" git history reword "$@" &&
 	rm fake-editor.sh message
 }
 

> > +test_expect_success 'refuses to work with merge commits' '
> > +	test_when_finished "rm -rf repo" &&
> > +	git init repo &&
> > +	(
> 
> Do we really need to set up a separate repo for each test? The test suite is
> slow enough already without running "git init" followed by a bunch calls to
> test_commit() in each test. Can we instead run "git reset --hard
> <known-starting-point> at the beginning of each test? That removes any
> interdependence between tests but saves a bunch of processes.

I prefer that style as it is extremely hard to reason about tests that
have interdependencies, and not all the state may be removed by a hard
reset.

> > +	test_when_finished "rm -rf repo" &&
> > +	git init repo &&
> > +	(
> > +		cd repo &&
> > +		test_commit first &&
> > +
> > +		write_script fake-editor.sh <<-\EOF &&
> > +		cp "$1" . &&
> > +		printf "\namend a comment\n" >>"$1"
> > +		EOF
> > +		test_set_editor "$(pwd)"/fake-editor.sh &&
> > +		git history reword HEAD &&
> > +
> > +		cat >expect <<-EOF &&
> > +		first
> > +
> > +		# Please enter the commit message for the reworded changes. Lines starting
> > +		# with ${SQ}#${SQ} will be ignored.
> > +		# Changes to be committed:
> > +		#	new file:   first.t
> > +		#
> > +		EOF
> > +		test_cmp expect COMMIT_EDITMSG &&
> > +
> > +		cat >expect <<-EOF &&
> > +		first
> > +
> > +		amend a comment
> > +
> > +		EOF
> > +		git log --format=%B >actual &&
> > +		test_cmp expect actual
> 
> We have test_commit_message() to do this which will accept the expected
> message on stdin.

Ah, indeed.

> > +	)
> > +'
> > +
> > +# For now, git-history(1) does not yet execute any hooks. This is subject to
> > +# change in the future, and if it does this test here is expected to start
> > +# failing. In other words, this test is not an endorsement of the current
> > +# status quo.
> > +test_expect_success 'hooks are not executed for rewritten commits' '
> > +	test_when_finished "rm -rf repo" &&
> > +	git init repo &&
> > +	(
> > +		cd repo &&
> > +		test_commit first &&
> > +		test_commit second &&
> > +		test_commit third &&
> > +
> > +		write_script .git/hooks/prepare-commit-msg <<-EOF &&
> > +		touch "$(pwd)/hooks.log
> 
> This has the same problem of expanding $(pwd) as fake-editor.sh. For
> debugging it would be nicer if the hook scripts did
> 
> 	echo "$hook_name" >>hooks.log
> 
> so we can easily see which hooks are causing the test to fail.

I'll rephrain from doing this as it would require `<<-EOF` instead of
`<<-\EOF`.

> > +		EOF
> > +		write_script .git/hooks/post-commit <<-EOF &&
> > +		touch "$(pwd)/hooks.log
> > +		EOF
> > +		write_script .git/hooks/post-rewrite <<-EOF &&
> > +		touch "$(pwd)/hooks.log
> > +		EOF
> 
> This is good idea. We should add tests for the "pre-commit" and "commit-msg"
> hooks as well.
> 
> Overall the test coverage looks good, the only thing we might want to add is
> a check for the reflog message. Thanks for working on this, I'll try and
> look at the rest of the patches sometime this week.

Thanks!

Patrick
Phillip Wood· Dec 10, 2025, 09:52 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 05/11] builtin/history: implement "reword" subcommand

On 02/12/2025 18:50, Patrick Steinhardt wrote:
Show 17 quoted lines
> On Mon, Nov 17, 2025 at 04:27:59PM +0000, Phillip Wood wrote:
>> Hi Patrick
>> On 27/10/2025 11:33, Patrick Steinhardt wrote:
>>
>>> +static int collect_commits(struct repository *repo,
>>> +			   struct commit *old_commit,
>>> +			   struct commit *new_commit,
>>> +			   struct strvec *out)
>>
>> Now that we're not using the sequencer it would be nice to stop messing
>> about converting object ids to and from strings and return an array of
>> "struct commit" instead of "struct strvec"
> 
> I was trying to avoid using a strvec, but honestly that turned out to be
> more pain than it is worth. We don't have functions like
> `strvec_splice()` for simple arrays, and there is no commit array struct
> that provides similar wrappers, either.

I'm surprised it was such a pain compared to the cost to using a strvec. We're forever converting from a string to a struct commit and back again which bloats the code and obscures the interesting and important parts. That cost will be paid each time we add a new subcommand. An implementation of 'struct commit_vec' that implements commit_vec_push(), commit_vec_splice() and commit_vec_clear() is only going to be 30 or 40 lines of code and gives us a solid foundation for this series. Open coding the array and adding a SPLICE_ARRAY macro would also be pretty simple.

Thanks
Phillip
Show 334 quoted lines
>>   > +{
>>> +	struct setup_revision_opt revision_opts = {
>>> +		.assume_dashdash = 1,
>>> +	};
>>> +	struct strvec revisions = STRVEC_INIT;
>>> +	struct commit *child;
>>> +	struct rev_info rev = { 0 };
>>> +	int ret;
>>> +
>>> +	repo_init_revisions(repo, &rev, NULL);
>>> +	strvec_push(&revisions, "");
>>> +	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
>>> +	if (old_commit)
>>> +		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
>>> +
>>> +	setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
>>> +	if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
>>
>> I'm not that familiar with the revision walking api, what 'revisions.nr !=
>> 1' check for here?
> 
> It's basically a check that the revision arguments have all been
> consumed, except for the initial empty argument. The interface is a bit
> weird.
> 
> [snip]
>>> +		if (!onto) {
>>> +			onto = commit;
>>> +		} else {
>>> +			struct tree *tree = repo_get_commit_tree(repo, commit);
>>> +			onto = replay_create_commit(repo, tree, commit, onto);
>>> +			if (!onto)
>>> +				break;
>>
>> Don't we want to avoid updating HEAD if replay_create_commit() fails?
> 
> Good point, yes.
> 
>>> +		}
>>> +	}
>>> +
>>> +	reset_opts.oid = &onto->object.oid;
>>> +	strbuf_addf(&buf, "%s: switch to rewritten %s", action, oid_to_hex(reset_opts.oid));
>>
>> We're not switching branches so I wonder if saying "history: <action> <oid>
>> <commit subject>" might be a more useful reflog entry
> 
> We're not switching branches, true, but we do switch to the rewritten
> commit. Also I'm not sure that printing the commit subject here would
> make sense, as the question becomes which subject to print: the one
> we're moving to, which is the new tip of the branch but may not be the
> commit we have rewritten? Or do we print the subject of the rewritten
> commit?
> 
>>> +static int fill_commit_message(struct repository *repo,
>>> +			       const struct object_id *old_tree,
>>> +			       const struct object_id *new_tree,
>>> +			       const char *default_message,
>>> +			       const char *action,
>>> +			       struct strbuf *out)
>>> +{
>>> +	const char *path = git_path_commit_editmsg();
>>> +	const char *hint =
>>> +		_("Please enter the commit message for the %s changes."
>>
>> Maybe "Please edit the commit message"? Also do we want to tell the user
>> they can abort by clearing the commit message?
> 
> The "Please edit the commit message" thing is taken from other commands
> that phrase it similarly. But it certainly does make sense to note that
> clearing the commit message aborts, will add.
> 
>>> +		  " Lines starting\nwith '%s' will be ignored.\n");
>>> +	struct wt_status s;
>>> +
>>> +	strbuf_addstr(out, default_message);
>>> +	strbuf_addch(out, '\n');
>>> +	strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
>>> +	write_file_buf(path, out->buf, out->len);
>>> +
>>> +	wt_status_prepare(repo, &s);
>>> +	FREE_AND_NULL(s.branch);
>>> +	s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
>>> +	s.commit_template = 1;
>>> +	s.colopts = 0;
>>> +	s.display_comment_prefix = 1;
>>> +	s.hints = 0;
>>> +	s.use_color = 0;
>>> +	s.whence = FROM_COMMIT;
>>> +	s.committable = 1;
>>
>> "git commit" reads a load of status related config settings, is any of that
>> relevant here?
> 
> Yeah, some of it is. We don't handle them all yet, but this will be
> backfilled in the future.
> 
>>> +	s.fp = fopen(git_path_commit_editmsg(), "a");
>>> +	if (!s.fp)
>>> +		return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
>>> +
>>> +	wt_status_collect_changes_trees(&s, old_tree, new_tree);
>>> +	wt_status_print(&s);
>>> +	wt_status_collect_free_buffers(&s);
>>> +	string_list_clear_func(&s.change, change_data_free);
>>> +
>>> +	strbuf_reset(out);
>>> +	if (launch_editor(path, out, NULL)) {
>>> +		fprintf(stderr, _("Please supply the message using the -m option.\n"));
>>
>> I'm not sure that's a very helpful suggestion as we don't support "-m" (it's
>> not very helpful when "git commit --amend" suggests it either). We should
>> just give up if the editor fails.
> 
> Ah, this is a leftover error message from previous iterations.
> 
>>> +static int cmd_history_reword(int argc,
>>> +			      const char **argv,
>>> +			      const char *prefix,
>>> +			      struct repository *repo)
>>> +{
>>> +	const char * const usage[] = {
>>> +		GIT_HISTORY_REWORD_USAGE,
>>> +		NULL,
>>> +	};
>>> +	struct option options[] = {
>>> +		OPT_END(),
>>> +	};
>>> +	struct strbuf final_message = STRBUF_INIT;
>>> +	struct commit *original_commit, *parent, *head;
>>> +	struct strvec commits = STRVEC_INIT;
>>> +	struct object_id parent_tree_oid, original_commit_tree_oid;
>>> +	struct object_id rewritten_commit;
>>> +	struct commit_list *from_list = NULL;
>>> +	const char *original_message, *original_body, *ptr;
>>> +	char *original_author = NULL;
>>> +	size_t len;
>>> +	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);
>>> +
>>> +	original_commit = lookup_commit_reference_by_name(argv[0]);
>>> +	if (!original_commit) {
>>> +		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
>>> +		goto out;
>>> +	}
>>> +	original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
>>
>> Looking at the implementation of repo_get_commit_tree() it can return NULL
> 
>>> diff --git a/t/t3450-history.sh b/t/t3450-history.sh
>>> index 417c343d43b..f513463b92b 100755
>>> --- a/t/t3450-history.sh
>>> +++ b/t/t3450-history.sh
>>> @@ -5,13 +5,13 @@ test_description='tests for git-history command'
>>>    . ./test-lib.sh
>>>    test_expect_success 'does nothing without any arguments' '
>>> -	git history >out 2>&1 &&
>>> -	test_must_be_empty out
>>> +	test_must_fail git history 2>err &&
>>> +	test_grep "need a subcommand" err
>>>    '
>>>    test_expect_success 'raises an error with unknown argument' '
>>>    	test_must_fail git history garbage 2>err &&
>>> -	test_grep "unrecognized argument: garbage" err
>>> +	test_grep "unknown subcommand: .garbage." err
>>>    '
>>>    test_done
>>
>> Do we really need a separate test file just for a couple of tests. I can see
>> that having a separate test file for each subcommand makes sense but can't
>> we just add these two tests to one of those?
> 
> I felt it was dirty to randomly add it to any of the other test suites,
> so I decided to instead have it in its own standalone file. It may also
> become relevant in the future if we ever needed commands like for
> example `git history --continue`, same as the sequencer-based commands
> have.
> 
>>> diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
>>> new file mode 100755
>>> index 00000000000..09dbc463c59
>>> --- /dev/null
>>> +++ b/t/t3451-history-reword.sh
>>> @@ -0,0 +1,237 @@
>>> +#!/bin/sh
>>> +
>>> +test_description='tests for git-history reword subcommand'
>>> +
>>> +. ./test-lib.sh
>>> +
>>> +reword_with_message () {
>>> +	cat >message &&
>>> +	write_script fake-editor.sh <<-EOF &&
>>> +	cp "$(pwd)/message" "\$1"
>>
>> Let's hope $(pwd) doesn't contain any dollar signs, backticks, backslashes
>> or double quotes. Doing
>>
>> 	export MSG_PATH="$(pwd)/message"
>> 	write_script fake-editor.sh <<-\EOF &&
>> 	cp "$MSG_PATH" "$1"
>> 	EOF
>>
>> would be safer
> 
> True. Will use this:
> 
> diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
> index 8b353e74dc..4c87953176 100755
> --- a/t/t3451-history-reword.sh
> +++ b/t/t3451-history-reword.sh
> @@ -6,11 +6,11 @@ test_description='tests for git-history reword subcommand'
>   
>   reword_with_message () {
>   	cat >message &&
> -	write_script fake-editor.sh <<-EOF &&
> -	cp "$(pwd)/message" "\$1"
> +	write_script fake-editor.sh <<-\EOF &&
> +	cp "$ORIG_PATH/message" "$1"
>   	EOF
>   	test_set_editor "$(pwd)"/fake-editor.sh &&
> -	git history reword "$@" &&
> +	ORIG_PATH="$(pwd)" git history reword "$@" &&
>   	rm fake-editor.sh message
>   }
>   
> 
>>> +test_expect_success 'refuses to work with merge commits' '
>>> +	test_when_finished "rm -rf repo" &&
>>> +	git init repo &&
>>> +	(
>>
>> Do we really need to set up a separate repo for each test? The test suite is
>> slow enough already without running "git init" followed by a bunch calls to
>> test_commit() in each test. Can we instead run "git reset --hard
>> <known-starting-point> at the beginning of each test? That removes any
>> interdependence between tests but saves a bunch of processes.
> 
> I prefer that style as it is extremely hard to reason about tests that
> have interdependencies, and not all the state may be removed by a hard
> reset.
> 
>>> +	test_when_finished "rm -rf repo" &&
>>> +	git init repo &&
>>> +	(
>>> +		cd repo &&
>>> +		test_commit first &&
>>> +
>>> +		write_script fake-editor.sh <<-\EOF &&
>>> +		cp "$1" . &&
>>> +		printf "\namend a comment\n" >>"$1"
>>> +		EOF
>>> +		test_set_editor "$(pwd)"/fake-editor.sh &&
>>> +		git history reword HEAD &&
>>> +
>>> +		cat >expect <<-EOF &&
>>> +		first
>>> +
>>> +		# Please enter the commit message for the reworded changes. Lines starting
>>> +		# with ${SQ}#${SQ} will be ignored.
>>> +		# Changes to be committed:
>>> +		#	new file:   first.t
>>> +		#
>>> +		EOF
>>> +		test_cmp expect COMMIT_EDITMSG &&
>>> +
>>> +		cat >expect <<-EOF &&
>>> +		first
>>> +
>>> +		amend a comment
>>> +
>>> +		EOF
>>> +		git log --format=%B >actual &&
>>> +		test_cmp expect actual
>>
>> We have test_commit_message() to do this which will accept the expected
>> message on stdin.
> 
> Ah, indeed.
> 
>>> +	)
>>> +'
>>> +
>>> +# For now, git-history(1) does not yet execute any hooks. This is subject to
>>> +# change in the future, and if it does this test here is expected to start
>>> +# failing. In other words, this test is not an endorsement of the current
>>> +# status quo.
>>> +test_expect_success 'hooks are not executed for rewritten commits' '
>>> +	test_when_finished "rm -rf repo" &&
>>> +	git init repo &&
>>> +	(
>>> +		cd repo &&
>>> +		test_commit first &&
>>> +		test_commit second &&
>>> +		test_commit third &&
>>> +
>>> +		write_script .git/hooks/prepare-commit-msg <<-EOF &&
>>> +		touch "$(pwd)/hooks.log
>>
>> This has the same problem of expanding $(pwd) as fake-editor.sh. For
>> debugging it would be nicer if the hook scripts did
>>
>> 	echo "$hook_name" >>hooks.log
>>
>> so we can easily see which hooks are causing the test to fail.
> 
> I'll rephrain from doing this as it would require `<<-EOF` instead of
> `<<-\EOF`.
> 
>>> +		EOF
>>> +		write_script .git/hooks/post-commit <<-EOF &&
>>> +		touch "$(pwd)/hooks.log
>>> +		EOF
>>> +		write_script .git/hooks/post-rewrite <<-EOF &&
>>> +		touch "$(pwd)/hooks.log
>>> +		EOF
>>
>> This is good idea. We should add tests for the "pre-commit" and "commit-msg"
>> hooks as well.
>>
>> Overall the test coverage looks good, the only thing we might want to add is
>> a check for the reflog message. Thanks for working on this, I'll try and
>> look at the rest of the patches sometime this week.
> 
> Thanks!
> 
> Patrick
> 
Elijah Newren· Nov 20, 2025, 07:03 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 05/11] builtin/history: implement "reword" subcommand

Phillip responded in good detail, but I wanted to comment on a few additional things...

On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 24 quoted lines
> +static int collect_commits(struct repository *repo,
> +                          struct commit *old_commit,
> +                          struct commit *new_commit,
> +                          struct strvec *out)
> +{
> +       struct setup_revision_opt revision_opts = {
> +               .assume_dashdash = 1,
> +       };
> +       struct strvec revisions = STRVEC_INIT;
> +       struct commit *child;
> +       struct rev_info rev = { 0 };
> +       int ret;
> +
> +       repo_init_revisions(repo, &rev, NULL);
> +       strvec_push(&revisions, "");
> +       strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
> +       if (old_commit)
> +               strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
> +
> +       setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
> +       if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
> +               ret = error(_("revision walk setup failed"));
> +               goto out;
> +       }

Don't we want to restrict the revision walk to descendants of old_commit (which can be done with `--ancestry-path`)?

Show 14 quoted lines
> +
> +       while ((child = get_revision(&rev))) {
> +               if (old_commit && !child->parents)
> +                       BUG("revision walk did not find child commit");
> +               if (child->parents && child->parents->next) {
> +                       ret = error(_("cannot rearrange commit history with merges"));
> +                       goto out;
> +               }
> +
> +               strvec_push(out, oid_to_hex(&child->object.oid));
> +
> +               if (child->parents && old_commit &&
> +                   commit_list_contains(old_commit, child->parents))
> +                       break;

Is this last if-check basically a workaround to not providing --ancestry-path to the revision walk? And won't it sometimes still get non-descendants of old_commit before reaching old_commit? Or, I guess that's not an issue since you error out when you hit merges, but once replay supports merges, there's more logic that needs changing than one expects with the way this is coded.

Show 8 quoted lines
> +       }
> +
> +       /*
> +        * Revisions are in newest-order-first. We have to reverse the
> +        * array though so that we pick the oldest commits first.
> +        */
> +       for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
> +               SWAP(out->v[i], out->v[j]);
Setting rev.reverse would obviate the need for this...
Show 9 quoted lines
> +
> +       ret = 0;
> +
> +out:
> +       strvec_clear(&revisions);
> +       release_revisions(&rev);
> +       reset_revision_walk();
> +       return ret;
> +}
You've pulled out some functions from builtin/replay, but you've
decided to hand re-roll all the revision walking.  Is that because you
first implemented on top of sequencer, and then transliterated to
replay?  If so, I think we could restructure this; I think what you
need is:
  * Create a new commit with an altered commit message.
  * Invoking whatever function(s) would be invoked by "git replay
--onto ${NEW_COMMIT_ID} --ancestry-path ^${OLD_COMMIT_ID} --branches"
(or as a first cut, even shelling out to that subprocess).
The first bullet point would be your fill_commit_message().

The second bullet point would allow you to perhaps drop your collect_commits(), replace_commits(), and apply_commits(), which feel like they are just re-implementing replay logic, and replace them with something like:

void replay_descendants(struct repository *repo,
                        const struct object_id *prev_head,
                        const struct object_id *new_head)
{
        struct strvec args = STRVEC_INIT;
        strvec_pushl(&args, "replay", "--onto", NULL);
        strvec_push(&args, oid_to_hex(new_head));
        strvec_push(&args, "--ancestry-path");
        strvec_pushf(&args, "^%s", oid_to_hex(prev_head));
        strvec_push(&args, "--branches");
        reset_revision_walk();
        cmd_replay(args.nr, args.v, NULL, repo);
}

...although maybe it's a little ugly to invoke cmd_replay() this way and maybe we want to restructure that out.

But, I am really late in providing my review, so if you want to go forward with your existing three functions and then perhaps we restructure later, that's fine too. The command is experimental, after all.

Show 11 quoted lines
> +       head = lookup_commit_reference_by_name("HEAD");
> +       if (!head) {
> +               ret = error(_("could not resolve HEAD to a commit"));
> +               goto out;
> +       }
> +
> +       commit_list_append(original_commit, &from_list);
> +       if (!repo_is_descendant_of(repo, head, from_list)) {
> +               ret = error (_("split commit must be reachable from current HEAD commit"));
> +               goto out;
> +       }

Why should it be required to be reachable from HEAD? Shouldn't it be possible to reword a commit from another branch?

Also, what about when a commit is reachable from both HEAD and other branches? I know you started by basing on git-rebase, and git-rebase restricts things to just one branch, but that was perhaps its biggest design flaw that couldn't be backward compatibly fixed without creating a new command. I'd rather avoid copying that flaw. (Maybe the user needs an error by default if more than one branch is affected, or they need to provide an additional flag to rewrite multiple branches, but only rewriting one branch when more than one is affected is just wrong to me unless the user explicitly specifies that's what they want.)

Show 14 quoted lines
> +       /* We retain authorship of the original commit. */
> +       original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
> +       ptr = find_commit_header(original_message, "author", &len);
> +       if (ptr)
> +               original_author = xmemdupz(ptr, len);
> +       find_commit_subject(original_message, &original_body);
> +
> +       ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
> +                                 original_body, "reworded", &final_message);
> +       if (ret < 0)
> +               goto out;
> +
> +       ret = commit_tree(final_message.buf, final_message.len, &original_commit_tree_oid,
> +                         original_commit->parents, &rewritten_commit, original_author, NULL);

Does the use of commit_tree() instead of commit_tree_extended() mean you discard additional headers on the reworded commit, such as encoding?

Patrick Steinhardt· Dec 2, 2025, 18:50 UTC · re: Elijah Newren · lore

Re: [PATCH v6 05/11] builtin/history: implement "reword" subcommand

On Wed, Nov 19, 2025 at 11:03:20PM -0800, Elijah Newren wrote:
Show 32 quoted lines
> Phillip responded in good detail, but I wanted to comment on a few
> additional things...
> 
> On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
> 
> > +static int collect_commits(struct repository *repo,
> > +                          struct commit *old_commit,
> > +                          struct commit *new_commit,
> > +                          struct strvec *out)
> > +{
> > +       struct setup_revision_opt revision_opts = {
> > +               .assume_dashdash = 1,
> > +       };
> > +       struct strvec revisions = STRVEC_INIT;
> > +       struct commit *child;
> > +       struct rev_info rev = { 0 };
> > +       int ret;
> > +
> > +       repo_init_revisions(repo, &rev, NULL);
> > +       strvec_push(&revisions, "");
> > +       strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
> > +       if (old_commit)
> > +               strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
> > +
> > +       setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
> > +       if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
> > +               ret = error(_("revision walk setup failed"));
> > +               goto out;
> > +       }
> 
> Don't we want to restrict the revision walk to descendants of
> old_commit (which can be done with `--ancestry-path`)?

We verify that both commits have direct ancestry and that there are no merge commits in the history, so this shouldn't be needed. But indeed, this makes the logic a bit easier to reason about.

Show 21 quoted lines
> > +
> > +       while ((child = get_revision(&rev))) {
> > +               if (old_commit && !child->parents)
> > +                       BUG("revision walk did not find child commit");
> > +               if (child->parents && child->parents->next) {
> > +                       ret = error(_("cannot rearrange commit history with merges"));
> > +                       goto out;
> > +               }
> > +
> > +               strvec_push(out, oid_to_hex(&child->object.oid));
> > +
> > +               if (child->parents && old_commit &&
> > +                   commit_list_contains(old_commit, child->parents))
> > +                       break;
> 
> Is this last if-check basically a workaround to not providing
> --ancestry-path to the revision walk?  And won't it sometimes still
> get non-descendants of old_commit before reaching old_commit?  Or, I
> guess that's not an issue since you error out when you hit merges, but
> once replay supports merges, there's more logic that needs changing
> than one expects with the way this is coded.

Yeah, this is not currently an issue as we explicitly rule out merges. Anyway, I'm using the flag now, so this isn't needed anymore.

Show 10 quoted lines
> > +       }
> > +
> > +       /*
> > +        * Revisions are in newest-order-first. We have to reverse the
> > +        * array though so that we pick the oldest commits first.
> > +        */
> > +       for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
> > +               SWAP(out->v[i], out->v[j]);
> 
> Setting rev.reverse would obviate the need for this...

Yup, true. I couldn't use 'reverse' before due to the way the loop was handled.

Show 50 quoted lines
> > +
> > +       ret = 0;
> > +
> > +out:
> > +       strvec_clear(&revisions);
> > +       release_revisions(&rev);
> > +       reset_revision_walk();
> > +       return ret;
> > +}
> 
> You've pulled out some functions from builtin/replay, but you've
> decided to hand re-roll all the revision walking.  Is that because you
> first implemented on top of sequencer, and then transliterated to
> replay?  If so, I think we could restructure this; I think what you
> need is:
>   * Create a new commit with an altered commit message.
>   * Invoking whatever function(s) would be invoked by "git replay
> --onto ${NEW_COMMIT_ID} --ancestry-path ^${OLD_COMMIT_ID} --branches"
> (or as a first cut, even shelling out to that subprocess).
> 
> The first bullet point would be your fill_commit_message().
> 
> The second bullet point would allow you to perhaps drop your
> collect_commits(), replace_commits(), and apply_commits(), which feel
> like they are just re-implementing replay logic, and replace them with
> something like:
> 
> void replay_descendants(struct repository *repo,
>                         const struct object_id *prev_head,
>                         const struct object_id *new_head)
> {
>         struct strvec args = STRVEC_INIT;
> 
>         strvec_pushl(&args, "replay", "--onto", NULL);
>         strvec_push(&args, oid_to_hex(new_head));
>         strvec_push(&args, "--ancestry-path");
>         strvec_pushf(&args, "^%s", oid_to_hex(prev_head));
>         strvec_push(&args, "--branches");
> 
>         reset_revision_walk();
>         cmd_replay(args.nr, args.v, NULL, repo);
> }
> 
> ...although maybe it's a little ugly to invoke cmd_replay() this way
> and maybe we want to restructure that out.
> 
> But, I am really late in providing my review, so if you want to go
> forward with your existing three functions and then perhaps we
> restructure later, that's fine too.  The command is experimental,
> after all.

I guess it's a combination of the transliteration and that I couldn't figure out how to easily do some things without shelling out. I plan on introducing features eventually that also allow for example to reorder commits, and I'm not clear that this is easy to do with the replay infra.

So for now I think I'd like to retain the current infra. But I certainly agree that we should revisit and see whether we can further refactor the interfaces provided by "replay.c" to cover more cases without shelling out.

Show 25 quoted lines
> > +       head = lookup_commit_reference_by_name("HEAD");
> > +       if (!head) {
> > +               ret = error(_("could not resolve HEAD to a commit"));
> > +               goto out;
> > +       }
> > +
> > +       commit_list_append(original_commit, &from_list);
> > +       if (!repo_is_descendant_of(repo, head, from_list)) {
> > +               ret = error (_("split commit must be reachable from current HEAD commit"));
> > +               goto out;
> > +       }
> 
> Why should it be required to be reachable from HEAD?  Shouldn't it be
> possible to reword a commit from another branch?
> 
> Also, what about when a commit is reachable from both HEAD and other
> branches?  I know you started by basing on git-rebase, and git-rebase
> restricts things to just one branch, but that was perhaps its biggest
> design flaw that couldn't be backward compatibly fixed without
> creating a new command.  I'd rather avoid copying that flaw.  (Maybe
> the user needs an error by default if more than one branch is
> affected, or they need to provide an additional flag to rewrite
> multiple branches, but only rewriting one branch when more than one is
> affected is just wrong to me unless the user explicitly specifies
> that's what they want.)

For now we the commands really only care about a single branch, the case where a commit exists on multiple branches is not considered. I'm not really sure whether I'd call this a flaw -- I think it's as easy way to think about the command for the user.

That being said, I certainly think that we can eventually introduce a flag to alter the behaviour so that it considers multiple branches in case the commit exists on more than one branch.

Show 18 quoted lines
> > +       /* We retain authorship of the original commit. */
> > +       original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
> > +       ptr = find_commit_header(original_message, "author", &len);
> > +       if (ptr)
> > +               original_author = xmemdupz(ptr, len);
> > +       find_commit_subject(original_message, &original_body);
> > +
> > +       ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
> > +                                 original_body, "reworded", &final_message);
> > +       if (ret < 0)
> > +               goto out;
> > +
> > +       ret = commit_tree(final_message.buf, final_message.len, &original_commit_tree_oid,
> > +                         original_commit->parents, &rewritten_commit, original_author, NULL);
> 
> Does the use of commit_tree() instead of commit_tree_extended() mean
> you discard additional headers on the reworded commit, such as
> encoding?
Good point, let me fix this.
Patrick
SZEDER Gábor· Nov 25, 2025, 08:31 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 05/11] builtin/history: implement "reword" subcommand

On Mon, Oct 27, 2025 at 12:33:53PM +0100, Patrick Steinhardt wrote:
> Implement a new "reword" subcommand for git-history(1). This subcommand
> is essentially the same as if a user performed an interactive rebase
> with a single commit changed to use the "reword" verb.
s/verb/instruction/

The behavior is not nearly "essentially the same", because 'git history reword' doesn't check out the reworded commit.

This is a substantial drawback when writing a commit message for anything non-trivial. I, for one, often like to take a look at the "big picture", i.e. the actual file content in the reworded commit, in case the diff included in the commit message template doesn't show enough context, or even run Git commands built from that particular revision to be able to accurately describe its behavior.

OTOH, I understand that this might be deemed desirable in some cases, like when rewording a commit to correct a simple typo, because source file mtimes stay intact, or when the worktree contains modified files.

It would be great if this new command could somehow support both use cases.

In any case, this significant behavior difference (and its drawbacks) is not mentioned let alone justified in the commit message, it is not documented anywhere, and it is not really tested, either.

Show 19 quoted lines
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  Documentation/git-history.adoc |   7 +-
>  builtin/history.c              | 331 ++++++++++++++++++++++++++++++++++++++++-
>  t/meson.build                  |   1 +
>  t/t3450-history.sh             |   6 +-
>  t/t3451-history-reword.sh      | 237 +++++++++++++++++++++++++++++
>  5 files changed, 573 insertions(+), 9 deletions(-)
> 
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> index 6bdfeb50e8b..bd903875120 100644
> --- a/Documentation/git-history.adoc
> +++ b/Documentation/git-history.adoc
> @@ -8,7 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history of the current branch
>  SYNOPSIS
>  --------
>  [synopsis]
> -git history [<options>]
> +git history reword <commit>

I'm not sure I like this interface, because I have to know in advance how to specify the revision I want to reword, and I usually don't know that. Choosing the commit from the rebase instruction sheet seems to be much simpler, more intuitive and less error prone.

Patrick Steinhardt· Dec 2, 2025, 18:50 UTC · re: SZEDER Gábor · lore

Re: [PATCH v6 05/11] builtin/history: implement "reword" subcommand

On Tue, Nov 25, 2025 at 09:31:24AM +0100, SZEDER Gábor wrote:
Show 9 quoted lines
> On Mon, Oct 27, 2025 at 12:33:53PM +0100, Patrick Steinhardt wrote:
> > Implement a new "reword" subcommand for git-history(1). This subcommand
> > is essentially the same as if a user performed an interactive rebase
> > with a single commit changed to use the "reword" verb.
> 
> s/verb/instruction/
> 
> The behavior is not nearly "essentially the same", because 'git
> history reword' doesn't check out the reworded commit.
True, this is a leftover from before. I'll reword this.
Show 17 quoted lines
> This is a substantial drawback when writing a commit message for
> anything non-trivial.  I, for one, often like to take a look at the
> "big picture", i.e. the actual file content in the reworded commit, in
> case the diff included in the commit message template doesn't show
> enough context, or even run Git commands built from that particular
> revision to be able to accurately describe its behavior.
> 
> OTOH, I understand that this might be deemed desirable in some cases,
> like when rewording a commit to correct a simple typo, because source
> file mtimes stay intact, or when the worktree contains modified files.
> 
> It would be great if this new command could somehow support both use
> cases.
> 
> In any case, this significant behavior difference (and its drawbacks)
> is not mentioned let alone justified in the commit message, it is not
> documented anywhere, and it is not really tested, either.

I guess it's a mixed bag. The big benefit of not having to check out the commit is that it's as fast as it gets. All we need to do is to rewrite commit history, and we don't need to check anything out. This has the obvious benefit of being fast, but also the less-obvious benefit of being able to deal with changes that exist in the working tree, only.

Show 24 quoted lines
> > Signed-off-by: Patrick Steinhardt <ps@pks.im>
> > ---
> >  Documentation/git-history.adoc |   7 +-
> >  builtin/history.c              | 331 ++++++++++++++++++++++++++++++++++++++++-
> >  t/meson.build                  |   1 +
> >  t/t3450-history.sh             |   6 +-
> >  t/t3451-history-reword.sh      | 237 +++++++++++++++++++++++++++++
> >  5 files changed, 573 insertions(+), 9 deletions(-)
> > 
> > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > index 6bdfeb50e8b..bd903875120 100644
> > --- a/Documentation/git-history.adoc
> > +++ b/Documentation/git-history.adoc
> > @@ -8,7 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history of the current branch
> >  SYNOPSIS
> >  --------
> >  [synopsis]
> > -git history [<options>]
> > +git history reword <commit>
> 
> I'm not sure I like this interface, because I have to know in advance
> how to specify the revision I want to reword, and I usually don't know
> that.  Choosing the commit from the rebase instruction sheet seems to
> be much simpler, more intuitive and less error prone.

Fair. For me I would very much prefer this new interface, but it's certainly a matter of taste.

Patrick
Patrick Steinhardt· Oct 27, 2025, 11:33 UTC · re: Patrick Steinhardt · lore

[PATCH v6 06/11] add-patch: split out header from "add-interactive.h"

While we have a "add-patch.c" code file, its declarations are part of "add-interactive.h". This makes it somewhat harder than necessary to find relevant code and to identify clear boundaries between the two subsystems.

Split up concerns and move declarations that relate to "add-patch.c" into a new "add-patch.h" header.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.h | 23 +++--------------------
 add-patch.c       |  1 +
 add-patch.h       | 26 ++++++++++++++++++++++++++
 3 files changed, 30 insertions(+), 20 deletions(-)
Show changes to 3 files +30 −20

add-interactive.h, add-patch.c, add-patch.h

diff --git a/add-interactive.h b/add-interactive.h
index da49502b765..2e3d1d871d2 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -1,14 +1,11 @@
 #ifndef ADD_INTERACTIVE_H
 #define ADD_INTERACTIVE_H
 
+#include "add-patch.h"
 #include "color.h"
 
-struct add_p_opt {
-	int context;
-	int interhunkcontext;
-};
-
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+struct pathspec;
+struct repository;
 
 struct add_i_state {
 	struct repository *r;
@@ -35,21 +32,7 @@ void init_add_i_state(struct add_i_state *s, struct repository *r,
 		      struct add_p_opt *add_p_opt);
 void clear_add_i_state(struct add_i_state *s);
 
-struct repository;
-struct pathspec;
 int run_add_i(struct repository *r, const struct pathspec *ps,
 	      struct add_p_opt *add_p_opt);
 
-enum add_p_mode {
-	ADD_P_ADD,
-	ADD_P_STASH,
-	ADD_P_RESET,
-	ADD_P_CHECKOUT,
-	ADD_P_WORKTREE,
-};
-
-int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
-	      const struct pathspec *ps);
-
 #endif
diff --git a/add-patch.c b/add-patch.c
index ae9a20d8f23..3594dd22534 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -3,6 +3,7 @@
 
 #include "git-compat-util.h"
 #include "add-interactive.h"
+#include "add-patch.h"
 #include "advice.h"
 #include "editor.h"
 #include "environment.h"
diff --git a/add-patch.h b/add-patch.h
new file mode 100644
index 00000000000..4394c741076
--- /dev/null
+++ b/add-patch.h
@@ -0,0 +1,26 @@
+#ifndef ADD_PATCH_H
+#define ADD_PATCH_H
+
+struct pathspec;
+struct repository;
+
+struct add_p_opt {
+	int context;
+	int interhunkcontext;
+};
+
+#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+
+enum add_p_mode {
+	ADD_P_ADD,
+	ADD_P_STASH,
+	ADD_P_RESET,
+	ADD_P_CHECKOUT,
+	ADD_P_WORKTREE,
+};
+
+int run_add_p(struct repository *r, enum add_p_mode mode,
+	      struct add_p_opt *o, const char *revision,
+	      const struct pathspec *ps);
+
+#endif
-- 
2.51.1.930.gacf6e81ea2.dirty
Elijah Newren· Nov 20, 2025, 07:03 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 06/11] add-patch: split out header from "add-interactive.h"

On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 107 quoted lines
>
> While we have a "add-patch.c" code file, its declarations are part of
> "add-interactive.h". This makes it somewhat harder than necessary to
> find relevant code and to identify clear boundaries between the two
> subsystems.
>
> Split up concerns and move declarations that relate to "add-patch.c"
> into a new "add-patch.h" header.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  add-interactive.h | 23 +++--------------------
>  add-patch.c       |  1 +
>  add-patch.h       | 26 ++++++++++++++++++++++++++
>  3 files changed, 30 insertions(+), 20 deletions(-)
>
> diff --git a/add-interactive.h b/add-interactive.h
> index da49502b765..2e3d1d871d2 100644
> --- a/add-interactive.h
> +++ b/add-interactive.h
> @@ -1,14 +1,11 @@
>  #ifndef ADD_INTERACTIVE_H
>  #define ADD_INTERACTIVE_H
>
> +#include "add-patch.h"
>  #include "color.h"
>
> -struct add_p_opt {
> -       int context;
> -       int interhunkcontext;
> -};
> -
> -#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
> +struct pathspec;
> +struct repository;
>
>  struct add_i_state {
>         struct repository *r;
> @@ -35,21 +32,7 @@ void init_add_i_state(struct add_i_state *s, struct repository *r,
>                       struct add_p_opt *add_p_opt);
>  void clear_add_i_state(struct add_i_state *s);
>
> -struct repository;
> -struct pathspec;
>  int run_add_i(struct repository *r, const struct pathspec *ps,
>               struct add_p_opt *add_p_opt);
>
> -enum add_p_mode {
> -       ADD_P_ADD,
> -       ADD_P_STASH,
> -       ADD_P_RESET,
> -       ADD_P_CHECKOUT,
> -       ADD_P_WORKTREE,
> -};
> -
> -int run_add_p(struct repository *r, enum add_p_mode mode,
> -             struct add_p_opt *o, const char *revision,
> -             const struct pathspec *ps);
> -
>  #endif
> diff --git a/add-patch.c b/add-patch.c
> index ae9a20d8f23..3594dd22534 100644
> --- a/add-patch.c
> +++ b/add-patch.c
> @@ -3,6 +3,7 @@
>
>  #include "git-compat-util.h"
>  #include "add-interactive.h"
> +#include "add-patch.h"
>  #include "advice.h"
>  #include "editor.h"
>  #include "environment.h"
> diff --git a/add-patch.h b/add-patch.h
> new file mode 100644
> index 00000000000..4394c741076
> --- /dev/null
> +++ b/add-patch.h
> @@ -0,0 +1,26 @@
> +#ifndef ADD_PATCH_H
> +#define ADD_PATCH_H
> +
> +struct pathspec;
> +struct repository;
> +
> +struct add_p_opt {
> +       int context;
> +       int interhunkcontext;
> +};
> +
> +#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
> +
> +enum add_p_mode {
> +       ADD_P_ADD,
> +       ADD_P_STASH,
> +       ADD_P_RESET,
> +       ADD_P_CHECKOUT,
> +       ADD_P_WORKTREE,
> +};
> +
> +int run_add_p(struct repository *r, enum add_p_mode mode,
> +             struct add_p_opt *o, const char *revision,
> +             const struct pathspec *ps);
> +
> +#endif
>
> --
> 2.51.1.930.gacf6e81ea2.dirty
Simple enough.
Patrick Steinhardt· Oct 27, 2025, 11:33 UTC · re: Patrick Steinhardt · lore

[PATCH v6 07/11] add-patch: split out `struct interactive_options`

The `struct add_p_opt` is reused both by our infra for "git add -p" and "git add -i". Users of `run_add_i()` for example are expected to pass `struct add_p_opt`. This is somewhat confusing and raises the question of which options apply to what part of the stack.

But things are even more confusing than that: while callers are expected to pass in `struct add_p_opt`, these options ultimately get used to initialize a `struct add_i_state` that is used by both subsystems. So we are basically going full circle here.

Refactor the code and split out a new `struct interactive_options` that hosts common options used by both. These options are then applied to a `struct interactive_config` that hosts common configuration.

This refactoring doesn't yet fully detangle the two subsystems from one another, as we still end up calling `init_add_i_state()` in the "git add -p" subsystem. This will be fixed in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.c  | 174 +++++++++++------------------------------------------
 add-interactive.h  |  23 +------
 add-patch.c        | 170 +++++++++++++++++++++++++++++++++++++++++++--------
 add-patch.h        |  36 ++++++++++-
 builtin/add.c      |  22 +++----
 builtin/checkout.c |   4 +-
 builtin/commit.c   |  16 ++---
 builtin/reset.c    |  16 ++---
 builtin/stash.c    |  46 +++++++-------
 commit.h           |   2 +-
 10 files changed, 270 insertions(+), 239 deletions(-)
Show changes to 10 files +270 −239

add-interactive.c, add-interactive.h, add-patch.c, add-patch.h, builtin/add.c, builtin/checkout.c, builtin/commit.c, builtin/reset.c, builtin/stash.c, commit.h

diff --git a/add-interactive.c b/add-interactive.c
index 68fc09547dd..05d2e7eefe3 100644
--- a/add-interactive.c
+++ b/add-interactive.c
@@ -3,7 +3,6 @@
 #include "git-compat-util.h"
 #include "add-interactive.h"
 #include "color.h"
-#include "config.h"
 #include "diffcore.h"
 #include "gettext.h"
 #include "hash.h"
@@ -20,119 +19,18 @@
 #include "prompt.h"
 #include "tree.h"
 
-static void init_color(struct repository *r, enum git_colorbool use_color,
-		       const char *section_and_slot, char *dst,
-		       const char *default_color)
-{
-	char *key = xstrfmt("color.%s", section_and_slot);
-	const char *value;
-
-	if (!want_color(use_color))
-		dst[0] = '\0';
-	else if (repo_config_get_value(r, key, &value) ||
-		 color_parse(value, dst))
-		strlcpy(dst, default_color, COLOR_MAXLEN);
-
-	free(key);
-}
-
-static enum git_colorbool check_color_config(struct repository *r, const char *var)
-{
-	const char *value;
-	enum git_colorbool ret;
-
-	if (repo_config_get_value(r, var, &value))
-		ret = GIT_COLOR_UNKNOWN;
-	else
-		ret = git_config_colorbool(var, value);
-
-	/*
-	 * Do not rely on want_color() to fall back to color.ui for us. It uses
-	 * the value parsed by git_color_config(), which may not have been
-	 * called by the main command.
-	 */
-	if (ret == GIT_COLOR_UNKNOWN &&
-	    !repo_config_get_value(r, "color.ui", &value))
-		ret = git_config_colorbool("color.ui", value);
-
-	return ret;
-}
-
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt)
+		      struct interactive_options *opts)
 {
 	s->r = r;
-	s->context = -1;
-	s->interhunkcontext = -1;
-
-	s->use_color_interactive = check_color_config(r, "color.interactive");
-
-	init_color(r, s->use_color_interactive, "interactive.header",
-		   s->header_color, GIT_COLOR_BOLD);
-	init_color(r, s->use_color_interactive, "interactive.help",
-		   s->help_color, GIT_COLOR_BOLD_RED);
-	init_color(r, s->use_color_interactive, "interactive.prompt",
-		   s->prompt_color, GIT_COLOR_BOLD_BLUE);
-	init_color(r, s->use_color_interactive, "interactive.error",
-		   s->error_color, GIT_COLOR_BOLD_RED);
-	strlcpy(s->reset_color_interactive,
-		want_color(s->use_color_interactive) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
-
-	s->use_color_diff = check_color_config(r, "color.diff");
-
-	init_color(r, s->use_color_diff, "diff.frag", s->fraginfo_color,
-		   diff_get_color(s->use_color_diff, DIFF_FRAGINFO));
-	init_color(r, s->use_color_diff, "diff.context", s->context_color,
-		   "fall back");
-	if (!strcmp(s->context_color, "fall back"))
-		init_color(r, s->use_color_diff, "diff.plain",
-			   s->context_color,
-			   diff_get_color(s->use_color_diff, DIFF_CONTEXT));
-	init_color(r, s->use_color_diff, "diff.old", s->file_old_color,
-		   diff_get_color(s->use_color_diff, DIFF_FILE_OLD));
-	init_color(r, s->use_color_diff, "diff.new", s->file_new_color,
-		   diff_get_color(s->use_color_diff, DIFF_FILE_NEW));
-	strlcpy(s->reset_color_diff,
-		want_color(s->use_color_diff) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
-
-	FREE_AND_NULL(s->interactive_diff_filter);
-	repo_config_get_string(r, "interactive.difffilter",
-			       &s->interactive_diff_filter);
-
-	FREE_AND_NULL(s->interactive_diff_algorithm);
-	repo_config_get_string(r, "diff.algorithm",
-			       &s->interactive_diff_algorithm);
-
-	if (!repo_config_get_int(r, "diff.context", &s->context))
-		if (s->context < 0)
-			die(_("%s cannot be negative"), "diff.context");
-	if (!repo_config_get_int(r, "diff.interHunkContext", &s->interhunkcontext))
-		if (s->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "diff.interHunkContext");
-
-	repo_config_get_bool(r, "interactive.singlekey", &s->use_single_key);
-	if (s->use_single_key)
-		setbuf(stdin, NULL);
-
-	if (add_p_opt->context != -1) {
-		if (add_p_opt->context < 0)
-			die(_("%s cannot be negative"), "--unified");
-		s->context = add_p_opt->context;
-	}
-	if (add_p_opt->interhunkcontext != -1) {
-		if (add_p_opt->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "--inter-hunk-context");
-		s->interhunkcontext = add_p_opt->interhunkcontext;
-	}
+	interactive_config_init(&s->cfg, r, opts);
 }
 
 void clear_add_i_state(struct add_i_state *s)
 {
-	FREE_AND_NULL(s->interactive_diff_filter);
-	FREE_AND_NULL(s->interactive_diff_algorithm);
+	interactive_config_clear(&s->cfg);
 	memset(s, 0, sizeof(*s));
-	s->use_color_interactive = GIT_COLOR_UNKNOWN;
-	s->use_color_diff = GIT_COLOR_UNKNOWN;
+	interactive_config_clear(&s->cfg);
 }
 
 /*
@@ -286,7 +184,7 @@ static void list(struct add_i_state *s, struct string_list *list, int *selected,
 		return;
 
 	if (opts->header)
-		color_fprintf_ln(stdout, s->header_color,
+		color_fprintf_ln(stdout, s->cfg.header_color,
 				 "%s", opts->header);
 
 	for (i = 0; i < list->nr; i++) {
@@ -354,7 +252,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 		list(s, &items->items, items->selected, &opts->list_opts);
 
-		color_fprintf(stdout, s->prompt_color, "%s", opts->prompt);
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", opts->prompt);
 		fputs(singleton ? "> " : ">> ", stdout);
 		fflush(stdout);
 
@@ -432,7 +330,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 			if (from < 0 || from >= items->items.nr ||
 			    (singleton && from + 1 != to)) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("Huh (%s)?"), p);
 				break;
 			} else if (singleton) {
@@ -992,7 +890,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 				free(files->items.items[i].string);
 			} else if (item->index.unmerged ||
 				 item->worktree.unmerged) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("ignoring unmerged: %s"),
 						 files->items.items[i].string);
 				free(item);
@@ -1014,9 +912,9 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 	opts->prompt = N_("Patch update");
 	count = list_and_choose(s, files, opts);
 	if (count > 0) {
-		struct add_p_opt add_p_opt = {
-			.context = s->context,
-			.interhunkcontext = s->interhunkcontext,
+		struct interactive_options opts = {
+			.context = s->cfg.context,
+			.interhunkcontext = s->cfg.interhunkcontext,
 		};
 		struct strvec args = STRVEC_INIT;
 		struct pathspec ps_selected = { 0 };
@@ -1028,7 +926,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 		parse_pathspec(&ps_selected,
 			       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,
 			       PATHSPEC_LITERAL_PATH, "", args.v);
-		res = run_add_p(s->r, ADD_P_ADD, &add_p_opt, NULL, &ps_selected);
+		res = run_add_p(s->r, ADD_P_ADD, &opts, NULL, &ps_selected);
 		strvec_clear(&args);
 		clear_pathspec(&ps_selected);
 	}
@@ -1064,10 +962,10 @@ static int run_diff(struct add_i_state *s, const struct pathspec *ps,
 		struct child_process cmd = CHILD_PROCESS_INIT;
 
 		strvec_pushl(&cmd.args, "git", "diff", "-p", "--cached", NULL);
-		if (s->context != -1)
-			strvec_pushf(&cmd.args, "--unified=%i", s->context);
-		if (s->interhunkcontext != -1)
-			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->interhunkcontext);
+		if (s->cfg.context != -1)
+			strvec_pushf(&cmd.args, "--unified=%i", s->cfg.context);
+		if (s->cfg.interhunkcontext != -1)
+			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
 		strvec_pushl(&cmd.args, oid_to_hex(!is_initial ? &oid :
 			     s->r->hash_algo->empty_tree), "--", NULL);
 		for (i = 0; i < files->items.nr; i++)
@@ -1085,17 +983,17 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 		    struct prefix_item_list *files UNUSED,
 		    struct list_and_choose_options *opts UNUSED)
 {
-	color_fprintf_ln(stdout, s->help_color, "status        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "status        - %s",
 			 _("show paths with changes"));
-	color_fprintf_ln(stdout, s->help_color, "update        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "update        - %s",
 			 _("add working tree state to the staged set of changes"));
-	color_fprintf_ln(stdout, s->help_color, "revert        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "revert        - %s",
 			 _("revert staged set of changes back to the HEAD version"));
-	color_fprintf_ln(stdout, s->help_color, "patch         - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "patch         - %s",
 			 _("pick hunks and update selectively"));
-	color_fprintf_ln(stdout, s->help_color, "diff          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "diff          - %s",
 			 _("view diff between HEAD and index"));
-	color_fprintf_ln(stdout, s->help_color, "add untracked - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "add untracked - %s",
 			 _("add contents of untracked files to the staged set of changes"));
 
 	return 0;
@@ -1103,21 +1001,21 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 
 static void choose_prompt_help(struct add_i_state *s)
 {
-	color_fprintf_ln(stdout, s->help_color, "%s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "%s",
 			 _("Prompt help:"));
-	color_fprintf_ln(stdout, s->help_color, "1          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "1          - %s",
 			 _("select a single item"));
-	color_fprintf_ln(stdout, s->help_color, "3-5        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "3-5        - %s",
 			 _("select a range of items"));
-	color_fprintf_ln(stdout, s->help_color, "2-3,6-9    - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "2-3,6-9    - %s",
 			 _("select multiple ranges"));
-	color_fprintf_ln(stdout, s->help_color, "foo        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "foo        - %s",
 			 _("select item based on unique prefix"));
-	color_fprintf_ln(stdout, s->help_color, "-...       - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "-...       - %s",
 			 _("unselect specified items"));
-	color_fprintf_ln(stdout, s->help_color, "*          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "*          - %s",
 			 _("choose all items"));
-	color_fprintf_ln(stdout, s->help_color, "           - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "           - %s",
 			 _("(empty) finish selecting"));
 }
 
@@ -1152,7 +1050,7 @@ static void print_command_item(int i, int selected UNUSED,
 
 static void command_prompt_help(struct add_i_state *s)
 {
-	const char *help_color = s->help_color;
+	const char *help_color = s->cfg.help_color;
 	color_fprintf_ln(stdout, help_color, "%s", _("Prompt help:"));
 	color_fprintf_ln(stdout, help_color, "1          - %s",
 			 _("select a numbered item"));
@@ -1163,7 +1061,7 @@ static void command_prompt_help(struct add_i_state *s)
 }
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt)
+	      struct interactive_options *interactive_opts)
 {
 	struct add_i_state s = { NULL };
 	struct print_command_item_data data = { "[", "]" };
@@ -1206,15 +1104,15 @@ int run_add_i(struct repository *r, const struct pathspec *ps,
 			->util = util;
 	}
 
-	init_add_i_state(&s, r, add_p_opt);
+	init_add_i_state(&s, r, interactive_opts);
 
 	/*
 	 * When color was asked for, use the prompt color for
 	 * highlighting, otherwise use square brackets.
 	 */
-	if (want_color(s.use_color_interactive)) {
-		data.color = s.prompt_color;
-		data.reset = s.reset_color_interactive;
+	if (want_color(s.cfg.use_color_interactive)) {
+		data.color = s.cfg.prompt_color;
+		data.reset = s.cfg.reset_color_interactive;
 	}
 	print_file_item_data.color = data.color;
 	print_file_item_data.reset = data.reset;
diff --git a/add-interactive.h b/add-interactive.h
index 2e3d1d871d2..eefa2edc7c1 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -2,37 +2,20 @@
 #define ADD_INTERACTIVE_H
 
 #include "add-patch.h"
-#include "color.h"
 
 struct pathspec;
 struct repository;
 
 struct add_i_state {
 	struct repository *r;
-	enum git_colorbool use_color_interactive;
-	enum git_colorbool use_color_diff;
-	char header_color[COLOR_MAXLEN];
-	char help_color[COLOR_MAXLEN];
-	char prompt_color[COLOR_MAXLEN];
-	char error_color[COLOR_MAXLEN];
-	char reset_color_interactive[COLOR_MAXLEN];
-
-	char fraginfo_color[COLOR_MAXLEN];
-	char context_color[COLOR_MAXLEN];
-	char file_old_color[COLOR_MAXLEN];
-	char file_new_color[COLOR_MAXLEN];
-	char reset_color_diff[COLOR_MAXLEN];
-
-	int use_single_key;
-	char *interactive_diff_filter, *interactive_diff_algorithm;
-	int context, interhunkcontext;
+	struct interactive_config cfg;
 };
 
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt);
+		      struct interactive_options *opts);
 void clear_add_i_state(struct add_i_state *s);
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt);
+	      struct interactive_options *opts);
 
 #endif
diff --git a/add-patch.c b/add-patch.c
index 3594dd22534..5c6969927ac 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -5,6 +5,8 @@
 #include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "config.h"
+#include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
@@ -279,6 +281,122 @@ struct add_p_state {
 	const char *revision;
 };
 
+static void init_color(struct repository *r,
+		       enum git_colorbool use_color,
+		       const char *section_and_slot, char *dst,
+		       const char *default_color)
+{
+	char *key = xstrfmt("color.%s", section_and_slot);
+	const char *value;
+
+	if (!want_color(use_color))
+		dst[0] = '\0';
+	else if (repo_config_get_value(r, key, &value) ||
+		 color_parse(value, dst))
+		strlcpy(dst, default_color, COLOR_MAXLEN);
+
+	free(key);
+}
+
+static enum git_colorbool check_color_config(struct repository *r, const char *var)
+{
+	const char *value;
+	enum git_colorbool ret;
+
+	if (repo_config_get_value(r, var, &value))
+		ret = GIT_COLOR_UNKNOWN;
+	else
+		ret = git_config_colorbool(var, value);
+
+	/*
+	 * Do not rely on want_color() to fall back to color.ui for us. It uses
+	 * the value parsed by git_color_config(), which may not have been
+	 * called by the main command.
+	 */
+	if (ret == GIT_COLOR_UNKNOWN &&
+	    !repo_config_get_value(r, "color.ui", &value))
+		ret = git_config_colorbool("color.ui", value);
+
+	return ret;
+}
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts)
+{
+	cfg->context = -1;
+	cfg->interhunkcontext = -1;
+
+	cfg->use_color_interactive = check_color_config(r, "color.interactive");
+
+	init_color(r, cfg->use_color_interactive, "interactive.header",
+		   cfg->header_color, GIT_COLOR_BOLD);
+	init_color(r, cfg->use_color_interactive, "interactive.help",
+		   cfg->help_color, GIT_COLOR_BOLD_RED);
+	init_color(r, cfg->use_color_interactive, "interactive.prompt",
+		   cfg->prompt_color, GIT_COLOR_BOLD_BLUE);
+	init_color(r, cfg->use_color_interactive, "interactive.error",
+		   cfg->error_color, GIT_COLOR_BOLD_RED);
+	strlcpy(cfg->reset_color_interactive,
+		want_color(cfg->use_color_interactive) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
+
+	cfg->use_color_diff = check_color_config(r, "color.diff");
+
+	init_color(r, cfg->use_color_diff, "diff.frag", cfg->fraginfo_color,
+		   diff_get_color(cfg->use_color_diff, DIFF_FRAGINFO));
+	init_color(r, cfg->use_color_diff, "diff.context", cfg->context_color,
+		   "fall back");
+	if (!strcmp(cfg->context_color, "fall back"))
+		init_color(r, cfg->use_color_diff, "diff.plain",
+			   cfg->context_color,
+			   diff_get_color(cfg->use_color_diff, DIFF_CONTEXT));
+	init_color(r, cfg->use_color_diff, "diff.old", cfg->file_old_color,
+		diff_get_color(cfg->use_color_diff, DIFF_FILE_OLD));
+	init_color(r, cfg->use_color_diff, "diff.new", cfg->file_new_color,
+		diff_get_color(cfg->use_color_diff, DIFF_FILE_NEW));
+	strlcpy(cfg->reset_color_diff,
+		want_color(cfg->use_color_diff) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
+
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	repo_config_get_string(r, "interactive.difffilter",
+			       &cfg->interactive_diff_filter);
+
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	repo_config_get_string(r, "diff.algorithm",
+			       &cfg->interactive_diff_algorithm);
+
+	if (!repo_config_get_int(r, "diff.context", &cfg->context))
+		if (cfg->context < 0)
+			die(_("%s cannot be negative"), "diff.context");
+	if (!repo_config_get_int(r, "diff.interHunkContext", &cfg->interhunkcontext))
+		if (cfg->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "diff.interHunkContext");
+
+	repo_config_get_bool(r, "interactive.singlekey", &cfg->use_single_key);
+	if (cfg->use_single_key)
+		setbuf(stdin, NULL);
+
+	if (opts->context != -1) {
+		if (opts->context < 0)
+			die(_("%s cannot be negative"), "--unified");
+		cfg->context = opts->context;
+	}
+	if (opts->interhunkcontext != -1) {
+		if (opts->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "--inter-hunk-context");
+		cfg->interhunkcontext = opts->interhunkcontext;
+	}
+}
+
+void interactive_config_clear(struct interactive_config *cfg)
+{
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	memset(cfg, 0, sizeof(*cfg));
+	cfg->use_color_interactive = GIT_COLOR_UNKNOWN;
+	cfg->use_color_diff = GIT_COLOR_UNKNOWN;
+}
+
 static void add_p_state_clear(struct add_p_state *s)
 {
 	size_t i;
@@ -299,9 +417,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.error_color, stdout);
+	fputs(s->s.cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.reset_color_interactive);
+	puts(s->s.cfg.reset_color_interactive);
 	va_end(args);
 }
 
@@ -424,12 +542,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.context);
-	if (s->s.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.interhunkcontext);
-	if (s->s.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.interactive_diff_algorithm);
+	if (s->s.cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
+	if (s->s.cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
+	if (s->s.cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -458,9 +576,9 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	}
 	strbuf_complete_line(plain);
 
-	if (want_color_fd(1, s->s.use_color_diff)) {
+	if (want_color_fd(1, s->s.cfg.use_color_diff)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.interactive_diff_filter;
+		const char *diff_filter = s->s.cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -693,7 +811,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.fraginfo_color);
+			strbuf_addstr(out, s->s.cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -715,7 +833,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.reset_color_diff);
+			strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1104,12 +1222,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.file_old_color :
+			      s->s.cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.file_new_color :
-			      s->s.context_color);
+			      s->s.cfg.file_new_color :
+			      s->s.cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.reset_color_diff);
+		strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1238,7 +1356,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.use_single_key) {
+	if (s->s.cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1252,7 +1370,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1560,15 +1678,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.reset_color_interactive)
-			fputs(s->s.reset_color_interactive, stdout);
+		if (*s->s.cfg.reset_color_interactive)
+			fputs(s->s.cfg.reset_color_interactive, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			break;
@@ -1730,7 +1848,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.header_color,
+				color_fprintf_ln(stdout, s->s.cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1748,7 +1866,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.help_color, "%s",
+			color_fprintf(stdout, s->s.cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1766,7 +1884,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.help_color,
+				color_fprintf_ln(stdout, s->s.cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1807,7 +1925,7 @@ static int patch_update_file(struct add_p_state *s,
 }
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
@@ -1815,7 +1933,7 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, o);
+	init_add_i_state(&s.s, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
diff --git a/add-patch.h b/add-patch.h
index 4394c741076..a4a05d9d145 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -1,15 +1,45 @@
 #ifndef ADD_PATCH_H
 #define ADD_PATCH_H
 
+#include "color.h"
+
 struct pathspec;
 struct repository;
 
-struct add_p_opt {
+struct interactive_options {
 	int context;
 	int interhunkcontext;
 };
 
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+#define INTERACTIVE_OPTIONS_INIT { \
+	.context = -1, \
+	.interhunkcontext = -1, \
+}
+
+struct interactive_config {
+	enum git_colorbool use_color_interactive;
+	enum git_colorbool use_color_diff;
+	char header_color[COLOR_MAXLEN];
+	char help_color[COLOR_MAXLEN];
+	char prompt_color[COLOR_MAXLEN];
+	char error_color[COLOR_MAXLEN];
+	char reset_color_interactive[COLOR_MAXLEN];
+
+	char fraginfo_color[COLOR_MAXLEN];
+	char context_color[COLOR_MAXLEN];
+	char file_old_color[COLOR_MAXLEN];
+	char file_new_color[COLOR_MAXLEN];
+	char reset_color_diff[COLOR_MAXLEN];
+
+	int use_single_key;
+	char *interactive_diff_filter, *interactive_diff_algorithm;
+	int context, interhunkcontext;
+};
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts);
+void interactive_config_clear(struct interactive_config *cfg);
 
 enum add_p_mode {
 	ADD_P_ADD,
@@ -20,7 +50,7 @@ enum add_p_mode {
 };
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
 #endif
diff --git a/builtin/add.c b/builtin/add.c
index 32709794b38..6f1e2130528 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -31,7 +31,7 @@ static const char * const builtin_add_usage[] = {
 	NULL
 };
 static int patch_interactive, add_interactive, edit_interactive;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int take_worktree_changes;
 static int add_renormalize;
 static int pathspec_file_nul;
@@ -160,7 +160,7 @@ static int refresh(struct repository *repo, int verbose, const struct pathspec *
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt)
+		    int patch, struct interactive_options *interactive_opts)
 {
 	struct pathspec pathspec;
 	int ret;
@@ -172,9 +172,9 @@ int interactive_add(struct repository *repo,
 		       prefix, argv);
 
 	if (patch)
-		ret = !!run_add_p(repo, ADD_P_ADD, add_p_opt, NULL, &pathspec);
+		ret = !!run_add_p(repo, ADD_P_ADD, interactive_opts, NULL, &pathspec);
 	else
-		ret = !!run_add_i(repo, &pathspec, add_p_opt);
+		ret = !!run_add_i(repo, &pathspec, interactive_opts);
 
 	clear_pathspec(&pathspec);
 	return ret;
@@ -256,8 +256,8 @@ static struct option builtin_add_options[] = {
 	OPT_GROUP(""),
 	OPT_BOOL('i', "interactive", &add_interactive, N_("interactive picking")),
 	OPT_BOOL('p', "patch", &patch_interactive, N_("select hunks interactively")),
-	OPT_DIFF_UNIFIED(&add_p_opt.context),
-	OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+	OPT_DIFF_UNIFIED(&interactive_opts.context),
+	OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 	OPT_BOOL('e', "edit", &edit_interactive, N_("edit current diff and apply")),
 	OPT__FORCE(&ignored_too, N_("allow adding otherwise ignored files"), 0),
 	OPT_BOOL('u', "update", &take_worktree_changes, N_("update tracked files")),
@@ -400,9 +400,9 @@ int cmd_add(int argc,
 	prepare_repo_settings(repo);
 	repo->settings.command_requires_full_index = 0;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (patch_interactive)
@@ -412,11 +412,11 @@ int cmd_add(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--dry-run", "--interactive/--patch");
 		if (pathspec_from_file)
 			die(_("options '%s' and '%s' cannot be used together"), "--pathspec-from-file", "--interactive/--patch");
-		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &add_p_opt));
+		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &interactive_opts));
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
diff --git a/builtin/checkout.c b/builtin/checkout.c
index f9453473fe2..d230b1f8995 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -546,7 +546,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 
 	if (opts->patch_mode) {
 		enum add_p_mode patch_mode;
-		struct add_p_opt add_p_opt = {
+		struct interactive_options interactive_opts = {
 			.context = opts->patch_context,
 			.interhunkcontext = opts->patch_interhunk_context,
 		};
@@ -575,7 +575,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 		else
 			BUG("either flag must have been set, worktree=%d, index=%d",
 			    opts->checkout_worktree, opts->checkout_index);
-		return !!run_add_p(the_repository, patch_mode, &add_p_opt,
+		return !!run_add_p(the_repository, patch_mode, &interactive_opts,
 				   rev, &opts->pathspec);
 	}
 
diff --git a/builtin/commit.c b/builtin/commit.c
index 0243f17d53c..640495cc57e 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -123,7 +123,7 @@ static const char *edit_message, *use_message;
 static char *fixup_message, *fixup_commit, *squash_message;
 static const char *fixup_prefix;
 static int all, also, interactive, patch_interactive, only, amend, signoff;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int edit_flag = -1; /* unspecified */
 static int quiet, verbose, no_verify, allow_empty, dry_run, renew_authorship;
 static int config_commit_verbose = -1; /* unspecified */
@@ -356,9 +356,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 	const char *ret;
 	char *path = NULL;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (is_status)
@@ -407,7 +407,7 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 		setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-		if (interactive_add(the_repository, argv, prefix, patch_interactive, &add_p_opt) != 0)
+		if (interactive_add(the_repository, argv, prefix, patch_interactive, &interactive_opts) != 0)
 			die(_("interactive add failed"));
 
 		the_repository->index_file = old_repo_index_file;
@@ -432,9 +432,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		ret = get_lock_file_path(&index_lock);
 		goto out;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
@@ -1742,8 +1742,8 @@ int cmd_commit(int argc,
 		OPT_BOOL('i', "include", &also, N_("add specified files to index for commit")),
 		OPT_BOOL(0, "interactive", &interactive, N_("interactively add files")),
 		OPT_BOOL('p', "patch", &patch_interactive, N_("interactively add changes")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('o', "only", &only, N_("commit only specified files")),
 		OPT_BOOL('n', "no-verify", &no_verify, N_("bypass pre-commit and commit-msg hooks")),
 		OPT_BOOL(0, "dry-run", &dry_run, N_("show what would be committed")),
diff --git a/builtin/reset.c b/builtin/reset.c
index ed35802af15..088449e1209 100644
--- a/builtin/reset.c
+++ b/builtin/reset.c
@@ -346,7 +346,7 @@ int cmd_reset(int argc,
 	struct object_id oid;
 	struct pathspec pathspec;
 	int intent_to_add = 0;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	const struct option options[] = {
 		OPT__QUIET(&quiet, N_("be quiet, only report errors")),
 		OPT_BOOL(0, "no-refresh", &no_refresh,
@@ -371,8 +371,8 @@ int cmd_reset(int argc,
 			       PARSE_OPT_OPTARG,
 			       option_parse_recurse_submodules_worktree_updater),
 		OPT_BOOL('p', "patch", &patch_mode, N_("select hunks interactively")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('N', "intent-to-add", &intent_to_add,
 				N_("record only the fact that removed paths will be added later")),
 		OPT_PATHSPEC_FROM_FILE(&pathspec_from_file),
@@ -423,9 +423,9 @@ int cmd_reset(int argc,
 		oidcpy(&oid, &tree->object.oid);
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	prepare_repo_settings(the_repository);
@@ -436,12 +436,12 @@ int cmd_reset(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--patch", "--{hard,mixed,soft}");
 		trace2_cmd_mode("patch-interactive");
 		update_ref_status = !!run_add_p(the_repository, ADD_P_RESET,
-						&add_p_opt, rev, &pathspec);
+						&interactive_opts, rev, &pathspec);
 		goto cleanup;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
diff --git a/builtin/stash.c b/builtin/stash.c
index 948eba06fbc..3b509052338 100644
--- a/builtin/stash.c
+++ b/builtin/stash.c
@@ -1306,7 +1306,7 @@ static int stash_staged(struct stash_info *info, struct strbuf *out_patch,
 
 static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 		       struct strbuf *out_patch, int quiet,
-		       struct add_p_opt *add_p_opt)
+		       struct interactive_options *interactive_opts)
 {
 	int ret = 0;
 	struct child_process cp_read_tree = CHILD_PROCESS_INIT;
@@ -1331,7 +1331,7 @@ static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 	old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 	setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-	ret = !!run_add_p(the_repository, ADD_P_STASH, add_p_opt, NULL, ps);
+	ret = !!run_add_p(the_repository, ADD_P_STASH, interactive_opts, NULL, ps);
 
 	the_repository->index_file = old_repo_index_file;
 	if (old_index_env && *old_index_env)
@@ -1427,7 +1427,8 @@ static int stash_working_tree(struct stash_info *info, const struct pathspec *ps
 }
 
 static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_buf,
-			   int include_untracked, int patch_mode, struct add_p_opt *add_p_opt,
+			   int include_untracked, int patch_mode,
+			   struct interactive_options *interactive_opts,
 			   int only_staged, struct stash_info *info, struct strbuf *patch,
 			   int quiet)
 {
@@ -1509,7 +1510,7 @@ static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_b
 		untracked_commit_option = 1;
 	}
 	if (patch_mode) {
-		ret = stash_patch(info, ps, patch, quiet, add_p_opt);
+		ret = stash_patch(info, ps, patch, quiet, interactive_opts);
 		if (ret < 0) {
 			if (!quiet)
 				fprintf_ln(stderr, _("Cannot save the current "
@@ -1595,7 +1596,8 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED,
 }
 
 static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int quiet,
-			 int keep_index, int patch_mode, struct add_p_opt *add_p_opt,
+			 int keep_index, int patch_mode,
+			 struct interactive_options *interactive_opts,
 			 int include_untracked, int only_staged)
 {
 	int ret = 0;
@@ -1667,7 +1669,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q
 	if (stash_msg)
 		strbuf_addstr(&stash_msg_buf, stash_msg);
 	if (do_create_stash(ps, &stash_msg_buf, include_untracked, patch_mode,
-			    add_p_opt, only_staged, &info, &patch, quiet)) {
+			    interactive_opts, only_staged, &info, &patch, quiet)) {
 		ret = -1;
 		goto done;
 	}
@@ -1841,7 +1843,7 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	char *pathspec_from_file = NULL;
 	struct pathspec ps;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1849,8 +1851,8 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1907,19 +1909,19 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	}
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index, patch_mode,
-			    &add_p_opt, include_untracked, only_staged);
+			    &interactive_opts, include_untracked, only_staged);
 
 	clear_pathspec(&ps);
 	free(pathspec_from_file);
@@ -1944,7 +1946,7 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	struct pathspec ps;
 	struct strbuf stash_msg_buf = STRBUF_INIT;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1952,8 +1954,8 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1973,20 +1975,20 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 
 	memset(&ps, 0, sizeof(ps));
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index,
-			    patch_mode, &add_p_opt, include_untracked,
+			    patch_mode, &interactive_opts, include_untracked,
 			    only_staged);
 
 	strbuf_release(&stash_msg_buf);
diff --git a/commit.h b/commit.h
index 1d6e0c7518b..7b6e59d6c19 100644
--- a/commit.h
+++ b/commit.h
@@ -258,7 +258,7 @@ int for_each_commit_graft(each_commit_graft_fn, void *);
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt);
+		    int patch, struct interactive_options *opts);
 
 struct commit_extra_header {
 	struct commit_extra_header *next;
-- 
2.51.1.930.gacf6e81ea2.dirty
Elijah Newren· Nov 20, 2025, 07:03 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 07/11] add-patch: split out `struct interactive_options`

On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 14 quoted lines
>
> The `struct add_p_opt` is reused both by our infra for "git add -p" and
> "git add -i". Users of `run_add_i()` for example are expected to pass
> `struct add_p_opt`. This is somewhat confusing and raises the question
> of which options apply to what part of the stack.
>
> But things are even more confusing than that: while callers are expected
> to pass in `struct add_p_opt`, these options ultimately get used to
> initialize a `struct add_i_state` that is used by both subsystems. So we
> are basically going full circle here.
>
> Refactor the code and split out a new `struct interactive_options` that
> hosts common options used by both. These options are then applied to a
> `struct interactive_config` that hosts common configuration.
Makes sense.

--color-moved helped me view part of the patch, and --color-words=. in some places showed just repeated additions of ".cfg". Still a pretty long patch; I'm almost curious if it could be split up into more steps to make the review easier (I admit to skimming), but scanning over it looks reasonable.

Phillip Wood· Nov 20, 2025, 15:05 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 07/11] add-patch: split out `struct interactive_options`

On 27/10/2025 11:33, Patrick Steinhardt wrote:
> The `struct add_p_opt` is reused both by our infra for "git add -p" and
> "git add -i". Users of `run_add_i()` for example are expected to pass
> `struct add_p_opt`. This is somewhat confusing and raises the question
> of which options apply to what part of the stack.

To some extent the config setting are intertwined because run_add_i() is a superset of run_add_p(). Callers of run_add_i() needs to initalize an instance of "struct add_p_opt" because they are indirectly calling run_add_p().

> But things are even more confusing than that: while callers are expected
> to pass in `struct add_p_opt`, these options ultimately get used to
> initialize a `struct add_i_state` that is used by both subsystems. So we
> are basically going full circle here.

It is certainly confusing that we have to initalize a "struct add_i_state" in run_add_p(). struct add_p_opt is only consumed in add-patch.c, the reason it apperas in add-interactive.c is that run_add_i() needs to pass it along to run_add_p().

> Refactor the code and split out a new `struct interactive_options` that
> hosts common options used by both. These options are then applied to a
> `struct interactive_config` that hosts common configuration.

I'm a little skeptical about renaming "sturct add_p_opt" as it only holds members that are relavent to run_add_p(). Also if we're trying to draw clear boundaries between the two subsystems hosting "struct interactive_options" and "struct interactive_config" in add-patch.c rather than add-interactive.c is potentially confusing.

> This refactoring doesn't yet fully detangle the two subsystems from one
> another, as we still end up calling `init_add_i_state()` in the "git add
> -p" subsystem. This will be fixed in a subsequent commit.

I think the ultimate aim of not having to initalize a "struct add_i_state" in run_add_p() is a good idea. I'm not sure though that having to pass a "struct interactive_options" to run_add_p() is any less confusing than having to pass a "struct add_p_opt" to run_add_i().

Thanks
Phillip
Show 1085 quoted lines
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>   add-interactive.c  | 174 +++++++++++------------------------------------------
>   add-interactive.h  |  23 +------
>   add-patch.c        | 170 +++++++++++++++++++++++++++++++++++++++++++--------
>   add-patch.h        |  36 ++++++++++-
>   builtin/add.c      |  22 +++----
>   builtin/checkout.c |   4 +-
>   builtin/commit.c   |  16 ++---
>   builtin/reset.c    |  16 ++---
>   builtin/stash.c    |  46 +++++++-------
>   commit.h           |   2 +-
>   10 files changed, 270 insertions(+), 239 deletions(-)
> 
> diff --git a/add-interactive.c b/add-interactive.c
> index 68fc09547dd..05d2e7eefe3 100644
> --- a/add-interactive.c
> +++ b/add-interactive.c
> @@ -3,7 +3,6 @@
>   #include "git-compat-util.h"
>   #include "add-interactive.h"
>   #include "color.h"
> -#include "config.h"
>   #include "diffcore.h"
>   #include "gettext.h"
>   #include "hash.h"
> @@ -20,119 +19,18 @@
>   #include "prompt.h"
>   #include "tree.h"
>   
> -static void init_color(struct repository *r, enum git_colorbool use_color,
> -		       const char *section_and_slot, char *dst,
> -		       const char *default_color)
> -{
> -	char *key = xstrfmt("color.%s", section_and_slot);
> -	const char *value;
> -
> -	if (!want_color(use_color))
> -		dst[0] = '\0';
> -	else if (repo_config_get_value(r, key, &value) ||
> -		 color_parse(value, dst))
> -		strlcpy(dst, default_color, COLOR_MAXLEN);
> -
> -	free(key);
> -}
> -
> -static enum git_colorbool check_color_config(struct repository *r, const char *var)
> -{
> -	const char *value;
> -	enum git_colorbool ret;
> -
> -	if (repo_config_get_value(r, var, &value))
> -		ret = GIT_COLOR_UNKNOWN;
> -	else
> -		ret = git_config_colorbool(var, value);
> -
> -	/*
> -	 * Do not rely on want_color() to fall back to color.ui for us. It uses
> -	 * the value parsed by git_color_config(), which may not have been
> -	 * called by the main command.
> -	 */
> -	if (ret == GIT_COLOR_UNKNOWN &&
> -	    !repo_config_get_value(r, "color.ui", &value))
> -		ret = git_config_colorbool("color.ui", value);
> -
> -	return ret;
> -}
> -
>   void init_add_i_state(struct add_i_state *s, struct repository *r,
> -		      struct add_p_opt *add_p_opt)
> +		      struct interactive_options *opts)
>   {
>   	s->r = r;
> -	s->context = -1;
> -	s->interhunkcontext = -1;
> -
> -	s->use_color_interactive = check_color_config(r, "color.interactive");
> -
> -	init_color(r, s->use_color_interactive, "interactive.header",
> -		   s->header_color, GIT_COLOR_BOLD);
> -	init_color(r, s->use_color_interactive, "interactive.help",
> -		   s->help_color, GIT_COLOR_BOLD_RED);
> -	init_color(r, s->use_color_interactive, "interactive.prompt",
> -		   s->prompt_color, GIT_COLOR_BOLD_BLUE);
> -	init_color(r, s->use_color_interactive, "interactive.error",
> -		   s->error_color, GIT_COLOR_BOLD_RED);
> -	strlcpy(s->reset_color_interactive,
> -		want_color(s->use_color_interactive) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
> -
> -	s->use_color_diff = check_color_config(r, "color.diff");
> -
> -	init_color(r, s->use_color_diff, "diff.frag", s->fraginfo_color,
> -		   diff_get_color(s->use_color_diff, DIFF_FRAGINFO));
> -	init_color(r, s->use_color_diff, "diff.context", s->context_color,
> -		   "fall back");
> -	if (!strcmp(s->context_color, "fall back"))
> -		init_color(r, s->use_color_diff, "diff.plain",
> -			   s->context_color,
> -			   diff_get_color(s->use_color_diff, DIFF_CONTEXT));
> -	init_color(r, s->use_color_diff, "diff.old", s->file_old_color,
> -		   diff_get_color(s->use_color_diff, DIFF_FILE_OLD));
> -	init_color(r, s->use_color_diff, "diff.new", s->file_new_color,
> -		   diff_get_color(s->use_color_diff, DIFF_FILE_NEW));
> -	strlcpy(s->reset_color_diff,
> -		want_color(s->use_color_diff) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
> -
> -	FREE_AND_NULL(s->interactive_diff_filter);
> -	repo_config_get_string(r, "interactive.difffilter",
> -			       &s->interactive_diff_filter);
> -
> -	FREE_AND_NULL(s->interactive_diff_algorithm);
> -	repo_config_get_string(r, "diff.algorithm",
> -			       &s->interactive_diff_algorithm);
> -
> -	if (!repo_config_get_int(r, "diff.context", &s->context))
> -		if (s->context < 0)
> -			die(_("%s cannot be negative"), "diff.context");
> -	if (!repo_config_get_int(r, "diff.interHunkContext", &s->interhunkcontext))
> -		if (s->interhunkcontext < 0)
> -			die(_("%s cannot be negative"), "diff.interHunkContext");
> -
> -	repo_config_get_bool(r, "interactive.singlekey", &s->use_single_key);
> -	if (s->use_single_key)
> -		setbuf(stdin, NULL);
> -
> -	if (add_p_opt->context != -1) {
> -		if (add_p_opt->context < 0)
> -			die(_("%s cannot be negative"), "--unified");
> -		s->context = add_p_opt->context;
> -	}
> -	if (add_p_opt->interhunkcontext != -1) {
> -		if (add_p_opt->interhunkcontext < 0)
> -			die(_("%s cannot be negative"), "--inter-hunk-context");
> -		s->interhunkcontext = add_p_opt->interhunkcontext;
> -	}
> +	interactive_config_init(&s->cfg, r, opts);
>   }
>   
>   void clear_add_i_state(struct add_i_state *s)
>   {
> -	FREE_AND_NULL(s->interactive_diff_filter);
> -	FREE_AND_NULL(s->interactive_diff_algorithm);
> +	interactive_config_clear(&s->cfg);
>   	memset(s, 0, sizeof(*s));
> -	s->use_color_interactive = GIT_COLOR_UNKNOWN;
> -	s->use_color_diff = GIT_COLOR_UNKNOWN;
> +	interactive_config_clear(&s->cfg);
>   }
>   
>   /*
> @@ -286,7 +184,7 @@ static void list(struct add_i_state *s, struct string_list *list, int *selected,
>   		return;
>   
>   	if (opts->header)
> -		color_fprintf_ln(stdout, s->header_color,
> +		color_fprintf_ln(stdout, s->cfg.header_color,
>   				 "%s", opts->header);
>   
>   	for (i = 0; i < list->nr; i++) {
> @@ -354,7 +252,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
>   
>   		list(s, &items->items, items->selected, &opts->list_opts);
>   
> -		color_fprintf(stdout, s->prompt_color, "%s", opts->prompt);
> +		color_fprintf(stdout, s->cfg.prompt_color, "%s", opts->prompt);
>   		fputs(singleton ? "> " : ">> ", stdout);
>   		fflush(stdout);
>   
> @@ -432,7 +330,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
>   
>   			if (from < 0 || from >= items->items.nr ||
>   			    (singleton && from + 1 != to)) {
> -				color_fprintf_ln(stderr, s->error_color,
> +				color_fprintf_ln(stderr, s->cfg.error_color,
>   						 _("Huh (%s)?"), p);
>   				break;
>   			} else if (singleton) {
> @@ -992,7 +890,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
>   				free(files->items.items[i].string);
>   			} else if (item->index.unmerged ||
>   				 item->worktree.unmerged) {
> -				color_fprintf_ln(stderr, s->error_color,
> +				color_fprintf_ln(stderr, s->cfg.error_color,
>   						 _("ignoring unmerged: %s"),
>   						 files->items.items[i].string);
>   				free(item);
> @@ -1014,9 +912,9 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
>   	opts->prompt = N_("Patch update");
>   	count = list_and_choose(s, files, opts);
>   	if (count > 0) {
> -		struct add_p_opt add_p_opt = {
> -			.context = s->context,
> -			.interhunkcontext = s->interhunkcontext,
> +		struct interactive_options opts = {
> +			.context = s->cfg.context,
> +			.interhunkcontext = s->cfg.interhunkcontext,
>   		};
>   		struct strvec args = STRVEC_INIT;
>   		struct pathspec ps_selected = { 0 };
> @@ -1028,7 +926,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
>   		parse_pathspec(&ps_selected,
>   			       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,
>   			       PATHSPEC_LITERAL_PATH, "", args.v);
> -		res = run_add_p(s->r, ADD_P_ADD, &add_p_opt, NULL, &ps_selected);
> +		res = run_add_p(s->r, ADD_P_ADD, &opts, NULL, &ps_selected);
>   		strvec_clear(&args);
>   		clear_pathspec(&ps_selected);
>   	}
> @@ -1064,10 +962,10 @@ static int run_diff(struct add_i_state *s, const struct pathspec *ps,
>   		struct child_process cmd = CHILD_PROCESS_INIT;
>   
>   		strvec_pushl(&cmd.args, "git", "diff", "-p", "--cached", NULL);
> -		if (s->context != -1)
> -			strvec_pushf(&cmd.args, "--unified=%i", s->context);
> -		if (s->interhunkcontext != -1)
> -			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->interhunkcontext);
> +		if (s->cfg.context != -1)
> +			strvec_pushf(&cmd.args, "--unified=%i", s->cfg.context);
> +		if (s->cfg.interhunkcontext != -1)
> +			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
>   		strvec_pushl(&cmd.args, oid_to_hex(!is_initial ? &oid :
>   			     s->r->hash_algo->empty_tree), "--", NULL);
>   		for (i = 0; i < files->items.nr; i++)
> @@ -1085,17 +983,17 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
>   		    struct prefix_item_list *files UNUSED,
>   		    struct list_and_choose_options *opts UNUSED)
>   {
> -	color_fprintf_ln(stdout, s->help_color, "status        - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "status        - %s",
>   			 _("show paths with changes"));
> -	color_fprintf_ln(stdout, s->help_color, "update        - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "update        - %s",
>   			 _("add working tree state to the staged set of changes"));
> -	color_fprintf_ln(stdout, s->help_color, "revert        - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "revert        - %s",
>   			 _("revert staged set of changes back to the HEAD version"));
> -	color_fprintf_ln(stdout, s->help_color, "patch         - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "patch         - %s",
>   			 _("pick hunks and update selectively"));
> -	color_fprintf_ln(stdout, s->help_color, "diff          - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "diff          - %s",
>   			 _("view diff between HEAD and index"));
> -	color_fprintf_ln(stdout, s->help_color, "add untracked - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "add untracked - %s",
>   			 _("add contents of untracked files to the staged set of changes"));
>   
>   	return 0;
> @@ -1103,21 +1001,21 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
>   
>   static void choose_prompt_help(struct add_i_state *s)
>   {
> -	color_fprintf_ln(stdout, s->help_color, "%s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "%s",
>   			 _("Prompt help:"));
> -	color_fprintf_ln(stdout, s->help_color, "1          - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "1          - %s",
>   			 _("select a single item"));
> -	color_fprintf_ln(stdout, s->help_color, "3-5        - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "3-5        - %s",
>   			 _("select a range of items"));
> -	color_fprintf_ln(stdout, s->help_color, "2-3,6-9    - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "2-3,6-9    - %s",
>   			 _("select multiple ranges"));
> -	color_fprintf_ln(stdout, s->help_color, "foo        - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "foo        - %s",
>   			 _("select item based on unique prefix"));
> -	color_fprintf_ln(stdout, s->help_color, "-...       - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "-...       - %s",
>   			 _("unselect specified items"));
> -	color_fprintf_ln(stdout, s->help_color, "*          - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "*          - %s",
>   			 _("choose all items"));
> -	color_fprintf_ln(stdout, s->help_color, "           - %s",
> +	color_fprintf_ln(stdout, s->cfg.help_color, "           - %s",
>   			 _("(empty) finish selecting"));
>   }
>   
> @@ -1152,7 +1050,7 @@ static void print_command_item(int i, int selected UNUSED,
>   
>   static void command_prompt_help(struct add_i_state *s)
>   {
> -	const char *help_color = s->help_color;
> +	const char *help_color = s->cfg.help_color;
>   	color_fprintf_ln(stdout, help_color, "%s", _("Prompt help:"));
>   	color_fprintf_ln(stdout, help_color, "1          - %s",
>   			 _("select a numbered item"));
> @@ -1163,7 +1061,7 @@ static void command_prompt_help(struct add_i_state *s)
>   }
>   
>   int run_add_i(struct repository *r, const struct pathspec *ps,
> -	      struct add_p_opt *add_p_opt)
> +	      struct interactive_options *interactive_opts)
>   {
>   	struct add_i_state s = { NULL };
>   	struct print_command_item_data data = { "[", "]" };
> @@ -1206,15 +1104,15 @@ int run_add_i(struct repository *r, const struct pathspec *ps,
>   			->util = util;
>   	}
>   
> -	init_add_i_state(&s, r, add_p_opt);
> +	init_add_i_state(&s, r, interactive_opts);
>   
>   	/*
>   	 * When color was asked for, use the prompt color for
>   	 * highlighting, otherwise use square brackets.
>   	 */
> -	if (want_color(s.use_color_interactive)) {
> -		data.color = s.prompt_color;
> -		data.reset = s.reset_color_interactive;
> +	if (want_color(s.cfg.use_color_interactive)) {
> +		data.color = s.cfg.prompt_color;
> +		data.reset = s.cfg.reset_color_interactive;
>   	}
>   	print_file_item_data.color = data.color;
>   	print_file_item_data.reset = data.reset;
> diff --git a/add-interactive.h b/add-interactive.h
> index 2e3d1d871d2..eefa2edc7c1 100644
> --- a/add-interactive.h
> +++ b/add-interactive.h
> @@ -2,37 +2,20 @@
>   #define ADD_INTERACTIVE_H
>   
>   #include "add-patch.h"
> -#include "color.h"
>   
>   struct pathspec;
>   struct repository;
>   
>   struct add_i_state {
>   	struct repository *r;
> -	enum git_colorbool use_color_interactive;
> -	enum git_colorbool use_color_diff;
> -	char header_color[COLOR_MAXLEN];
> -	char help_color[COLOR_MAXLEN];
> -	char prompt_color[COLOR_MAXLEN];
> -	char error_color[COLOR_MAXLEN];
> -	char reset_color_interactive[COLOR_MAXLEN];
> -
> -	char fraginfo_color[COLOR_MAXLEN];
> -	char context_color[COLOR_MAXLEN];
> -	char file_old_color[COLOR_MAXLEN];
> -	char file_new_color[COLOR_MAXLEN];
> -	char reset_color_diff[COLOR_MAXLEN];
> -
> -	int use_single_key;
> -	char *interactive_diff_filter, *interactive_diff_algorithm;
> -	int context, interhunkcontext;
> +	struct interactive_config cfg;
>   };
>   
>   void init_add_i_state(struct add_i_state *s, struct repository *r,
> -		      struct add_p_opt *add_p_opt);
> +		      struct interactive_options *opts);
>   void clear_add_i_state(struct add_i_state *s);
>   
>   int run_add_i(struct repository *r, const struct pathspec *ps,
> -	      struct add_p_opt *add_p_opt);
> +	      struct interactive_options *opts);
>   
>   #endif
> diff --git a/add-patch.c b/add-patch.c
> index 3594dd22534..5c6969927ac 100644
> --- a/add-patch.c
> +++ b/add-patch.c
> @@ -5,6 +5,8 @@
>   #include "add-interactive.h"
>   #include "add-patch.h"
>   #include "advice.h"
> +#include "config.h"
> +#include "diff.h"
>   #include "editor.h"
>   #include "environment.h"
>   #include "gettext.h"
> @@ -279,6 +281,122 @@ struct add_p_state {
>   	const char *revision;
>   };
>   
> +static void init_color(struct repository *r,
> +		       enum git_colorbool use_color,
> +		       const char *section_and_slot, char *dst,
> +		       const char *default_color)
> +{
> +	char *key = xstrfmt("color.%s", section_and_slot);
> +	const char *value;
> +
> +	if (!want_color(use_color))
> +		dst[0] = '\0';
> +	else if (repo_config_get_value(r, key, &value) ||
> +		 color_parse(value, dst))
> +		strlcpy(dst, default_color, COLOR_MAXLEN);
> +
> +	free(key);
> +}
> +
> +static enum git_colorbool check_color_config(struct repository *r, const char *var)
> +{
> +	const char *value;
> +	enum git_colorbool ret;
> +
> +	if (repo_config_get_value(r, var, &value))
> +		ret = GIT_COLOR_UNKNOWN;
> +	else
> +		ret = git_config_colorbool(var, value);
> +
> +	/*
> +	 * Do not rely on want_color() to fall back to color.ui for us. It uses
> +	 * the value parsed by git_color_config(), which may not have been
> +	 * called by the main command.
> +	 */
> +	if (ret == GIT_COLOR_UNKNOWN &&
> +	    !repo_config_get_value(r, "color.ui", &value))
> +		ret = git_config_colorbool("color.ui", value);
> +
> +	return ret;
> +}
> +
> +void interactive_config_init(struct interactive_config *cfg,
> +			     struct repository *r,
> +			     struct interactive_options *opts)
> +{
> +	cfg->context = -1;
> +	cfg->interhunkcontext = -1;
> +
> +	cfg->use_color_interactive = check_color_config(r, "color.interactive");
> +
> +	init_color(r, cfg->use_color_interactive, "interactive.header",
> +		   cfg->header_color, GIT_COLOR_BOLD);
> +	init_color(r, cfg->use_color_interactive, "interactive.help",
> +		   cfg->help_color, GIT_COLOR_BOLD_RED);
> +	init_color(r, cfg->use_color_interactive, "interactive.prompt",
> +		   cfg->prompt_color, GIT_COLOR_BOLD_BLUE);
> +	init_color(r, cfg->use_color_interactive, "interactive.error",
> +		   cfg->error_color, GIT_COLOR_BOLD_RED);
> +	strlcpy(cfg->reset_color_interactive,
> +		want_color(cfg->use_color_interactive) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
> +
> +	cfg->use_color_diff = check_color_config(r, "color.diff");
> +
> +	init_color(r, cfg->use_color_diff, "diff.frag", cfg->fraginfo_color,
> +		   diff_get_color(cfg->use_color_diff, DIFF_FRAGINFO));
> +	init_color(r, cfg->use_color_diff, "diff.context", cfg->context_color,
> +		   "fall back");
> +	if (!strcmp(cfg->context_color, "fall back"))
> +		init_color(r, cfg->use_color_diff, "diff.plain",
> +			   cfg->context_color,
> +			   diff_get_color(cfg->use_color_diff, DIFF_CONTEXT));
> +	init_color(r, cfg->use_color_diff, "diff.old", cfg->file_old_color,
> +		diff_get_color(cfg->use_color_diff, DIFF_FILE_OLD));
> +	init_color(r, cfg->use_color_diff, "diff.new", cfg->file_new_color,
> +		diff_get_color(cfg->use_color_diff, DIFF_FILE_NEW));
> +	strlcpy(cfg->reset_color_diff,
> +		want_color(cfg->use_color_diff) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
> +
> +	FREE_AND_NULL(cfg->interactive_diff_filter);
> +	repo_config_get_string(r, "interactive.difffilter",
> +			       &cfg->interactive_diff_filter);
> +
> +	FREE_AND_NULL(cfg->interactive_diff_algorithm);
> +	repo_config_get_string(r, "diff.algorithm",
> +			       &cfg->interactive_diff_algorithm);
> +
> +	if (!repo_config_get_int(r, "diff.context", &cfg->context))
> +		if (cfg->context < 0)
> +			die(_("%s cannot be negative"), "diff.context");
> +	if (!repo_config_get_int(r, "diff.interHunkContext", &cfg->interhunkcontext))
> +		if (cfg->interhunkcontext < 0)
> +			die(_("%s cannot be negative"), "diff.interHunkContext");
> +
> +	repo_config_get_bool(r, "interactive.singlekey", &cfg->use_single_key);
> +	if (cfg->use_single_key)
> +		setbuf(stdin, NULL);
> +
> +	if (opts->context != -1) {
> +		if (opts->context < 0)
> +			die(_("%s cannot be negative"), "--unified");
> +		cfg->context = opts->context;
> +	}
> +	if (opts->interhunkcontext != -1) {
> +		if (opts->interhunkcontext < 0)
> +			die(_("%s cannot be negative"), "--inter-hunk-context");
> +		cfg->interhunkcontext = opts->interhunkcontext;
> +	}
> +}
> +
> +void interactive_config_clear(struct interactive_config *cfg)
> +{
> +	FREE_AND_NULL(cfg->interactive_diff_filter);
> +	FREE_AND_NULL(cfg->interactive_diff_algorithm);
> +	memset(cfg, 0, sizeof(*cfg));
> +	cfg->use_color_interactive = GIT_COLOR_UNKNOWN;
> +	cfg->use_color_diff = GIT_COLOR_UNKNOWN;
> +}
> +
>   static void add_p_state_clear(struct add_p_state *s)
>   {
>   	size_t i;
> @@ -299,9 +417,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
>   	va_list args;
>   
>   	va_start(args, fmt);
> -	fputs(s->s.error_color, stdout);
> +	fputs(s->s.cfg.error_color, stdout);
>   	vprintf(fmt, args);
> -	puts(s->s.reset_color_interactive);
> +	puts(s->s.cfg.reset_color_interactive);
>   	va_end(args);
>   }
>   
> @@ -424,12 +542,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
>   	int res;
>   
>   	strvec_pushv(&args, s->mode->diff_cmd);
> -	if (s->s.context != -1)
> -		strvec_pushf(&args, "--unified=%i", s->s.context);
> -	if (s->s.interhunkcontext != -1)
> -		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.interhunkcontext);
> -	if (s->s.interactive_diff_algorithm)
> -		strvec_pushf(&args, "--diff-algorithm=%s", s->s.interactive_diff_algorithm);
> +	if (s->s.cfg.context != -1)
> +		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
> +	if (s->s.cfg.interhunkcontext != -1)
> +		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
> +	if (s->s.cfg.interactive_diff_algorithm)
> +		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
>   	if (s->revision) {
>   		struct object_id oid;
>   		strvec_push(&args,
> @@ -458,9 +576,9 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
>   	}
>   	strbuf_complete_line(plain);
>   
> -	if (want_color_fd(1, s->s.use_color_diff)) {
> +	if (want_color_fd(1, s->s.cfg.use_color_diff)) {
>   		struct child_process colored_cp = CHILD_PROCESS_INIT;
> -		const char *diff_filter = s->s.interactive_diff_filter;
> +		const char *diff_filter = s->s.cfg.interactive_diff_filter;
>   
>   		setup_child_process(s, &colored_cp, NULL);
>   		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
> @@ -693,7 +811,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
>   				   hunk->colored_end - hunk->colored_start);
>   			return;
>   		} else {
> -			strbuf_addstr(out, s->s.fraginfo_color);
> +			strbuf_addstr(out, s->s.cfg.fraginfo_color);
>   			p = s->colored.buf + header->colored_extra_start;
>   			len = header->colored_extra_end
>   				- header->colored_extra_start;
> @@ -715,7 +833,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
>   		if (len)
>   			strbuf_add(out, p, len);
>   		else if (colored)
> -			strbuf_addf(out, "%s\n", s->s.reset_color_diff);
> +			strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
>   		else
>   			strbuf_addch(out, '\n');
>   	}
> @@ -1104,12 +1222,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
>   
>   		strbuf_addstr(&s->colored,
>   			      plain[current] == '-' ?
> -			      s->s.file_old_color :
> +			      s->s.cfg.file_old_color :
>   			      plain[current] == '+' ?
> -			      s->s.file_new_color :
> -			      s->s.context_color);
> +			      s->s.cfg.file_new_color :
> +			      s->s.cfg.context_color);
>   		strbuf_add(&s->colored, plain + current, eol - current);
> -		strbuf_addstr(&s->colored, s->s.reset_color_diff);
> +		strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
>   		if (next > eol)
>   			strbuf_add(&s->colored, plain + eol, next - eol);
>   		current = next;
> @@ -1238,7 +1356,7 @@ static int run_apply_check(struct add_p_state *s,
>   
>   static int read_single_character(struct add_p_state *s)
>   {
> -	if (s->s.use_single_key) {
> +	if (s->s.cfg.use_single_key) {
>   		int res = read_key_without_echo(&s->answer);
>   		printf("%s\n", res == EOF ? "" : s->answer.buf);
>   		return res;
> @@ -1252,7 +1370,7 @@ static int read_single_character(struct add_p_state *s)
>   static int prompt_yesno(struct add_p_state *s, const char *prompt)
>   {
>   	for (;;) {
> -		color_fprintf(stdout, s->s.prompt_color, "%s", _(prompt));
> +		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
>   		fflush(stdout);
>   		if (read_single_character(s) == EOF)
>   			return -1;
> @@ -1560,15 +1678,15 @@ static int patch_update_file(struct add_p_state *s,
>   		else
>   			prompt_mode_type = PROMPT_HUNK;
>   
> -		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.prompt_color,
> +		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
>   			      (uintmax_t)hunk_index + 1,
>   			      (uintmax_t)(file_diff->hunk_nr
>   						? file_diff->hunk_nr
>   						: 1));
>   		printf(_(s->mode->prompt_mode[prompt_mode_type]),
>   		       s->buf.buf);
> -		if (*s->s.reset_color_interactive)
> -			fputs(s->s.reset_color_interactive, stdout);
> +		if (*s->s.cfg.reset_color_interactive)
> +			fputs(s->s.cfg.reset_color_interactive, stdout);
>   		fflush(stdout);
>   		if (read_single_character(s) == EOF)
>   			break;
> @@ -1730,7 +1848,7 @@ static int patch_update_file(struct add_p_state *s,
>   				err(s, _("Sorry, cannot split this hunk"));
>   			} else if (!split_hunk(s, file_diff,
>   					     hunk - file_diff->hunk)) {
> -				color_fprintf_ln(stdout, s->s.header_color,
> +				color_fprintf_ln(stdout, s->s.cfg.header_color,
>   						 _("Split into %d hunks."),
>   						 (int)splittable_into);
>   				rendered_hunk_index = -1;
> @@ -1748,7 +1866,7 @@ static int patch_update_file(struct add_p_state *s,
>   		} else if (s->answer.buf[0] == '?') {
>   			const char *p = _(help_patch_remainder), *eol = p;
>   
> -			color_fprintf(stdout, s->s.help_color, "%s",
> +			color_fprintf(stdout, s->s.cfg.help_color, "%s",
>   				      _(s->mode->help_patch_text));
>   
>   			/*
> @@ -1766,7 +1884,7 @@ static int patch_update_file(struct add_p_state *s,
>   				if (*p != '?' && !strchr(s->buf.buf, *p))
>   					continue;
>   
> -				color_fprintf_ln(stdout, s->s.help_color,
> +				color_fprintf_ln(stdout, s->s.cfg.help_color,
>   						 "%.*s", (int)(eol - p), p);
>   			}
>   		} else {
> @@ -1807,7 +1925,7 @@ static int patch_update_file(struct add_p_state *s,
>   }
>   
>   int run_add_p(struct repository *r, enum add_p_mode mode,
> -	      struct add_p_opt *o, const char *revision,
> +	      struct interactive_options *opts, const char *revision,
>   	      const struct pathspec *ps)
>   {
>   	struct add_p_state s = {
> @@ -1815,7 +1933,7 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
>   	};
>   	size_t i, binary_count = 0;
>   
> -	init_add_i_state(&s.s, r, o);
> +	init_add_i_state(&s.s, r, opts);
>   
>   	if (mode == ADD_P_STASH)
>   		s.mode = &patch_mode_stash;
> diff --git a/add-patch.h b/add-patch.h
> index 4394c741076..a4a05d9d145 100644
> --- a/add-patch.h
> +++ b/add-patch.h
> @@ -1,15 +1,45 @@
>   #ifndef ADD_PATCH_H
>   #define ADD_PATCH_H
>   
> +#include "color.h"
> +
>   struct pathspec;
>   struct repository;
>   
> -struct add_p_opt {
> +struct interactive_options {
>   	int context;
>   	int interhunkcontext;
>   };
>   
> -#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
> +#define INTERACTIVE_OPTIONS_INIT { \
> +	.context = -1, \
> +	.interhunkcontext = -1, \
> +}
> +
> +struct interactive_config {
> +	enum git_colorbool use_color_interactive;
> +	enum git_colorbool use_color_diff;
> +	char header_color[COLOR_MAXLEN];
> +	char help_color[COLOR_MAXLEN];
> +	char prompt_color[COLOR_MAXLEN];
> +	char error_color[COLOR_MAXLEN];
> +	char reset_color_interactive[COLOR_MAXLEN];
> +
> +	char fraginfo_color[COLOR_MAXLEN];
> +	char context_color[COLOR_MAXLEN];
> +	char file_old_color[COLOR_MAXLEN];
> +	char file_new_color[COLOR_MAXLEN];
> +	char reset_color_diff[COLOR_MAXLEN];
> +
> +	int use_single_key;
> +	char *interactive_diff_filter, *interactive_diff_algorithm;
> +	int context, interhunkcontext;
> +};
> +
> +void interactive_config_init(struct interactive_config *cfg,
> +			     struct repository *r,
> +			     struct interactive_options *opts);
> +void interactive_config_clear(struct interactive_config *cfg);
>   
>   enum add_p_mode {
>   	ADD_P_ADD,
> @@ -20,7 +50,7 @@ enum add_p_mode {
>   };
>   
>   int run_add_p(struct repository *r, enum add_p_mode mode,
> -	      struct add_p_opt *o, const char *revision,
> +	      struct interactive_options *opts, const char *revision,
>   	      const struct pathspec *ps);
>   
>   #endif
> diff --git a/builtin/add.c b/builtin/add.c
> index 32709794b38..6f1e2130528 100644
> --- a/builtin/add.c
> +++ b/builtin/add.c
> @@ -31,7 +31,7 @@ static const char * const builtin_add_usage[] = {
>   	NULL
>   };
>   static int patch_interactive, add_interactive, edit_interactive;
> -static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
> +static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
>   static int take_worktree_changes;
>   static int add_renormalize;
>   static int pathspec_file_nul;
> @@ -160,7 +160,7 @@ static int refresh(struct repository *repo, int verbose, const struct pathspec *
>   int interactive_add(struct repository *repo,
>   		    const char **argv,
>   		    const char *prefix,
> -		    int patch, struct add_p_opt *add_p_opt)
> +		    int patch, struct interactive_options *interactive_opts)
>   {
>   	struct pathspec pathspec;
>   	int ret;
> @@ -172,9 +172,9 @@ int interactive_add(struct repository *repo,
>   		       prefix, argv);
>   
>   	if (patch)
> -		ret = !!run_add_p(repo, ADD_P_ADD, add_p_opt, NULL, &pathspec);
> +		ret = !!run_add_p(repo, ADD_P_ADD, interactive_opts, NULL, &pathspec);
>   	else
> -		ret = !!run_add_i(repo, &pathspec, add_p_opt);
> +		ret = !!run_add_i(repo, &pathspec, interactive_opts);
>   
>   	clear_pathspec(&pathspec);
>   	return ret;
> @@ -256,8 +256,8 @@ static struct option builtin_add_options[] = {
>   	OPT_GROUP(""),
>   	OPT_BOOL('i', "interactive", &add_interactive, N_("interactive picking")),
>   	OPT_BOOL('p', "patch", &patch_interactive, N_("select hunks interactively")),
> -	OPT_DIFF_UNIFIED(&add_p_opt.context),
> -	OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
> +	OPT_DIFF_UNIFIED(&interactive_opts.context),
> +	OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
>   	OPT_BOOL('e', "edit", &edit_interactive, N_("edit current diff and apply")),
>   	OPT__FORCE(&ignored_too, N_("allow adding otherwise ignored files"), 0),
>   	OPT_BOOL('u', "update", &take_worktree_changes, N_("update tracked files")),
> @@ -400,9 +400,9 @@ int cmd_add(int argc,
>   	prepare_repo_settings(repo);
>   	repo->settings.command_requires_full_index = 0;
>   
> -	if (add_p_opt.context < -1)
> +	if (interactive_opts.context < -1)
>   		die(_("'%s' cannot be negative"), "--unified");
> -	if (add_p_opt.interhunkcontext < -1)
> +	if (interactive_opts.interhunkcontext < -1)
>   		die(_("'%s' cannot be negative"), "--inter-hunk-context");
>   
>   	if (patch_interactive)
> @@ -412,11 +412,11 @@ int cmd_add(int argc,
>   			die(_("options '%s' and '%s' cannot be used together"), "--dry-run", "--interactive/--patch");
>   		if (pathspec_from_file)
>   			die(_("options '%s' and '%s' cannot be used together"), "--pathspec-from-file", "--interactive/--patch");
> -		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &add_p_opt));
> +		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &interactive_opts));
>   	} else {
> -		if (add_p_opt.context != -1)
> +		if (interactive_opts.context != -1)
>   			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
> -		if (add_p_opt.interhunkcontext != -1)
> +		if (interactive_opts.interhunkcontext != -1)
>   			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
>   	}
>   
> diff --git a/builtin/checkout.c b/builtin/checkout.c
> index f9453473fe2..d230b1f8995 100644
> --- a/builtin/checkout.c
> +++ b/builtin/checkout.c
> @@ -546,7 +546,7 @@ static int checkout_paths(const struct checkout_opts *opts,
>   
>   	if (opts->patch_mode) {
>   		enum add_p_mode patch_mode;
> -		struct add_p_opt add_p_opt = {
> +		struct interactive_options interactive_opts = {
>   			.context = opts->patch_context,
>   			.interhunkcontext = opts->patch_interhunk_context,
>   		};
> @@ -575,7 +575,7 @@ static int checkout_paths(const struct checkout_opts *opts,
>   		else
>   			BUG("either flag must have been set, worktree=%d, index=%d",
>   			    opts->checkout_worktree, opts->checkout_index);
> -		return !!run_add_p(the_repository, patch_mode, &add_p_opt,
> +		return !!run_add_p(the_repository, patch_mode, &interactive_opts,
>   				   rev, &opts->pathspec);
>   	}
>   
> diff --git a/builtin/commit.c b/builtin/commit.c
> index 0243f17d53c..640495cc57e 100644
> --- a/builtin/commit.c
> +++ b/builtin/commit.c
> @@ -123,7 +123,7 @@ static const char *edit_message, *use_message;
>   static char *fixup_message, *fixup_commit, *squash_message;
>   static const char *fixup_prefix;
>   static int all, also, interactive, patch_interactive, only, amend, signoff;
> -static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
> +static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
>   static int edit_flag = -1; /* unspecified */
>   static int quiet, verbose, no_verify, allow_empty, dry_run, renew_authorship;
>   static int config_commit_verbose = -1; /* unspecified */
> @@ -356,9 +356,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
>   	const char *ret;
>   	char *path = NULL;
>   
> -	if (add_p_opt.context < -1)
> +	if (interactive_opts.context < -1)
>   		die(_("'%s' cannot be negative"), "--unified");
> -	if (add_p_opt.interhunkcontext < -1)
> +	if (interactive_opts.interhunkcontext < -1)
>   		die(_("'%s' cannot be negative"), "--inter-hunk-context");
>   
>   	if (is_status)
> @@ -407,7 +407,7 @@ static const char *prepare_index(const char **argv, const char *prefix,
>   		old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
>   		setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
>   
> -		if (interactive_add(the_repository, argv, prefix, patch_interactive, &add_p_opt) != 0)
> +		if (interactive_add(the_repository, argv, prefix, patch_interactive, &interactive_opts) != 0)
>   			die(_("interactive add failed"));
>   
>   		the_repository->index_file = old_repo_index_file;
> @@ -432,9 +432,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
>   		ret = get_lock_file_path(&index_lock);
>   		goto out;
>   	} else {
> -		if (add_p_opt.context != -1)
> +		if (interactive_opts.context != -1)
>   			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
> -		if (add_p_opt.interhunkcontext != -1)
> +		if (interactive_opts.interhunkcontext != -1)
>   			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
>   	}
>   
> @@ -1742,8 +1742,8 @@ int cmd_commit(int argc,
>   		OPT_BOOL('i', "include", &also, N_("add specified files to index for commit")),
>   		OPT_BOOL(0, "interactive", &interactive, N_("interactively add files")),
>   		OPT_BOOL('p', "patch", &patch_interactive, N_("interactively add changes")),
> -		OPT_DIFF_UNIFIED(&add_p_opt.context),
> -		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
> +		OPT_DIFF_UNIFIED(&interactive_opts.context),
> +		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
>   		OPT_BOOL('o', "only", &only, N_("commit only specified files")),
>   		OPT_BOOL('n', "no-verify", &no_verify, N_("bypass pre-commit and commit-msg hooks")),
>   		OPT_BOOL(0, "dry-run", &dry_run, N_("show what would be committed")),
> diff --git a/builtin/reset.c b/builtin/reset.c
> index ed35802af15..088449e1209 100644
> --- a/builtin/reset.c
> +++ b/builtin/reset.c
> @@ -346,7 +346,7 @@ int cmd_reset(int argc,
>   	struct object_id oid;
>   	struct pathspec pathspec;
>   	int intent_to_add = 0;
> -	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
> +	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
>   	const struct option options[] = {
>   		OPT__QUIET(&quiet, N_("be quiet, only report errors")),
>   		OPT_BOOL(0, "no-refresh", &no_refresh,
> @@ -371,8 +371,8 @@ int cmd_reset(int argc,
>   			       PARSE_OPT_OPTARG,
>   			       option_parse_recurse_submodules_worktree_updater),
>   		OPT_BOOL('p', "patch", &patch_mode, N_("select hunks interactively")),
> -		OPT_DIFF_UNIFIED(&add_p_opt.context),
> -		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
> +		OPT_DIFF_UNIFIED(&interactive_opts.context),
> +		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
>   		OPT_BOOL('N', "intent-to-add", &intent_to_add,
>   				N_("record only the fact that removed paths will be added later")),
>   		OPT_PATHSPEC_FROM_FILE(&pathspec_from_file),
> @@ -423,9 +423,9 @@ int cmd_reset(int argc,
>   		oidcpy(&oid, &tree->object.oid);
>   	}
>   
> -	if (add_p_opt.context < -1)
> +	if (interactive_opts.context < -1)
>   		die(_("'%s' cannot be negative"), "--unified");
> -	if (add_p_opt.interhunkcontext < -1)
> +	if (interactive_opts.interhunkcontext < -1)
>   		die(_("'%s' cannot be negative"), "--inter-hunk-context");
>   
>   	prepare_repo_settings(the_repository);
> @@ -436,12 +436,12 @@ int cmd_reset(int argc,
>   			die(_("options '%s' and '%s' cannot be used together"), "--patch", "--{hard,mixed,soft}");
>   		trace2_cmd_mode("patch-interactive");
>   		update_ref_status = !!run_add_p(the_repository, ADD_P_RESET,
> -						&add_p_opt, rev, &pathspec);
> +						&interactive_opts, rev, &pathspec);
>   		goto cleanup;
>   	} else {
> -		if (add_p_opt.context != -1)
> +		if (interactive_opts.context != -1)
>   			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
> -		if (add_p_opt.interhunkcontext != -1)
> +		if (interactive_opts.interhunkcontext != -1)
>   			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
>   	}
>   
> diff --git a/builtin/stash.c b/builtin/stash.c
> index 948eba06fbc..3b509052338 100644
> --- a/builtin/stash.c
> +++ b/builtin/stash.c
> @@ -1306,7 +1306,7 @@ static int stash_staged(struct stash_info *info, struct strbuf *out_patch,
>   
>   static int stash_patch(struct stash_info *info, const struct pathspec *ps,
>   		       struct strbuf *out_patch, int quiet,
> -		       struct add_p_opt *add_p_opt)
> +		       struct interactive_options *interactive_opts)
>   {
>   	int ret = 0;
>   	struct child_process cp_read_tree = CHILD_PROCESS_INIT;
> @@ -1331,7 +1331,7 @@ static int stash_patch(struct stash_info *info, const struct pathspec *ps,
>   	old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
>   	setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
>   
> -	ret = !!run_add_p(the_repository, ADD_P_STASH, add_p_opt, NULL, ps);
> +	ret = !!run_add_p(the_repository, ADD_P_STASH, interactive_opts, NULL, ps);
>   
>   	the_repository->index_file = old_repo_index_file;
>   	if (old_index_env && *old_index_env)
> @@ -1427,7 +1427,8 @@ static int stash_working_tree(struct stash_info *info, const struct pathspec *ps
>   }
>   
>   static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_buf,
> -			   int include_untracked, int patch_mode, struct add_p_opt *add_p_opt,
> +			   int include_untracked, int patch_mode,
> +			   struct interactive_options *interactive_opts,
>   			   int only_staged, struct stash_info *info, struct strbuf *patch,
>   			   int quiet)
>   {
> @@ -1509,7 +1510,7 @@ static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_b
>   		untracked_commit_option = 1;
>   	}
>   	if (patch_mode) {
> -		ret = stash_patch(info, ps, patch, quiet, add_p_opt);
> +		ret = stash_patch(info, ps, patch, quiet, interactive_opts);
>   		if (ret < 0) {
>   			if (!quiet)
>   				fprintf_ln(stderr, _("Cannot save the current "
> @@ -1595,7 +1596,8 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED,
>   }
>   
>   static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int quiet,
> -			 int keep_index, int patch_mode, struct add_p_opt *add_p_opt,
> +			 int keep_index, int patch_mode,
> +			 struct interactive_options *interactive_opts,
>   			 int include_untracked, int only_staged)
>   {
>   	int ret = 0;
> @@ -1667,7 +1669,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q
>   	if (stash_msg)
>   		strbuf_addstr(&stash_msg_buf, stash_msg);
>   	if (do_create_stash(ps, &stash_msg_buf, include_untracked, patch_mode,
> -			    add_p_opt, only_staged, &info, &patch, quiet)) {
> +			    interactive_opts, only_staged, &info, &patch, quiet)) {
>   		ret = -1;
>   		goto done;
>   	}
> @@ -1841,7 +1843,7 @@ static int push_stash(int argc, const char **argv, const char *prefix,
>   	const char *stash_msg = NULL;
>   	char *pathspec_from_file = NULL;
>   	struct pathspec ps;
> -	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
> +	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
>   	struct option options[] = {
>   		OPT_BOOL('k', "keep-index", &keep_index,
>   			 N_("keep index")),
> @@ -1849,8 +1851,8 @@ static int push_stash(int argc, const char **argv, const char *prefix,
>   			 N_("stash staged changes only")),
>   		OPT_BOOL('p', "patch", &patch_mode,
>   			 N_("stash in patch mode")),
> -		OPT_DIFF_UNIFIED(&add_p_opt.context),
> -		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
> +		OPT_DIFF_UNIFIED(&interactive_opts.context),
> +		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
>   		OPT__QUIET(&quiet, N_("quiet mode")),
>   		OPT_BOOL('u', "include-untracked", &include_untracked,
>   			 N_("include untracked files in stash")),
> @@ -1907,19 +1909,19 @@ static int push_stash(int argc, const char **argv, const char *prefix,
>   	}
>   
>   	if (!patch_mode) {
> -		if (add_p_opt.context != -1)
> +		if (interactive_opts.context != -1)
>   			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
> -		if (add_p_opt.interhunkcontext != -1)
> +		if (interactive_opts.interhunkcontext != -1)
>   			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
>   	}
>   
> -	if (add_p_opt.context < -1)
> +	if (interactive_opts.context < -1)
>   		die(_("'%s' cannot be negative"), "--unified");
> -	if (add_p_opt.interhunkcontext < -1)
> +	if (interactive_opts.interhunkcontext < -1)
>   		die(_("'%s' cannot be negative"), "--inter-hunk-context");
>   
>   	ret = do_push_stash(&ps, stash_msg, quiet, keep_index, patch_mode,
> -			    &add_p_opt, include_untracked, only_staged);
> +			    &interactive_opts, include_untracked, only_staged);
>   
>   	clear_pathspec(&ps);
>   	free(pathspec_from_file);
> @@ -1944,7 +1946,7 @@ static int save_stash(int argc, const char **argv, const char *prefix,
>   	const char *stash_msg = NULL;
>   	struct pathspec ps;
>   	struct strbuf stash_msg_buf = STRBUF_INIT;
> -	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
> +	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
>   	struct option options[] = {
>   		OPT_BOOL('k', "keep-index", &keep_index,
>   			 N_("keep index")),
> @@ -1952,8 +1954,8 @@ static int save_stash(int argc, const char **argv, const char *prefix,
>   			 N_("stash staged changes only")),
>   		OPT_BOOL('p', "patch", &patch_mode,
>   			 N_("stash in patch mode")),
> -		OPT_DIFF_UNIFIED(&add_p_opt.context),
> -		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
> +		OPT_DIFF_UNIFIED(&interactive_opts.context),
> +		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
>   		OPT__QUIET(&quiet, N_("quiet mode")),
>   		OPT_BOOL('u', "include-untracked", &include_untracked,
>   			 N_("include untracked files in stash")),
> @@ -1973,20 +1975,20 @@ static int save_stash(int argc, const char **argv, const char *prefix,
>   
>   	memset(&ps, 0, sizeof(ps));
>   
> -	if (add_p_opt.context < -1)
> +	if (interactive_opts.context < -1)
>   		die(_("'%s' cannot be negative"), "--unified");
> -	if (add_p_opt.interhunkcontext < -1)
> +	if (interactive_opts.interhunkcontext < -1)
>   		die(_("'%s' cannot be negative"), "--inter-hunk-context");
>   
>   	if (!patch_mode) {
> -		if (add_p_opt.context != -1)
> +		if (interactive_opts.context != -1)
>   			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
> -		if (add_p_opt.interhunkcontext != -1)
> +		if (interactive_opts.interhunkcontext != -1)
>   			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
>   	}
>   
>   	ret = do_push_stash(&ps, stash_msg, quiet, keep_index,
> -			    patch_mode, &add_p_opt, include_untracked,
> +			    patch_mode, &interactive_opts, include_untracked,
>   			    only_staged);
>   
>   	strbuf_release(&stash_msg_buf);
> diff --git a/commit.h b/commit.h
> index 1d6e0c7518b..7b6e59d6c19 100644
> --- a/commit.h
> +++ b/commit.h
> @@ -258,7 +258,7 @@ int for_each_commit_graft(each_commit_graft_fn, void *);
>   int interactive_add(struct repository *repo,
>   		    const char **argv,
>   		    const char *prefix,
> -		    int patch, struct add_p_opt *add_p_opt);
> +		    int patch, struct interactive_options *opts);
>   
>   struct commit_extra_header {
>   	struct commit_extra_header *next;
> 
Patrick Steinhardt· Dec 2, 2025, 18:48 UTC · re: Phillip Wood · lore

Re: [PATCH v6 07/11] add-patch: split out `struct interactive_options`

On Thu, Nov 20, 2025 at 03:05:17PM +0000, Phillip Wood wrote:
Show 10 quoted lines
> On 27/10/2025 11:33, Patrick Steinhardt wrote:
> > Refactor the code and split out a new `struct interactive_options` that
> > hosts common options used by both. These options are then applied to a
> > `struct interactive_config` that hosts common configuration.
> 
> I'm a little skeptical about renaming "sturct add_p_opt" as it only holds
> members that are relavent to run_add_p(). Also if we're trying to draw clear
> boundaries between the two subsystems hosting "struct interactive_options"
> and "struct interactive_config" in add-patch.c rather than add-interactive.c
> is potentially confusing.

I didn't want to add it to "add-interactive.c" though because of the direction of the dependency: "add-interactive.c" will depend on "add-patch.c", not the other way round.

We could of course split out the new options into a separate file altogether. But that felt a bit heavy-handed to me.

Show 8 quoted lines
> > This refactoring doesn't yet fully detangle the two subsystems from one
> > another, as we still end up calling `init_add_i_state()` in the "git add
> > -p" subsystem. This will be fixed in a subsequent commit.
> 
> I think the ultimate aim of not having to initalize a "struct add_i_state"
> in run_add_p() is a good idea. I'm not sure though that having to pass a
> "struct interactive_options" to run_add_p() is any less confusing than
> having to pass a "struct add_p_opt" to run_add_i().

I agree that the end result is still a bit confusing. But what I wanted to achieve is that at least the two subsystems are clearly separated from so that one doesn't have to wonder anymore which parts interact with one another. It was extremely puzzling to me at first, and the end result here is significantly easier to understand from my point of view.

Patrick
Patrick Steinhardt· Oct 27, 2025, 11:33 UTC · re: Patrick Steinhardt · lore

[PATCH v6 08/11] add-patch: remove dependency on "add-interactive" subsystem

With the preceding commit we have split out interactive configuration that is used by both "git add -p" and "git add -i". But we still initialize that configuration in the "add -p" subsystem by calling `init_add_i_state()`, even though we only do so to initialize the interactive configuration as well as a repository pointer.

Stop doing so and instead store and initialize the interactive configuration in `struct add_p_state` directly.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 70 ++++++++++++++++++++++++++++++++-----------------------------
 1 file changed, 37 insertions(+), 33 deletions(-)
Show changes to add-patch.c +37 −33
diff --git a/add-patch.c b/add-patch.c
index 5c6969927a..790c848e79 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -2,7 +2,6 @@
 #define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
-#include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
 #include "config.h"
@@ -263,7 +262,8 @@ struct hunk {
 };
 
 struct add_p_state {
-	struct add_i_state s;
+	struct repository *r;
+	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
 	/* parsed diff */
@@ -408,7 +408,7 @@ static void add_p_state_clear(struct add_p_state *s)
 	for (i = 0; i < s->file_diff_nr; i++)
 		free(s->file_diff[i].hunk);
 	free(s->file_diff);
-	clear_add_i_state(&s->s);
+	interactive_config_clear(&s->cfg);
 }
 
 __attribute__((format (printf, 2, 3)))
@@ -417,9 +417,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.cfg.error_color, stdout);
+	fputs(s->cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.cfg.reset_color_interactive);
+	puts(s->cfg.reset_color_interactive);
 	va_end(args);
 }
 
@@ -437,7 +437,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->s.r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
 }
 
 static int parse_range(const char **p,
@@ -542,12 +542,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.cfg.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
-	if (s->s.cfg.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
-	if (s->s.cfg.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
+	if (s->cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->cfg.context);
+	if (s->cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
+	if (s->cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -576,9 +576,9 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	}
 	strbuf_complete_line(plain);
 
-	if (want_color_fd(1, s->s.cfg.use_color_diff)) {
+	if (want_color_fd(1, s->cfg.use_color_diff)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.cfg.interactive_diff_filter;
+		const char *diff_filter = s->cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -811,7 +811,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.cfg.fraginfo_color);
+			strbuf_addstr(out, s->cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -833,7 +833,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
+			strbuf_addf(out, "%s\n", s->cfg.reset_color_diff);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1222,12 +1222,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.cfg.file_old_color :
+			      s->cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.cfg.file_new_color :
-			      s->s.cfg.context_color);
+			      s->cfg.file_new_color :
+			      s->cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
+		strbuf_addstr(&s->colored, s->cfg.reset_color_diff);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1356,7 +1356,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.cfg.use_single_key) {
+	if (s->cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1370,7 +1370,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1678,15 +1678,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.cfg.reset_color_interactive)
-			fputs(s->s.cfg.reset_color_interactive, stdout);
+		if (*s->cfg.reset_color_interactive)
+			fputs(s->cfg.reset_color_interactive, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			break;
@@ -1848,7 +1848,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.cfg.header_color,
+				color_fprintf_ln(stdout, s->cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1866,7 +1866,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.cfg.help_color, "%s",
+			color_fprintf(stdout, s->cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1884,7 +1884,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.cfg.help_color,
+				color_fprintf_ln(stdout, s->cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1904,7 +1904,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->s.r->index);
+		discard_index(s->r->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1915,8 +1915,8 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->s.r) >= 0)
-			repo_refresh_and_write_index(s->s.r, REFRESH_QUIET, 0,
+		if (repo_read_index(s->r) >= 0)
+			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
 	}
 
@@ -1929,11 +1929,15 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
-		{ r }, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT
+		.r = r,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, opts);
+	interactive_config_init(&s.cfg, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
-- 
2.51.1.930.gacf6e81ea2.dirty
Elijah Newren· Nov 20, 2025, 07:03 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 08/11] add-patch: remove dependency on "add-interactive" subsystem

On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 238 quoted lines
>
> With the preceding commit we have split out interactive configuration
> that is used by both "git add -p" and "git add -i". But we still
> initialize that configuration in the "add -p" subsystem by calling
> `init_add_i_state()`, even though we only do so to initialize the
> interactive configuration as well as a repository pointer.
>
> Stop doing so and instead store and initialize the interactive
> configuration in `struct add_p_state` directly.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  add-patch.c | 70 ++++++++++++++++++++++++++++++++-----------------------------
>  1 file changed, 37 insertions(+), 33 deletions(-)
>
> diff --git a/add-patch.c b/add-patch.c
> index 5c6969927a..790c848e79 100644
> --- a/add-patch.c
> +++ b/add-patch.c
> @@ -2,7 +2,6 @@
>  #define DISABLE_SIGN_COMPARE_WARNINGS
>
>  #include "git-compat-util.h"
> -#include "add-interactive.h"
>  #include "add-patch.h"
>  #include "advice.h"
>  #include "config.h"
> @@ -263,7 +262,8 @@ struct hunk {
>  };
>
>  struct add_p_state {
> -       struct add_i_state s;
> +       struct repository *r;
> +       struct interactive_config cfg;
>         struct strbuf answer, buf;
>
>         /* parsed diff */
> @@ -408,7 +408,7 @@ static void add_p_state_clear(struct add_p_state *s)
>         for (i = 0; i < s->file_diff_nr; i++)
>                 free(s->file_diff[i].hunk);
>         free(s->file_diff);
> -       clear_add_i_state(&s->s);
> +       interactive_config_clear(&s->cfg);
>  }
>
>  __attribute__((format (printf, 2, 3)))
> @@ -417,9 +417,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
>         va_list args;
>
>         va_start(args, fmt);
> -       fputs(s->s.cfg.error_color, stdout);
> +       fputs(s->cfg.error_color, stdout);
>         vprintf(fmt, args);
> -       puts(s->s.cfg.reset_color_interactive);
> +       puts(s->cfg.reset_color_interactive);
>         va_end(args);
>  }
>
> @@ -437,7 +437,7 @@ static void setup_child_process(struct add_p_state *s,
>
>         cp->git_cmd = 1;
>         strvec_pushf(&cp->env,
> -                    INDEX_ENVIRONMENT "=%s", s->s.r->index_file);
> +                    INDEX_ENVIRONMENT "=%s", s->r->index_file);
>  }
>
>  static int parse_range(const char **p,
> @@ -542,12 +542,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
>         int res;
>
>         strvec_pushv(&args, s->mode->diff_cmd);
> -       if (s->s.cfg.context != -1)
> -               strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
> -       if (s->s.cfg.interhunkcontext != -1)
> -               strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
> -       if (s->s.cfg.interactive_diff_algorithm)
> -               strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
> +       if (s->cfg.context != -1)
> +               strvec_pushf(&args, "--unified=%i", s->cfg.context);
> +       if (s->cfg.interhunkcontext != -1)
> +               strvec_pushf(&args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
> +       if (s->cfg.interactive_diff_algorithm)
> +               strvec_pushf(&args, "--diff-algorithm=%s", s->cfg.interactive_diff_algorithm);
>         if (s->revision) {
>                 struct object_id oid;
>                 strvec_push(&args,
> @@ -576,9 +576,9 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
>         }
>         strbuf_complete_line(plain);
>
> -       if (want_color_fd(1, s->s.cfg.use_color_diff)) {
> +       if (want_color_fd(1, s->cfg.use_color_diff)) {
>                 struct child_process colored_cp = CHILD_PROCESS_INIT;
> -               const char *diff_filter = s->s.cfg.interactive_diff_filter;
> +               const char *diff_filter = s->cfg.interactive_diff_filter;
>
>                 setup_child_process(s, &colored_cp, NULL);
>                 xsnprintf((char *)args.v[color_arg_index], 8, "--color");
> @@ -811,7 +811,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
>                                    hunk->colored_end - hunk->colored_start);
>                         return;
>                 } else {
> -                       strbuf_addstr(out, s->s.cfg.fraginfo_color);
> +                       strbuf_addstr(out, s->cfg.fraginfo_color);
>                         p = s->colored.buf + header->colored_extra_start;
>                         len = header->colored_extra_end
>                                 - header->colored_extra_start;
> @@ -833,7 +833,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
>                 if (len)
>                         strbuf_add(out, p, len);
>                 else if (colored)
> -                       strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
> +                       strbuf_addf(out, "%s\n", s->cfg.reset_color_diff);
>                 else
>                         strbuf_addch(out, '\n');
>         }
> @@ -1222,12 +1222,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
>
>                 strbuf_addstr(&s->colored,
>                               plain[current] == '-' ?
> -                             s->s.cfg.file_old_color :
> +                             s->cfg.file_old_color :
>                               plain[current] == '+' ?
> -                             s->s.cfg.file_new_color :
> -                             s->s.cfg.context_color);
> +                             s->cfg.file_new_color :
> +                             s->cfg.context_color);
>                 strbuf_add(&s->colored, plain + current, eol - current);
> -               strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
> +               strbuf_addstr(&s->colored, s->cfg.reset_color_diff);
>                 if (next > eol)
>                         strbuf_add(&s->colored, plain + eol, next - eol);
>                 current = next;
> @@ -1356,7 +1356,7 @@ static int run_apply_check(struct add_p_state *s,
>
>  static int read_single_character(struct add_p_state *s)
>  {
> -       if (s->s.cfg.use_single_key) {
> +       if (s->cfg.use_single_key) {
>                 int res = read_key_without_echo(&s->answer);
>                 printf("%s\n", res == EOF ? "" : s->answer.buf);
>                 return res;
> @@ -1370,7 +1370,7 @@ static int read_single_character(struct add_p_state *s)
>  static int prompt_yesno(struct add_p_state *s, const char *prompt)
>  {
>         for (;;) {
> -               color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
> +               color_fprintf(stdout, s->cfg.prompt_color, "%s", _(prompt));
>                 fflush(stdout);
>                 if (read_single_character(s) == EOF)
>                         return -1;
> @@ -1678,15 +1678,15 @@ static int patch_update_file(struct add_p_state *s,
>                 else
>                         prompt_mode_type = PROMPT_HUNK;
>
> -               printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
> +               printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->cfg.prompt_color,
>                               (uintmax_t)hunk_index + 1,
>                               (uintmax_t)(file_diff->hunk_nr
>                                                 ? file_diff->hunk_nr
>                                                 : 1));
>                 printf(_(s->mode->prompt_mode[prompt_mode_type]),
>                        s->buf.buf);
> -               if (*s->s.cfg.reset_color_interactive)
> -                       fputs(s->s.cfg.reset_color_interactive, stdout);
> +               if (*s->cfg.reset_color_interactive)
> +                       fputs(s->cfg.reset_color_interactive, stdout);
>                 fflush(stdout);
>                 if (read_single_character(s) == EOF)
>                         break;
> @@ -1848,7 +1848,7 @@ static int patch_update_file(struct add_p_state *s,
>                                 err(s, _("Sorry, cannot split this hunk"));
>                         } else if (!split_hunk(s, file_diff,
>                                              hunk - file_diff->hunk)) {
> -                               color_fprintf_ln(stdout, s->s.cfg.header_color,
> +                               color_fprintf_ln(stdout, s->cfg.header_color,
>                                                  _("Split into %d hunks."),
>                                                  (int)splittable_into);
>                                 rendered_hunk_index = -1;
> @@ -1866,7 +1866,7 @@ static int patch_update_file(struct add_p_state *s,
>                 } else if (s->answer.buf[0] == '?') {
>                         const char *p = _(help_patch_remainder), *eol = p;
>
> -                       color_fprintf(stdout, s->s.cfg.help_color, "%s",
> +                       color_fprintf(stdout, s->cfg.help_color, "%s",
>                                       _(s->mode->help_patch_text));
>
>                         /*
> @@ -1884,7 +1884,7 @@ static int patch_update_file(struct add_p_state *s,
>                                 if (*p != '?' && !strchr(s->buf.buf, *p))
>                                         continue;
>
> -                               color_fprintf_ln(stdout, s->s.cfg.help_color,
> +                               color_fprintf_ln(stdout, s->cfg.help_color,
>                                                  "%.*s", (int)(eol - p), p);
>                         }
>                 } else {
> @@ -1904,7 +1904,7 @@ static int patch_update_file(struct add_p_state *s,
>                 strbuf_reset(&s->buf);
>                 reassemble_patch(s, file_diff, 0, &s->buf);
>
> -               discard_index(s->s.r->index);
> +               discard_index(s->r->index);
>                 if (s->mode->apply_for_checkout)
>                         apply_for_checkout(s, &s->buf,
>                                            s->mode->is_reverse);
> @@ -1915,8 +1915,8 @@ static int patch_update_file(struct add_p_state *s,
>                                          NULL, 0, NULL, 0))
>                                 error(_("'git apply' failed"));
>                 }
> -               if (repo_read_index(s->s.r) >= 0)
> -                       repo_refresh_and_write_index(s->s.r, REFRESH_QUIET, 0,
> +               if (repo_read_index(s->r) >= 0)
> +                       repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
>                                                      1, NULL, NULL, NULL);
>         }
>
> @@ -1929,11 +1929,15 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
>               const struct pathspec *ps)
>  {
>         struct add_p_state s = {
> -               { r }, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT
> +               .r = r,
> +               .answer = STRBUF_INIT,
> +               .buf = STRBUF_INIT,
> +               .plain = STRBUF_INIT,
> +               .colored = STRBUF_INIT,
>         };
>         size_t i, binary_count = 0;
>
> -       init_add_i_state(&s.s, r, opts);
> +       interactive_config_init(&s.cfg, r, opts);
>
>         if (mode == ADD_P_STASH)
>                 s.mode = &patch_mode_stash;
>
> --
> 2.51.1.930.gacf6e81ea2.dirty

Viewing the patch under --color-words=. makes it clear that in most places in the patch you were just removing "s." for accessing fields. Just the beginning and end of the patch are better viewed without that field. Anyway, all looks reasonable to me.

Phillip Wood· Nov 20, 2025, 15:05 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 08/11] add-patch: remove dependency on "add-interactive" subsystem

On 27/10/2025 11:33, Patrick Steinhardt wrote:
Show 8 quoted lines
> With the preceding commit we have split out interactive configuration
> that is used by both "git add -p" and "git add -i". But we still
> initialize that configuration in the "add -p" subsystem by calling
> `init_add_i_state()`, even though we only do so to initialize the
> interactive configuration as well as a repository pointer.
> 
> Stop doing so and instead store and initialize the interactive
> configuration in `struct add_p_state` directly.
Makes sense
Thanks
Phillip
Show 227 quoted lines
> 
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>   add-patch.c | 70 ++++++++++++++++++++++++++++++++-----------------------------
>   1 file changed, 37 insertions(+), 33 deletions(-)
> 
> diff --git a/add-patch.c b/add-patch.c
> index 5c6969927a..790c848e79 100644
> --- a/add-patch.c
> +++ b/add-patch.c
> @@ -2,7 +2,6 @@
>   #define DISABLE_SIGN_COMPARE_WARNINGS
>   
>   #include "git-compat-util.h"
> -#include "add-interactive.h"
>   #include "add-patch.h"
>   #include "advice.h"
>   #include "config.h"
> @@ -263,7 +262,8 @@ struct hunk {
>   };
>   
>   struct add_p_state {
> -	struct add_i_state s;
> +	struct repository *r;
> +	struct interactive_config cfg;
>   	struct strbuf answer, buf;
>   
>   	/* parsed diff */
> @@ -408,7 +408,7 @@ static void add_p_state_clear(struct add_p_state *s)
>   	for (i = 0; i < s->file_diff_nr; i++)
>   		free(s->file_diff[i].hunk);
>   	free(s->file_diff);
> -	clear_add_i_state(&s->s);
> +	interactive_config_clear(&s->cfg);
>   }
>   
>   __attribute__((format (printf, 2, 3)))
> @@ -417,9 +417,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
>   	va_list args;
>   
>   	va_start(args, fmt);
> -	fputs(s->s.cfg.error_color, stdout);
> +	fputs(s->cfg.error_color, stdout);
>   	vprintf(fmt, args);
> -	puts(s->s.cfg.reset_color_interactive);
> +	puts(s->cfg.reset_color_interactive);
>   	va_end(args);
>   }
>   
> @@ -437,7 +437,7 @@ static void setup_child_process(struct add_p_state *s,
>   
>   	cp->git_cmd = 1;
>   	strvec_pushf(&cp->env,
> -		     INDEX_ENVIRONMENT "=%s", s->s.r->index_file);
> +		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
>   }
>   
>   static int parse_range(const char **p,
> @@ -542,12 +542,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
>   	int res;
>   
>   	strvec_pushv(&args, s->mode->diff_cmd);
> -	if (s->s.cfg.context != -1)
> -		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
> -	if (s->s.cfg.interhunkcontext != -1)
> -		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
> -	if (s->s.cfg.interactive_diff_algorithm)
> -		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
> +	if (s->cfg.context != -1)
> +		strvec_pushf(&args, "--unified=%i", s->cfg.context);
> +	if (s->cfg.interhunkcontext != -1)
> +		strvec_pushf(&args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
> +	if (s->cfg.interactive_diff_algorithm)
> +		strvec_pushf(&args, "--diff-algorithm=%s", s->cfg.interactive_diff_algorithm);
>   	if (s->revision) {
>   		struct object_id oid;
>   		strvec_push(&args,
> @@ -576,9 +576,9 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
>   	}
>   	strbuf_complete_line(plain);
>   
> -	if (want_color_fd(1, s->s.cfg.use_color_diff)) {
> +	if (want_color_fd(1, s->cfg.use_color_diff)) {
>   		struct child_process colored_cp = CHILD_PROCESS_INIT;
> -		const char *diff_filter = s->s.cfg.interactive_diff_filter;
> +		const char *diff_filter = s->cfg.interactive_diff_filter;
>   
>   		setup_child_process(s, &colored_cp, NULL);
>   		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
> @@ -811,7 +811,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
>   				   hunk->colored_end - hunk->colored_start);
>   			return;
>   		} else {
> -			strbuf_addstr(out, s->s.cfg.fraginfo_color);
> +			strbuf_addstr(out, s->cfg.fraginfo_color);
>   			p = s->colored.buf + header->colored_extra_start;
>   			len = header->colored_extra_end
>   				- header->colored_extra_start;
> @@ -833,7 +833,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
>   		if (len)
>   			strbuf_add(out, p, len);
>   		else if (colored)
> -			strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
> +			strbuf_addf(out, "%s\n", s->cfg.reset_color_diff);
>   		else
>   			strbuf_addch(out, '\n');
>   	}
> @@ -1222,12 +1222,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
>   
>   		strbuf_addstr(&s->colored,
>   			      plain[current] == '-' ?
> -			      s->s.cfg.file_old_color :
> +			      s->cfg.file_old_color :
>   			      plain[current] == '+' ?
> -			      s->s.cfg.file_new_color :
> -			      s->s.cfg.context_color);
> +			      s->cfg.file_new_color :
> +			      s->cfg.context_color);
>   		strbuf_add(&s->colored, plain + current, eol - current);
> -		strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
> +		strbuf_addstr(&s->colored, s->cfg.reset_color_diff);
>   		if (next > eol)
>   			strbuf_add(&s->colored, plain + eol, next - eol);
>   		current = next;
> @@ -1356,7 +1356,7 @@ static int run_apply_check(struct add_p_state *s,
>   
>   static int read_single_character(struct add_p_state *s)
>   {
> -	if (s->s.cfg.use_single_key) {
> +	if (s->cfg.use_single_key) {
>   		int res = read_key_without_echo(&s->answer);
>   		printf("%s\n", res == EOF ? "" : s->answer.buf);
>   		return res;
> @@ -1370,7 +1370,7 @@ static int read_single_character(struct add_p_state *s)
>   static int prompt_yesno(struct add_p_state *s, const char *prompt)
>   {
>   	for (;;) {
> -		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
> +		color_fprintf(stdout, s->cfg.prompt_color, "%s", _(prompt));
>   		fflush(stdout);
>   		if (read_single_character(s) == EOF)
>   			return -1;
> @@ -1678,15 +1678,15 @@ static int patch_update_file(struct add_p_state *s,
>   		else
>   			prompt_mode_type = PROMPT_HUNK;
>   
> -		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
> +		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->cfg.prompt_color,
>   			      (uintmax_t)hunk_index + 1,
>   			      (uintmax_t)(file_diff->hunk_nr
>   						? file_diff->hunk_nr
>   						: 1));
>   		printf(_(s->mode->prompt_mode[prompt_mode_type]),
>   		       s->buf.buf);
> -		if (*s->s.cfg.reset_color_interactive)
> -			fputs(s->s.cfg.reset_color_interactive, stdout);
> +		if (*s->cfg.reset_color_interactive)
> +			fputs(s->cfg.reset_color_interactive, stdout);
>   		fflush(stdout);
>   		if (read_single_character(s) == EOF)
>   			break;
> @@ -1848,7 +1848,7 @@ static int patch_update_file(struct add_p_state *s,
>   				err(s, _("Sorry, cannot split this hunk"));
>   			} else if (!split_hunk(s, file_diff,
>   					     hunk - file_diff->hunk)) {
> -				color_fprintf_ln(stdout, s->s.cfg.header_color,
> +				color_fprintf_ln(stdout, s->cfg.header_color,
>   						 _("Split into %d hunks."),
>   						 (int)splittable_into);
>   				rendered_hunk_index = -1;
> @@ -1866,7 +1866,7 @@ static int patch_update_file(struct add_p_state *s,
>   		} else if (s->answer.buf[0] == '?') {
>   			const char *p = _(help_patch_remainder), *eol = p;
>   
> -			color_fprintf(stdout, s->s.cfg.help_color, "%s",
> +			color_fprintf(stdout, s->cfg.help_color, "%s",
>   				      _(s->mode->help_patch_text));
>   
>   			/*
> @@ -1884,7 +1884,7 @@ static int patch_update_file(struct add_p_state *s,
>   				if (*p != '?' && !strchr(s->buf.buf, *p))
>   					continue;
>   
> -				color_fprintf_ln(stdout, s->s.cfg.help_color,
> +				color_fprintf_ln(stdout, s->cfg.help_color,
>   						 "%.*s", (int)(eol - p), p);
>   			}
>   		} else {
> @@ -1904,7 +1904,7 @@ static int patch_update_file(struct add_p_state *s,
>   		strbuf_reset(&s->buf);
>   		reassemble_patch(s, file_diff, 0, &s->buf);
>   
> -		discard_index(s->s.r->index);
> +		discard_index(s->r->index);
>   		if (s->mode->apply_for_checkout)
>   			apply_for_checkout(s, &s->buf,
>   					   s->mode->is_reverse);
> @@ -1915,8 +1915,8 @@ static int patch_update_file(struct add_p_state *s,
>   					 NULL, 0, NULL, 0))
>   				error(_("'git apply' failed"));
>   		}
> -		if (repo_read_index(s->s.r) >= 0)
> -			repo_refresh_and_write_index(s->s.r, REFRESH_QUIET, 0,
> +		if (repo_read_index(s->r) >= 0)
> +			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
>   						     1, NULL, NULL, NULL);
>   	}
>   
> @@ -1929,11 +1929,15 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
>   	      const struct pathspec *ps)
>   {
>   	struct add_p_state s = {
> -		{ r }, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT
> +		.r = r,
> +		.answer = STRBUF_INIT,
> +		.buf = STRBUF_INIT,
> +		.plain = STRBUF_INIT,
> +		.colored = STRBUF_INIT,
>   	};
>   	size_t i, binary_count = 0;
>   
> -	init_add_i_state(&s.s, r, opts);
> +	interactive_config_init(&s.cfg, r, opts);
>   
>   	if (mode == ADD_P_STASH)
>   		s.mode = &patch_mode_stash;
> 
Patrick Steinhardt· Oct 27, 2025, 11:33 UTC · re: Patrick Steinhardt · lore

[PATCH v6 09/11] add-patch: add support for in-memory index patching

With `run_add_p()` callers have the ability to apply changes from a specific revision to a repository's index. This infra supports several different modes, like for example applying changes to the index, working tree or both.

One feature that is missing though is the ability to apply changes to an in-memory index different from the repository's index. Add a new function `run_add_p_index()` to plug this gap.

This new function will be used in a subsequent commit.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 add-patch.h |   8 +++++
 2 files changed, 116 insertions(+), 4 deletions(-)
Show changes to 2 files +116 −4

add-patch.c, add-patch.h

diff --git a/add-patch.c b/add-patch.c
index 790c848e799..9c1688bd5a0 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -4,11 +4,13 @@
 #include "git-compat-util.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "commit.h"
 #include "config.h"
 #include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "object-name.h"
 #include "pager.h"
 #include "read-cache-ll.h"
@@ -47,7 +49,7 @@ static struct patch_mode patch_mode_add = {
 		N_("Stage mode change [y,n,q,a,d%s,?]? "),
 		N_("Stage deletion [y,n,q,a,d%s,?]? "),
 		N_("Stage addition [y,n,q,a,d%s,?]? "),
-		N_("Stage this hunk [y,n,q,a,d%s,?]? ")
+		N_("Stage this hunk [y,n,q,a,d%s,?]? "),
 	},
 	.edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
 			     "will immediately be marked for staging."),
@@ -263,6 +265,8 @@ struct hunk {
 
 struct add_p_state {
 	struct repository *r;
+	struct index_state *index;
+	const char *index_file;
 	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
@@ -437,7 +441,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->index_file);
 }
 
 static int parse_range(const char **p,
@@ -1904,7 +1908,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->r->index);
+		discard_index(s->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1915,9 +1919,11 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->r) >= 0)
+		if (read_index_from(s->index, s->index_file, s->r->gitdir) >= 0 &&
+		    s->index == s->r->index) {
 			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
+		}
 	}
 
 	putchar('\n');
@@ -1930,6 +1936,8 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 {
 	struct add_p_state s = {
 		.r = r,
+		.index = r->index,
+		.index_file = r->index_file,
 		.answer = STRBUF_INIT,
 		.buf = STRBUF_INIT,
 		.plain = STRBUF_INIT,
@@ -1988,3 +1996,99 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	add_p_state_clear(&s);
 	return 0;
 }
+
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps)
+{
+	struct patch_mode mode = {
+		.apply_args = { "--cached", NULL },
+		.apply_check_args = { "--cached", NULL },
+		.prompt_mode = {
+			N_("Stage mode change [y,n,q,a,d%s,?]? "),
+			N_("Stage deletion [y,n,q,a,d%s,?]? "),
+			N_("Stage addition [y,n,q,a,d%s,?]? "),
+			N_("Stage this hunk [y,n,q,a,d%s,?]? ")
+		},
+		.edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
+				     "will immediately be marked for staging."),
+		.help_patch_text =
+			N_("y - stage this hunk\n"
+			   "n - do not stage this hunk\n"
+			   "q - quit; do not stage this hunk or any of the remaining "
+				"ones\n"
+			   "a - stage this hunk and all later hunks in the file\n"
+			   "d - do not stage this hunk or any of the later hunks in "
+				"the file\n"),
+		.index_only = 1,
+	};
+	struct add_p_state s = {
+		.r = r,
+		.index = index,
+		.index_file = index_file,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
+		.mode = &mode,
+		.revision = revision,
+	};
+	struct strbuf parent_revision = STRBUF_INIT;
+	char parent_tree_oid[GIT_MAX_HEXSZ + 1];
+	size_t binary_count = 0;
+	struct commit *commit;
+	int ret;
+
+	commit = lookup_commit_reference_by_name(revision);
+	if (!commit) {
+		err(&s, _("Revision does not refer to a commit"));
+		ret = -1;
+		goto out;
+	}
+
+	if (commit->parents)
+		oid_to_hex_r(parent_tree_oid, get_commit_tree_oid(commit->parents->item));
+	else
+		oid_to_hex_r(parent_tree_oid, r->hash_algo->empty_tree);
+
+	strbuf_addf(&parent_revision, "%s~", revision);
+	mode.diff_cmd[0] = "diff-tree";
+	mode.diff_cmd[1] = "-r";
+	mode.diff_cmd[2] = parent_tree_oid;
+
+	interactive_config_init(&s.cfg, r, opts);
+
+	if (parse_diff(&s, ps) < 0) {
+		ret = -1;
+		goto out;
+	}
+
+	for (size_t i = 0; i < s.file_diff_nr; i++) {
+		if (s.file_diff[i].binary && !s.file_diff[i].hunk_nr)
+			binary_count++;
+		else if (patch_update_file(&s, s.file_diff + i))
+			break;
+	}
+
+	if (s.file_diff_nr == 0) {
+		err(&s, _("No changes."));
+		ret = -1;
+		goto out;
+	}
+
+	if (binary_count == s.file_diff_nr) {
+		err(&s, _("Only binary files changed."));
+		ret = -1;
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	strbuf_release(&parent_revision);
+	add_p_state_clear(&s);
+	return ret;
+}
diff --git a/add-patch.h b/add-patch.h
index a4a05d9d145..901c42fd7b6 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -3,6 +3,7 @@
 
 #include "color.h"
 
+struct index_state;
 struct pathspec;
 struct repository;
 
@@ -53,4 +54,11 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps);
+
 #endif
-- 
2.51.1.930.gacf6e81ea2.dirty
Elijah Newren· Nov 20, 2025, 07:04 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 09/11] add-patch: add support for in-memory index patching

On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 95 quoted lines
> +int run_add_p_index(struct repository *r,
> +                   struct index_state *index,
> +                   const char *index_file,
> +                   struct interactive_options *opts,
> +                   const char *revision,
> +                   const struct pathspec *ps)
> +{
> +       struct patch_mode mode = {
> +               .apply_args = { "--cached", NULL },
> +               .apply_check_args = { "--cached", NULL },
> +               .prompt_mode = {
> +                       N_("Stage mode change [y,n,q,a,d%s,?]? "),
> +                       N_("Stage deletion [y,n,q,a,d%s,?]? "),
> +                       N_("Stage addition [y,n,q,a,d%s,?]? "),
> +                       N_("Stage this hunk [y,n,q,a,d%s,?]? ")
> +               },
> +               .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
> +                                    "will immediately be marked for staging."),
> +               .help_patch_text =
> +                       N_("y - stage this hunk\n"
> +                          "n - do not stage this hunk\n"
> +                          "q - quit; do not stage this hunk or any of the remaining "
> +                               "ones\n"
> +                          "a - stage this hunk and all later hunks in the file\n"
> +                          "d - do not stage this hunk or any of the later hunks in "
> +                               "the file\n"),
> +               .index_only = 1,
> +       };
> +       struct add_p_state s = {
> +               .r = r,
> +               .index = index,
> +               .index_file = index_file,
> +               .answer = STRBUF_INIT,
> +               .buf = STRBUF_INIT,
> +               .plain = STRBUF_INIT,
> +               .colored = STRBUF_INIT,
> +               .mode = &mode,
> +               .revision = revision,
> +       };
> +       struct strbuf parent_revision = STRBUF_INIT;
> +       char parent_tree_oid[GIT_MAX_HEXSZ + 1];
> +       size_t binary_count = 0;
> +       struct commit *commit;
> +       int ret;
> +
> +       commit = lookup_commit_reference_by_name(revision);
> +       if (!commit) {
> +               err(&s, _("Revision does not refer to a commit"));
> +               ret = -1;
> +               goto out;
> +       }
> +
> +       if (commit->parents)
> +               oid_to_hex_r(parent_tree_oid, get_commit_tree_oid(commit->parents->item));
> +       else
> +               oid_to_hex_r(parent_tree_oid, r->hash_algo->empty_tree);
> +
> +       strbuf_addf(&parent_revision, "%s~", revision);
> +       mode.diff_cmd[0] = "diff-tree";
> +       mode.diff_cmd[1] = "-r";
> +       mode.diff_cmd[2] = parent_tree_oid;
> +
> +       interactive_config_init(&s.cfg, r, opts);
> +
> +       if (parse_diff(&s, ps) < 0) {
> +               ret = -1;
> +               goto out;
> +       }
> +
> +       for (size_t i = 0; i < s.file_diff_nr; i++) {
> +               if (s.file_diff[i].binary && !s.file_diff[i].hunk_nr)
> +                       binary_count++;
> +               else if (patch_update_file(&s, s.file_diff + i))
> +                       break;
> +       }
> +
> +       if (s.file_diff_nr == 0) {
> +               err(&s, _("No changes."));
> +               ret = -1;
> +               goto out;
> +       }
> +
> +       if (binary_count == s.file_diff_nr) {
> +               err(&s, _("Only binary files changed."));
> +               ret = -1;
> +               goto out;
> +       }
> +
> +       ret = 0;
> +
> +out:
> +       strbuf_release(&parent_revision);
> +       add_p_state_clear(&s);
> +       return ret;
> +}

I'm totally unfamiliar with add-patch.[ch] beyond what I've been reviewing in this series, so this may be a dumb/naive question, but why add a sibling run_add_p_index() to run_add_p() via copy+paste+modify? (Or is it not copy+paste+modify in some interesting way?) I'm worried the two will drift, and I'm curious whether run_add_p() should just be calling run_add_p_index() and just passing r->index for the index field. Is there a reason that doesn't work?

Phillip Wood· Nov 20, 2025, 15:05 UTC · re: Elijah Newren · lore

Re: [PATCH v6 09/11] add-patch: add support for in-memory index patching

On 20/11/2025 07:04, Elijah Newren wrote:
Show 10 quoted lines
> On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
> 
> I'm totally unfamiliar with add-patch.[ch] beyond what I've been
> reviewing in this series, so this may be a dumb/naive question, but
> why add a sibling run_add_p_index() to run_add_p() via
> copy+paste+modify?  (Or is it not copy+paste+modify in some
> interesting way?)  I'm worried the two will drift, and I'm curious
> whether run_add_p() should just be calling run_add_p_index() and just
> passing r->index for the index field.  Is there a reason that doesn't
> work?

That's a very good question. I also wondered if there is a way to prevent any future changes from accidentally using "s->r->index" instead of the "s->index" but I don't think there is.

Thanks
Phillip
Patrick Steinhardt· Dec 2, 2025, 18:49 UTC · re: Elijah Newren · lore

Re: [PATCH v6 09/11] add-patch: add support for in-memory index patching

On Wed, Nov 19, 2025 at 11:04:21PM -0800, Elijah Newren wrote:
Show 106 quoted lines
> On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
> 
> > +int run_add_p_index(struct repository *r,
> > +                   struct index_state *index,
> > +                   const char *index_file,
> > +                   struct interactive_options *opts,
> > +                   const char *revision,
> > +                   const struct pathspec *ps)
> > +{
> > +       struct patch_mode mode = {
> > +               .apply_args = { "--cached", NULL },
> > +               .apply_check_args = { "--cached", NULL },
> > +               .prompt_mode = {
> > +                       N_("Stage mode change [y,n,q,a,d%s,?]? "),
> > +                       N_("Stage deletion [y,n,q,a,d%s,?]? "),
> > +                       N_("Stage addition [y,n,q,a,d%s,?]? "),
> > +                       N_("Stage this hunk [y,n,q,a,d%s,?]? ")
> > +               },
> > +               .edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
> > +                                    "will immediately be marked for staging."),
> > +               .help_patch_text =
> > +                       N_("y - stage this hunk\n"
> > +                          "n - do not stage this hunk\n"
> > +                          "q - quit; do not stage this hunk or any of the remaining "
> > +                               "ones\n"
> > +                          "a - stage this hunk and all later hunks in the file\n"
> > +                          "d - do not stage this hunk or any of the later hunks in "
> > +                               "the file\n"),
> > +               .index_only = 1,
> > +       };
> > +       struct add_p_state s = {
> > +               .r = r,
> > +               .index = index,
> > +               .index_file = index_file,
> > +               .answer = STRBUF_INIT,
> > +               .buf = STRBUF_INIT,
> > +               .plain = STRBUF_INIT,
> > +               .colored = STRBUF_INIT,
> > +               .mode = &mode,
> > +               .revision = revision,
> > +       };
> > +       struct strbuf parent_revision = STRBUF_INIT;
> > +       char parent_tree_oid[GIT_MAX_HEXSZ + 1];
> > +       size_t binary_count = 0;
> > +       struct commit *commit;
> > +       int ret;
> > +
> > +       commit = lookup_commit_reference_by_name(revision);
> > +       if (!commit) {
> > +               err(&s, _("Revision does not refer to a commit"));
> > +               ret = -1;
> > +               goto out;
> > +       }
> > +
> > +       if (commit->parents)
> > +               oid_to_hex_r(parent_tree_oid, get_commit_tree_oid(commit->parents->item));
> > +       else
> > +               oid_to_hex_r(parent_tree_oid, r->hash_algo->empty_tree);
> > +
> > +       strbuf_addf(&parent_revision, "%s~", revision);
> > +       mode.diff_cmd[0] = "diff-tree";
> > +       mode.diff_cmd[1] = "-r";
> > +       mode.diff_cmd[2] = parent_tree_oid;
> > +
> > +       interactive_config_init(&s.cfg, r, opts);
> > +
> > +       if (parse_diff(&s, ps) < 0) {
> > +               ret = -1;
> > +               goto out;
> > +       }
> > +
> > +       for (size_t i = 0; i < s.file_diff_nr; i++) {
> > +               if (s.file_diff[i].binary && !s.file_diff[i].hunk_nr)
> > +                       binary_count++;
> > +               else if (patch_update_file(&s, s.file_diff + i))
> > +                       break;
> > +       }
> > +
> > +       if (s.file_diff_nr == 0) {
> > +               err(&s, _("No changes."));
> > +               ret = -1;
> > +               goto out;
> > +       }
> > +
> > +       if (binary_count == s.file_diff_nr) {
> > +               err(&s, _("Only binary files changed."));
> > +               ret = -1;
> > +               goto out;
> > +       }
> > +
> > +       ret = 0;
> > +
> > +out:
> > +       strbuf_release(&parent_revision);
> > +       add_p_state_clear(&s);
> > +       return ret;
> > +}
> 
> I'm totally unfamiliar with add-patch.[ch] beyond what I've been
> reviewing in this series, so this may be a dumb/naive question, but
> why add a sibling run_add_p_index() to run_add_p() via
> copy+paste+modify?  (Or is it not copy+paste+modify in some
> interesting way?)  I'm worried the two will drift, and I'm curious
> whether run_add_p() should just be calling run_add_p_index() and just
> passing r->index for the index field.  Is there a reason that doesn't
> work?

Most of the function isn't actually copy-paste-modify. Out of the ~90 lines of code of the new function only ~20 are the same. We could of course introduce a function to share those lines, but it doesn't save us _that_ much.

I think the logic is non-trivial enough though to warrant it being duplicated regardless of that.

Patrick
Patrick Steinhardt· Oct 27, 2025, 11:33 UTC · re: Patrick Steinhardt · lore

[PATCH v6 10/11] cache-tree: allow writing in-memory index as tree

The function `write_in_core_index_as_tree()` takes a repository and writes its index into a tree object. What this function cannot do though is to take an _arbitrary_ in-memory index.

Introduce a new `struct index_state` parameter so that the caller can pass a different index than the one belonging to the repository. This will be used in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/checkout.c | 3 ++-
 cache-tree.c       | 5 ++---
 cache-tree.h       | 3 ++-
 3 files changed, 6 insertions(+), 5 deletions(-)
Show changes to 3 files +6 −5

builtin/checkout.c, cache-tree.c, cache-tree.h

diff --git a/builtin/checkout.c b/builtin/checkout.c
index d230b1f8995..0b90f398feb 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -902,7 +902,8 @@ static int merge_working_tree(const struct checkout_opts *opts,
 					   0);
 			init_ui_merge_options(&o, the_repository);
 			o.verbosity = 0;
-			work = write_in_core_index_as_tree(the_repository);
+			work = write_in_core_index_as_tree(the_repository,
+							   the_repository->index);
 
 			ret = reset_tree(new_tree,
 					 opts, 1,
diff --git a/cache-tree.c b/cache-tree.c
index 2aba47060e9..b67d0d703d2 100644
--- a/cache-tree.c
+++ b/cache-tree.c
@@ -699,11 +699,11 @@ static int write_index_as_tree_internal(struct object_id *oid,
 	return 0;
 }
 
-struct tree* write_in_core_index_as_tree(struct repository *repo) {
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state) {
 	struct object_id o;
 	int was_valid, ret;
 
-	struct index_state *index_state	= repo->index;
 	was_valid = index_state->cache_tree &&
 		    cache_tree_fully_valid(index_state->cache_tree);
 
@@ -723,7 +723,6 @@ struct tree* write_in_core_index_as_tree(struct repository *repo) {
 	return lookup_tree(repo, &index_state->cache_tree->oid);
 }
 
-
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix)
 {
 	int entries, was_valid;
diff --git a/cache-tree.h b/cache-tree.h
index b82c4963e7c..f8bddae5235 100644
--- a/cache-tree.h
+++ b/cache-tree.h
@@ -47,7 +47,8 @@ int cache_tree_verify(struct repository *, struct index_state *);
 #define WRITE_TREE_UNMERGED_INDEX (-2)
 #define WRITE_TREE_PREFIX_ERROR (-3)
 
-struct tree* write_in_core_index_as_tree(struct repository *repo);
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state);
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix);
 void prime_cache_tree(struct repository *, struct index_state *, struct tree *);
 
-- 
2.51.1.930.gacf6e81ea2.dirty
Elijah Newren· Nov 20, 2025, 07:04 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 10/11] cache-tree: allow writing in-memory index as tree

On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 8 quoted lines
>
> The function `write_in_core_index_as_tree()` takes a repository and
> writes its index into a tree object. What this function cannot do though
> is to take an _arbitrary_ in-memory index.
>
> Introduce a new `struct index_state` parameter so that the caller can
> pass a different index than the one belonging to the repository. This
> will be used in a subsequent commit.
Make sense.
Show 44 quoted lines
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  builtin/checkout.c | 3 ++-
>  cache-tree.c       | 5 ++---
>  cache-tree.h       | 3 ++-
>  3 files changed, 6 insertions(+), 5 deletions(-)
>
> diff --git a/builtin/checkout.c b/builtin/checkout.c
> index d230b1f8995..0b90f398feb 100644
> --- a/builtin/checkout.c
> +++ b/builtin/checkout.c
> @@ -902,7 +902,8 @@ static int merge_working_tree(const struct checkout_opts *opts,
>                                            0);
>                         init_ui_merge_options(&o, the_repository);
>                         o.verbosity = 0;
> -                       work = write_in_core_index_as_tree(the_repository);
> +                       work = write_in_core_index_as_tree(the_repository,
> +                                                          the_repository->index);
>
>                         ret = reset_tree(new_tree,
>                                          opts, 1,
> diff --git a/cache-tree.c b/cache-tree.c
> index 2aba47060e9..b67d0d703d2 100644
> --- a/cache-tree.c
> +++ b/cache-tree.c
> @@ -699,11 +699,11 @@ static int write_index_as_tree_internal(struct object_id *oid,
>         return 0;
>  }
>
> -struct tree* write_in_core_index_as_tree(struct repository *repo) {
> +struct tree *write_in_core_index_as_tree(struct repository *repo,
> +                                        struct index_state *index_state) {
>         struct object_id o;
>         int was_valid, ret;
>
> -       struct index_state *index_state = repo->index;
>         was_valid = index_state->cache_tree &&
>                     cache_tree_fully_valid(index_state->cache_tree);
>
> @@ -723,7 +723,6 @@ struct tree* write_in_core_index_as_tree(struct repository *repo) {
>         return lookup_tree(repo, &index_state->cache_tree->oid);
>  }
>
> -
Why the random whitespace change?
Show 20 quoted lines
>  int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix)
>  {
>         int entries, was_valid;
> diff --git a/cache-tree.h b/cache-tree.h
> index b82c4963e7c..f8bddae5235 100644
> --- a/cache-tree.h
> +++ b/cache-tree.h
> @@ -47,7 +47,8 @@ int cache_tree_verify(struct repository *, struct index_state *);
>  #define WRITE_TREE_UNMERGED_INDEX (-2)
>  #define WRITE_TREE_PREFIX_ERROR (-3)
>
> -struct tree* write_in_core_index_as_tree(struct repository *repo);
> +struct tree *write_in_core_index_as_tree(struct repository *repo,
> +                                        struct index_state *index_state);
>  int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix);
>  void prime_cache_tree(struct repository *, struct index_state *, struct tree *);
>
>
> --
> 2.51.1.930.gacf6e81ea2.dirty
Looks good other than the random whitespace change.
Patrick Steinhardt· Oct 27, 2025, 11:33 UTC · re: Patrick Steinhardt · lore

[PATCH v6 11/11] builtin/history: implement "split" subcommand

It is quite a common use case that one wants to split up one commit into multiple commits by moving parts of the changes of the original commit out into a separate commit. This is quite an involved operation though:

  1. Identify the commit in question that is to be dropped.
  2. Perform an interactive rebase on top of that commit's parent.
  3. Modify the instruction sheet to "edit" the commit that is to be
     split up.
  4. Drop the commit via "git reset HEAD~".
  5. Stage changes that should go into the first commit and commit it.
  6. Stage changes that should go into the second commit and commit it.
  7. Finalize the rebase.

This is quite complex, and overall I would claim that most people who are not experts in Git would struggle with this flow.

Introduce a new "split" subcommand for git-history(1) to make this way easier. All the user needs to do is to say `git history split $COMMIT`. From hereon, Git asks the user which parts of the commit shall be moved out into a separate commit and, once done, asks the user for the commit message. Git then creates that split-out commit and applies the original commit on top of it.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  62 ++++++
 builtin/history.c              | 218 +++++++++++++++++++++
 t/meson.build                  |   1 +
 t/t3452-history-split.sh       | 432 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 713 insertions(+)
Show changes to 4 files +711 −0

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3452-history-split.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index bd903875120..3d6b2665f8d 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -9,6 +9,7 @@ SYNOPSIS
 --------
 [synopsis]
 git history reword <commit>
+git history split <commit> [--] [<pathspec>...]
 
 DESCRIPTION
 -----------
@@ -37,6 +38,26 @@ Several commands are available to rewrite history in different ways:
 	details of this commit remain unchanged. This command will spawn an
 	editor with the current message of that commit.
 
+`split <commit> [--] [<pathspec>...]`::
+	Interactively split up <commit> into two commits by choosing
+	hunks introduced by it that will be moved into the new split-out
+	commit. These hunks will then be written into a new commit that
+	becomes the parent of the previous commit. The original commit
+	stays intact, except that its parent will be the newly split-out
+	commit.
++
+The commit message of the new commit will be asked for by launching the
+configured editor. Authorship of the commit will be the same as for the
+original commit.
++
+If passed, _<pathspec>_ can be used to limit which changes shall be split out
+of the original commit. Files not matching any of the pathspecs will remain
+part of the original commit. For more details, see the 'pathspec' entry in
+linkgit:gitglossary[7].
++
+It is invalid to select either all or no hunks, as that would lead to
+one of the commits becoming empty.
+
 CONFIGURATION
 -------------
 
@@ -44,6 +65,47 @@ include::includes/cmd-config-section-all.adoc[]
 
 include::config/sequencer.adoc[]
 
+EXAMPLES
+--------
+
+Split a commit
+~~~~~~~~~~~~~~
+
+----------
+$ git log --stat --oneline
+3f81232 (HEAD -> main) original
+ bar | 1 +
+ foo | 1 +
+ 2 files changed, 2 insertions(+)
+
+$ git history split HEAD
+diff --git a/bar b/bar
+new file mode 100644
+index 0000000..5716ca5
+--- /dev/null
++++ b/bar
+@@ -0,0 +1 @@
++bar
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? y
+
+diff --git a/foo b/foo
+new file mode 100644
+index 0000000..257cc56
+--- /dev/null
++++ b/foo
+@@ -0,0 +1 @@
++foo
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? n
+
+$ git log --stat --oneline
+7cebe64 (HEAD -> main) original
+ foo | 1 +
+ 1 file changed, 1 insertion(+)
+d1582f3 split-out commit
+ bar | 1 +
+ 1 file changed, 1 insertion(+)
+----------
+
 GIT
 ---
 Part of the linkgit:git[1] suite
diff --git a/builtin/history.c b/builtin/history.c
index cb251ae2e01..cae841707d0 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,6 +1,7 @@
 #define USE_THE_REPOSITORY_VARIABLE
 
 #include "builtin.h"
+#include "cache-tree.h"
 #include "commit-reach.h"
 #include "commit.h"
 #include "config.h"
@@ -8,17 +9,22 @@
 #include "environment.h"
 #include "gettext.h"
 #include "hex.h"
+#include "oidmap.h"
 #include "parse-options.h"
+#include "path.h"
+#include "read-cache.h"
 #include "refs.h"
 #include "replay.h"
 #include "reset.h"
 #include "revision.h"
+#include "run-command.h"
 #include "sequencer.h"
 #include "strvec.h"
 #include "tree.h"
 #include "wt-status.h"
 
 #define GIT_HISTORY_REWORD_USAGE N_("git history reword <commit>")
+#define GIT_HISTORY_SPLIT_USAGE  N_("git history split <commit> [--] [<pathspec>...]")
 
 static int collect_commits(struct repository *repo,
 			   struct commit *old_commit,
@@ -323,6 +329,216 @@ static int cmd_history_reword(int argc,
 	return ret;
 }
 
+static int split_commit(struct repository *repo,
+			struct commit *original_commit,
+			struct pathspec *pathspec,
+			struct object_id *out)
+{
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
+	struct strbuf index_file = STRBUF_INIT, split_message = STRBUF_INIT;
+	struct child_process read_tree_cmd = CHILD_PROCESS_INIT;
+	struct index_state index = INDEX_STATE_INIT(repo);
+	struct object_id original_commit_tree_oid, parent_tree_oid;
+	const char *original_message, *original_body, *ptr;
+	char original_commit_oid[GIT_MAX_HEXSZ + 1];
+	char *original_author = NULL;
+	struct commit_list *parents = NULL;
+	struct commit *first_commit;
+	struct tree *split_tree;
+	size_t len;
+	int ret;
+
+	if (original_commit->parents)
+		parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
+	else
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
+
+	/*
+	* Construct the first commit. This is done by taking the original
+	* commit parent's tree and selectively patching changes from the diff
+	* between that parent and its child.
+	*/
+	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
+
+	read_tree_cmd.git_cmd = 1;
+	strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
+	strvec_push(&read_tree_cmd.args, "read-tree");
+	strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
+	ret = run_command(&read_tree_cmd);
+	if (ret < 0)
+		goto out;
+
+	ret = read_index_from(&index, index_file.buf, repo->gitdir);
+	if (ret < 0) {
+		ret = error(_("failed reading temporary index"));
+		goto out;
+	}
+
+	oid_to_hex_r(original_commit_oid, &original_commit->object.oid);
+	ret = run_add_p_index(repo, &index, index_file.buf, &interactive_opts,
+			      original_commit_oid, pathspec);
+	if (ret < 0)
+		goto out;
+
+	split_tree = write_in_core_index_as_tree(repo, &index);
+	if (!split_tree) {
+		ret = error(_("failed split tree"));
+		goto out;
+	}
+
+	unlink(index_file.buf);
+
+	/*
+	* We disallow the cases where either the split-out commit or the
+	* original commit would become empty. Consequently, if we see that the
+	* new tree ID matches either of those trees we abort.
+	*/
+	if (oideq(&split_tree->object.oid, &parent_tree_oid)) {
+		ret = error(_("split commit is empty"));
+		goto out;
+	} else if (oideq(&split_tree->object.oid, &original_commit_tree_oid)) {
+		ret = error(_("split commit tree matches original commit"));
+		goto out;
+	}
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+
+	ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
+				  "", "split-out", &split_message);
+	if (ret < 0)
+		goto out;
+
+	ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
+			  original_commit->parents, &out[0], original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing split-out commit"));
+		goto out;
+	}
+
+	/*
+	* The second commit is much simpler to construct, as we can simply use
+	* the original commit details, except that we adjust its parent to be
+	* the newly split-out commit.
+	*/
+	find_commit_subject(original_message, &original_body);
+	first_commit = lookup_commit_reference(repo, &out[0]);
+	commit_list_append(first_commit, &parents);
+
+	ret = commit_tree(original_body, strlen(original_body), &original_commit_tree_oid,
+			  parents, &out[1], original_author, NULL);
+	if (ret < 0) {
+		ret = error(_("failed writing second commit"));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	if (index_file.len)
+		unlink(index_file.buf);
+	strbuf_release(&split_message);
+	strbuf_release(&index_file);
+	free_commit_list(parents);
+	free(original_author);
+	release_index(&index);
+	return ret;
+}
+
+static int cmd_history_split(int argc,
+			     const char **argv,
+			     const char *prefix,
+			     struct repository *repo)
+{
+	const char * const usage[] = {
+		GIT_HISTORY_SPLIT_USAGE,
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct oidmap rewritten_commits = OIDMAP_INIT;
+	struct commit *original_commit, *parent, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct commit_list *from_list = NULL;
+	struct object_id split_commits[2];
+	struct pathspec pathspec = { 0 };
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc < 1) {
+		ret = error(_("command expects a revision"));
+		goto out;
+	}
+	repo_config(repo, git_default_config, NULL);
+
+	original_commit = lookup_commit_reference_by_name(argv[0]);
+	if (!original_commit) {
+		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
+		goto out;
+	}
+
+	parent = original_commit->parents ? original_commit->parents->item : NULL;
+	if (parent && repo_parse_commit(repo, parent)) {
+		ret = error(_("unable to parse commit %s"),
+			    oid_to_hex(&parent->object.oid));
+		goto out;
+	}
+
+	head = lookup_commit_reference_by_name("HEAD");
+	if (!head) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	commit_list_append(original_commit, &from_list);
+	if (!repo_is_descendant_of(repo, head, from_list)) {
+		ret = error(_("split commit must be reachable from current HEAD commit"));
+		goto out;
+	}
+
+	parse_pathspec(&pathspec, 0,
+		PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
+		prefix, argv + 1);
+
+	/*
+	 * Collect the list of commits that we'll have to reapply now already.
+	 * This ensures that we'll abort early on in case the range of commits
+	 * contains merges, which we do not yet handle.
+	 */
+	ret = collect_commits(repo, parent, head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/*
+	 * Then we split up the commit and replace the original commit with the
+	 * new ones.
+	 */
+	ret = split_commit(repo, original_commit, &pathspec, split_commits);
+	if (ret < 0)
+		goto out;
+
+	replace_commits(&commits, &original_commit->object.oid,
+			split_commits, ARRAY_SIZE(split_commits));
+
+	ret = apply_commits(repo, &commits, parent, head, "split");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	oidmap_clear(&rewritten_commits, 0);
+	free_commit_list(from_list);
+	clear_pathspec(&pathspec);
+	strvec_clear(&commits);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -330,11 +546,13 @@ int cmd_history(int argc,
 {
 	const char * const usage[] = {
 		GIT_HISTORY_REWORD_USAGE,
+		GIT_HISTORY_SPLIT_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
 		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 a3ec9199947..5d3014a768f 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -386,6 +386,7 @@ integration_tests = [
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
   't3451-history-reword.sh',
+  't3452-history-split.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh
new file mode 100755
index 00000000000..2aac28afdf0
--- /dev/null
+++ b/t/t3452-history-split.sh
@@ -0,0 +1,432 @@
+#!/bin/sh
+
+test_description='tests for git-history split subcommand'
+
+. ./test-lib.sh
+
+set_fake_editor () {
+	write_script fake-editor.sh <<-EOF &&
+	echo "$@" >"\$1"
+	EOF
+	test_set_editor "$(pwd)"/fake-editor.sh
+}
+
+expect_log () {
+	git log --format="%s" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+expect_tree_entries () {
+	git ls-tree --name-only "$1" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history split HEAD 2>err &&
+		test_grep "cannot rearrange commit history with merges" err &&
+		test_must_fail git history split HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work with unrelated commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		test_must_fail git history split ours 2>err &&
+		test_grep "split commit must be reachable from current HEAD commit" err
+	)
+'
+
+test_expect_success 'can split up tip commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git symbolic-ref HEAD >expect &&
+		set_fake_editor "split-out commit" &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		initial
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m root &&
+		test_commit tip &&
+
+		set_fake_editor "split-out commit" &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		root
+		split-out commit
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up in-between commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+		test_commit tip &&
+
+		set_fake_editor "split-out commit" &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		split-me
+		split-out commit
+		initial
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can pick multiple hunks' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar baz foo qux &&
+		git add . &&
+		git commit -m split-me &&
+
+		set_fake_editor "split-out-commit" &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		y
+		n
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		baz
+		foo
+		qux
+		EOF
+	)
+'
+
+
+test_expect_success 'can use only last hunk' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		set_fake_editor "split-out commit" &&
+		git history split HEAD <<-EOF &&
+		n
+		y
+		EOF
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		set_fake_editor "" &&
+		test_must_fail git history split HEAD <<-EOF 2>err &&
+		y
+		n
+		EOF
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_expect_success 'commit message editor sees split-out changes' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		echo "some commit message" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		cat >expect <<-EOF &&
+
+		# Please enter the commit message for the split-out changes. Lines starting
+		# with ${SQ}#${SQ} will be ignored.
+		# Changes to be committed:
+		#	new file:   bar
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		expect_log <<-EOF
+		split-me
+		some commit message
+		EOF
+	)
+'
+
+test_expect_success 'can use pathspec to limit what gets split' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		set_fake_editor "split-out commit" &&
+		git history split HEAD -- foo <<-EOF &&
+		y
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'refuses to create empty split-out commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		n
+		n
+		EOF
+		test_grep "split commit is empty" err
+	)
+'
+
+test_expect_success 'hooks are executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+		old_head=$(git rev-parse HEAD) &&
+
+		write_script .git/hooks/prepare-commit-msg <<-EOF &&
+		touch "$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-commit <<-EOF &&
+		touch "$(pwd)/hooks.log"
+		EOF
+		write_script .git/hooks/post-rewrite <<-EOF &&
+		touch "$(pwd)/hooks.log"
+		EOF
+
+		set_fake_editor "split-out commit" &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		split-me
+		split-out commit
+		EOF
+
+		test_path_is_missing hooks.log
+	)
+'
+
+test_expect_success 'refuses to create empty original commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		y
+		y
+		EOF
+		test_grep "split commit tree matches original commit" err
+	)
+'
+
+test_expect_success 'retains changes in the worktree and index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		echo a >a &&
+		echo b >b &&
+		git add . &&
+		git commit -m "initial commit" &&
+		echo a-modified >a &&
+		echo b-modified >b &&
+		git add b &&
+		set_fake_editor "a-only" &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		a
+		EOF
+		expect_tree_entries HEAD <<-EOF &&
+		a
+		b
+		EOF
+
+		cat >expect <<-\EOF &&
+		 M a
+		M  b
+		?? actual
+		?? expect
+		?? fake-editor.sh
+		EOF
+		git status --porcelain >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.51.1.930.gacf6e81ea2.dirty
Elijah Newren· Nov 20, 2025, 07:05 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 11/11] builtin/history: implement "split" subcommand

On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 61 quoted lines
>
> It is quite a common use case that one wants to split up one commit into
> multiple commits by moving parts of the changes of the original commit
> out into a separate commit. This is quite an involved operation though:
>
>   1. Identify the commit in question that is to be dropped.
>
>   2. Perform an interactive rebase on top of that commit's parent.
>
>   3. Modify the instruction sheet to "edit" the commit that is to be
>      split up.
>
>   4. Drop the commit via "git reset HEAD~".
>
>   5. Stage changes that should go into the first commit and commit it.
>
>   6. Stage changes that should go into the second commit and commit it.
>
>   7. Finalize the rebase.
>
> This is quite complex, and overall I would claim that most people who
> are not experts in Git would struggle with this flow.
>
> Introduce a new "split" subcommand for git-history(1) to make this way
> easier. All the user needs to do is to say `git history split $COMMIT`.
> From hereon, Git asks the user which parts of the commit shall be moved
> out into a separate commit and, once done, asks the user for the commit
> message. Git then creates that split-out commit and applies the original
> commit on top of it.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  Documentation/git-history.adoc |  62 ++++++
>  builtin/history.c              | 218 +++++++++++++++++++++
>  t/meson.build                  |   1 +
>  t/t3452-history-split.sh       | 432 +++++++++++++++++++++++++++++++++++++++++
>  4 files changed, 713 insertions(+)
>
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> index bd903875120..3d6b2665f8d 100644
> --- a/Documentation/git-history.adoc
> +++ b/Documentation/git-history.adoc
> @@ -9,6 +9,7 @@ SYNOPSIS
>  --------
>  [synopsis]
>  git history reword <commit>
> +git history split <commit> [--] [<pathspec>...]
>
>  DESCRIPTION
>  -----------
> @@ -37,6 +38,26 @@ Several commands are available to rewrite history in different ways:
>         details of this commit remain unchanged. This command will spawn an
>         editor with the current message of that commit.
>
> +`split <commit> [--] [<pathspec>...]`::
> +       Interactively split up <commit> into two commits by choosing
> +       hunks introduced by it that will be moved into the new split-out
> +       commit. These hunks will then be written into a new commit that
> +       becomes the parent of the previous commit. The original commit
> +       stays intact, except that its parent will be the newly split-out
> +       commit.
Always two?  Should we allow someone to split into three or four?
> ++
> +The commit message of the new commit will be asked for by launching the
> +configured editor. Authorship of the commit will be the same as for the
> +original commit.
Which one is the new one?  Aren't they both?
Show 8 quoted lines
> ++
> +If passed, _<pathspec>_ can be used to limit which changes shall be split out
> +of the original commit. Files not matching any of the pathspecs will remain
> +part of the original commit. For more details, see the 'pathspec' entry in
> +linkgit:gitglossary[7].
> ++
> +It is invalid to select either all or no hunks, as that would lead to
> +one of the commits becoming empty.

If the user edits a hunk, what happens then? Is this function prepared to deal with that?

Show 177 quoted lines
> +
>  CONFIGURATION
>  -------------
>
> @@ -44,6 +65,47 @@ include::includes/cmd-config-section-all.adoc[]
>
>  include::config/sequencer.adoc[]
>
> +EXAMPLES
> +--------
> +
> +Split a commit
> +~~~~~~~~~~~~~~
> +
> +----------
> +$ git log --stat --oneline
> +3f81232 (HEAD -> main) original
> + bar | 1 +
> + foo | 1 +
> + 2 files changed, 2 insertions(+)
> +
> +$ git history split HEAD
> +diff --git a/bar b/bar
> +new file mode 100644
> +index 0000000..5716ca5
> +--- /dev/null
> ++++ b/bar
> +@@ -0,0 +1 @@
> ++bar
> +(1/1) Stage addition [y,n,q,a,d,e,p,?]? y
> +
> +diff --git a/foo b/foo
> +new file mode 100644
> +index 0000000..257cc56
> +--- /dev/null
> ++++ b/foo
> +@@ -0,0 +1 @@
> ++foo
> +(1/1) Stage addition [y,n,q,a,d,e,p,?]? n
> +
> +$ git log --stat --oneline
> +7cebe64 (HEAD -> main) original
> + foo | 1 +
> + 1 file changed, 1 insertion(+)
> +d1582f3 split-out commit
> + bar | 1 +
> + 1 file changed, 1 insertion(+)
> +----------
> +
>  GIT
>  ---
>  Part of the linkgit:git[1] suite
> diff --git a/builtin/history.c b/builtin/history.c
> index cb251ae2e01..cae841707d0 100644
> --- a/builtin/history.c
> +++ b/builtin/history.c
> @@ -1,6 +1,7 @@
>  #define USE_THE_REPOSITORY_VARIABLE
>
>  #include "builtin.h"
> +#include "cache-tree.h"
>  #include "commit-reach.h"
>  #include "commit.h"
>  #include "config.h"
> @@ -8,17 +9,22 @@
>  #include "environment.h"
>  #include "gettext.h"
>  #include "hex.h"
> +#include "oidmap.h"
>  #include "parse-options.h"
> +#include "path.h"
> +#include "read-cache.h"
>  #include "refs.h"
>  #include "replay.h"
>  #include "reset.h"
>  #include "revision.h"
> +#include "run-command.h"
>  #include "sequencer.h"
>  #include "strvec.h"
>  #include "tree.h"
>  #include "wt-status.h"
>
>  #define GIT_HISTORY_REWORD_USAGE N_("git history reword <commit>")
> +#define GIT_HISTORY_SPLIT_USAGE  N_("git history split <commit> [--] [<pathspec>...]")
>
>  static int collect_commits(struct repository *repo,
>                            struct commit *old_commit,
> @@ -323,6 +329,216 @@ static int cmd_history_reword(int argc,
>         return ret;
>  }
>
> +static int split_commit(struct repository *repo,
> +                       struct commit *original_commit,
> +                       struct pathspec *pathspec,
> +                       struct object_id *out)
> +{
> +       struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
> +       struct strbuf index_file = STRBUF_INIT, split_message = STRBUF_INIT;
> +       struct child_process read_tree_cmd = CHILD_PROCESS_INIT;
> +       struct index_state index = INDEX_STATE_INIT(repo);
> +       struct object_id original_commit_tree_oid, parent_tree_oid;
> +       const char *original_message, *original_body, *ptr;
> +       char original_commit_oid[GIT_MAX_HEXSZ + 1];
> +       char *original_author = NULL;
> +       struct commit_list *parents = NULL;
> +       struct commit *first_commit;
> +       struct tree *split_tree;
> +       size_t len;
> +       int ret;
> +
> +       if (original_commit->parents)
> +               parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
> +       else
> +               oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
> +       original_commit_tree_oid = *get_commit_tree_oid(original_commit);
> +
> +       /*
> +       * Construct the first commit. This is done by taking the original
> +       * commit parent's tree and selectively patching changes from the diff
> +       * between that parent and its child.
> +       */
> +       repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
> +
> +       read_tree_cmd.git_cmd = 1;
> +       strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
> +       strvec_push(&read_tree_cmd.args, "read-tree");
> +       strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
> +       ret = run_command(&read_tree_cmd);
> +       if (ret < 0)
> +               goto out;
> +
> +       ret = read_index_from(&index, index_file.buf, repo->gitdir);
> +       if (ret < 0) {
> +               ret = error(_("failed reading temporary index"));
> +               goto out;
> +       }
> +
> +       oid_to_hex_r(original_commit_oid, &original_commit->object.oid);
> +       ret = run_add_p_index(repo, &index, index_file.buf, &interactive_opts,
> +                             original_commit_oid, pathspec);
> +       if (ret < 0)
> +               goto out;
> +
> +       split_tree = write_in_core_index_as_tree(repo, &index);
> +       if (!split_tree) {
> +               ret = error(_("failed split tree"));
> +               goto out;
> +       }
> +
> +       unlink(index_file.buf);
> +
> +       /*
> +       * We disallow the cases where either the split-out commit or the
> +       * original commit would become empty. Consequently, if we see that the
> +       * new tree ID matches either of those trees we abort.
> +       */
> +       if (oideq(&split_tree->object.oid, &parent_tree_oid)) {
> +               ret = error(_("split commit is empty"));
> +               goto out;
> +       } else if (oideq(&split_tree->object.oid, &original_commit_tree_oid)) {
> +               ret = error(_("split commit tree matches original commit"));
> +               goto out;
> +       }
> +
> +       /* We retain authorship of the original commit. */
> +       original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
> +       ptr = find_commit_header(original_message, "author", &len);
> +       if (ptr)
> +               original_author = xmemdupz(ptr, len);
> +
> +       ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
> +                                 "", "split-out", &split_message);
> +       if (ret < 0)
> +               goto out;
> +
> +       ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
> +                         original_commit->parents, &out[0], original_author, NULL);
As with reword, you are discarding all extended headers?
Show 16 quoted lines
> +       if (ret < 0) {
> +               ret = error(_("failed writing split-out commit"));
> +               goto out;
> +       }
> +
> +       /*
> +       * The second commit is much simpler to construct, as we can simply use
> +       * the original commit details, except that we adjust its parent to be
> +       * the newly split-out commit.
> +       */
> +       find_commit_subject(original_message, &original_body);
> +       first_commit = lookup_commit_reference(repo, &out[0]);
> +       commit_list_append(first_commit, &parents);
> +
> +       ret = commit_tree(original_body, strlen(original_body), &original_commit_tree_oid,
> +                         parents, &out[1], original_author, NULL);

I don't understand why the second commit is the one that is to retain the commit message. I can see that users would sometimes want that, but I don't see why it'd be hardcoded.

Show 69 quoted lines
> +       if (ret < 0) {
> +               ret = error(_("failed writing second commit"));
> +               goto out;
> +       }
> +
> +       ret = 0;
> +
> +out:
> +       if (index_file.len)
> +               unlink(index_file.buf);
> +       strbuf_release(&split_message);
> +       strbuf_release(&index_file);
> +       free_commit_list(parents);
> +       free(original_author);
> +       release_index(&index);
> +       return ret;
> +}
> +
> +static int cmd_history_split(int argc,
> +                            const char **argv,
> +                            const char *prefix,
> +                            struct repository *repo)
> +{
> +       const char * const usage[] = {
> +               GIT_HISTORY_SPLIT_USAGE,
> +               NULL,
> +       };
> +       struct option options[] = {
> +               OPT_END(),
> +       };
> +       struct oidmap rewritten_commits = OIDMAP_INIT;
> +       struct commit *original_commit, *parent, *head;
> +       struct strvec commits = STRVEC_INIT;
> +       struct commit_list *from_list = NULL;
> +       struct object_id split_commits[2];
> +       struct pathspec pathspec = { 0 };
> +       int ret;
> +
> +       argc = parse_options(argc, argv, prefix, options, usage, 0);
> +       if (argc < 1) {
> +               ret = error(_("command expects a revision"));
> +               goto out;
> +       }
> +       repo_config(repo, git_default_config, NULL);
> +
> +       original_commit = lookup_commit_reference_by_name(argv[0]);
> +       if (!original_commit) {
> +               ret = error(_("commit to be split cannot be found: %s"), argv[0]);
> +               goto out;
> +       }
> +
> +       parent = original_commit->parents ? original_commit->parents->item : NULL;
> +       if (parent && repo_parse_commit(repo, parent)) {
> +               ret = error(_("unable to parse commit %s"),
> +                           oid_to_hex(&parent->object.oid));
> +               goto out;
> +       }
> +
> +       head = lookup_commit_reference_by_name("HEAD");
> +       if (!head) {
> +               ret = error(_("could not resolve HEAD to a commit"));
> +               goto out;
> +       }
> +
> +       commit_list_append(original_commit, &from_list);
> +       if (!repo_is_descendant_of(repo, head, from_list)) {
> +               ret = error(_("split commit must be reachable from current HEAD commit"));
> +               goto out;
> +       }

Again, I don't see why the commit to be split needs to be an ancestor of HEAD; seems like an arbitrary requirement.

Show 28 quoted lines
> +
> +       parse_pathspec(&pathspec, 0,
> +               PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
> +               prefix, argv + 1);
> +
> +       /*
> +        * Collect the list of commits that we'll have to reapply now already.
> +        * This ensures that we'll abort early on in case the range of commits
> +        * contains merges, which we do not yet handle.
> +        */
> +       ret = collect_commits(repo, parent, head, &commits);
> +       if (ret < 0)
> +               goto out;
> +
> +       /*
> +        * Then we split up the commit and replace the original commit with the
> +        * new ones.
> +        */
> +       ret = split_commit(repo, original_commit, &pathspec, split_commits);
> +       if (ret < 0)
> +               goto out;
> +
> +       replace_commits(&commits, &original_commit->object.oid,
> +                       split_commits, ARRAY_SIZE(split_commits));
> +
> +       ret = apply_commits(repo, &commits, parent, head, "split");
> +       if (ret < 0)
> +               goto out;

Much like with "reword", I think we could drop your auxiliary functions (collect_commits(), replace_commits(), apply_commits()), since replay already handles revision walking.

However, unlike with "reword" you've got a slight mess here. If the user edits the hunk to be applied, then (1) the rest of the replayed commits may have conflicts (which replay doesn't handle yet), and (2) after replaying you'll need to reset your working tree and index to match the rebased result, which will be tricky if you had either staged or unstaged modifications.

Show 104 quoted lines
> +
> +       ret = 0;
> +
> +out:
> +       oidmap_clear(&rewritten_commits, 0);
> +       free_commit_list(from_list);
> +       clear_pathspec(&pathspec);
> +       strvec_clear(&commits);
> +       return ret;
> +}
> +
>  int cmd_history(int argc,
>                 const char **argv,
>                 const char *prefix,
> @@ -330,11 +546,13 @@ int cmd_history(int argc,
>  {
>         const char * const usage[] = {
>                 GIT_HISTORY_REWORD_USAGE,
> +               GIT_HISTORY_SPLIT_USAGE,
>                 NULL,
>         };
>         parse_opt_subcommand_fn *fn = NULL;
>         struct option options[] = {
>                 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 a3ec9199947..5d3014a768f 100644
> --- a/t/meson.build
> +++ b/t/meson.build
> @@ -386,6 +386,7 @@ integration_tests = [
>    't3438-rebase-broken-files.sh',
>    't3450-history.sh',
>    't3451-history-reword.sh',
> +  't3452-history-split.sh',
>    't3500-cherry.sh',
>    't3501-revert-cherry-pick.sh',
>    't3502-cherry-pick-merge.sh',
> diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh
> new file mode 100755
> index 00000000000..2aac28afdf0
> --- /dev/null
> +++ b/t/t3452-history-split.sh
> @@ -0,0 +1,432 @@
> +#!/bin/sh
> +
> +test_description='tests for git-history split subcommand'
> +
> +. ./test-lib.sh
> +
> +set_fake_editor () {
> +       write_script fake-editor.sh <<-EOF &&
> +       echo "$@" >"\$1"
> +       EOF
> +       test_set_editor "$(pwd)"/fake-editor.sh
> +}
> +
> +expect_log () {
> +       git log --format="%s" >actual &&
> +       cat >expect &&
> +       test_cmp expect actual
> +}
> +
> +expect_tree_entries () {
> +       git ls-tree --name-only "$1" >actual &&
> +       cat >expect &&
> +       test_cmp expect actual
> +}
> +
> +test_expect_success 'refuses to work with merge commits' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               test_commit base &&
> +               git branch branch &&
> +               test_commit ours &&
> +               git switch branch &&
> +               test_commit theirs &&
> +               git switch - &&
> +               git merge theirs &&
> +               test_must_fail git history split HEAD 2>err &&
> +               test_grep "cannot rearrange commit history with merges" err &&
> +               test_must_fail git history split HEAD~ 2>err &&
> +               test_grep "cannot rearrange commit history with merges" err
> +       )
> +'
> +
> +test_expect_success 'refuses to work with unrelated commits' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               test_commit base &&
> +               git branch branch &&
> +               test_commit ours &&
> +               git switch branch &&
> +               test_commit theirs &&
> +               test_must_fail git history split ours 2>err &&
> +               test_grep "split commit must be reachable from current HEAD commit" err
> +       )
> +'

I don't understand why this test is desirable. I guess that's not surprising given that I called into question why you'd want this to be an error in the relevant code.

Show 316 quoted lines
> +test_expect_success 'can split up tip commit' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               test_commit initial &&
> +               touch bar foo &&
> +               git add . &&
> +               git commit -m split-me &&
> +
> +               git symbolic-ref HEAD >expect &&
> +               set_fake_editor "split-out commit" &&
> +               git history split HEAD <<-EOF &&
> +               y
> +               n
> +               EOF
> +               git symbolic-ref HEAD >actual &&
> +               test_cmp expect actual &&
> +
> +               expect_log <<-EOF &&
> +               split-me
> +               split-out commit
> +               initial
> +               EOF
> +
> +               expect_tree_entries HEAD~ <<-EOF &&
> +               bar
> +               initial.t
> +               EOF
> +
> +               expect_tree_entries HEAD <<-EOF
> +               bar
> +               foo
> +               initial.t
> +               EOF
> +       )
> +'
> +
> +test_expect_success 'can split up root commit' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               touch bar foo &&
> +               git add . &&
> +               git commit -m root &&
> +               test_commit tip &&
> +
> +               set_fake_editor "split-out commit" &&
> +               git history split HEAD~ <<-EOF &&
> +               y
> +               n
> +               EOF
> +
> +               expect_log <<-EOF &&
> +               tip
> +               root
> +               split-out commit
> +               EOF
> +
> +               expect_tree_entries HEAD~2 <<-EOF &&
> +               bar
> +               EOF
> +
> +               expect_tree_entries HEAD~ <<-EOF &&
> +               bar
> +               foo
> +               EOF
> +
> +               expect_tree_entries HEAD <<-EOF
> +               bar
> +               foo
> +               tip.t
> +               EOF
> +       )
> +'
> +
> +test_expect_success 'can split up in-between commit' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               test_commit initial &&
> +               touch bar foo &&
> +               git add . &&
> +               git commit -m split-me &&
> +               test_commit tip &&
> +
> +               set_fake_editor "split-out commit" &&
> +               git history split HEAD~ <<-EOF &&
> +               y
> +               n
> +               EOF
> +
> +               expect_log <<-EOF &&
> +               tip
> +               split-me
> +               split-out commit
> +               initial
> +               EOF
> +
> +               expect_tree_entries HEAD~2 <<-EOF &&
> +               bar
> +               initial.t
> +               EOF
> +
> +               expect_tree_entries HEAD~ <<-EOF &&
> +               bar
> +               foo
> +               initial.t
> +               EOF
> +
> +               expect_tree_entries HEAD <<-EOF
> +               bar
> +               foo
> +               initial.t
> +               tip.t
> +               EOF
> +       )
> +'
> +
> +test_expect_success 'can pick multiple hunks' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               touch bar baz foo qux &&
> +               git add . &&
> +               git commit -m split-me &&
> +
> +               set_fake_editor "split-out-commit" &&
> +               git history split HEAD <<-EOF &&
> +               y
> +               n
> +               y
> +               n
> +               EOF
> +
> +               expect_tree_entries HEAD~ <<-EOF &&
> +               bar
> +               foo
> +               EOF
> +
> +               expect_tree_entries HEAD <<-EOF
> +               bar
> +               baz
> +               foo
> +               qux
> +               EOF
> +       )
> +'
> +
> +
> +test_expect_success 'can use only last hunk' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               touch bar foo &&
> +               git add . &&
> +               git commit -m split-me &&
> +
> +               set_fake_editor "split-out commit" &&
> +               git history split HEAD <<-EOF &&
> +               n
> +               y
> +               EOF
> +
> +               expect_log <<-EOF &&
> +               split-me
> +               split-out commit
> +               EOF
> +
> +               expect_tree_entries HEAD~ <<-EOF &&
> +               foo
> +               EOF
> +
> +               expect_tree_entries HEAD <<-EOF
> +               bar
> +               foo
> +               EOF
> +       )
> +'
> +
> +test_expect_success 'aborts with empty commit message' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               touch bar foo &&
> +               git add . &&
> +               git commit -m split-me &&
> +
> +               set_fake_editor "" &&
> +               test_must_fail git history split HEAD <<-EOF 2>err &&
> +               y
> +               n
> +               EOF
> +               test_grep "Aborting commit due to empty commit message." err
> +       )
> +'
> +
> +test_expect_success 'commit message editor sees split-out changes' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               touch bar foo &&
> +               git add . &&
> +               git commit -m split-me &&
> +
> +               write_script fake-editor.sh <<-\EOF &&
> +               cp "$1" . &&
> +               echo "some commit message" >>"$1"
> +               EOF
> +               test_set_editor "$(pwd)"/fake-editor.sh &&
> +
> +               git history split HEAD <<-EOF &&
> +               y
> +               n
> +               EOF
> +
> +               cat >expect <<-EOF &&
> +
> +               # Please enter the commit message for the split-out changes. Lines starting
> +               # with ${SQ}#${SQ} will be ignored.
> +               # Changes to be committed:
> +               #       new file:   bar
> +               #
> +               EOF
> +               test_cmp expect COMMIT_EDITMSG &&
> +
> +               expect_log <<-EOF
> +               split-me
> +               some commit message
> +               EOF
> +       )
> +'
> +
> +test_expect_success 'can use pathspec to limit what gets split' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               touch bar foo &&
> +               git add . &&
> +               git commit -m split-me &&
> +
> +               set_fake_editor "split-out commit" &&
> +               git history split HEAD -- foo <<-EOF &&
> +               y
> +               EOF
> +
> +               expect_tree_entries HEAD~ <<-EOF &&
> +               foo
> +               EOF
> +
> +               expect_tree_entries HEAD <<-EOF
> +               bar
> +               foo
> +               EOF
> +       )
> +'
> +
> +test_expect_success 'refuses to create empty split-out commit' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               test_commit base &&
> +               touch bar foo &&
> +               git add . &&
> +               git commit -m split-me &&
> +
> +               test_must_fail git history split HEAD 2>err <<-EOF &&
> +               n
> +               n
> +               EOF
> +               test_grep "split commit is empty" err
> +       )
> +'
> +
> +test_expect_success 'hooks are executed for rewritten commits' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               touch bar foo &&
> +               git add . &&
> +               git commit -m split-me &&
> +               old_head=$(git rev-parse HEAD) &&
> +
> +               write_script .git/hooks/prepare-commit-msg <<-EOF &&
> +               touch "$(pwd)/hooks.log"
> +               EOF
> +               write_script .git/hooks/post-commit <<-EOF &&
> +               touch "$(pwd)/hooks.log"
> +               EOF
> +               write_script .git/hooks/post-rewrite <<-EOF &&
> +               touch "$(pwd)/hooks.log"
> +               EOF
> +
> +               set_fake_editor "split-out commit" &&
> +               git history split HEAD <<-EOF &&
> +               y
> +               n
> +               EOF
> +
> +               expect_log <<-EOF &&
> +               split-me
> +               split-out commit
> +               EOF
> +
> +               test_path_is_missing hooks.log
> +       )
> +'

`test_path_is_missing hooks.log` suggests the hooks are NOT executed for rewritten commits; your cover letter and documentation said hooks wouldn't run either, so I'm guessing the test description is the bug here left over from an earlier round?

Show 54 quoted lines
> +test_expect_success 'refuses to create empty original commit' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               touch bar foo &&
> +               git add . &&
> +               git commit -m split-me &&
> +
> +               test_must_fail git history split HEAD 2>err <<-EOF &&
> +               y
> +               y
> +               EOF
> +               test_grep "split commit tree matches original commit" err
> +       )
> +'
> +
> +test_expect_success 'retains changes in the worktree and index' '
> +       test_when_finished "rm -rf repo" &&
> +       git init repo &&
> +       (
> +               cd repo &&
> +               echo a >a &&
> +               echo b >b &&
> +               git add . &&
> +               git commit -m "initial commit" &&
> +               echo a-modified >a &&
> +               echo b-modified >b &&
> +               git add b &&
> +               set_fake_editor "a-only" &&
> +               git history split HEAD <<-EOF &&
> +               y
> +               n
> +               EOF
> +
> +               expect_tree_entries HEAD~ <<-EOF &&
> +               a
> +               EOF
> +               expect_tree_entries HEAD <<-EOF &&
> +               a
> +               b
> +               EOF
> +
> +               cat >expect <<-\EOF &&
> +                M a
> +               M  b
> +               ?? actual
> +               ?? expect
> +               ?? fake-editor.sh
> +               EOF
> +               git status --porcelain >actual &&
> +               test_cmp expect actual
> +       )
> +'

...but does this test mean we need to prevent users from editing hunks when splitting commits? If we don't, how can we retain changes in the worktree and index?

Patrick Steinhardt· Dec 2, 2025, 18:49 UTC · re: Elijah Newren · lore

Re: [PATCH v6 11/11] builtin/history: implement "split" subcommand

On Wed, Nov 19, 2025 at 11:05:37PM -0800, Elijah Newren wrote:
Show 18 quoted lines
> On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
> > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > index bd903875120..3d6b2665f8d 100644
> > --- a/Documentation/git-history.adoc
> > +++ b/Documentation/git-history.adoc
> > @@ -37,6 +38,26 @@ Several commands are available to rewrite history in different ways:
> >         details of this commit remain unchanged. This command will spawn an
> >         editor with the current message of that commit.
> >
> > +`split <commit> [--] [<pathspec>...]`::
> > +       Interactively split up <commit> into two commits by choosing
> > +       hunks introduced by it that will be moved into the new split-out
> > +       commit. These hunks will then be written into a new commit that
> > +       becomes the parent of the previous commit. The original commit
> > +       stays intact, except that its parent will be the newly split-out
> > +       commit.
> 
> Always two?  Should we allow someone to split into three or four?

For now it's always two, yes. This is mostly modeled after `jj split`, which also does the same. For the sake of simplicity I'd suggest to keep it like this by default, but I could certainly see that we introduce a new option in the future that allows the user to split into multiple commits. In that case, we would simply loop around the interactive prompt until all remaining hunks have been selected.

Show 6 quoted lines
> > ++
> > +The commit message of the new commit will be asked for by launching the
> > +configured editor. Authorship of the commit will be the same as for the
> > +original commit.
> 
> Which one is the new one?  Aren't they both?

I'll change the behaviour to ask for a message for both commits, so I'll adapt this.

Show 11 quoted lines
> > ++
> > +If passed, _<pathspec>_ can be used to limit which changes shall be split out
> > +of the original commit. Files not matching any of the pathspecs will remain
> > +part of the original commit. For more details, see the 'pathspec' entry in
> > +linkgit:gitglossary[7].
> > ++
> > +It is invalid to select either all or no hunks, as that would lead to
> > +one of the commits becoming empty.
> 
> If the user edits a hunk, what happens then?  Is this function
> prepared to deal with that?

The second commit will retain the original tree, so there wouldn't be a conflict even in that case. It's a good question though -- maybe we should disable editing hunks for now?

Show 5 quoted lines
> > +diff --git a/foo b/foo
> > +new file mode 100644
> > +index 0000000..257cc56
> > +--- /dev/null
> > ++++ b/foo
[snip]
Show 15 quoted lines
> > +       /* We retain authorship of the original commit. */
> > +       original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
> > +       ptr = find_commit_header(original_message, "author", &len);
> > +       if (ptr)
> > +               original_author = xmemdupz(ptr, len);
> > +
> > +       ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
> > +                                 "", "split-out", &split_message);
> > +       if (ret < 0)
> > +               goto out;
> > +
> > +       ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
> > +                         original_commit->parents, &out[0], original_author, NULL);
> 
> As with reword, you are discarding all extended headers?
Good catch, will fix!
Show 20 quoted lines
> > +       if (ret < 0) {
> > +               ret = error(_("failed writing split-out commit"));
> > +               goto out;
> > +       }
> > +
> > +       /*
> > +       * The second commit is much simpler to construct, as we can simply use
> > +       * the original commit details, except that we adjust its parent to be
> > +       * the newly split-out commit.
> > +       */
> > +       find_commit_subject(original_message, &original_body);
> > +       first_commit = lookup_commit_reference(repo, &out[0]);
> > +       commit_list_append(first_commit, &parents);
> > +
> > +       ret = commit_tree(original_body, strlen(original_body), &original_commit_tree_oid,
> > +                         parents, &out[1], original_author, NULL);
> 
> I don't understand why the second commit is the one that is to retain
> the commit message.  I can see that users would sometimes want that,
> but I don't see why it'd be hardcoded.

My thinking here is that "I am splitting changes out of a specific commit", so that commit still continues to exist.

[snip]
Show 8 quoted lines
> > +       commit_list_append(original_commit, &from_list);
> > +       if (!repo_is_descendant_of(repo, head, from_list)) {
> > +               ret = error(_("split commit must be reachable from current HEAD commit"));
> > +               goto out;
> > +       }
> 
> Again, I don't see why the commit to be split needs to be an ancestor
> of HEAD; seems like an arbitrary requirement.

This is done for the sake of simplicity: I'd rather want to be as restrictive as possible initially and then extend git-history(1) to handle more cases as we go forward.

[snip]
Show 18 quoted lines
> > +       /*
> > +        * Then we split up the commit and replace the original commit with the
> > +        * new ones.
> > +        */
> > +       ret = split_commit(repo, original_commit, &pathspec, split_commits);
> > +       if (ret < 0)
> > +               goto out;
> > +
> > +       replace_commits(&commits, &original_commit->object.oid,
> > +                       split_commits, ARRAY_SIZE(split_commits));
> > +
> > +       ret = apply_commits(repo, &commits, parent, head, "split");
> > +       if (ret < 0)
> > +               goto out;
> 
> Much like with "reword", I think we could drop your auxiliary
> functions (collect_commits(), replace_commits(), apply_commits()),
> since replay already handles revision walking.

I mostly introduced these functions because I want to extend git-history(1) going forward to also handle cases that git-replay(1) doesn't currently handle. This includes for example also reordering of commits, which I think isn't easily possible with the replay subsystem?

Show 6 quoted lines
> However, unlike with "reword" you've got a slight mess here.  If the
> user edits the hunk to be applied, then (1) the rest of the replayed
> commits may have conflicts (which replay doesn't handle yet), and (2)
> after replaying you'll need to reset your working tree and index to
> match the rebased result, which will be tricky if you had either
> staged or unstaged modifications.

Yeah, the "editing" part is an actual oversight on my part. I'll restrict this for now.

Show 6 quoted lines
> > diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh
> > new file mode 100755
> > index 00000000000..2aac28afdf0
> > --- /dev/null
> > +++ b/t/t3452-history-split.sh
> > @@ -0,0 +1,432 @@
[snip]
Show 18 quoted lines
> > +test_expect_success 'refuses to work with unrelated commits' '
> > +       test_when_finished "rm -rf repo" &&
> > +       git init repo &&
> > +       (
> > +               cd repo &&
> > +               test_commit base &&
> > +               git branch branch &&
> > +               test_commit ours &&
> > +               git switch branch &&
> > +               test_commit theirs &&
> > +               test_must_fail git history split ours 2>err &&
> > +               test_grep "split commit must be reachable from current HEAD commit" err
> > +       )
> > +'
> 
> I don't understand why this test is desirable.  I guess that's not
> surprising given that I called into question why you'd want this to be
> an error in the relevant code.

The question is what happens in the case where you edit a commit that is unrelated to the current history. Which branches would be updated? Do we update any at all? If not, what would the user-visible result be? We're getting into territory where semantics are not immediately obvious, and I want git-history(1) to be a command that makes history editing easy for the most common use cases.

If we find good semantics in the future for how to perform such an operation I'm very much in favor of adding that behaviour. But I think that this is outside the scope of this series right now, as I rather care about making the easy and obvious parts work first.

And meanwhile, while we still have restrictions like these in place, I want to ensure that we don't accidentally do the wrong thing in cases that we don't yet support. Hence the test.

Show 39 quoted lines
> > +test_expect_success 'hooks are executed for rewritten commits' '
> > +       test_when_finished "rm -rf repo" &&
> > +       git init repo &&
> > +       (
> > +               cd repo &&
> > +               touch bar foo &&
> > +               git add . &&
> > +               git commit -m split-me &&
> > +               old_head=$(git rev-parse HEAD) &&
> > +
> > +               write_script .git/hooks/prepare-commit-msg <<-EOF &&
> > +               touch "$(pwd)/hooks.log"
> > +               EOF
> > +               write_script .git/hooks/post-commit <<-EOF &&
> > +               touch "$(pwd)/hooks.log"
> > +               EOF
> > +               write_script .git/hooks/post-rewrite <<-EOF &&
> > +               touch "$(pwd)/hooks.log"
> > +               EOF
> > +
> > +               set_fake_editor "split-out commit" &&
> > +               git history split HEAD <<-EOF &&
> > +               y
> > +               n
> > +               EOF
> > +
> > +               expect_log <<-EOF &&
> > +               split-me
> > +               split-out commit
> > +               EOF
> > +
> > +               test_path_is_missing hooks.log
> > +       )
> > +'
> 
> `test_path_is_missing hooks.log` suggests the hooks are NOT executed
> for rewritten commits; your cover letter and documentation said hooks
> wouldn't run either, so I'm guessing the test description is the bug
> here left over from an earlier round?
It is indeed, thanks!
Show 41 quoted lines
> > +test_expect_success 'retains changes in the worktree and index' '
> > +       test_when_finished "rm -rf repo" &&
> > +       git init repo &&
> > +       (
> > +               cd repo &&
> > +               echo a >a &&
> > +               echo b >b &&
> > +               git add . &&
> > +               git commit -m "initial commit" &&
> > +               echo a-modified >a &&
> > +               echo b-modified >b &&
> > +               git add b &&
> > +               set_fake_editor "a-only" &&
> > +               git history split HEAD <<-EOF &&
> > +               y
> > +               n
> > +               EOF
> > +
> > +               expect_tree_entries HEAD~ <<-EOF &&
> > +               a
> > +               EOF
> > +               expect_tree_entries HEAD <<-EOF &&
> > +               a
> > +               b
> > +               EOF
> > +
> > +               cat >expect <<-\EOF &&
> > +                M a
> > +               M  b
> > +               ?? actual
> > +               ?? expect
> > +               ?? fake-editor.sh
> > +               EOF
> > +               git status --porcelain >actual &&
> > +               test_cmp expect actual
> > +       )
> > +'
> 
> ...but does this test mean we need to prevent users from editing hunks
> when splitting commits?  If we don't, how can we retain changes in the
> worktree and index?

I'll disallow editing hunks for now. We may reintroduce this ability in the future, but let's focus on the easy parts for now :)

Patrick
Phillip Wood· Nov 21, 2025, 14:31 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 11/11] builtin/history: implement "split" subcommand

Hi Patrick
On 27/10/2025 11:33, Patrick Steinhardt wrote:
Show 21 quoted lines
> It is quite a common use case that one wants to split up one commit into
> multiple commits by moving parts of the changes of the original commit
> out into a separate commit. This is quite an involved operation though:
> 
>    1. Identify the commit in question that is to be dropped.
> 
>    2. Perform an interactive rebase on top of that commit's parent.
> 
>    3. Modify the instruction sheet to "edit" the commit that is to be
>       split up.
> 
>    4. Drop the commit via "git reset HEAD~".
> 
>    5. Stage changes that should go into the first commit and commit it.
> 
>    6. Stage changes that should go into the second commit and commit it.
> 
>    7. Finalize the rebase.
> 
> This is quite complex, and overall I would claim that most people who
> are not experts in Git would struggle with this flow.

If they want to test the split commit it's even more complicated because they need to stash the unstaged changes. We should think about how we can add support for testing split commits to this command in the future.

Show 6 quoted lines
> Introduce a new "split" subcommand for git-history(1) to make this way
> easier. All the user needs to do is to say `git history split $COMMIT`.
>  From hereon, Git asks the user which parts of the commit shall be moved
> out into a separate commit and, once done, asks the user for the commit
> message. Git then creates that split-out commit and applies the original
> commit on top of it.

As others have said (and I thought we'd agreed c.f. <aMfdR3JE4zq-2j9b@pks.im>) I think it would be better to prompt the user to edit the existing commit message when creating both commits. Elsewhere Elijah mention being able to split a commit into more than two commits. I wonder if we could loop running run_add_p_index() and committing the result until there are no more changes left. It does mean that the user has to actively select changes for the final commit though which might be annoying. We can always play with that later.

Looking below I do wonder if we can share more code between subcommands when it comes to checking the commit we're given on the command line and re-creating a commit and having the user edit the message.

> +static int split_commit(struct repository *repo,
> +			struct commit *original_commit,
> +			struct pathspec *pathspec,
> +			struct object_id *out)
 > {
Show 12 quoted lines
> [...]> +	/*
> +	* Construct the first commit. This is done by taking the original
> +	* commit parent's tree and selectively patching changes from the diff
> +	* between that parent and its child.
> +	*/
> +	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
> +
> +	read_tree_cmd.git_cmd = 1;
> +	strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
> +	strvec_push(&read_tree_cmd.args, "read-tree");
> +	strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
> +	ret = run_command(&read_tree_cmd);

Why do we need to fork "read-tree" here rather than call unpack_trees() ourselves?

Show 18 quoted lines
> [...]
> +	/* We retain authorship of the original commit. */
> +	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
> +	ptr = find_commit_header(original_message, "author", &len);
> +	if (ptr)
> +		original_author = xmemdupz(ptr, len);
> +
> +	ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
> +				  "", "split-out", &split_message);
> +	if (ret < 0)
> +		goto out;
> +
> +	ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
> +			  original_commit->parents, &out[0], original_author, NULL);
> +	if (ret < 0) {
> +		ret = error(_("failed writing split-out commit"));
> +		goto out;
> +	}

Don't we have the same code for rewording a commit, maybe we should package this up into a shared helper function.

Show 51 quoted lines
> +static int cmd_history_split(int argc,
> +			     const char **argv,
> +			     const char *prefix,
> +			     struct repository *repo)
> +{
> +	const char * const usage[] = {
> +		GIT_HISTORY_SPLIT_USAGE,
> +		NULL,
> +	};
> +	struct option options[] = {
> +		OPT_END(),
> +	};
> +	struct oidmap rewritten_commits = OIDMAP_INIT;
> +	struct commit *original_commit, *parent, *head;
> +	struct strvec commits = STRVEC_INIT;
> +	struct commit_list *from_list = NULL;
> +	struct object_id split_commits[2];
> +	struct pathspec pathspec = { 0 };
> +	int ret;
> +
> +	argc = parse_options(argc, argv, prefix, options, usage, 0);
> +	if (argc < 1) {
> +		ret = error(_("command expects a revision"));
> +		goto out;
> +	}
> +	repo_config(repo, git_default_config, NULL);
> +
> +	original_commit = lookup_commit_reference_by_name(argv[0]);
> +	if (!original_commit) {
> +		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
> +		goto out;
> +	}
> +
> +	parent = original_commit->parents ? original_commit->parents->item : NULL;
> +	if (parent && repo_parse_commit(repo, parent)) {
> +		ret = error(_("unable to parse commit %s"),
> +			    oid_to_hex(&parent->object.oid));
> +		goto out;
> +	}
> +
> +	head = lookup_commit_reference_by_name("HEAD");
> +	if (!head) {
> +		ret = error(_("could not resolve HEAD to a commit"));
> +		goto out;
> +	}
> +
> +	commit_list_append(original_commit, &from_list);
> +	if (!repo_is_descendant_of(repo, head, from_list)) {
> +		ret = error(_("split commit must be reachable from current HEAD commit"));
> +		goto out;
> +	}

This is very similar to cmd_history_reword() up to this point. When we add the "drop" and "amend" subcommands they're going to want to do the same checks.

> +	parse_pathspec(&pathspec, 0,
> +		PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
> +		prefix, argv + 1);

This and calling split_commit() below are the only real differences with cmd_history_reword(), is it worth trying to share some more code between the two?

Show 20 quoted lines
> diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh
> [...]
> +test_expect_success 'refuses to work with merge commits' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit base &&
> +		git branch branch &&
> +		test_commit ours &&
> +		git switch branch &&
> +		test_commit theirs &&
> +		git switch - &&
> +		git merge theirs &&
> +		test_must_fail git history split HEAD 2>err &&
> +		test_grep "cannot rearrange commit history with merges" err &&
> +		test_must_fail git history split HEAD~ 2>err &&
> +		test_grep "cannot rearrange commit history with merges" err
> +	)
> +'
My comments from the reword tests apply here as well.
Thanks
Phillip
Show 390 quoted lines
> +
> +test_expect_success 'refuses to work with unrelated commits' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit base &&
> +		git branch branch &&
> +		test_commit ours &&
> +		git switch branch &&
> +		test_commit theirs &&
> +		test_must_fail git history split ours 2>err &&
> +		test_grep "split commit must be reachable from current HEAD commit" err
> +	)
> +'
> +
> +test_expect_success 'can split up tip commit' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit initial &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		git symbolic-ref HEAD >expect &&
> +		set_fake_editor "split-out commit" &&
> +		git history split HEAD <<-EOF &&
> +		y
> +		n
> +		EOF
> +		git symbolic-ref HEAD >actual &&
> +		test_cmp expect actual &&
> +
> +		expect_log <<-EOF &&
> +		split-me
> +		split-out commit
> +		initial
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		bar
> +		initial.t
> +		EOF
> +
> +		expect_tree_entries HEAD <<-EOF
> +		bar
> +		foo
> +		initial.t
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'can split up root commit' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m root &&
> +		test_commit tip &&
> +
> +		set_fake_editor "split-out commit" &&
> +		git history split HEAD~ <<-EOF &&
> +		y
> +		n
> +		EOF
> +
> +		expect_log <<-EOF &&
> +		tip
> +		root
> +		split-out commit
> +		EOF
> +
> +		expect_tree_entries HEAD~2 <<-EOF &&
> +		bar
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		bar
> +		foo
> +		EOF
> +
> +		expect_tree_entries HEAD <<-EOF
> +		bar
> +		foo
> +		tip.t
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'can split up in-between commit' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit initial &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +		test_commit tip &&
> +
> +		set_fake_editor "split-out commit" &&
> +		git history split HEAD~ <<-EOF &&
> +		y
> +		n
> +		EOF
> +
> +		expect_log <<-EOF &&
> +		tip
> +		split-me
> +		split-out commit
> +		initial
> +		EOF
> +
> +		expect_tree_entries HEAD~2 <<-EOF &&
> +		bar
> +		initial.t
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		bar
> +		foo
> +		initial.t
> +		EOF
> +
> +		expect_tree_entries HEAD <<-EOF
> +		bar
> +		foo
> +		initial.t
> +		tip.t
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'can pick multiple hunks' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar baz foo qux &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		set_fake_editor "split-out-commit" &&
> +		git history split HEAD <<-EOF &&
> +		y
> +		n
> +		y
> +		n
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		bar
> +		foo
> +		EOF
> +
> +		expect_tree_entries HEAD <<-EOF
> +		bar
> +		baz
> +		foo
> +		qux
> +		EOF
> +	)
> +'
> +
> +
> +test_expect_success 'can use only last hunk' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		set_fake_editor "split-out commit" &&
> +		git history split HEAD <<-EOF &&
> +		n
> +		y
> +		EOF
> +
> +		expect_log <<-EOF &&
> +		split-me
> +		split-out commit
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		foo
> +		EOF
> +
> +		expect_tree_entries HEAD <<-EOF
> +		bar
> +		foo
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'aborts with empty commit message' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		set_fake_editor "" &&
> +		test_must_fail git history split HEAD <<-EOF 2>err &&
> +		y
> +		n
> +		EOF
> +		test_grep "Aborting commit due to empty commit message." err
> +	)
> +'
> +
> +test_expect_success 'commit message editor sees split-out changes' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		write_script fake-editor.sh <<-\EOF &&
> +		cp "$1" . &&
> +		echo "some commit message" >>"$1"
> +		EOF
> +		test_set_editor "$(pwd)"/fake-editor.sh &&
> +
> +		git history split HEAD <<-EOF &&
> +		y
> +		n
> +		EOF
> +
> +		cat >expect <<-EOF &&
> +
> +		# Please enter the commit message for the split-out changes. Lines starting
> +		# with ${SQ}#${SQ} will be ignored.
> +		# Changes to be committed:
> +		#	new file:   bar
> +		#
> +		EOF
> +		test_cmp expect COMMIT_EDITMSG &&
> +
> +		expect_log <<-EOF
> +		split-me
> +		some commit message
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'can use pathspec to limit what gets split' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		set_fake_editor "split-out commit" &&
> +		git history split HEAD -- foo <<-EOF &&
> +		y
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		foo
> +		EOF
> +
> +		expect_tree_entries HEAD <<-EOF
> +		bar
> +		foo
> +		EOF
> +	)
> +'
> +
> +test_expect_success 'refuses to create empty split-out commit' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		test_commit base &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		test_must_fail git history split HEAD 2>err <<-EOF &&
> +		n
> +		n
> +		EOF
> +		test_grep "split commit is empty" err
> +	)
> +'
> +
> +test_expect_success 'hooks are executed for rewritten commits' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +		old_head=$(git rev-parse HEAD) &&
> +
> +		write_script .git/hooks/prepare-commit-msg <<-EOF &&
> +		touch "$(pwd)/hooks.log"
> +		EOF
> +		write_script .git/hooks/post-commit <<-EOF &&
> +		touch "$(pwd)/hooks.log"
> +		EOF
> +		write_script .git/hooks/post-rewrite <<-EOF &&
> +		touch "$(pwd)/hooks.log"
> +		EOF
> +
> +		set_fake_editor "split-out commit" &&
> +		git history split HEAD <<-EOF &&
> +		y
> +		n
> +		EOF
> +
> +		expect_log <<-EOF &&
> +		split-me
> +		split-out commit
> +		EOF
> +
> +		test_path_is_missing hooks.log
> +	)
> +'
> +
> +test_expect_success 'refuses to create empty original commit' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		touch bar foo &&
> +		git add . &&
> +		git commit -m split-me &&
> +
> +		test_must_fail git history split HEAD 2>err <<-EOF &&
> +		y
> +		y
> +		EOF
> +		test_grep "split commit tree matches original commit" err
> +	)
> +'
> +
> +test_expect_success 'retains changes in the worktree and index' '
> +	test_when_finished "rm -rf repo" &&
> +	git init repo &&
> +	(
> +		cd repo &&
> +		echo a >a &&
> +		echo b >b &&
> +		git add . &&
> +		git commit -m "initial commit" &&
> +		echo a-modified >a &&
> +		echo b-modified >b &&
> +		git add b &&
> +		set_fake_editor "a-only" &&
> +		git history split HEAD <<-EOF &&
> +		y
> +		n
> +		EOF
> +
> +		expect_tree_entries HEAD~ <<-EOF &&
> +		a
> +		EOF
> +		expect_tree_entries HEAD <<-EOF &&
> +		a
> +		b
> +		EOF
> +
> +		cat >expect <<-\EOF &&
> +		 M a
> +		M  b
> +		?? actual
> +		?? expect
> +		?? fake-editor.sh
> +		EOF
> +		git status --porcelain >actual &&
> +		test_cmp expect actual
> +	)
> +'
> +
> +test_done
> 
Patrick Steinhardt· Dec 2, 2025, 18:51 UTC · re: Phillip Wood · lore

Re: [PATCH v6 11/11] builtin/history: implement "split" subcommand

On Fri, Nov 21, 2025 at 02:31:14PM +0000, Phillip Wood wrote:
Show 44 quoted lines
> Hi Patrick
> 
> On 27/10/2025 11:33, Patrick Steinhardt wrote:
> > It is quite a common use case that one wants to split up one commit into
> > multiple commits by moving parts of the changes of the original commit
> > out into a separate commit. This is quite an involved operation though:
> > 
> >    1. Identify the commit in question that is to be dropped.
> > 
> >    2. Perform an interactive rebase on top of that commit's parent.
> > 
> >    3. Modify the instruction sheet to "edit" the commit that is to be
> >       split up.
> > 
> >    4. Drop the commit via "git reset HEAD~".
> > 
> >    5. Stage changes that should go into the first commit and commit it.
> > 
> >    6. Stage changes that should go into the second commit and commit it.
> > 
> >    7. Finalize the rebase.
> > 
> > This is quite complex, and overall I would claim that most people who
> > are not experts in Git would struggle with this flow.
> 
> If they want to test the split commit it's even more complicated because
> they need to stash the unstaged changes. We should think about how we can
> add support for testing split commits to this command in the future.
> 
> > Introduce a new "split" subcommand for git-history(1) to make this way
> > easier. All the user needs to do is to say `git history split $COMMIT`.
> >  From hereon, Git asks the user which parts of the commit shall be moved
> > out into a separate commit and, once done, asks the user for the commit
> > message. Git then creates that split-out commit and applies the original
> > commit on top of it.
> 
> As others have said (and I thought we'd agreed c.f.
> <aMfdR3JE4zq-2j9b@pks.im>) I think it would be better to prompt the user to
> edit the existing commit message when creating both commits. Elsewhere
> Elijah mention being able to split a commit into more than two commits. I
> wonder if we could loop running run_add_p_index() and committing the result
> until there are no more changes left. It does mean that the user has to
> actively select changes for the final commit though which might be annoying.
> We can always play with that later.
Ah, right. Changing this now to prompt for both commit messages.
Show 24 quoted lines
> Looking below I do wonder if we can share more code between subcommands when
> it comes to checking the commit we're given on the command line and
> re-creating a commit and having the user edit the message.
> 
> > +static int split_commit(struct repository *repo,
> > +			struct commit *original_commit,
> > +			struct pathspec *pathspec,
> > +			struct object_id *out)
> > {
> > [...]> +	/*
> > +	* Construct the first commit. This is done by taking the original
> > +	* commit parent's tree and selectively patching changes from the diff
> > +	* between that parent and its child.
> > +	*/
> > +	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
> > +
> > +	read_tree_cmd.git_cmd = 1;
> > +	strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
> > +	strvec_push(&read_tree_cmd.args, "read-tree");
> > +	strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
> > +	ret = run_command(&read_tree_cmd);
> 
> Why do we need to fork "read-tree" here rather than call unpack_trees()
> ourselves?

This is an artifact of how the `run_add_p()` interfaces work. They unfortunately do not work on top of an in-memory index, but they work on an on-disk index.

Show 21 quoted lines
> > [...]
> > +	/* We retain authorship of the original commit. */
> > +	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
> > +	ptr = find_commit_header(original_message, "author", &len);
> > +	if (ptr)
> > +		original_author = xmemdupz(ptr, len);
> > +
> > +	ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
> > +				  "", "split-out", &split_message);
> > +	if (ret < 0)
> > +		goto out;
> > +
> > +	ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
> > +			  original_commit->parents, &out[0], original_author, NULL);
> > +	if (ret < 0) {
> > +		ret = error(_("failed writing split-out commit"));
> > +		goto out;
> > +	}
> 
> Don't we have the same code for rewording a commit, maybe we should package
> this up into a shared helper function.
Hm, indeed, there's a bit of non-trivial logic here. I'll refactor this.
Show 63 quoted lines
> > +static int cmd_history_split(int argc,
> > +			     const char **argv,
> > +			     const char *prefix,
> > +			     struct repository *repo)
> > +{
> > +	const char * const usage[] = {
> > +		GIT_HISTORY_SPLIT_USAGE,
> > +		NULL,
> > +	};
> > +	struct option options[] = {
> > +		OPT_END(),
> > +	};
> > +	struct oidmap rewritten_commits = OIDMAP_INIT;
> > +	struct commit *original_commit, *parent, *head;
> > +	struct strvec commits = STRVEC_INIT;
> > +	struct commit_list *from_list = NULL;
> > +	struct object_id split_commits[2];
> > +	struct pathspec pathspec = { 0 };
> > +	int ret;
> > +
> > +	argc = parse_options(argc, argv, prefix, options, usage, 0);
> > +	if (argc < 1) {
> > +		ret = error(_("command expects a revision"));
> > +		goto out;
> > +	}
> > +	repo_config(repo, git_default_config, NULL);
> > +
> > +	original_commit = lookup_commit_reference_by_name(argv[0]);
> > +	if (!original_commit) {
> > +		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
> > +		goto out;
> > +	}
> > +
> > +	parent = original_commit->parents ? original_commit->parents->item : NULL;
> > +	if (parent && repo_parse_commit(repo, parent)) {
> > +		ret = error(_("unable to parse commit %s"),
> > +			    oid_to_hex(&parent->object.oid));
> > +		goto out;
> > +	}
> > +
> > +	head = lookup_commit_reference_by_name("HEAD");
> > +	if (!head) {
> > +		ret = error(_("could not resolve HEAD to a commit"));
> > +		goto out;
> > +	}
> > +
> > +	commit_list_append(original_commit, &from_list);
> > +	if (!repo_is_descendant_of(repo, head, from_list)) {
> > +		ret = error(_("split commit must be reachable from current HEAD commit"));
> > +		goto out;
> > +	}
> 
> This is very similar to cmd_history_reword() up to this point. When we add
> the "drop" and "amend" subcommands they're going to want to do the same
> checks.
> 
> > +	parse_pathspec(&pathspec, 0,
> > +		PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
> > +		prefix, argv + 1);
> 
> This and calling split_commit() below are the only real differences with
> cmd_history_reword(), is it worth trying to share some more code between the
> two?
Yup, done.
Thanks!
Patrick
Phillip Wood· Dec 10, 2025, 09:51 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 11/11] builtin/history: implement "split" subcommand

On 02/12/2025 18:51, Patrick Steinhardt wrote:
Show 20 quoted lines
> On Fri, Nov 21, 2025 at 02:31:14PM +0000, Phillip Wood wrote:
>> On 27/10/2025 11:33, Patrick Steinhardt wrote:
>>> +	* Construct the first commit. This is done by taking the original
>>> +	* commit parent's tree and selectively patching changes from the diff
>>> +	* between that parent and its child.
>>> +	*/
>>> +	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
>>> +
>>> +	read_tree_cmd.git_cmd = 1;
>>> +	strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
>>> +	strvec_push(&read_tree_cmd.args, "read-tree");
>>> +	strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
>>> +	ret = run_command(&read_tree_cmd);
>>
>> Why do we need to fork "read-tree" here rather than call unpack_trees()
>> ourselves?
> 
> This is an artifact of how the `run_add_p()` interfaces work. They
> unfortunately do not work on top of an in-memory index, but they work on
> an on-disk index.

Oh I see, but why does that mean we need to fork a subprocess rather than writing the index to disc in this process?

Thanks
Phillip
Show 94 quoted lines
>>> [...]
>>> +	/* We retain authorship of the original commit. */
>>> +	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
>>> +	ptr = find_commit_header(original_message, "author", &len);
>>> +	if (ptr)
>>> +		original_author = xmemdupz(ptr, len);
>>> +
>>> +	ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
>>> +				  "", "split-out", &split_message);
>>> +	if (ret < 0)
>>> +		goto out;
>>> +
>>> +	ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
>>> +			  original_commit->parents, &out[0], original_author, NULL);
>>> +	if (ret < 0) {
>>> +		ret = error(_("failed writing split-out commit"));
>>> +		goto out;
>>> +	}
>>
>> Don't we have the same code for rewording a commit, maybe we should package
>> this up into a shared helper function.
> 
> Hm, indeed, there's a bit of non-trivial logic here. I'll refactor this.
> 
>>> +static int cmd_history_split(int argc,
>>> +			     const char **argv,
>>> +			     const char *prefix,
>>> +			     struct repository *repo)
>>> +{
>>> +	const char * const usage[] = {
>>> +		GIT_HISTORY_SPLIT_USAGE,
>>> +		NULL,
>>> +	};
>>> +	struct option options[] = {
>>> +		OPT_END(),
>>> +	};
>>> +	struct oidmap rewritten_commits = OIDMAP_INIT;
>>> +	struct commit *original_commit, *parent, *head;
>>> +	struct strvec commits = STRVEC_INIT;
>>> +	struct commit_list *from_list = NULL;
>>> +	struct object_id split_commits[2];
>>> +	struct pathspec pathspec = { 0 };
>>> +	int ret;
>>> +
>>> +	argc = parse_options(argc, argv, prefix, options, usage, 0);
>>> +	if (argc < 1) {
>>> +		ret = error(_("command expects a revision"));
>>> +		goto out;
>>> +	}
>>> +	repo_config(repo, git_default_config, NULL);
>>> +
>>> +	original_commit = lookup_commit_reference_by_name(argv[0]);
>>> +	if (!original_commit) {
>>> +		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
>>> +		goto out;
>>> +	}
>>> +
>>> +	parent = original_commit->parents ? original_commit->parents->item : NULL;
>>> +	if (parent && repo_parse_commit(repo, parent)) {
>>> +		ret = error(_("unable to parse commit %s"),
>>> +			    oid_to_hex(&parent->object.oid));
>>> +		goto out;
>>> +	}
>>> +
>>> +	head = lookup_commit_reference_by_name("HEAD");
>>> +	if (!head) {
>>> +		ret = error(_("could not resolve HEAD to a commit"));
>>> +		goto out;
>>> +	}
>>> +
>>> +	commit_list_append(original_commit, &from_list);
>>> +	if (!repo_is_descendant_of(repo, head, from_list)) {
>>> +		ret = error(_("split commit must be reachable from current HEAD commit"));
>>> +		goto out;
>>> +	}
>>
>> This is very similar to cmd_history_reword() up to this point. When we add
>> the "drop" and "amend" subcommands they're going to want to do the same
>> checks.
>>
>>> +	parse_pathspec(&pathspec, 0,
>>> +		PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
>>> +		prefix, argv + 1);
>>
>> This and calling split_commit() below are the only real differences with
>> cmd_history_reword(), is it worth trying to share some more code between the
>> two?
> 
> Yup, done.
> 
> Thanks!
> 
> Patrick
> 
Patrick Steinhardt· Dec 19, 2025, 13:00 UTC · re: Phillip Wood · lore

Re: [PATCH v6 11/11] builtin/history: implement "split" subcommand

On Wed, Dec 10, 2025 at 09:51:33AM +0000, Phillip Wood wrote:
Show 24 quoted lines
> On 02/12/2025 18:51, Patrick Steinhardt wrote:
> > On Fri, Nov 21, 2025 at 02:31:14PM +0000, Phillip Wood wrote:
> > > On 27/10/2025 11:33, Patrick Steinhardt wrote:
> > > > +	* Construct the first commit. This is done by taking the original
> > > > +	* commit parent's tree and selectively patching changes from the diff
> > > > +	* between that parent and its child.
> > > > +	*/
> > > > +	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
> > > > +
> > > > +	read_tree_cmd.git_cmd = 1;
> > > > +	strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
> > > > +	strvec_push(&read_tree_cmd.args, "read-tree");
> > > > +	strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
> > > > +	ret = run_command(&read_tree_cmd);
> > > 
> > > Why do we need to fork "read-tree" here rather than call unpack_trees()
> > > ourselves?
> > 
> > This is an artifact of how the `run_add_p()` interfaces work. They
> > unfortunately do not work on top of an in-memory index, but they work on
> > an on-disk index.
> 
> Oh I see, but why does that mean we need to fork a subprocess rather than
> writing the index to disc in this process?

Fair indeed. I guess it was laziness because other parts of Git did it the same way. Anyway, let me change this now.

Patrick
Sergey Organov· Nov 12, 2025, 19:13 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

Hi Patrick,
Patrick Steinhardt <ps@pks.im> writes:
Show 6 quoted lines
> Hi,
>
> over recent months I've been playing around with Jujutsu quite
> frequently. While I still prefer using Git, there's been a couple
> features in it that I really like and that I'd like to have in Git, as
> well.

[disclaimer: I didn't follow all the discussions closely enough, so excuse me if these things below have been already addressed]

Are you aware of 'git revise', I wonder?
https://github.com/mystor/git-revise
In particular, its 
 git revise --interactive --edit

feature helps a lot in massive editing of commit messages. It creates single file with all the commit messages (similar to todo list), and then applies all the changes you make to the file as it revises particular commits. Very handy.

It'd be nice to have something like this in the mainstream Git.
BTW, it has commit split feature as well, though I didn't use it myself.

Thanks, -- Sergey Organov

Elijah Newren· Nov 20, 2025, 07:07 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On Mon, Oct 27, 2025 at 4:34 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 29 quoted lines
>
> Hi,
>
> over recent months I've been playing around with Jujutsu quite
> frequently. While I still prefer using Git, there's been a couple
> features in it that I really like and that I'd like to have in Git, as
> well.
>
> A copule of these features relate to history editing. Most importantly,
> I really dig the following commands:
>
>   - jj-abandon(1) to drop a specific commit from your history.
>
>   - jj-absorb(1) to take some changes and automatically apply them to
>     commits in your history that last modified the respective hunks.
>
>   - jj-split(1) to split a commit into two.
>
>   - jj-new(1) to insert a new commit after or before a specific other
>     commit.
>
> Not all of these commands can be ported directly into Git. jj-new(1) for
> example doesn't really make a ton of sense for us, I'd claim. But some
> of these commands _do_ make sense.
>
> This patch series is a starting point for such a command. I've
> significantly slimmed it down from the first couple revisions now
> following the discussions at the Contributor's Summit yesterday. This
> was my intent anyway, as I already mentioned on the last iteration.

Sorry for taking so long to review the series now that it's based on replay. Thanks for working on this!

Junio C Hamano· Nov 20, 2025, 20:28 UTC · re: Elijah Newren · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

Elijah Newren <newren@gmail.com> writes:
Show 7 quoted lines
>> This patch series is a starting point for such a command. I've
>> significantly slimmed it down from the first couple revisions now
>> following the discussions at the Contributor's Summit yesterday. This
>> was my intent anyway, as I already mentioned on the last iteration.
>
> Sorry for taking so long to review the series now that it's based on
> replay.  Thanks for working on this!

With your comments and Phillip's, it seems that we are very close to a good stopping point. Let me mark the topic as expecting a hopefully small and final reroll before getting ready for 'next'.

Thanks, all.
Elijah Newren· Nov 20, 2025, 20:40 UTC · re: Junio C Hamano · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On Thu, Nov 20, 2025 at 12:28 PM Junio C Hamano <gitster@pobox.com> wrote:
Show 16 quoted lines
>
> Elijah Newren <newren@gmail.com> writes:
>
> >> This patch series is a starting point for such a command. I've
> >> significantly slimmed it down from the first couple revisions now
> >> following the discussions at the Contributor's Summit yesterday. This
> >> was my intent anyway, as I already mentioned on the last iteration.
> >
> > Sorry for taking so long to review the series now that it's based on
> > replay.  Thanks for working on this!
>
> With your comments and Phillip's, it seems that we are very close to
> a good stopping point.  Let me mark the topic as expecting a
> hopefully small and final reroll before getting ready for 'next'.
>
> Thanks, all.

I'm a little unsure if it'll be small or just one reroll. Some of the changes for patches 5 & 9 might be big (but straightforward), there's also a couple design related questions (single branch, HEAD-centric) that might bring up bigger usability issues to address (if a commit being edited is part of multiple branches, do we just rewrite all of them by default, or error out unless the user specifies how they want it handled)?, and a potential gotcha on patch 11 (how can you preserve the index and working tree if the user edits the patch while splitting a commit?) that may require rethinking or restricting that feature.

Junio C Hamano· Nov 20, 2025, 20:49 UTC · re: Elijah Newren · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

Elijah Newren <newren@gmail.com> writes:
Show 27 quoted lines
> On Thu, Nov 20, 2025 at 12:28 PM Junio C Hamano <gitster@pobox.com> wrote:
>>
>> Elijah Newren <newren@gmail.com> writes:
>>
>> >> This patch series is a starting point for such a command. I've
>> >> significantly slimmed it down from the first couple revisions now
>> >> following the discussions at the Contributor's Summit yesterday. This
>> >> was my intent anyway, as I already mentioned on the last iteration.
>> >
>> > Sorry for taking so long to review the series now that it's based on
>> > replay.  Thanks for working on this!
>>
>> With your comments and Phillip's, it seems that we are very close to
>> a good stopping point.  Let me mark the topic as expecting a
>> hopefully small and final reroll before getting ready for 'next'.
>>
>> Thanks, all.
>
> I'm a little unsure if it'll be small or just one reroll.  Some of the
> changes for patches 5 & 9 might be big (but straightforward), there's
> also a couple design related questions (single branch, HEAD-centric)
> that might bring up bigger usability issues to address (if a commit
> being edited is part of multiple branches, do we just rewrite all of
> them by default, or error out unless the user specifies how they want
> it handled)?, and a potential gotcha on patch 11 (how can you preserve
> the index and working tree if the user edits the patch while splitting
> a commit?) that may require rethinking or restricting that feature.

Perhaps. But I thought the existing patches limited its initial scope small and manageable that by operating only on a single strand of pearls, with an intention to extend to cover more cases later. I was hoping that we can start small and simple, initially limiting it to single branch, etc., in other areas that require design decisions.

Elijah Newren· Nov 20, 2025, 22:02 UTC · re: Junio C Hamano · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On Thu, Nov 20, 2025 at 12:49 PM Junio C Hamano <gitster@pobox.com> wrote:
Show 37 quoted lines
>
> Elijah Newren <newren@gmail.com> writes:
>
> > On Thu, Nov 20, 2025 at 12:28 PM Junio C Hamano <gitster@pobox.com> wrote:
> >>
> >> Elijah Newren <newren@gmail.com> writes:
> >>
> >> >> This patch series is a starting point for such a command. I've
> >> >> significantly slimmed it down from the first couple revisions now
> >> >> following the discussions at the Contributor's Summit yesterday. This
> >> >> was my intent anyway, as I already mentioned on the last iteration.
> >> >
> >> > Sorry for taking so long to review the series now that it's based on
> >> > replay.  Thanks for working on this!
> >>
> >> With your comments and Phillip's, it seems that we are very close to
> >> a good stopping point.  Let me mark the topic as expecting a
> >> hopefully small and final reroll before getting ready for 'next'.
> >>
> >> Thanks, all.
> >
> > I'm a little unsure if it'll be small or just one reroll.  Some of the
> > changes for patches 5 & 9 might be big (but straightforward), there's
> > also a couple design related questions (single branch, HEAD-centric)
> > that might bring up bigger usability issues to address (if a commit
> > being edited is part of multiple branches, do we just rewrite all of
> > them by default, or error out unless the user specifies how they want
> > it handled)?, and a potential gotcha on patch 11 (how can you preserve
> > the index and working tree if the user edits the patch while splitting
> > a commit?) that may require rethinking or restricting that feature.
>
> Perhaps.  But I thought the existing patches limited its initial
> scope small and manageable that by operating only on a single strand
> of pearls, with an intention to extend to cover more cases later.  I
> was hoping that we can start small and simple, initially limiting it
> to single branch, etc., in other areas that require design
> decisions.

So, you are referring to the single branch, HEAD-centric piece of the feedback. The funny thing there is that operating on a more limited case, without checking and verifying that you are indeed in the more limited case (and erroring out if not), risks painting us into a corner or providing some really buggy behavior when we aren't actually in that case. To me, it opens a can of worms and makes the problem scope bigger instead of smaller. Funnily enough, the single branch thing is also the one piece of this that I think could be solved by a fairly small change in the reroll (and I pointed out how in the comments), so the limited view really didn't buy anything here IMO.

The other problems are independent of whether you try to limit the scope initially in such a manner:

Are the testcases and the code requiring something for the feature (ensuring the index and worktree are preserved) doing something that is incompatible with the capabilities given to the user (allowing them to edit the patch while splitting, so that they stage stuff that wasn't part of the original commit)? Or...is it assumed that the split commits always "sum" to the changes in the original commit, meaning the "other" patch immediately undoes those extra changes? (Perhaps it's the latter, which I didn't think of until now, so maybe we are closer to a solution than I realized. In fact, re-reading the code that looks like it does do that and I just missed it. But, perhaps having users edit the patch when splitting commits is a special case that should be called out in the docs, since that might surprise users who try it?)

I'm also worried about extended header handling for the edited (reworded or split) commits. That seems to have been overlooked in this series, despite the fact that in early versions extended headers were explicitly called out for the remainder of the commits being replayed/rebased, so it seems interesting that they weren't considered for the commits explicitly being edited.

And I'm a bit surprised that the original commit message for a split commit is automatically associated with the second commit; if I had been forced to choose, I would have assumed it should be associated with the first.

Granted, I think good progress is being made and perhaps the changes needed for the rest aren't that huge (and maybe there's more pieces I'm not quite understanding yet similar to the two-split-patches-always-summing-to-the-original), I was just a little surprised that my comments are summarized by "expecting a small and final reroll". :-)

Phillip Wood· Nov 21, 2025, 14:31 UTC · re: Elijah Newren · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On 20/11/2025 22:02, Elijah Newren wrote:
Show 30 quoted lines
> On Thu, Nov 20, 2025 at 12:49 PM Junio C Hamano <gitster@pobox.com> wrote:
>> Elijah Newren <newren@gmail.com> writes:
>>> 
>>> I'm a little unsure if it'll be small or just one reroll.  Some of the
>>> changes for patches 5 & 9 might be big (but straightforward), there's
>>> also a couple design related questions (single branch, HEAD-centric)
>>> that might bring up bigger usability issues to address (if a commit
>>> being edited is part of multiple branches, do we just rewrite all of
>>> them by default, or error out unless the user specifies how they want
>>> it handled)?, and a potential gotcha on patch 11 (how can you preserve
>>> the index and working tree if the user edits the patch while splitting
>>> a commit?) that may require rethinking or restricting that feature.
>>
>> Perhaps.  But I thought the existing patches limited its initial
>> scope small and manageable that by operating only on a single strand
>> of pearls, with an intention to extend to cover more cases later.  I
>> was hoping that we can start small and simple, initially limiting it
>> to single branch, etc., in other areas that require design
>> decisions.
> 
> So, you are referring to the single branch, HEAD-centric piece of the
> feedback.  The funny thing there is that operating on a more limited
> case, without checking and verifying that you are indeed in the more
> limited case (and erroring out if not), risks painting us into a
> corner or providing some really buggy behavior when we aren't actually
> in that case.  To me, it opens a can of worms and makes the problem
> scope bigger instead of smaller.  Funnily enough, the single branch
> thing is also the one piece of this that I think could be solved by a
> fairly small change in the reroll (and I pointed out how in the
> comments), so the limited view really didn't buy anything here IMO.

I can't find that comment. Are you referring to reusing more of the replay machinery? If so we have the problem that the user gives a single commit to "git history" so we don't have a handy revision range to pass to the replay machinery unless we assume we're rewriting an ancestor of HEAD or we go and find all the branches descended from the commit the user gave us. Long term we should certainly do the latter but depending on how much work it is to implement that we may want to go with the single branch case at first

Show 10 quoted lines
> The other problems are independent of whether you try to limit the
> scope initially in such a manner:
> 
> Are the testcases and the code requiring something for the feature
> (ensuring the index and worktree are preserved) doing something that
> is incompatible with the capabilities given to the user (allowing them
> to edit the patch while splitting, so that they stage stuff that
> wasn't part of the original commit)?  Or...is it assumed that the
> split commits always "sum" to the changes in the original commit,
> meaning the "other" patch immediately undoes those extra changes?

Yes that's what's implemented. I think that makes sense for the "split" command. Often when splitting a commit one needs to make small changes to the diff in order for the result to compile but you still want the same end state from the sum of the split commits.

Show 6 quoted lines
> I'm also worried about extended header handling for the edited
> (reworded or split) commits.  That seems to have been overlooked in
> this series, despite the fact that in early versions extended headers
> were explicitly called out for the remainder of the commits being
> replayed/rebased, so it seems interesting that they weren't considered
> for the commits explicitly being edited.

What headers does it make sense to copy when splitting a commit? When rewording it is more likely that copying the extended headers is what the user wants but the example of the "encoding" header you gave does not make sense to me as we re-encode the commit message and author data when the user edit's the message so we're not preserving the original encoding.

> And I'm a bit surprised that the original commit message for a split
> commit is automatically associated with the second commit; if I had
> been forced to choose, I would have assumed it should be associated
> with the first.

I don't think it is safe to assume either - we should prompt the user to edit the message when creating both commits and seed the editor with the original message.

Show 6 quoted lines
> Granted, I think good progress is being made and perhaps the changes
> needed for the rest aren't that huge (and maybe there's more pieces
> I'm not quite understanding yet similar to the
> two-split-patches-always-summing-to-the-original), I was just a little
> surprised that my comments are summarized by "expecting a small and
> final reroll".  :-)

Yes I'm not expecting any new functionality but I am expecting a bit more than tiny cleanup.

Thanks
Phillip
Junio C Hamano· Nov 21, 2025, 16:01 UTC · re: Phillip Wood · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

Phillip Wood <phillip.wood123@gmail.com> writes:
Show 6 quoted lines
>> Granted, I think good progress is being made and perhaps the changes
>> needed for the rest aren't that huge (and maybe there's more pieces
>> I'm not quite understanding yet similar to the
>> two-split-patches-always-summing-to-the-original), I was just a little
>> surprised that my comments are summarized by "expecting a small and
>> final reroll".  :-)

It was because I didn't think (and I still do not think) your comments are something for the immediate future, for a tool that wants to present its minimum-serviceable experimental version to users so that the users can experiment, extend it more and fix its behaviour incrementally. I may have been probably wrong, but I was getting an impression from the reviews that it is getting to there, not the feature-perfect version that needs only maintenance from now on, but a minimum-serviceable one.

We could instead of collect all the I want moon and I want cheeze comments and iterate until the tool has all of them before it hits 'next', but I do not think it is often what we do to a new feature.

> Yes I'm not expecting any new functionality but I am expecting a bit 
> more than tiny cleanup.

OK. Then we'd need a non-trivial amount of work before we get there.

Thanks.
Elijah Newren· Nov 23, 2025, 02:54 UTC · re: Junio C Hamano · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On Fri, Nov 21, 2025 at 8:01 AM Junio C Hamano <gitster@pobox.com> wrote:
Show 22 quoted lines
>
> Phillip Wood <phillip.wood123@gmail.com> writes:
>
> >> Granted, I think good progress is being made and perhaps the changes
> >> needed for the rest aren't that huge (and maybe there's more pieces
> >> I'm not quite understanding yet similar to the
> >> two-split-patches-always-summing-to-the-original), I was just a little
> >> surprised that my comments are summarized by "expecting a small and
> >> final reroll".  :-)
>
> It was because I didn't think (and I still do not think) your
> comments are something for the immediate future, for a tool that
> wants to present its minimum-serviceable experimental version to
> users so that the users can experiment, extend it more and fix its
> behaviour incrementally.  I may have been probably wrong, but I was
> getting an impression from the reviews that it is getting to there,
> not the feature-perfect version that needs only maintenance from now
> on, but a minimum-serviceable one.
>
> We could instead of collect all the I want moon and I want cheeze
> comments and iterate until the tool has all of them before it hits
> 'next', but I do not think it is often what we do to a new feature.

You're reading my feedback as feature requests rather than as bugs and/or possible paint-ourselves-in-a-corner situations in the presented implementation? I must have described things rather poorly; if they were just feature requests, I'd agree we could just implement them later.

But maybe I see where the confusion arises, since you were focusing solely on the single branch thing; that's the one issue where it's perhaps not as clear whether I was discussing a bug or a new feature. Let me try to explain that case another way:

The early cover letters said they focused on a case where just a single branch was involved, yet they don't check whether there really is only one branch involved for safe operation.

If a user tries to reword or split a commit that is in the history of multiple branches, the current implementation does not check and makes the branches diverge. Some users may want that, though I suspect most would be negatively surprised. The commit messages and code do not even discuss this case. If we merge the code as-is and then later notice and fix this problem soon enough, maybe we'd be fine, but I always worry a bit about a git-switch/git-restore kind of case where it sits long enough and people depend on side-effects in a way that prevents us from fixing it. Besides, since the bug has been identified and there are multiple not-too-hard ways to fix, I think we should do something. Some possibilities:

  * Document this case and warn users to check on their own (not that
friendly, but might be good enough for the first cut).
  * Check if the user-specified commit is part of multiple branches
and error out, unless the user provides a flag verifying that they
want histories to diverge.
  * Just rewrite all relevant branches

The third of those could sound like a feature request in isolation, but I raised it primarily because it's a potential fix to this overlooked bug. I mentioned all three possible fixes, but assumed others didn't realize how simple that third option was, so I pointed out how easy it was with some code (~12 lines, which also replace many more existing lines of code). I personally think the third option is *less work* than the second option, and that the focus on trying to limit to a single branch is creating more work rather than simplifying the problem. But if folks really do want to limit to a single branch despite the code existing to handle the more general case, then we can implement one of the other solutions. (If we do so, I still think choice three is more friendly to users, to cpus, and to future extension of these features while also simultaneously simplifying the existing code; so I'll volunteer to investigate and post patches on top of this series if others decide to go with one of the other choices for the initial version of the feature.) My main point here is just that there is a clear, un-discussed bug, and it should be addressed somehow in the initial version of the feature.

Patrick Steinhardt· Dec 2, 2025, 18:49 UTC · re: Elijah Newren · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On Sat, Nov 22, 2025 at 06:54:00PM -0800, Elijah Newren wrote:
Show 29 quoted lines
> On Fri, Nov 21, 2025 at 8:01 AM Junio C Hamano <gitster@pobox.com> wrote:
> >
> > Phillip Wood <phillip.wood123@gmail.com> writes:
> >
> > >> Granted, I think good progress is being made and perhaps the changes
> > >> needed for the rest aren't that huge (and maybe there's more pieces
> > >> I'm not quite understanding yet similar to the
> > >> two-split-patches-always-summing-to-the-original), I was just a little
> > >> surprised that my comments are summarized by "expecting a small and
> > >> final reroll".  :-)
> >
> > It was because I didn't think (and I still do not think) your
> > comments are something for the immediate future, for a tool that
> > wants to present its minimum-serviceable experimental version to
> > users so that the users can experiment, extend it more and fix its
> > behaviour incrementally.  I may have been probably wrong, but I was
> > getting an impression from the reviews that it is getting to there,
> > not the feature-perfect version that needs only maintenance from now
> > on, but a minimum-serviceable one.
> >
> > We could instead of collect all the I want moon and I want cheeze
> > comments and iterate until the tool has all of them before it hits
> > 'next', but I do not think it is often what we do to a new feature.
> 
> You're reading my feedback as feature requests rather than as bugs
> and/or possible paint-ourselves-in-a-corner situations in the
> presented implementation?  I must have described things rather poorly;
> if they were just feature requests, I'd agree we could just implement
> them later.

The command is explicitly marked as experimental so that we can iterate on its behaviour as needed. So I don't think we're painting us into a corner yet.

Show 9 quoted lines
> But maybe I see where the confusion arises, since you were focusing
> solely on the single branch thing; that's the one issue where it's
> perhaps not as clear whether I was discussing a bug or a new feature.
> Let me try to explain that case another way:
> 
> 
> The early cover letters said they focused on a case where just a
> single branch was involved, yet they don't check whether there really
> is only one branch involved for safe operation.

I think this depends on the definition. We _do_ verify that the commit that is to be edited is part of the current branch. What we _don't_ check is that the commit is _only_ part of that current branch.

I'm not sure whether that is something we want. I myself have the same commit on multiple branches quite regularly, as I tend to queue up multiple dependent patch series. But I wouldn't ever want a history edit to affect all of these branches myself, I really only want it to modify the branch I'm currently on.

Show 37 quoted lines
> If a user tries to reword or split a commit that is in the history of
> multiple branches, the current implementation does not check and makes
> the branches diverge.  Some users may want that, though I suspect most
> would be negatively surprised.  The commit messages and code do not
> even discuss this case.  If we merge the code as-is and then later
> notice and fix this problem soon enough, maybe we'd be fine, but I
> always worry a bit about a git-switch/git-restore kind of case where
> it sits long enough and people depend on side-effects in a way that
> prevents us from fixing it.  Besides, since the bug has been
> identified and there are multiple not-too-hard ways to fix, I think we
> should do something.  Some possibilities:
> 
>   * Document this case and warn users to check on their own (not that
> friendly, but might be good enough for the first cut).
>   * Check if the user-specified commit is part of multiple branches
> and error out, unless the user provides a flag verifying that they
> want histories to diverge.
>   * Just rewrite all relevant branches
> 
> The third of those could sound like a feature request in isolation,
> but I raised it primarily because it's a potential fix to this
> overlooked bug.  I mentioned all three possible fixes, but assumed
> others didn't realize how simple that third option was, so I pointed
> out how easy it was with some code (~12 lines, which also replace many
> more existing lines of code).  I personally think the third option is
> *less work* than the second option, and that the focus on trying to
> limit to a single branch is creating more work rather than simplifying
> the problem.  But if folks really do want to limit to a single branch
> despite the code existing to handle the more general case, then we can
> implement one of the other solutions.  (If we do so, I still think
> choice three is more friendly to users, to cpus, and to future
> extension of these features while also simultaneously simplifying the
> existing code; so I'll volunteer to investigate and post patches on
> top of this series if others decide to go with one of the other
> choices for the initial version of the feature.)  My main point here
> is just that there is a clear, un-discussed bug, and it should be
> addressed somehow in the initial version of the feature.

So with the above clarification I wouldn't call any of this a bug, but rather working as designed. We could of course still print a warning in that case to protect the user, but one problem I see is that generating this warning could be quite expensive as we'd now have to walk all references. That might be cheap in case the user only has short-lived feature branchs. But it may very well not be cheap in case they for example have old release branches checked out, as we'd now have to potentially walk a significant portiion of history.

Consequently I'm leaning more into the direction of doing nothing. It's not really clear to me that this is a bug, and we still can introduce a flag in the future that opts into the behaviour of rewriting relevant branches. That behaviour certainly can be useful, but I'd claim that it would be rather surprising to the user if that was the default.

Patrick
Elijah Newren· Dec 5, 2025, 08:49 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On Tue, Dec 2, 2025 at 10:50 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 35 quoted lines
>
> On Sat, Nov 22, 2025 at 06:54:00PM -0800, Elijah Newren wrote:
> > On Fri, Nov 21, 2025 at 8:01 AM Junio C Hamano <gitster@pobox.com> wrote:
> > >
> > > Phillip Wood <phillip.wood123@gmail.com> writes:
> > >
> > > >> Granted, I think good progress is being made and perhaps the changes
> > > >> needed for the rest aren't that huge (and maybe there's more pieces
> > > >> I'm not quite understanding yet similar to the
> > > >> two-split-patches-always-summing-to-the-original), I was just a little
> > > >> surprised that my comments are summarized by "expecting a small and
> > > >> final reroll".  :-)
> > >
> > > It was because I didn't think (and I still do not think) your
> > > comments are something for the immediate future, for a tool that
> > > wants to present its minimum-serviceable experimental version to
> > > users so that the users can experiment, extend it more and fix its
> > > behaviour incrementally.  I may have been probably wrong, but I was
> > > getting an impression from the reviews that it is getting to there,
> > > not the feature-perfect version that needs only maintenance from now
> > > on, but a minimum-serviceable one.
> > >
> > > We could instead of collect all the I want moon and I want cheeze
> > > comments and iterate until the tool has all of them before it hits
> > > 'next', but I do not think it is often what we do to a new feature.
> >
> > You're reading my feedback as feature requests rather than as bugs
> > and/or possible paint-ourselves-in-a-corner situations in the
> > presented implementation?  I must have described things rather poorly;
> > if they were just feature requests, I'd agree we could just implement
> > them later.
>
> The command is explicitly marked as experimental so that we can iterate
> on its behaviour as needed. So I don't think we're painting us into a
> corner yet.

I'm aware it's marked as experimental and even commented on that at least once in this series. I'm not sure that's sufficient _in this kind of case_ for the following reasons:

* Users tend to not have multiple branches sharing commits and then
try to operate on those, it will only come up rarely.  (I still think
it's a very important usecase, just that it's not common, in part
because git has trained people away from this kind of usecase.)
* Since the command is marked as experimental, it might extend the
timeline until we see such uses.
* The implemented behavior is not always a bug, some users will want
it (it's the kind of thing that makes sense as an option).

Combining the above, it might be a long time before folks really hit this behavior and start pointing out its problems, rather than hitting them early on. By then, we don't know how many users we have, just that it's been a long time, and we are further faced with the fact that some subset of users have begun to depend upon the existing behavior. The combination may make it hard to change at that point.

To me, it feels very much like risking painting ourselves into a corner. If it were an issue I felt people would likely hit right away OR it was an issue which users would always view as a bug, then I'd agree with you that there's no (or very little) risk with just proceeding as-is.

Show 19 quoted lines
> > But maybe I see where the confusion arises, since you were focusing
> > solely on the single branch thing; that's the one issue where it's
> > perhaps not as clear whether I was discussing a bug or a new feature.
> > Let me try to explain that case another way:
> >
> >
> > The early cover letters said they focused on a case where just a
> > single branch was involved, yet they don't check whether there really
> > is only one branch involved for safe operation.
>
> I think this depends on the definition. We _do_ verify that the commit
> that is to be edited is part of the current branch. What we _don't_
> check is that the commit is _only_ part of that current branch.
>
> I'm not sure whether that is something we want. I myself have the same
> commit on multiple branches quite regularly, as I tend to queue up
> multiple dependent patch series. But I wouldn't ever want a history edit
> to affect all of these branches myself, I really only want it to modify
> the branch I'm currently on.

To me at least, that feels crazy. Rebase was broken for multiple interconnected or dependent branches precisely because you could only update one branch, then needed to find the subset of the next branch not contained in the first as well as finding where to graft that next set onto in order to manually rebase it, then repeat for the third branch, and so on. That's precisely the design mistake that made me give up on rebase and write something else...and you want to explicitly copy it?

(--update-refs did come along later to help in the common case that each new branch fully contained the previous ones, but it didn't help when you had multiple branches that built on some common base, or had other interesting topologies, and thus didn't really fix the underlying problem.)

Show 19 quoted lines
> > If a user tries to reword or split a commit that is in the history of
> > multiple branches, the current implementation does not check and makes
> > the branches diverge.  Some users may want that, though I suspect most
> > would be negatively surprised.  The commit messages and code do not
> > even discuss this case.  If we merge the code as-is and then later
> > notice and fix this problem soon enough, maybe we'd be fine, but I
> > always worry a bit about a git-switch/git-restore kind of case where
> > it sits long enough and people depend on side-effects in a way that
> > prevents us from fixing it.  Besides, since the bug has been
> > identified and there are multiple not-too-hard ways to fix, I think we
> > should do something.  Some possibilities:
> >
> >   * Document this case and warn users to check on their own (not that
> > friendly, but might be good enough for the first cut).
> >   * Check if the user-specified commit is part of multiple branches
> > and error out, unless the user provides a flag verifying that they
> > want histories to diverge.
> >   * Just rewrite all relevant branches
> >
[...]
Show 8 quoted lines
> So with the above clarification I wouldn't call any of this a bug, but
> rather working as designed. We could of course still print a warning in
> that case to protect the user, but one problem I see is that generating
> this warning could be quite expensive as we'd now have to walk all
> references. That might be cheap in case the user only has short-lived
> feature branchs. But it may very well not be cheap in case they for
> example have old release branches checked out, as we'd now have to
> potentially walk a significant portiion of history.

Munging user's histories in unexpected ways and depending on them to figure out on their own that such has happened may well be much more expensive. Granted, that's human time rather than cpu time, so it's not directly comparable, but this feels like a big foot-gun, and I think big foot-guns deserve good checks and warnings. I agree there's a cost here, but I think the cost is warranted, especially since such an error message would be the thing that frees us to iterate and define the multi-branch behavior later.

Show 5 quoted lines
> Consequently I'm leaning more into the direction of doing nothing. It's
> not really clear to me that this is a bug, and we still can introduce a
> flag in the future that opts into the behaviour of rewriting relevant
> branches. That behaviour certainly can be useful, but I'd claim that
> it would be rather surprising to the user if that was the default.

Well, as I stated above, this is basically copying what I view as the fundamental design mistake of git-rebase. The many other points of feedback I had on this series (e.g. extended headers, reusing replay's walking, etc.) are things I could easily negotiate on; this one bothers me much, much more. To me, it ruins the command and makes me feel it is unsuitable for inclusion in git; this is, after all, the kind of thing that made me decide to write yet another command to workaround such a flaw. If the series is merged with this behavior, I'm going to be in the awkward position of feeling I need to actively recommend against its usage unless _and until_ we either

(a) check that a commit is only part of one branch before proceeding, (b) always require the user to specify with a flag how to handle commits that happen to be part of multiple branches (even when a commit only happens to be part of one branch, in order to allow us to not bother checking whether it's part of more), or (c) rewrite all branches that contain the given commit by default (with an option to only rewrite the current one).

That said, obviously the choice of whether the series is merged isn't up to me. And maybe I'm in the minority, and others don't care about this issue at all. But it's how I feel about it.

Patrick Steinhardt· Dec 9, 2025, 07:53 UTC · re: Elijah Newren · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On Fri, Dec 05, 2025 at 12:49:04AM -0800, Elijah Newren wrote:
Show 30 quoted lines
> On Tue, Dec 2, 2025 at 10:50 AM Patrick Steinhardt <ps@pks.im> wrote:
> > Consequently I'm leaning more into the direction of doing nothing. It's
> > not really clear to me that this is a bug, and we still can introduce a
> > flag in the future that opts into the behaviour of rewriting relevant
> > branches. That behaviour certainly can be useful, but I'd claim that
> > it would be rather surprising to the user if that was the default.
> 
> Well, as I stated above, this is basically copying what I view as the
> fundamental design mistake of git-rebase.  The many other points of
> feedback I had on this series (e.g. extended headers, reusing replay's
> walking, etc.) are things I could easily negotiate on; this one
> bothers me much, much more.  To me, it ruins the command and makes me
> feel it is unsuitable for inclusion in git; this is, after all, the
> kind of thing that made me decide to write yet another command to
> workaround such a flaw.  If the series is merged with this behavior,
> I'm going to be in the awkward position of feeling I need to actively
> recommend against its usage unless _and until_ we either
> 
> (a) check that a commit is only part of one branch before proceeding,
> (b) always require the user to specify with a flag how to handle
> commits that happen to be part of multiple branches (even when a
> commit only happens to be part of one branch, in order to allow us to
> not bother checking whether it's part of more),
> or
> (c) rewrite all branches that contain the given commit by default
> (with an option to only rewrite the current one).
> 
> That said, obviously the choice of whether the series is merged isn't
> up to me.  And maybe I'm in the minority, and others don't care about
> this issue at all.  But it's how I feel about it.

I guess it's a matter of workflows and tastes, and there's never going to be the one correct way of doing things. I don't think (b) is a good option as it makes things more complex even for the simplest cases. But I wouldn't be opposed to a combination of (a) and (b) if we can implement (a) efficiently.

Do we already have logic like this in git-replay(1)?
Patrick
Martin von Zweigbergk· Dec 9, 2025, 17:43 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On Mon, Dec 8, 2025 at 11:53 PM Patrick Steinhardt <ps@pks.im> wrote:
Show 38 quoted lines
>
> On Fri, Dec 05, 2025 at 12:49:04AM -0800, Elijah Newren wrote:
> > On Tue, Dec 2, 2025 at 10:50 AM Patrick Steinhardt <ps@pks.im> wrote:
> > > Consequently I'm leaning more into the direction of doing nothing. It's
> > > not really clear to me that this is a bug, and we still can introduce a
> > > flag in the future that opts into the behaviour of rewriting relevant
> > > branches. That behaviour certainly can be useful, but I'd claim that
> > > it would be rather surprising to the user if that was the default.
> >
> > Well, as I stated above, this is basically copying what I view as the
> > fundamental design mistake of git-rebase.  The many other points of
> > feedback I had on this series (e.g. extended headers, reusing replay's
> > walking, etc.) are things I could easily negotiate on; this one
> > bothers me much, much more.  To me, it ruins the command and makes me
> > feel it is unsuitable for inclusion in git; this is, after all, the
> > kind of thing that made me decide to write yet another command to
> > workaround such a flaw.  If the series is merged with this behavior,
> > I'm going to be in the awkward position of feeling I need to actively
> > recommend against its usage unless _and until_ we either
> >
> > (a) check that a commit is only part of one branch before proceeding,
> > (b) always require the user to specify with a flag how to handle
> > commits that happen to be part of multiple branches (even when a
> > commit only happens to be part of one branch, in order to allow us to
> > not bother checking whether it's part of more),
> > or
> > (c) rewrite all branches that contain the given commit by default
> > (with an option to only rewrite the current one).
> >
> > That said, obviously the choice of whether the series is merged isn't
> > up to me.  And maybe I'm in the minority, and others don't care about
> > this issue at all.  But it's how I feel about it.
>
> I guess it's a matter of workflows and tastes, and there's never going
> to be the one correct way of doing things. I don't think (b) is a good
> option as it makes things more complex even for the simplest cases. But
> I wouldn't be opposed to a combination of (a) and (b) if we can
> implement (a) efficiently.

FWIW, I agree with Elijah that (c) is the right end state. That's perhaps not surprising given that that's what jj has been doing for many years (as many of you know already, I'm one of the jj maintainers). I think it's very rare that we hear from users that they want to rewrite a commit and its descendants while leaving some of the descendants in place. We have a `jj duplicate` command they can use, but that won't move any bookmarks (branches) over. So if you have bookmark `foo` with descendant bookmarks `bar` and `baz` and you want to amend `foo` while moving `bar` over and keeping `baz` in place, the simplest way is probably to duplicate all the necessary commit (something like `jj duplicate -r main..bar`) and then manually move over `foo` and `bar`. That seems like such a rare use case that we haven't had a reason to make it simpler so far.

Regarding performance of (a), I would think that walking all branches/tags until you reach the to-be-modified commit (or reach an older generation) can usually be done quickly enough, but obviously there are many others on this list who know that better than me :) But I have also heard that some Git repos have tens (hundreds?) of thousands of branches. Hopefully those refs are just on some server where users won't run `git history`.

>
> Do we already have logic like this in git-replay(1)?
>
> Patrick
Phillip Wood· Dec 10, 2025, 11:32 UTC · re: Martin von Zweigbergk · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On 09/12/2025 17:43, Martin von Zweigbergk wrote:
Show 23 quoted lines
> On Mon, Dec 8, 2025 at 11:53 PM Patrick Steinhardt <ps@pks.im> wrote:
>> On Fri, Dec 05, 2025 at 12:49:04AM -0800, Elijah Newren wrote:
>>>
>>> (a) check that a commit is only part of one branch before proceeding,
>>> (b) always require the user to specify with a flag how to handle
>>> commits that happen to be part of multiple branches (even when a
>>> commit only happens to be part of one branch, in order to allow us to
>>> not bother checking whether it's part of more),
>>> or
>>> (c) rewrite all branches that contain the given commit by default
>>> (with an option to only rewrite the current one).
>>>
>>> That said, obviously the choice of whether the series is merged isn't
>>> up to me.  And maybe I'm in the minority, and others don't care about
>>> this issue at all.  But it's how I feel about it.
>>
>> I guess it's a matter of workflows and tastes, and there's never going
>> to be the one correct way of doing things. I don't think (b) is a good
>> option as it makes things more complex even for the simplest cases. But
>> I wouldn't be opposed to a combination of (a) and (b) if we can
>> implement (a) efficiently.
> 
> FWIW, I agree with Elijah that (c) is the right end state.

FWIW I think so too, but there does seem to be a significant number of people who find that behavior surprising so maybe we need a flag to control what gets rewritten.

Thanks
Phillip
Show 27 quoted lines
> That's
> perhaps not surprising given that that's what jj has been doing for
> many years (as many of you know already, I'm one of the jj
> maintainers). I think it's very rare that we hear from users that they
> want to rewrite a commit and its descendants while leaving some of the
> descendants in place. We have a `jj duplicate` command they can use,
> but that won't move any bookmarks (branches) over. So if you have
> bookmark `foo` with descendant bookmarks `bar` and `baz` and you want
> to amend `foo` while moving `bar` over and keeping `baz` in place, the
> simplest way is probably to duplicate all the necessary commit
> (something like `jj duplicate -r main..bar`) and then manually move
> over `foo` and `bar`. That seems like such a rare use case that we
> haven't had a reason to make it simpler so far.
> 
> Regarding performance of (a), I would think that walking all
> branches/tags until you reach the to-be-modified commit (or reach an
> older generation) can usually be done quickly enough, but obviously
> there are many others on this list who know that better than me :) But
> I have also heard that some Git repos have tens (hundreds?) of
> thousands of branches. Hopefully those refs are just on some server
> where users won't run `git history`.
> 
>>
>> Do we already have logic like this in git-replay(1)?
>>
>> Patrick
> 
Elijah Newren· Dec 10, 2025, 06:55 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On Mon, Dec 8, 2025 at 11:53 PM Patrick Steinhardt <ps@pks.im> wrote:
Show 40 quoted lines
>
> On Fri, Dec 05, 2025 at 12:49:04AM -0800, Elijah Newren wrote:
> > On Tue, Dec 2, 2025 at 10:50 AM Patrick Steinhardt <ps@pks.im> wrote:
> > > Consequently I'm leaning more into the direction of doing nothing. It's
> > > not really clear to me that this is a bug, and we still can introduce a
> > > flag in the future that opts into the behaviour of rewriting relevant
> > > branches. That behaviour certainly can be useful, but I'd claim that
> > > it would be rather surprising to the user if that was the default.
> >
> > Well, as I stated above, this is basically copying what I view as the
> > fundamental design mistake of git-rebase.  The many other points of
> > feedback I had on this series (e.g. extended headers, reusing replay's
> > walking, etc.) are things I could easily negotiate on; this one
> > bothers me much, much more.  To me, it ruins the command and makes me
> > feel it is unsuitable for inclusion in git; this is, after all, the
> > kind of thing that made me decide to write yet another command to
> > workaround such a flaw.  If the series is merged with this behavior,
> > I'm going to be in the awkward position of feeling I need to actively
> > recommend against its usage unless _and until_ we either
> >
> > (a) check that a commit is only part of one branch before proceeding,
> > (b) always require the user to specify with a flag how to handle
> > commits that happen to be part of multiple branches (even when a
> > commit only happens to be part of one branch, in order to allow us to
> > not bother checking whether it's part of more),
> > or
> > (c) rewrite all branches that contain the given commit by default
> > (with an option to only rewrite the current one).
> >
> > That said, obviously the choice of whether the series is merged isn't
> > up to me.  And maybe I'm in the minority, and others don't care about
> > this issue at all.  But it's how I feel about it.
>
> I guess it's a matter of workflows and tastes, and there's never going
> to be the one correct way of doing things. I don't think (b) is a good
> option as it makes things more complex even for the simplest cases. But
> I wouldn't be opposed to a combination of (a) and (b) if we can
> implement (a) efficiently.
>
> Do we already have logic like this in git-replay(1)?

No, git-replay was written from the beginning with the idea in mind of handling multiple branches (e.g. letting Junio edit a single commit in someone's topic and updating all the subsequent commits and merges without having to individually fuss with them all, or similarly for the Git For Windows or Microsoft Git forks, or at a smaller level if I have multiple topics that share a few commits and I want to update one of those), so the idea was always (c) by default, with options for alternate behavior, which is kind of the opposite angle you are approaching from. Anyway, because of that view, nothing like (a) was ever implemented.

Kristoffer Haugsbakk· Dec 9, 2025, 18:29 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On Tue, Dec 2, 2025, at 19:49, Patrick Steinhardt wrote:
Show 7 quoted lines
>>[snip]
>
> I'm not sure whether that is something we want. I myself have the same
> commit on multiple branches quite regularly, as I tend to queue up
> multiple dependent patch series. But I wouldn't ever want a history edit
> to affect all of these branches myself, I really only want it to modify
> the branch I'm currently on.
Let’s say with the current subcommands I want to

• Split a commit because it contains unrelated formatting fixups • I want to fix typos in a commit message and also add a paragraph

Then let’s say that there are three branches that contain these commits. I can’t think of a case where I intend to only make these changes for one of the branches.

A default of update-all-branches-in-range seems like the best default.
I also agree with everything Elijah wrote here:
https://lore.kernel.org/git/CABPp-BFtx7-vLFbVqbHar=UZb1CGX5=ufMA4hrJRkSYuB14_Tw@mail.gmail.com/

I haven’t used Jujutsu yet. But apparently it is much less branch-focused. But for us who use branches and sometimes need “dependent branches” or “stacked branches” the default equivalent of `--update-refs` seems like a win to me.

Okay, maybe let’s say that I am really making intentionally-divergent histories and one commit needs to be reworded for that divergent context. That could happen. But I can’t imageine where I would do that.

Show 6 quoted lines
>[snip]
> Consequently I'm leaning more into the direction of doing nothing. It's
> not really clear to me that this is a bug, and we still can introduce a
> flag in the future that opts into the behaviour of rewriting relevant
> branches. That behaviour certainly can be useful, but I'd claim that
> it would be rather surprising to the user if that was the default.

Maybe it’s surprising with the current defaults of other commands. But for a new-and-better (for some circumstances or all) commands defaulting to updating all branches sounds great.

I have read or heard about the “mega merge” strategy in Jujutsu.[1] Being able to (this is how I imagine it could work) make a temporary integration branch where N branches can be edited by making edits to them and having all the branches be updated sounds amazing.[2][3] I have found myself doing temporary integration branches where I make fixes on top and manually cherry-picking them to the correct target afterwards.

🔗 1: https://news.ycombinator.com/item?id=44650248
† 2: Maybe the commits are “edited” and then the integration merges are
     re-done which sounds simple in the case of splitting commits and
     rewording commit messages. Well, “simple”, that’s easy for me to
     say from the peanut gallery. :) (I am aware that git-history(1)
     does not support merges in this current iteration)
† 3: I am thinking of the “common case” of `-update-refs` but what
     Elijah pointed out about “other interesting topologies” sounds even
     better. :) In the link from above:
     https://lore.kernel.org/git/CABPp-BFtx7-vLFbVqbHar=UZb1CGX5=ufMA4hrJRkSYuB14_Tw@mail.gmail.com/
D. Ben Knoble· Dec 12, 2025, 22:00 UTC · re: Kristoffer Haugsbakk · lore

Working on top of mega merges

On Tue, Dec 9, 2025 at 1:29 PM Kristoffer Haugsbakk <kristofferhaugsbakk@fastmail.com> wrote:

Show 5 quoted lines
>
> On Tue, Dec 2, 2025, at 19:49, Patrick Steinhardt wrote:
> >>[snip]
>
> I haven’t used Jujutsu yet.
Same, however:
Show 6 quoted lines
> I have read or heard about the “mega merge” strategy in Jujutsu.[1]
> Being able to (this is how I imagine it could work) make a temporary
> integration branch where N branches can be edited by making edits to
> them and having all the branches be updated sounds amazing.[2][3] I have
> found myself doing temporary integration branches where I make fixes on
> top and manually cherry-picking them to the correct target afterwards.

I have done something like this once. Created a few independent branches, wrangled an octopus merge together, and then worked on top of that. When I wanted to commit, I did "commit --fixup" with the appropriate branch/commit, then eventually "rebase --autosquash --rebase-merges" or something. In that particular case, I started with empty commits on each branch, I think.

You could probably shuffle commits (like to add a new one to a sub branch) using "rebase --interactive…" there, too, but I don't think I tried that.

Anyway, the workflow did its job, but I didn't end up with something I think I'd try in practice very often, at least not yet. It did make me wonder what from jj we'd need to make things smoother, though.

-- 
D. Ben Knoble
Elijah Newren· Nov 23, 2025, 02:30 UTC · re: Phillip Wood · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On Fri, Nov 21, 2025 at 6:31 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
>
[...]
Show 13 quoted lines
> > So, you are referring to the single branch, HEAD-centric piece of the
> > feedback.  The funny thing there is that operating on a more limited
> > case, without checking and verifying that you are indeed in the more
> > limited case (and erroring out if not), risks painting us into a
> > corner or providing some really buggy behavior when we aren't actually
> > in that case.  To me, it opens a can of worms and makes the problem
> > scope bigger instead of smaller.  Funnily enough, the single branch
> > thing is also the one piece of this that I think could be solved by a
> > fairly small change in the reroll (and I pointed out how in the
> > comments), so the limited view really didn't buy anything here IMO.
>
> I can't find that comment. Are you referring to reusing more of the
> replay machinery?

Yeah, what's needed is the equivalent of running "git replay --onto ${NEW_COMMIT_ID} --ancestry-path ^${OLD_COMMIT_ID} --branches", as noted in more detail over at https://lore.kernel.org/git/CABPp-BEm1QBP+CuSOn5FaE3XJVFg+Qbfzdp560u00ZERbNm6qQ@mail.gmail.com/ .

Show 5 quoted lines
> If so we have the problem that the user gives a single
> commit to "git history" so we don't have a handy revision range to pass
> to the replay machinery unless we assume we're rewriting an ancestor of
> HEAD or we go and find all the branches descended from the commit the
> user gave us.

The range is included in the command above: "--ancestry-path ^${OLD_COMMIT_ID} --branches"

And because of this, we don't even really need to "find" all the branches as a separate step, it's just part of the same revision walk for rewriting commits.

Whereas if we do want to only handle a single branch as the current implementation does, then we *need* to do an extra revision walk to ensure that the commit is not also part of any other branch and error out if it is, because disconnecting the histories would be very counterintuitive in most cases. If users really do want to disconnect histories of two branches sharing a commit, we should require the user to provide some flag to explicitly specify such to signal that it is okay for us to bypass such a check and just rewrite one branch. Such a check is missing from the current code.

> Long term we should certainly do the latter but depending
> on how much work it is to implement that we may want to go with the
> single branch case at first

I showed the implementation of the latter, and it's actually (much) less code than what's already in this series; see the replay_descendants() function I posted at the same link above.

My replay-edit work used a just slightly modified form of that function, because editing a commit and replaying all commits from all branches that reached the OLD_COMMIT_ID, to now be replayed on top of NEW_COMMIT_ID, is exactly what was needed there too. (If you're curious about the modifications: I had an extra --brief-stats option because I found it nice to provide some user feedback about what was updated, and I pulled the "--branches" portion of the command from a ${GIT_DIR}/REPLAY_EDIT file, because that allowed me to give users the opportunity to disconnect histories via some mechanism that would put a single branch name in that file instead of "--branches".)

Show 15 quoted lines
> > The other problems are independent of whether you try to limit the
> > scope initially in such a manner:
> >
> > Are the testcases and the code requiring something for the feature
> > (ensuring the index and worktree are preserved) doing something that
> > is incompatible with the capabilities given to the user (allowing them
> > to edit the patch while splitting, so that they stage stuff that
> > wasn't part of the original commit)?  Or...is it assumed that the
> > split commits always "sum" to the changes in the original commit,
> > meaning the "other" patch immediately undoes those extra changes?
>
> Yes that's what's implemented. I think that makes sense for the "split"
> command. Often when splitting a commit one needs to make small changes
> to the diff in order for the result to compile but you still want the
> same end state from the sum of the split commits.

Makes sense; thanks for confirming. I just didn't realize this was the case while reviewing the patches until my response above.

Show 13 quoted lines
> > I'm also worried about extended header handling for the edited
> > (reworded or split) commits.  That seems to have been overlooked in
> > this series, despite the fact that in early versions extended headers
> > were explicitly called out for the remainder of the commits being
> > replayed/rebased, so it seems interesting that they weren't considered
> > for the commits explicitly being edited.
>
> What headers does it make sense to copy when splitting a commit? When
> rewording it is more likely that copying the extended headers is what
> the user wants but the example of the "encoding" header you gave does
> not make sense to me as we re-encode the commit message and author data
> when the user edit's the message so we're not preserving the original
> encoding.

I agree that when rewording we probably want to copy most extended headers, but you make a good point about encoding. For splitting, I agree it's less clear, and I'm not sure I know the answer. But I expected the topic to at least be discussed and mentioned in the relevant commit messages. It appears to have been silently overlooked, and I'm worried it's the kind of topic that doesn't come up often, meaning that if we don't discuss now and just pick whatever behavior we get from implementation side-effects, then people will come back in a year or two and point out we got it buggy but it's too late to change it.

Show 8 quoted lines
> > And I'm a bit surprised that the original commit message for a split
> > commit is automatically associated with the second commit; if I had
> > been forced to choose, I would have assumed it should be associated
> > with the first.
>
> I don't think it is safe to assume either - we should prompt the user to
> edit the message when creating both commits and seed the editor with the
> original message.

That sounds like a better solution to me for that particular issue, and probably wouldn't be hard to implement.

Phillip Wood· Nov 24, 2025, 16:31 UTC · re: Elijah Newren · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On 23/11/2025 02:30, Elijah Newren wrote:
Show 21 quoted lines
> On Fri, Nov 21, 2025 at 6:31 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
>>
> [...]
>>> So, you are referring to the single branch, HEAD-centric piece of the
>>> feedback.  The funny thing there is that operating on a more limited
>>> case, without checking and verifying that you are indeed in the more
>>> limited case (and erroring out if not), risks painting us into a
>>> corner or providing some really buggy behavior when we aren't actually
>>> in that case.  To me, it opens a can of worms and makes the problem
>>> scope bigger instead of smaller.  Funnily enough, the single branch
>>> thing is also the one piece of this that I think could be solved by a
>>> fairly small change in the reroll (and I pointed out how in the
>>> comments), so the limited view really didn't buy anything here IMO.
>>
>> I can't find that comment. Are you referring to reusing more of the
>> replay machinery?
> 
> Yeah, what's needed is the equivalent of running "git replay --onto
> ${NEW_COMMIT_ID} --ancestry-path ^${OLD_COMMIT_ID} --branches", as
> noted in more detail over at
> https://lore.kernel.org/git/CABPp-BEm1QBP+CuSOn5FaE3XJVFg+Qbfzdp560u00ZERbNm6qQ@mail.gmail.com/
Thanks, I'd somehow missed that when I read that message the first time
Show 12 quoted lines
>> If so we have the problem that the user gives a single
>> commit to "git history" so we don't have a handy revision range to pass
>> to the replay machinery unless we assume we're rewriting an ancestor of
>> HEAD or we go and find all the branches descended from the commit the
>> user gave us.
> 
> The range is included in the command above: "--ancestry-path
> ^${OLD_COMMIT_ID} --branches"
> 
> And because of this, we don't even really need to "find" all the
> branches as a separate step, it's just part of the same revision walk
> for rewriting commits.

Oh, so --branches means we consider all the branches and --ancestry-path excludes the those that are not descended from the commit we're rewriting - nice. We'd need to be careful about modifying the commit at the tip of a branch though as in that case we'd exclude the branch from the set of commits with ^{OLD_COMMIT_ID} and so "git replay" would not update that branch.

In general rewriting multiple branches can be confusing if those branches are checked out elsewhere and the HEAD of that worktree suddenly changes but for rewording and splitting commits as implemented here the final tree is the same after the rewrite so it should be fine. The other potential problem with rewriting multiple branches is that we need to ensure two separate "git history" processes running at the same time in two different worktrees don't try to update the same branch. "git rebase --update-refs" has some logic to prevent that.

Show 5 quoted lines
> Whereas if we do want to only handle a single branch as the current
> implementation does, then we *need* to do an extra revision walk to
> ensure that the commit is not also part of any other branch and error
> out if it is, because disconnecting the histories would be very
> counterintuitive in most cases. 

Oh - I had not understood what the "extra work" you were talking about before was - that makes it clear.

Show 24 quoted lines
> If users really do want to disconnect
> histories of two branches sharing a commit, we should require the user
> to provide some flag to explicitly specify such to signal that it is
> okay for us to bypass such a check and just rewrite one branch.  Such
> a check is missing from the current code.
> 
>> Long term we should certainly do the latter but depending
>> on how much work it is to implement that we may want to go with the
>> single branch case at first
> 
> I showed the implementation of the latter, and it's actually (much)
> less code than what's already in this series; see the
> replay_descendants() function I posted at the same link above.
> 
> My replay-edit work used a just slightly modified form of that
> function, because editing a commit and replaying all commits from all
> branches that reached the OLD_COMMIT_ID, to now be replayed on top of
> NEW_COMMIT_ID, is exactly what was needed there too.  (If you're
> curious about the modifications: I had an extra --brief-stats option
> because I found it nice to provide some user feedback about what was
> updated, and I pulled the "--branches" portion of the command from a
> ${GIT_DIR}/REPLAY_EDIT file, because that allowed me to give users the
> opportunity to disconnect histories via some mechanism that would put
> a single branch name in that file instead of "--branches".)

Interesting - I watched you're git merge talk about it recently and it looked quite impressive.

Show 24 quoted lines
>>> I'm also worried about extended header handling for the edited
>>> (reworded or split) commits.  That seems to have been overlooked in
>>> this series, despite the fact that in early versions extended headers
>>> were explicitly called out for the remainder of the commits being
>>> replayed/rebased, so it seems interesting that they weren't considered
>>> for the commits explicitly being edited.
>>
>> What headers does it make sense to copy when splitting a commit? When
>> rewording it is more likely that copying the extended headers is what
>> the user wants but the example of the "encoding" header you gave does
>> not make sense to me as we re-encode the commit message and author data
>> when the user edit's the message so we're not preserving the original
>> encoding.
> 
> I agree that when rewording we probably want to copy most extended
> headers, but you make a good point about encoding.  For splitting, I
> agree it's less clear, and I'm not sure I know the answer.  But I
> expected the topic to at least be discussed and mentioned in the
> relevant commit messages.  It appears to have been silently
> overlooked, and I'm worried it's the kind of topic that doesn't come
> up often, meaning that if we don't discuss now and just pick whatever
> behavior we get from implementation side-effects, then people will
> come back in a year or two and point out we got it buggy but it's too
> late to change it.

It would certainly be worth adding a comment about commit headers in the commit message.

Thanks
Phillip
Show 11 quoted lines
>>> And I'm a bit surprised that the original commit message for a split
>>> commit is automatically associated with the second commit; if I had
>>> been forced to choose, I would have assumed it should be associated
>>> with the first.
>>
>> I don't think it is safe to assume either - we should prompt the user to
>> edit the message when creating both commits and seed the editor with the
>> original message.
> 
> That sounds like a better solution to me for that particular issue,
> and probably wouldn't be hard to implement.
Elijah Newren· Nov 25, 2025, 03:39 UTC · re: Phillip Wood · lore

Re: [PATCH v6 00/11] Introduce git-history(1) command for easy history editing

On Mon, Nov 24, 2025 at 8:31 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
>
> On 23/11/2025 02:30, Elijah Newren wrote:
> > On Fri, Nov 21, 2025 at 6:31 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
> >>
[...]
Show 13 quoted lines
> > The range is included in the command above: "--ancestry-path
> > ^${OLD_COMMIT_ID} --branches"
> >
> > And because of this, we don't even really need to "find" all the
> > branches as a separate step, it's just part of the same revision walk
> > for rewriting commits.
>
> Oh, so --branches means we consider all the branches and --ancestry-path
> excludes the those that are not descended from the commit we're
> rewriting - nice. We'd need to be careful about modifying the commit at
> the tip of a branch though as in that case we'd exclude the branch from
> the set of commits with ^{OLD_COMMIT_ID} and so "git replay" would not
> update that branch.
Ah, indeed, that's a good callout.
[...]
> > My replay-edit work used a just slightly modified form of that
> > function,
[...]
>
> Interesting - I watched you're git merge talk about it recently and it
> looked quite impressive.
Thanks.  :-)
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 00/12] Introduce git-history(1) command for easy history editing

Hi,

over recent months I've been playing around with Jujutsu quite frequently. While I still prefer using Git, there's been a couple features in it that I really like and that I'd like to have in Git, as well.

A copule of these features relate to history editing. Most importantly, I really dig the following commands:

  - jj-abandon(1) to drop a specific commit from your history.
  - jj-absorb(1) to take some changes and automatically apply them to
    commits in your history that last modified the respective hunks.
  - jj-split(1) to split a commit into two.
  - jj-new(1) to insert a new commit after or before a specific other
    commit.

Not all of these commands can be ported directly into Git. jj-new(1) for example doesn't really make a ton of sense for us, I'd claim. But some of these commands _do_ make sense.

This patch series is a starting point for such a command. I've significantly slimmed it down from the first couple revisions now following the discussions at the Contributor's Summit yesterday. This was my intent anyway, as I already mentioned on the last iteration.

Changes in v7:
  - I've rebased the patch series on top of f0ef5b6d9b (The fifth batch,
    2025-11-30) with pw/replay-exclude-gpgsig-fix at 9f3a115087 (replay:
    do not copy "gpgsign-sha256" header, 2025-11-26) merged into it to
    fix some conflicts.
  - I refactored a bunch of code to be shared between split and reword.
  - I am now using "--ancestry-path" and "--reverse" to enumerate
    commits.
  - `git history split` now asks for commit messages for both commits.
  - `git history split` no longer allows the user to edit hunks so that
    conflicts become impossible. This may be reintroduced at a later
    point in time.
  - A bunch of test improvements.
  - Several commit message and documentation improvements.
  - Link to v6: https://lore.kernel.org/r/20251027-b4-pks-history-builtin-v6-0-407dd3f57ad3@pks.im
Changes in v6:
  - I've rebased the patch series again to pull in the latest updates
    from sa/replay-atomic-ref-updates and fix conflicts. It is now based
    on 4e98b730f1 (The twenty-fourth batch, 2025-10-24) with ab661bb1bb
    (replay: add replay.refAction config option, 2025-10-23) merged into
    it.
  - I've dropped the "-m" options for now, so commit messages are always
    asked for via the editor. These can be introduced in a subsequent
    patch series once discussion around them has settled.
  - We don't use the merge machinery anymore to pick the commits.
  - Drop the commit to parse commits in the replay machinery. It didn't
    seem to be necessary in v5 anymore, and now that we don't use the
    merge machinery at all we don't ever take that code path in the
    first place.
  - Link to v5:
    https://lore.kernel.org/r/20251021-b4-pks-history-builtin-v5-0-78d23f578fe6@pks.im
Changes in v5:
  - I've changed the patch series to be based on top of 133d151831 (The
    twenty-first batch, 2025-10-20) with sa/replay-atomic-ref-updates at
    a1c22e627e (SQAUASH??? t0450 band-aid, 2025-10-14) merged into it.
    This is one the one hand to fix a conflict, but also to get some of
    the CI updates to make GitLab CI work again.
  - Some slight commit message improvements.
  - Deduplicate subcommand usage strings by using defines.
  - Fix the desendancy checks to properly verify that HEAD is a
    descendant of the commit to be rewritten. Also add some tests for
    this.
  - Fix the hint that mentions that lines starting with the comment
    character will be tripped after having written the commit message.
  - Move an include to the correct commit.
  - Link to v4: https://lore.kernel.org/r/20251001-b4-pks-history-builtin-v4-0-8e61ddb86317@pks.im
Changes in v4:
  - I've rebuilt the patch series. It is now based on 821f583da6 (The
    thirteenth batcn, 2025-09-29) with sa/replay-atomic-ref-updates
    at 665c66a743 (replay: make atomic ref updates the default behavior,
    2025-09-27) merged into it. This should fix all conflicts with seen.
  - I've reworked this patch series to use the same infra as
    git-replay(1), as discussed during the Contributor's Summit.
  - I've slimmed down the patch series to only tackle those commands
    that cannot result in a conflict to keep it simple. I also learned
    that Elijah has been working on a "git replay edit" command, so I
    dropped that command so that we can instead use his version.
  - During the Contributor's Summit we have agreed that for now, we
    won't care about hook execution just yet. This may be backfilled at
    a later point in time.
  - I dropped "commit.verbose" handling for now, as my understanding of
    it was wrong at first. This is something we should backfill.
  - Link to v3: https://lore.kernel.org/r/20250904-b4-pks-history-builtin-v3-0-509053514755@pks.im
Changes in v3:
  - Add logic to drive the "post-rewrite" hook and add tests to verify
    that all hooks are executed as expected.
  - Deduplicate logic to turn a replay action into a todo command.
  - Move the addition of tests for the top-level git-history(1) command
    to the correct commit.
  - Some smaller commit message fixes.
  - Honor "commit.verbose".
  - Fix copy-paste error with an error message.
  - Link to v2: https://lore.kernel.org/r/20250824-b4-pks-history-builtin-v2-0-964ac12f65bd@pks.im
Changes in v2:
  - Add a new "reword" subcommand.
  - List git-history(1) in "command-list.txt".
  - Add some missing error handling.
  - Simplify calling convention of `apply_commits()` to handle root
    commits internally instead of requiring every caller to do so.
  - Add tests to verify that git-history(1) refuses to work with changes
    in the worktree or index.
  - Mark git-history(1) as experimental.
  - Introduce commands to manage interrupted history edits.
  - A bunch of improvements to the manpage.
  - Link to v1: https://lore.kernel.org/r/20250819-b4-pks-history-builtin-v1-0-9b77c32688fe@pks.im
Thanks!
Patrick
---
Patrick Steinhardt (12):
      wt-status: provide function to expose status for trees
      replay: extract logic to pick commits
      replay: stop using `the_repository`
      builtin: add new "history" command
      builtin/history: implement "reword" subcommand
      add-patch: split out header from "add-interactive.h"
      add-patch: split out `struct interactive_options`
      add-patch: remove dependency on "add-interactive" subsystem
      add-patch: add support for in-memory index patching
      add-patch: allow disabling editing of hunks
      cache-tree: allow writing in-memory index as tree
      builtin/history: implement "split" subcommand
 .gitignore                     |   1 +
 Documentation/git-history.adoc | 109 ++++++++
 Documentation/meson.build      |   1 +
 Makefile                       |   2 +
 add-interactive.c              | 174 +++----------
 add-interactive.h              |  46 +---
 add-patch.c                    | 335 +++++++++++++++++++++----
 add-patch.h                    |  71 ++++++
 builtin.h                      |   1 +
 builtin/add.c                  |  22 +-
 builtin/checkout.c             |   9 +-
 builtin/commit.c               |  16 +-
 builtin/history.c              | 558 +++++++++++++++++++++++++++++++++++++++++
 builtin/replay.c               | 110 +-------
 builtin/reset.c                |  16 +-
 builtin/stash.c                |  46 ++--
 cache-tree.c                   |   4 +-
 cache-tree.h                   |   3 +-
 command-list.txt               |   1 +
 commit.h                       |   2 +-
 git.c                          |   1 +
 meson.build                    |   2 +
 replay.c                       | 115 +++++++++
 replay.h                       |  23 ++
 t/meson.build                  |   3 +
 t/t3450-history.sh             |  17 ++
 t/t3451-history-reword.sh      | 236 +++++++++++++++++
 t/t3452-history-split.sh       | 452 +++++++++++++++++++++++++++++++++
 wt-status.c                    |  24 ++
 wt-status.h                    |   9 +
 30 files changed, 2014 insertions(+), 395 deletions(-)
Range-diff versus v6:
 1:  d59b2ff389 =  1:  ccf19f9067 wt-status: provide function to expose status for trees
 2:  7b80b4d482 !  2:  39c5a05ee1 replay: extract logic to pick commits
    @@ Commit message
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
     
      ## Makefile ##
    -@@ Makefile: LIB_OBJS += reftable/tree.o
    - LIB_OBJS += reftable/writer.o
    - LIB_OBJS += remote.o
    +@@ Makefile: LIB_OBJS += repack-geometry.o
    + LIB_OBJS += repack-midx.o
    + LIB_OBJS += repack-promisor.o
      LIB_OBJS += replace-object.o
     +LIB_OBJS += replay.o
      LIB_OBJS += repo-settings.o
    @@ builtin/replay.c
      #include "strmap.h"
      #include <oidset.h>
     @@ builtin/replay.c: enum ref_action_mode {
    - 	REF_ACTION_PRINT
    + 	REF_ACTION_PRINT,
      };
      
     -static const char *short_commit_name(struct repository *repo,
    @@ builtin/replay.c: static struct commit *peel_committish(struct repository *repo,
     -	const char *message = repo_logmsg_reencode(repo, based_on,
     -						   NULL, out_enc);
     -	const char *orig_message = NULL;
    --	const char *exclude_gpgsig[] = { "gpgsig", NULL };
    +-	const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
     -
     -	commit_list_insert(parent, &parents);
     -	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
    @@ builtin/replay.c: static void determine_replay_mode(struct repository *repo,
     -	return create_commit(repo, result->tree, pickme, replayed_base);
     -}
     -
    - static int handle_ref_update(enum ref_action_mode mode,
    - 			     struct ref_transaction *transaction,
    - 			     const char *refname,
    + static enum ref_action_mode parse_ref_action_mode(const char *ref_action, const char *source)
    + {
    + 	if (!ref_action || !strcmp(ref_action, "update"))
     @@ builtin/replay.c: int cmd_replay(int argc,
      		if (commit->parents->next)
      			die(_("replaying merge commits is not supported yet!"));
    @@ builtin/replay.c: int cmd_replay(int argc,
     
      ## meson.build ##
     @@ meson.build: libgit_sources = [
    -   'reftable/writer.c',
    -   'remote.c',
    +   'repack-midx.c',
    +   'repack-promisor.c',
        'replace-object.c',
     +  'replay.c',
        'repo-settings.c',
    @@ replay.c (new)
     +	const char *message = repo_logmsg_reencode(repo, based_on,
     +						   NULL, out_enc);
     +	const char *orig_message = NULL;
    -+	const char *exclude_gpgsig[] = { "gpgsig", NULL };
    ++	const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
     +
     +	commit_list_insert(parent, &parents);
     +	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
 3:  3f66e8423d =  3:  fe73ba4059 replay: stop using `the_repository`
 4:  f64dba0b08 !  4:  c162f407db builtin: add new "history" command
    @@ Metadata
      ## Commit message ##
         builtin: add new "history" command
     
    -    When rewriting history via git-rebase(1) there are a couple of very
    -    common use cases:
    +    When rewriting history via git-rebase(1) there are a few very common use
    +    cases:
     
           - The ordering of two commits should be reversed.
     
    @@ Commit message
     
           - Multiple commits should be squashed into one.
     
    +      - Editing an existing commit that is not the tip of the current
    +        branch.
    +
         While these operations are all doable, it often feels needlessly kludgey
         to do so by doing an interactive rebase, using the editor to say what
         one wants, and then perform the actions. Furthermore, some operations
    @@ Commit message
     
         Add a new "history" command to plug this gap. This command will have
         several different subcommands to imperatively rewrite history for common
    -    use cases like the above. These subcommands will be implemented in
    -    subsequent commits.
    +    use cases like the above. Some of these subcommands will be implemented
    +    in subsequent commits.
     
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
     
    @@ Documentation/git-history.adoc (new)
     +
     +THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE.
     +
    -+This command is similar to linkgit:git-rebase[1] and uses the same
    -+underlying machinery. You should use rebases if you want to reapply a range of
    -+commits onto a different base, or interactive rebases if you want to edit a
    ++This command is related to linkgit:git-rebase[1] in that both commands can be
    ++used to rewrite history. You should use rebases if you want to reapply a range
    ++of commits onto a different base, or interactive rebases if you want to edit a
     +range of commits.
     +
     +Note that this command does not (yet) work with histories that contain
    @@ Documentation/git-history.adoc (new)
     +
     +include::includes/cmd-config-section-all.adoc[]
     +
    -+include::config/sequencer.adoc[]
    -+
     +GIT
     +---
     +Part of the linkgit:git[1] suite
 5:  3c1c599e9f !  5:  3ecbe6ec83 builtin/history: implement "reword" subcommand
    @@ Commit message
         builtin/history: implement "reword" subcommand
     
         Implement a new "reword" subcommand for git-history(1). This subcommand
    -    is essentially the same as if a user performed an interactive rebase
    -    with a single commit changed to use the "reword" verb.
    +    is similar to the user performing an interactive rebase with a single
    +    commit changed to use the "reword" instruction.
    +
    +    The major difference is that we do not check out the commit that is to
    +    be reworded. This has the obvious benefit of being significantly faster
    +    compared to git-rebase(1), but even more importantly it allows the user
    +    to rewrite history even if there are local changes in the working tree
    +    or in the index.
     
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
     
    @@ builtin/history.c
     +	int ret;
     +
     +	repo_init_revisions(repo, &rev, NULL);
    ++	rev.reverse = 1;
     +	strvec_push(&revisions, "");
     +	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
    -+	if (old_commit)
    ++	if (old_commit) {
     +		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
    ++		strvec_pushf(&revisions, "--ancestry-path=%s", oid_to_hex(&old_commit->object.oid));
    ++	}
     +
     +	setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
     +	if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
    @@ builtin/history.c
     +		}
     +
     +		strvec_push(out, oid_to_hex(&child->object.oid));
    -+
    -+		if (child->parents && old_commit &&
    -+		    commit_list_contains(old_commit, child->parents))
    -+			break;
     +	}
     +
    -+	/*
    -+	 * Revisions are in newest-order-first. We have to reverse the
    -+	 * array though so that we pick the oldest commits first.
    -+	 */
    -+	for (size_t i = 0, j = out->nr - 1; i < j; i++, j--)
    -+		SWAP(out->v[i], out->v[j]);
    -+
     +	ret = 0;
     +
     +out:
    @@ builtin/history.c
     +	return ret;
     +}
     +
    ++static int gather_commits_between_head_and_revision(struct repository *repo,
    ++						    const char *revision,
    ++						    struct commit **original_commit,
    ++						    struct commit **parent_commit,
    ++						    struct commit **head,
    ++						    struct strvec *commits)
    ++{
    ++	struct commit_list *from_list = NULL;
    ++	int ret;
    ++
    ++	*original_commit = lookup_commit_reference_by_name(revision);
    ++	if (!*original_commit) {
    ++		ret = error(_("commit cannot be found: %s"), revision);
    ++		goto out;
    ++	}
    ++
    ++	*parent_commit = (*original_commit)->parents ? (*original_commit)->parents->item : NULL;
    ++	if (*parent_commit && repo_parse_commit(repo, *parent_commit)) {
    ++		ret = error(_("unable to parse commit %s"),
    ++			    oid_to_hex(&(*parent_commit)->object.oid));
    ++		goto out;
    ++	}
    ++
    ++	*head = lookup_commit_reference_by_name("HEAD");
    ++	if (!(*head)) {
    ++		ret = error(_("could not resolve HEAD to a commit"));
    ++		goto out;
    ++	}
    ++
    ++	commit_list_append(*original_commit, &from_list);
    ++	if (!repo_is_descendant_of(repo, *head, from_list)) {
    ++		ret = error(_("commit must be reachable from current HEAD commit"));
    ++		goto out;
    ++	}
    ++
    ++	/*
    ++	 * Collect the list of commits that we'll have to reapply now already.
    ++	 * This ensures that we'll abort early on in case the range of commits
    ++	 * contains merges, which we do not yet handle.
    ++	 */
    ++	ret = collect_commits(repo, *parent_commit, *head, commits);
    ++	if (ret < 0)
    ++		goto out;
    ++
    ++out:
    ++	free_commit_list(from_list);
    ++	return ret;
    ++}
    ++
     +static void replace_commits(struct strvec *commits,
     +			    const struct object_id *commit_to_replace,
     +			    const struct object_id *replacements,
    @@ builtin/history.c
     +		} else {
     +			struct tree *tree = repo_get_commit_tree(repo, commit);
     +			onto = replay_create_commit(repo, tree, commit, onto);
    -+			if (!onto)
    -+				break;
    ++			if (!onto) {
    ++				ret = -1;
    ++				goto out;
    ++			}
     +		}
     +	}
     +
    @@ builtin/history.c
     +	const char *path = git_path_commit_editmsg();
     +	const char *hint =
     +		_("Please enter the commit message for the %s changes."
    -+		  " Lines starting\nwith '%s' will be ignored.\n");
    ++		  " Lines starting\nwith '%s' will be ignored, and an"
    ++		  " empty message aborts the commit.\n");
     +	struct wt_status s;
     +
     +	strbuf_addstr(out, default_message);
    @@ builtin/history.c
     +
     +	strbuf_reset(out);
     +	if (launch_editor(path, out, NULL)) {
    -+		fprintf(stderr, _("Please supply the message using the -m option.\n"));
    ++		fprintf(stderr, _("Aborting commit as launching the editor failed.\n"));
     +		return -1;
     +	}
     +	strbuf_stripspace(out, comment_line_str);
    @@ builtin/history.c
     +	return 0;
     +}
     +
    ++static int commit_tree_with_edited_message(struct repository *repo,
    ++					   const char *action,
    ++					   struct commit *original_commit,
    ++					   const struct object_id *new_tree_oid,
    ++					   const struct commit_list *parents,
    ++					   const struct object_id *parent_tree_oid,
    ++					   struct object_id *out)
    ++{
    ++	const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
    ++	const char *original_message, *original_body, *ptr;
    ++	struct commit_extra_header *original_extra_headers = NULL;
    ++	struct strbuf commit_message = STRBUF_INIT;
    ++	char *original_author = NULL;
    ++	size_t len;
    ++	int ret;
    ++
    ++	/* We retain authorship of the original commit. */
    ++	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
    ++	ptr = find_commit_header(original_message, "author", &len);
    ++	if (ptr)
    ++		original_author = xmemdupz(ptr, len);
    ++	find_commit_subject(original_message, &original_body);
    ++
    ++	ret = fill_commit_message(repo, parent_tree_oid, new_tree_oid,
    ++				  original_body, action, &commit_message);
    ++	if (ret < 0)
    ++		goto out;
    ++
    ++	original_extra_headers = read_commit_extra_headers(original_commit, exclude_gpgsig);
    ++
    ++	ret = commit_tree_extended(commit_message.buf, commit_message.len, new_tree_oid,
    ++				   parents, out, original_author, NULL, NULL,
    ++				   original_extra_headers);
    ++	if (ret < 0)
    ++		goto out;
    ++
    ++out:
    ++	free_commit_extra_headers(original_extra_headers);
    ++	strbuf_release(&commit_message);
    ++	free(original_author);
    ++	return ret;
    ++}
    ++
     +static int cmd_history_reword(int argc,
     +			      const char **argv,
     +			      const char *prefix,
    @@ builtin/history.c
     +	struct option options[] = {
     +		OPT_END(),
     +	};
    -+	struct strbuf final_message = STRBUF_INIT;
     +	struct commit *original_commit, *parent, *head;
     +	struct strvec commits = STRVEC_INIT;
     +	struct object_id parent_tree_oid, original_commit_tree_oid;
     +	struct object_id rewritten_commit;
    -+	struct commit_list *from_list = NULL;
    -+	const char *original_message, *original_body, *ptr;
    -+	char *original_author = NULL;
    -+	size_t len;
     +	int ret;
     +
     +	argc = parse_options(argc, argv, prefix, options, usage, 0);
    @@ builtin/history.c
     +	}
     +	repo_config(repo, git_default_config, NULL);
     +
    -+	original_commit = lookup_commit_reference_by_name(argv[0]);
    -+	if (!original_commit) {
    -+		ret = error(_("commit to be reworded cannot be found: %s"), argv[0]);
    ++	ret = gather_commits_between_head_and_revision(repo, argv[0], &original_commit,
    ++						       &parent, &head, &commits);
    ++	if (ret < 0)
     +		goto out;
    -+	}
    -+	original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
     +
    -+	parent = original_commit->parents ? original_commit->parents->item : NULL;
    -+	if (parent) {
    -+		if (repo_parse_commit(repo, parent)) {
    -+			ret = error(_("unable to parse commit %s"),
    -+				    oid_to_hex(&parent->object.oid));
    -+			goto out;
    -+		}
    ++	original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
    ++	if (parent)
     +		parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
    -+	} else {
    ++	else
     +		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
    -+	}
    -+
    -+	head = lookup_commit_reference_by_name("HEAD");
    -+	if (!head) {
    -+		ret = error(_("could not resolve HEAD to a commit"));
    -+		goto out;
    -+	}
    -+
    -+	commit_list_append(original_commit, &from_list);
    -+	if (!repo_is_descendant_of(repo, head, from_list)) {
    -+		ret = error (_("split commit must be reachable from current HEAD commit"));
    -+		goto out;
    -+	}
    -+
    -+	/*
    -+	 * Collect the list of commits that we'll have to reapply now already.
    -+	 * This ensures that we'll abort early on in case the range of commits
    -+	 * contains merges, which we do not yet handle.
    -+	 */
    -+	ret = collect_commits(repo, parent, head, &commits);
    -+	if (ret < 0)
    -+		goto out;
     +
     +	/* We retain authorship of the original commit. */
    -+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
    -+	ptr = find_commit_header(original_message, "author", &len);
    -+	if (ptr)
    -+		original_author = xmemdupz(ptr, len);
    -+	find_commit_subject(original_message, &original_body);
    -+
    -+	ret = fill_commit_message(repo, &parent_tree_oid, &original_commit_tree_oid,
    -+				  original_body, "reworded", &final_message);
    -+	if (ret < 0)
    -+		goto out;
    -+
    -+	ret = commit_tree(final_message.buf, final_message.len, &original_commit_tree_oid,
    -+			  original_commit->parents, &rewritten_commit, original_author, NULL);
    ++	ret = commit_tree_with_edited_message(repo, "reworded", original_commit,
    ++					      &original_commit_tree_oid,
    ++					      original_commit->parents, &parent_tree_oid,
    ++					      &rewritten_commit);
     +	if (ret < 0) {
     +		ret = error(_("failed writing reworded commit"));
     +		goto out;
    @@ builtin/history.c
     +	ret = 0;
     +
     +out:
    -+	strbuf_release(&final_message);
    -+	free_commit_list(from_list);
     +	strvec_clear(&commits);
    -+	free(original_author);
     +	return ret;
     +}
      
    @@ t/t3451-history-reword.sh (new)
     +
     +reword_with_message () {
     +	cat >message &&
    -+	write_script fake-editor.sh <<-EOF &&
    -+	cp "$(pwd)/message" "\$1"
    ++	write_script fake-editor.sh <<-\EOF &&
    ++	cp message "$1"
     +	EOF
     +	test_set_editor "$(pwd)"/fake-editor.sh &&
     +	git history reword "$@" &&
    @@ t/t3451-history-reword.sh (new)
     +		git switch branch &&
     +		test_commit theirs &&
     +		test_must_fail git history reword ours 2>err &&
    -+		test_grep "split commit must be reachable from current HEAD commit" err
    ++		test_grep "commit must be reachable from current HEAD commit" err
     +	)
     +'
     +
    @@ t/t3451-history-reword.sh (new)
     +		first
     +		EOF
     +		git log --format=%s >actual &&
    -+		test_cmp expect actual
    ++		test_cmp expect actual &&
    ++
    ++		git reflog >reflog &&
    ++		test_grep "reword: updating HEAD" reflog
     +	)
     +'
     +
    @@ t/t3451-history-reword.sh (new)
     +	)
     +'
     +
    -+test_expect_success 'can use editor to rewrite commit message' '
    ++test_expect_success 'editor shows proper status' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
     +	(
    @@ t/t3451-history-reword.sh (new)
     +		first
     +
     +		# Please enter the commit message for the reworded changes. Lines starting
    -+		# with ${SQ}#${SQ} will be ignored.
    ++		# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
     +		# Changes to be committed:
     +		#	new file:   first.t
     +		#
     +		EOF
     +		test_cmp expect COMMIT_EDITMSG &&
     +
    -+		cat >expect <<-EOF &&
    ++		test_commit_message HEAD <<-\EOF
     +		first
     +
     +		amend a comment
    -+
     +		EOF
    -+		git log --format=%B >actual &&
    -+		test_cmp expect actual
     +	)
     +'
     +
    @@ t/t3451-history-reword.sh (new)
     +		test_commit second &&
     +		test_commit third &&
     +
    -+		write_script .git/hooks/prepare-commit-msg <<-EOF &&
    -+		touch "$(pwd)/hooks.log
    -+		EOF
    -+		write_script .git/hooks/post-commit <<-EOF &&
    -+		touch "$(pwd)/hooks.log
    -+		EOF
    -+		write_script .git/hooks/post-rewrite <<-EOF &&
    -+		touch "$(pwd)/hooks.log
    -+		EOF
    ++		ORIG_PATH="$(pwd)" &&
    ++		export ORIG_PATH &&
    ++		for hook in prepare-commit-msg pre-commit post-commit post-rewrite commit-msg
    ++		do
    ++			write_script .git/hooks/$hook <<-\EOF || exit 1
    ++			touch "$ORIG_PATH/hooks.log
    ++			EOF
    ++		done &&
     +
     +		reword_with_message HEAD~ <<-EOF &&
     +		second reworded
 6:  7d736c2f8d =  6:  a1c6ad546d add-patch: split out header from "add-interactive.h"
 7:  da50162d06 !  7:  eea2ac7a18 add-patch: split out `struct interactive_options`
    @@ add-patch.c: static int patch_update_file(struct add_p_state *s,
     +		if (*s->s.cfg.reset_color_interactive)
     +			fputs(s->s.cfg.reset_color_interactive, stdout);
      		fflush(stdout);
    - 		if (read_single_character(s) == EOF)
    - 			break;
    + 		if (read_single_character(s) == EOF) {
    + 			quit = 1;
     @@ add-patch.c: static int patch_update_file(struct add_p_state *s,
      				err(s, _("Sorry, cannot split this hunk"));
      			} else if (!split_hunk(s, file_diff,
 8:  a888ae8a29 !  8:  95be396a5a add-patch: remove dependency on "add-interactive" subsystem
    @@ add-patch.c: static int patch_update_file(struct add_p_state *s,
     +		if (*s->cfg.reset_color_interactive)
     +			fputs(s->cfg.reset_color_interactive, stdout);
      		fflush(stdout);
    - 		if (read_single_character(s) == EOF)
    - 			break;
    + 		if (read_single_character(s) == EOF) {
    + 			quit = 1;
     @@ add-patch.c: static int patch_update_file(struct add_p_state *s,
      				err(s, _("Sorry, cannot split this hunk"));
      			} else if (!split_hunk(s, file_diff,
 9:  e84e2e265d !  9:  d114c622a8 add-patch: add support for in-memory index patching
    @@ add-patch.c: static int patch_update_file(struct add_p_state *s,
      	}
      
      	putchar('\n');
    -@@ add-patch.c: int run_add_p(struct repository *r, enum add_p_mode mode,
    + 	return quit;
    + }
    + 
    ++static int run_add_p_common(struct add_p_state *state,
    ++			    const struct pathspec *ps)
    ++{
    ++	size_t binary_count = 0;
    ++
    ++	if (parse_diff(state, ps) < 0)
    ++		return -1;
    ++
    ++	for (size_t i = 0; i < state->file_diff_nr; i++) {
    ++		if (state->file_diff[i].binary && !state->file_diff[i].hunk_nr)
    ++			binary_count++;
    ++		else if (patch_update_file(state, state->file_diff + i))
    ++			break;
    ++	}
    ++
    ++	if (state->file_diff_nr == 0)
    ++		err(state, _("No changes."));
    ++	else if (binary_count == state->file_diff_nr)
    ++		err(state, _("Only binary files changed."));
    ++
    ++	return 0;
    ++}
    ++
    + int run_add_p(struct repository *r, enum add_p_mode mode,
    + 	      struct interactive_options *opts, const char *revision,
    + 	      const struct pathspec *ps)
      {
      	struct add_p_state s = {
      		.r = r,
    @@ add-patch.c: int run_add_p(struct repository *r, enum add_p_mode mode,
      		.answer = STRBUF_INIT,
      		.buf = STRBUF_INIT,
      		.plain = STRBUF_INIT,
    + 		.colored = STRBUF_INIT,
    + 	};
    +-	size_t i, binary_count = 0;
    ++	int ret;
    + 
    + 	interactive_config_init(&s.cfg, r, opts);
    + 
     @@ add-patch.c: int run_add_p(struct repository *r, enum add_p_mode mode,
    + 	if (repo_read_index(r) < 0 ||
    + 	    (!s.mode->index_only &&
    + 	     repo_refresh_and_write_index(r, REFRESH_QUIET, 0, 1,
    +-					  NULL, NULL, NULL) < 0) ||
    +-	    parse_diff(&s, ps) < 0) {
    +-		add_p_state_clear(&s);
    +-		return -1;
    ++					  NULL, NULL, NULL) < 0)) {
    ++		ret = -1;
    ++		goto out;
    + 	}
    + 
    +-	for (i = 0; i < s.file_diff_nr; i++)
    +-		if (s.file_diff[i].binary && !s.file_diff[i].hunk_nr)
    +-			binary_count++;
    +-		else if (patch_update_file(&s, s.file_diff + i))
    +-			break;
    ++	ret = run_add_p_common(&s, ps);
    ++	if (ret < 0)
    ++		goto out;
    + 
    +-	if (s.file_diff_nr == 0)
    +-		err(&s, _("No changes."));
    +-	else if (binary_count == s.file_diff_nr)
    +-		err(&s, _("Only binary files changed."));
    ++	ret = 0;
    + 
    ++out:
      	add_p_state_clear(&s);
    - 	return 0;
    - }
    +-	return 0;
    ++	return ret;
    ++}
     +
     +int run_add_p_index(struct repository *r,
     +		    struct index_state *index,
    @@ add-patch.c: int run_add_p(struct repository *r, enum add_p_mode mode,
     +	};
     +	struct strbuf parent_revision = STRBUF_INIT;
     +	char parent_tree_oid[GIT_MAX_HEXSZ + 1];
    -+	size_t binary_count = 0;
     +	struct commit *commit;
     +	int ret;
     +
    @@ add-patch.c: int run_add_p(struct repository *r, enum add_p_mode mode,
     +
     +	interactive_config_init(&s.cfg, r, opts);
     +
    -+	if (parse_diff(&s, ps) < 0) {
    -+		ret = -1;
    ++	ret = run_add_p_common(&s, ps);
    ++	if (ret < 0)
     +		goto out;
    -+	}
    -+
    -+	for (size_t i = 0; i < s.file_diff_nr; i++) {
    -+		if (s.file_diff[i].binary && !s.file_diff[i].hunk_nr)
    -+			binary_count++;
    -+		else if (patch_update_file(&s, s.file_diff + i))
    -+			break;
    -+	}
    -+
    -+	if (s.file_diff_nr == 0) {
    -+		err(&s, _("No changes."));
    -+		ret = -1;
    -+		goto out;
    -+	}
    -+
    -+	if (binary_count == s.file_diff_nr) {
    -+		err(&s, _("Only binary files changed."));
    -+		ret = -1;
    -+		goto out;
    -+	}
     +
     +	ret = 0;
     +
    @@ add-patch.c: int run_add_p(struct repository *r, enum add_p_mode mode,
     +	strbuf_release(&parent_revision);
     +	add_p_state_clear(&s);
     +	return ret;
    -+}
    + }
     
      ## add-patch.h ##
     @@
 -:  ---------- > 10:  6f8c443002 add-patch: allow disabling editing of hunks
10:  2a43a306de ! 11:  f5f4ab21bb cache-tree: allow writing in-memory index as tree
    @@ cache-tree.c: static int write_index_as_tree_internal(struct object_id *oid,
      	was_valid = index_state->cache_tree &&
      		    cache_tree_fully_valid(index_state->cache_tree);
      
    -@@ cache-tree.c: struct tree* write_in_core_index_as_tree(struct repository *repo) {
    - 	return lookup_tree(repo, &index_state->cache_tree->oid);
    - }
    - 
    --
    - int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix)
    - {
    - 	int entries, was_valid;
     
      ## cache-tree.h ##
     @@ cache-tree.h: int cache_tree_verify(struct repository *, struct index_state *);
11:  bbb837e8d6 ! 12:  0d294bf943 builtin/history: implement "split" subcommand
    @@ Documentation/git-history.adoc: Several commands are available to rewrite histor
     +	stays intact, except that its parent will be the newly split-out
     +	commit.
     ++
    -+The commit message of the new commit will be asked for by launching the
    -+configured editor. Authorship of the commit will be the same as for the
    ++The commit messages of the split-up commits will be asked for by launching
    ++the configured editor. Authorship of the commit will be the same as for the
     +original commit.
     ++
     +If passed, _<pathspec>_ can be used to limit which changes shall be split out
    @@ Documentation/git-history.adoc: Several commands are available to rewrite histor
      CONFIGURATION
      -------------
      
    -@@ Documentation/git-history.adoc: include::includes/cmd-config-section-all.adoc[]
    - 
    - include::config/sequencer.adoc[]
    + include::includes/cmd-config-section-all.adoc[]
      
     +EXAMPLES
     +--------
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +			struct object_id *out)
     +{
     +	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
    -+	struct strbuf index_file = STRBUF_INIT, split_message = STRBUF_INIT;
    ++	struct strbuf index_file = STRBUF_INIT;
     +	struct child_process read_tree_cmd = CHILD_PROCESS_INIT;
     +	struct index_state index = INDEX_STATE_INIT(repo);
     +	struct object_id original_commit_tree_oid, parent_tree_oid;
    -+	const char *original_message, *original_body, *ptr;
     +	char original_commit_oid[GIT_MAX_HEXSZ + 1];
    -+	char *original_author = NULL;
     +	struct commit_list *parents = NULL;
     +	struct commit *first_commit;
     +	struct tree *split_tree;
    -+	size_t len;
     +	int ret;
     +
     +	if (original_commit->parents)
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +
     +	oid_to_hex_r(original_commit_oid, &original_commit->object.oid);
     +	ret = run_add_p_index(repo, &index, index_file.buf, &interactive_opts,
    -+			      original_commit_oid, pathspec);
    ++			      original_commit_oid, pathspec, ADD_P_DISALLOW_EDIT);
     +	if (ret < 0)
     +		goto out;
     +
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +		goto out;
     +	}
     +
    -+	/* We retain authorship of the original commit. */
    -+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
    -+	ptr = find_commit_header(original_message, "author", &len);
    -+	if (ptr)
    -+		original_author = xmemdupz(ptr, len);
    -+
    -+	ret = fill_commit_message(repo, &parent_tree_oid, &split_tree->object.oid,
    -+				  "", "split-out", &split_message);
    -+	if (ret < 0)
    -+		goto out;
    -+
    -+	ret = commit_tree(split_message.buf, split_message.len, &split_tree->object.oid,
    -+			  original_commit->parents, &out[0], original_author, NULL);
    ++	/*
    ++	 * 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(repo, "split-out", original_commit,
    ++					      &split_tree->object.oid,
    ++					      original_commit->parents, &parent_tree_oid, &out[0]);
     +	if (ret < 0) {
     +		ret = error(_("failed writing split-out commit"));
     +		goto out;
     +	}
     +
     +	/*
    -+	* The second commit is much simpler to construct, as we can simply use
    -+	* the original commit details, except that we adjust its parent to be
    -+	* the newly split-out commit.
    ++	* The second commit is constructed from the original tree. The base to
    ++	* diff against and the parent in this case is the first split-out
    ++	* commit.
     +	*/
    -+	find_commit_subject(original_message, &original_body);
     +	first_commit = lookup_commit_reference(repo, &out[0]);
     +	commit_list_append(first_commit, &parents);
     +
    -+	ret = commit_tree(original_body, strlen(original_body), &original_commit_tree_oid,
    -+			  parents, &out[1], original_author, NULL);
    ++	ret = commit_tree_with_edited_message(repo, "split-out", original_commit,
    ++					      &original_commit_tree_oid,
    ++					      parents, get_commit_tree_oid(first_commit), &out[1]);
     +	if (ret < 0) {
    -+		ret = error(_("failed writing second commit"));
    ++		ret = error(_("failed writing split-out commit"));
     +		goto out;
     +	}
     +
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +out:
     +	if (index_file.len)
     +		unlink(index_file.buf);
    -+	strbuf_release(&split_message);
     +	strbuf_release(&index_file);
     +	free_commit_list(parents);
    -+	free(original_author);
     +	release_index(&index);
     +	return ret;
     +}
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +	struct option options[] = {
     +		OPT_END(),
     +	};
    -+	struct oidmap rewritten_commits = OIDMAP_INIT;
     +	struct commit *original_commit, *parent, *head;
     +	struct strvec commits = STRVEC_INIT;
    -+	struct commit_list *from_list = NULL;
     +	struct object_id split_commits[2];
     +	struct pathspec pathspec = { 0 };
     +	int ret;
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +	}
     +	repo_config(repo, git_default_config, NULL);
     +
    -+	original_commit = lookup_commit_reference_by_name(argv[0]);
    -+	if (!original_commit) {
    -+		ret = error(_("commit to be split cannot be found: %s"), argv[0]);
    -+		goto out;
    -+	}
    -+
    -+	parent = original_commit->parents ? original_commit->parents->item : NULL;
    -+	if (parent && repo_parse_commit(repo, parent)) {
    -+		ret = error(_("unable to parse commit %s"),
    -+			    oid_to_hex(&parent->object.oid));
    -+		goto out;
    -+	}
    -+
    -+	head = lookup_commit_reference_by_name("HEAD");
    -+	if (!head) {
    -+		ret = error(_("could not resolve HEAD to a commit"));
    -+		goto out;
    -+	}
    -+
    -+	commit_list_append(original_commit, &from_list);
    -+	if (!repo_is_descendant_of(repo, head, from_list)) {
    -+		ret = error(_("split commit must be reachable from current HEAD commit"));
    -+		goto out;
    -+	}
    -+
     +	parse_pathspec(&pathspec, 0,
     +		PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
     +		prefix, argv + 1);
     +
    -+	/*
    -+	 * Collect the list of commits that we'll have to reapply now already.
    -+	 * This ensures that we'll abort early on in case the range of commits
    -+	 * contains merges, which we do not yet handle.
    -+	 */
    -+	ret = collect_commits(repo, parent, head, &commits);
    ++	ret = gather_commits_between_head_and_revision(repo, argv[0], &original_commit,
    ++						       &parent, &head, &commits);
     +	if (ret < 0)
     +		goto out;
     +
    @@ builtin/history.c: static int cmd_history_reword(int argc,
     +	ret = 0;
     +
     +out:
    -+	oidmap_clear(&rewritten_commits, 0);
    -+	free_commit_list(from_list);
     +	clear_pathspec(&pathspec);
     +	strvec_clear(&commits);
     +	return ret;
    @@ t/t3452-history-split.sh (new)
     +
     +. ./test-lib.sh
     +
    ++# The fake editor takes multiple arguments, each of which represents a commit
    ++# message. Subsequent invocations of the editor will then yield those messages
    ++# in order.
    ++#
     +set_fake_editor () {
    -+	write_script fake-editor.sh <<-EOF &&
    -+	echo "$@" >"\$1"
    ++	printf "%s\n" "$@" >fake-input &&
    ++	write_script fake-editor.sh <<-\EOF &&
    ++	head -n1 fake-input >"$1"
    ++	sed 1d fake-input >fake-input.trimmed &&
    ++	mv fake-input.trimmed fake-input
     +	EOF
     +	test_set_editor "$(pwd)"/fake-editor.sh
     +}
    @@ t/t3452-history-split.sh (new)
     +		git switch branch &&
     +		test_commit theirs &&
     +		test_must_fail git history split ours 2>err &&
    -+		test_grep "split commit must be reachable from current HEAD commit" err
    ++		test_grep "commit must be reachable from current HEAD commit" err
     +	)
     +'
     +
    @@ t/t3452-history-split.sh (new)
     +		git commit -m split-me &&
     +
     +		git symbolic-ref HEAD >expect &&
    -+		set_fake_editor "split-out commit" &&
    ++		set_fake_editor "first" "second" &&
     +		git history split HEAD <<-EOF &&
     +		y
     +		n
    @@ t/t3452-history-split.sh (new)
     +		test_cmp expect actual &&
     +
     +		expect_log <<-EOF &&
    -+		split-me
    -+		split-out commit
    ++		second
    ++		first
     +		initial
     +		EOF
     +
    @@ t/t3452-history-split.sh (new)
     +		initial.t
     +		EOF
     +
    -+		expect_tree_entries HEAD <<-EOF
    ++		expect_tree_entries HEAD <<-EOF &&
     +		bar
     +		foo
     +		initial.t
     +		EOF
    ++
    ++		git reflog >reflog &&
    ++		test_grep "split: updating HEAD" reflog
     +	)
     +'
     +
    @@ t/t3452-history-split.sh (new)
     +		git commit -m root &&
     +		test_commit tip &&
     +
    -+		set_fake_editor "split-out commit" &&
    ++		set_fake_editor "first" "second" &&
     +		git history split HEAD~ <<-EOF &&
     +		y
     +		n
    @@ t/t3452-history-split.sh (new)
     +
     +		expect_log <<-EOF &&
     +		tip
    -+		root
    -+		split-out commit
    ++		second
    ++		first
     +		EOF
     +
     +		expect_tree_entries HEAD~2 <<-EOF &&
    @@ t/t3452-history-split.sh (new)
     +		git commit -m split-me &&
     +		test_commit tip &&
     +
    -+		set_fake_editor "split-out commit" &&
    ++		set_fake_editor "first" "second" &&
     +		git history split HEAD~ <<-EOF &&
     +		y
     +		n
    @@ t/t3452-history-split.sh (new)
     +
     +		expect_log <<-EOF &&
     +		tip
    -+		split-me
    -+		split-out commit
    ++		second
    ++		first
     +		initial
     +		EOF
     +
    @@ t/t3452-history-split.sh (new)
     +		git add . &&
     +		git commit -m split-me &&
     +
    -+		set_fake_editor "split-out-commit" &&
    ++		set_fake_editor "first" "second" &&
     +		git history split HEAD <<-EOF &&
     +		y
     +		n
    @@ t/t3452-history-split.sh (new)
     +		git add . &&
     +		git commit -m split-me &&
     +
    -+		set_fake_editor "split-out commit" &&
    ++		set_fake_editor "first" "second" &&
     +		git history split HEAD <<-EOF &&
     +		n
     +		y
     +		EOF
     +
     +		expect_log <<-EOF &&
    -+		split-me
    -+		split-out commit
    ++		second
    ++		first
     +		EOF
     +
     +		expect_tree_entries HEAD~ <<-EOF &&
    @@ t/t3452-history-split.sh (new)
     +		git commit -m split-me &&
     +
     +		write_script fake-editor.sh <<-\EOF &&
    -+		cp "$1" . &&
    -+		echo "some commit message" >>"$1"
    ++		cat "$1" >>MESSAGES &&
    ++		echo "some commit message" >"$1"
     +		EOF
     +		test_set_editor "$(pwd)"/fake-editor.sh &&
     +
    @@ t/t3452-history-split.sh (new)
     +		n
     +		EOF
     +
    ++		# Note that we expect to see the messages twice, once for each
    ++		# of the commits. The committed files are different though.
     +		cat >expect <<-EOF &&
    ++		split-me
     +
     +		# Please enter the commit message for the split-out changes. Lines starting
    -+		# with ${SQ}#${SQ} will be ignored.
    ++		# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
     +		# Changes to be committed:
     +		#	new file:   bar
     +		#
    ++		split-me
    ++
    ++		# Please enter the commit message for the split-out changes. Lines starting
    ++		# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
    ++		# Changes to be committed:
    ++		#	new file:   foo
    ++		#
     +		EOF
    -+		test_cmp expect COMMIT_EDITMSG &&
    ++		test_cmp expect MESSAGES &&
     +
     +		expect_log <<-EOF
    -+		split-me
    ++		some commit message
     +		some commit message
     +		EOF
     +	)
    @@ t/t3452-history-split.sh (new)
     +		git add . &&
     +		git commit -m split-me &&
     +
    -+		set_fake_editor "split-out commit" &&
    ++		set_fake_editor "first" "second" &&
     +		git history split HEAD -- foo <<-EOF &&
     +		y
     +		EOF
    @@ t/t3452-history-split.sh (new)
     +	)
     +'
     +
    -+test_expect_success 'hooks are executed for rewritten commits' '
    ++test_expect_success 'hooks are not executed for rewritten commits' '
     +	test_when_finished "rm -rf repo" &&
     +	git init repo &&
     +	(
    @@ t/t3452-history-split.sh (new)
     +		git commit -m split-me &&
     +		old_head=$(git rev-parse HEAD) &&
     +
    -+		write_script .git/hooks/prepare-commit-msg <<-EOF &&
    -+		touch "$(pwd)/hooks.log"
    -+		EOF
    -+		write_script .git/hooks/post-commit <<-EOF &&
    -+		touch "$(pwd)/hooks.log"
    -+		EOF
    -+		write_script .git/hooks/post-rewrite <<-EOF &&
    -+		touch "$(pwd)/hooks.log"
    -+		EOF
    ++		ORIG_PATH="$(pwd)" &&
    ++		export ORIG_PATH &&
    ++		for hook in prepare-commit-msg pre-commit post-commit post-rewrite commit-msg
    ++		do
    ++			write_script .git/hooks/$hook <<-\EOF || exit 1
    ++			touch "$ORIG_PATH/hooks.log
    ++			EOF
    ++		done &&
     +
    -+		set_fake_editor "split-out commit" &&
    ++		set_fake_editor "first" "second" &&
     +		git history split HEAD <<-EOF &&
     +		y
     +		n
     +		EOF
     +
     +		expect_log <<-EOF &&
    -+		split-me
    -+		split-out commit
    ++		second
    ++		first
     +		EOF
     +
     +		test_path_is_missing hooks.log
    @@ t/t3452-history-split.sh (new)
     +		echo a-modified >a &&
     +		echo b-modified >b &&
     +		git add b &&
    -+		set_fake_editor "a-only" &&
    ++		set_fake_editor "a-only" "remainder" &&
     +		git history split HEAD <<-EOF &&
     +		y
     +		n
    @@ t/t3452-history-split.sh (new)
     +		?? actual
     +		?? expect
     +		?? fake-editor.sh
    ++		?? fake-input
     +		EOF
     +		git status --porcelain >actual &&
     +		test_cmp expect actual

--- base-commit: 0d4583b45432bff1cdc7689c648fb51be3d7b321 change-id: 20250819-b4-pks-history-builtin-83398f9a05f0

Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 01/12] wt-status: provide function to expose status for trees

The "wt-status" subsystem is responsible for printing status information around the current state of the working tree. This most importantly includes information around whether the working tree or the index have any changes.

We're about to introduce a new command where the changes in neither of them are actually relevant to us. Instead, what we want is to format the changes between two different trees. While it is a little bit of a stretch to add this as functionality to _working tree_ status, it doesn't make any sense to open-code this functionality, either.

Implement a new function `wt_status_collect_changes_trees()` that diffs two trees and formats the status accordingly. This function is not yet used, but will be in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 wt-status.c | 24 ++++++++++++++++++++++++
 wt-status.h |  9 +++++++++
 2 files changed, 33 insertions(+)
Show changes to 2 files +33 −0

wt-status.c, wt-status.h

diff --git a/wt-status.c b/wt-status.c
index e12adb26b9..95942399f8 100644
--- a/wt-status.c
+++ b/wt-status.c
@@ -612,6 +612,30 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q,
 	}
 }
 
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish)
+{
+	struct diff_options opts = { 0 };
+
+	repo_diff_setup(s->repo, &opts);
+	opts.output_format = DIFF_FORMAT_CALLBACK;
+	opts.format_callback = wt_status_collect_updated_cb;
+	opts.format_callback_data = s;
+	opts.detect_rename = s->detect_rename >= 0 ? s->detect_rename : opts.detect_rename;
+	opts.rename_limit = s->rename_limit >= 0 ? s->rename_limit : opts.rename_limit;
+	opts.rename_score = s->rename_score >= 0 ? s->rename_score : opts.rename_score;
+	opts.flags.recursive = 1;
+	diff_setup_done(&opts);
+
+	diff_tree_oid(old_treeish, new_treeish, "", &opts);
+	diffcore_std(&opts);
+	diff_flush(&opts);
+	wt_status_get_state(s->repo, &s->state, 0);
+
+	diff_free(&opts);
+}
+
 static void wt_status_collect_changes_worktree(struct wt_status *s)
 {
 	struct rev_info rev;
diff --git a/wt-status.h b/wt-status.h
index e40a27214a..e9fe32e98c 100644
--- a/wt-status.h
+++ b/wt-status.h
@@ -153,6 +153,15 @@ void wt_status_add_cut_line(struct wt_status *s);
 void wt_status_prepare(struct repository *r, struct wt_status *s);
 void wt_status_print(struct wt_status *s);
 void wt_status_collect(struct wt_status *s);
+
+/*
+ * Collect all changes between the two trees. Changes will be displayed as if
+ * they were staged into the index.
+ */
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish);
+
 /*
  * Frees the buffers allocated by wt_status_collect.
  */
-- 
2.52.0.239.gd5f0c6e74e.dirty
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 02/12] replay: extract logic to pick commits

We're about to add a new git-history(1) command that will reuse some of the same infrastructure as git-replay(1). To prepare for this, extract the logic to pick a commit into a new "replay.c" file so that it can be shared between both commands.

Rename the function to have a "replay_" prefix to clearly indicate its subsystem.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Makefile         |   1 +
 builtin/replay.c | 110 ++--------------------------------------------------
 meson.build      |   1 +
 replay.c         | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 replay.h         |  23 +++++++++++
 5 files changed, 143 insertions(+), 107 deletions(-)
Show changes to 5 files +143 −107

Makefile, builtin/replay.c, meson.build, replay.c, replay.h

diff --git a/Makefile b/Makefile
index 237b56fc9d..809b094595 100644
--- a/Makefile
+++ b/Makefile
@@ -1274,6 +1274,7 @@ LIB_OBJS += repack-geometry.o
 LIB_OBJS += repack-midx.o
 LIB_OBJS += repack-promisor.o
 LIB_OBJS += replace-object.o
+LIB_OBJS += replay.o
 LIB_OBJS += repo-settings.o
 LIB_OBJS += repository.o
 LIB_OBJS += rerere.o
diff --git a/builtin/replay.c b/builtin/replay.c
index 1f9ffd2b3e..f974a8c963 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -2,7 +2,6 @@
  * "git replay" builtin command
  */
 
-#define USE_THE_REPOSITORY_VARIABLE
 #define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
@@ -16,6 +15,7 @@
 #include "object-name.h"
 #include "parse-options.h"
 #include "refs.h"
+#include "replay.h"
 #include "revision.h"
 #include "strmap.h"
 #include <oidset.h>
@@ -26,13 +26,6 @@ enum ref_action_mode {
 	REF_ACTION_PRINT,
 };
 
-static const char *short_commit_name(struct repository *repo,
-				     struct commit *commit)
-{
-	return repo_find_unique_abbrev(repo, &commit->object.oid,
-				       DEFAULT_ABBREV);
-}
-
 static struct commit *peel_committish(struct repository *repo, const char *name)
 {
 	struct object *obj;
@@ -45,59 +38,6 @@ static struct commit *peel_committish(struct repository *repo, const char *name)
 						  OBJ_COMMIT);
 }
 
-static char *get_author(const char *message)
-{
-	size_t len;
-	const char *a;
-
-	a = find_commit_header(message, "author", &len);
-	if (a)
-		return xmemdupz(a, len);
-
-	return NULL;
-}
-
-static struct commit *create_commit(struct repository *repo,
-				    struct tree *tree,
-				    struct commit *based_on,
-				    struct commit *parent)
-{
-	struct object_id ret;
-	struct object *obj = NULL;
-	struct commit_list *parents = NULL;
-	char *author;
-	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
-	struct commit_extra_header *extra = NULL;
-	struct strbuf msg = STRBUF_INIT;
-	const char *out_enc = get_commit_output_encoding();
-	const char *message = repo_logmsg_reencode(repo, based_on,
-						   NULL, out_enc);
-	const char *orig_message = NULL;
-	const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
-
-	commit_list_insert(parent, &parents);
-	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
-	find_commit_subject(message, &orig_message);
-	strbuf_addstr(&msg, orig_message);
-	author = get_author(message);
-	reset_ident_date();
-	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
-				 &ret, author, NULL, sign_commit, extra)) {
-		error(_("failed to write commit object"));
-		goto out;
-	}
-
-	obj = parse_object(repo, &ret);
-
-out:
-	repo_unuse_commit_buffer(the_repository, based_on, message);
-	free_commit_extra_headers(extra);
-	free_commit_list(parents);
-	strbuf_release(&msg);
-	free(author);
-	return (struct commit *)obj;
-}
-
 struct ref_info {
 	struct commit *onto;
 	struct strset positive_refs;
@@ -246,50 +186,6 @@ static void determine_replay_mode(struct repository *repo,
 	strset_clear(&rinfo.positive_refs);
 }
 
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
-{
-	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
-	if (pos == kh_end(replayed_commits))
-		return fallback;
-	return kh_value(replayed_commits, pos);
-}
-
-static struct commit *pick_regular_commit(struct repository *repo,
-					  struct commit *pickme,
-					  kh_oid_map_t *replayed_commits,
-					  struct commit *onto,
-					  struct merge_options *merge_opt,
-					  struct merge_result *result)
-{
-	struct commit *base, *replayed_base;
-	struct tree *pickme_tree, *base_tree;
-
-	base = pickme->parents->item;
-	replayed_base = mapped_commit(replayed_commits, base, onto);
-
-	result->tree = repo_get_commit_tree(repo, replayed_base);
-	pickme_tree = repo_get_commit_tree(repo, pickme);
-	base_tree = repo_get_commit_tree(repo, base);
-
-	merge_opt->branch1 = short_commit_name(repo, replayed_base);
-	merge_opt->branch2 = short_commit_name(repo, pickme);
-	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
-
-	merge_incore_nonrecursive(merge_opt,
-				  base_tree,
-				  result->tree,
-				  pickme_tree,
-				  result);
-
-	free((char*)merge_opt->ancestor);
-	merge_opt->ancestor = NULL;
-	if (!result->clean)
-		return NULL;
-	return create_commit(repo, result->tree, pickme, replayed_base);
-}
-
 static enum ref_action_mode parse_ref_action_mode(const char *ref_action, const char *source)
 {
 	if (!ref_action || !strcmp(ref_action, "update"))
@@ -495,8 +391,8 @@ int cmd_replay(int argc,
 		if (commit->parents->next)
 			die(_("replaying merge commits is not supported yet!"));
 
-		last_commit = pick_regular_commit(repo, commit, replayed_commits,
-						  onto, &merge_opt, &result);
+		last_commit = replay_pick_regular_commit(repo, commit, replayed_commits,
+							 onto, &merge_opt, &result);
 		if (!last_commit)
 			break;
 
diff --git a/meson.build b/meson.build
index f1b3615659..1878dff2b1 100644
--- a/meson.build
+++ b/meson.build
@@ -470,6 +470,7 @@ libgit_sources = [
   'repack-midx.c',
   'repack-promisor.c',
   'replace-object.c',
+  'replay.c',
   'repo-settings.c',
   'repository.c',
   'rerere.c',
diff --git a/replay.c b/replay.c
new file mode 100644
index 0000000000..fb906e9b51
--- /dev/null
+++ b/replay.c
@@ -0,0 +1,115 @@
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "git-compat-util.h"
+#include "commit.h"
+#include "environment.h"
+#include "gettext.h"
+#include "ident.h"
+#include "object.h"
+#include "object-name.h"
+#include "replay.h"
+#include "tree.h"
+
+static const char *short_commit_name(struct repository *repo,
+				     struct commit *commit)
+{
+	return repo_find_unique_abbrev(repo, &commit->object.oid,
+				       DEFAULT_ABBREV);
+}
+
+static char *get_author(const char *message)
+{
+	size_t len;
+	const char *a;
+
+	a = find_commit_header(message, "author", &len);
+	if (a)
+		return xmemdupz(a, len);
+
+	return NULL;
+}
+
+struct commit *replay_create_commit(struct repository *repo,
+				    struct tree *tree,
+				    struct commit *based_on,
+				    struct commit *parent)
+{
+	struct object_id ret;
+	struct object *obj = NULL;
+	struct commit_list *parents = NULL;
+	char *author;
+	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
+	struct commit_extra_header *extra = NULL;
+	struct strbuf msg = STRBUF_INIT;
+	const char *out_enc = get_commit_output_encoding();
+	const char *message = repo_logmsg_reencode(repo, based_on,
+						   NULL, out_enc);
+	const char *orig_message = NULL;
+	const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
+
+	commit_list_insert(parent, &parents);
+	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
+	find_commit_subject(message, &orig_message);
+	strbuf_addstr(&msg, orig_message);
+	author = get_author(message);
+	reset_ident_date();
+	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
+				 &ret, author, NULL, sign_commit, extra)) {
+		error(_("failed to write commit object"));
+		goto out;
+	}
+
+	obj = parse_object(repo, &ret);
+
+out:
+	repo_unuse_commit_buffer(the_repository, based_on, message);
+	free_commit_extra_headers(extra);
+	free_commit_list(parents);
+	strbuf_release(&msg);
+	free(author);
+	return (struct commit *)obj;
+}
+
+static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
+				    struct commit *commit,
+				    struct commit *fallback)
+{
+	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
+	if (pos == kh_end(replayed_commits))
+		return fallback;
+	return kh_value(replayed_commits, pos);
+}
+
+struct commit *replay_pick_regular_commit(struct repository *repo,
+					  struct commit *pickme,
+					  kh_oid_map_t *replayed_commits,
+					  struct commit *onto,
+					  struct merge_options *merge_opt,
+					  struct merge_result *result)
+{
+	struct commit *base, *replayed_base;
+	struct tree *pickme_tree, *base_tree;
+
+	base = pickme->parents->item;
+	replayed_base = mapped_commit(replayed_commits, base, onto);
+
+	result->tree = repo_get_commit_tree(repo, replayed_base);
+	pickme_tree = repo_get_commit_tree(repo, pickme);
+	base_tree = repo_get_commit_tree(repo, base);
+
+	merge_opt->branch1 = short_commit_name(repo, replayed_base);
+	merge_opt->branch2 = short_commit_name(repo, pickme);
+	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
+
+	merge_incore_nonrecursive(merge_opt,
+				  base_tree,
+				  result->tree,
+				  pickme_tree,
+				  result);
+
+	free((char*)merge_opt->ancestor);
+	merge_opt->ancestor = NULL;
+	if (!result->clean)
+		return NULL;
+	return replay_create_commit(repo, result->tree, pickme, replayed_base);
+}
diff --git a/replay.h b/replay.h
new file mode 100644
index 0000000000..d6535ee56c
--- /dev/null
+++ b/replay.h
@@ -0,0 +1,23 @@
+#ifndef REPLAY_H
+#define REPLAY_H
+
+#include "khash.h"
+#include "merge-ort.h"
+#include "repository.h"
+
+struct commit;
+struct tree;
+
+struct commit *replay_create_commit(struct repository *repo,
+				    struct tree *tree,
+				    struct commit *based_on,
+				    struct commit *parent);
+
+struct commit *replay_pick_regular_commit(struct repository *repo,
+					  struct commit *pickme,
+					  kh_oid_map_t *replayed_commits,
+					  struct commit *onto,
+					  struct merge_options *merge_opt,
+					  struct merge_result *result);
+
+#endif
-- 
2.52.0.239.gd5f0c6e74e.dirty
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 03/12] replay: stop using `the_repository`

In `create_commit()` we're using `the_repository` even though we already have a repository passed to use as an argument. Fix this.

Note that we still cannot get rid of `USE_THE_REPOSITORY_VARIABLE`. This is because we use `DEFAULT_ABBREV and `get_commit_output_encoding()`, both of which are stored as global variables that can be modified via the Git configuration.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 replay.c | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)
Show changes to replay.c +1 −1
diff --git a/replay.c b/replay.c
index fb906e9b51..13983dbc56 100644
--- a/replay.c
+++ b/replay.c
@@ -62,7 +62,7 @@ struct commit *replay_create_commit(struct repository *repo,
 	obj = parse_object(repo, &ret);
 
 out:
-	repo_unuse_commit_buffer(the_repository, based_on, message);
+	repo_unuse_commit_buffer(repo, based_on, message);
 	free_commit_extra_headers(extra);
 	free_commit_list(parents);
 	strbuf_release(&msg);
-- 
2.52.0.239.gd5f0c6e74e.dirty
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 04/12] builtin: add new "history" command

When rewriting history via git-rebase(1) there are a few very common use cases:

  - The ordering of two commits should be reversed.
  - A commit should be split up into two commits.
  - A commit should be dropped from the history completely.
  - Multiple commits should be squashed into one.
  - Editing an existing commit that is not the tip of the current
    branch.

While these operations are all doable, it often feels needlessly kludgey to do so by doing an interactive rebase, using the editor to say what one wants, and then perform the actions. Furthermore, some operations like splitting up a commit into two are way more involved than that and require a whole series of commands.

Add a new "history" command to plug this gap. This command will have several different subcommands to imperatively rewrite history for common use cases like the above. Some of these subcommands will be implemented in subsequent commits.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 .gitignore                     |  1 +
 Documentation/git-history.adoc | 42 ++++++++++++++++++++++++++++++++++++++++++
 Documentation/meson.build      |  1 +
 Makefile                       |  1 +
 builtin.h                      |  1 +
 builtin/history.c              | 22 ++++++++++++++++++++++
 command-list.txt               |  1 +
 git.c                          |  1 +
 meson.build                    |  1 +
 t/meson.build                  |  1 +
 t/t3450-history.sh             | 17 +++++++++++++++++
 11 files changed, 89 insertions(+)
Show changes to 11 files +89 −0

.gitignore, Documentation/git-history.adoc, Documentation/meson.build, Makefile, builtin.h, builtin/history.c, command-list.txt, git.c, meson.build, t/meson.build, t/t3450-history.sh

diff --git a/.gitignore b/.gitignore
index 78a45cb5be..24635cf2d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,6 +79,7 @@
 /git-grep
 /git-hash-object
 /git-help
+/git-history
 /git-hook
 /git-http-backend
 /git-http-fetch
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
new file mode 100644
index 0000000000..67b8ce2040
--- /dev/null
+++ b/Documentation/git-history.adoc
@@ -0,0 +1,42 @@
+git-history(1)
+==============
+
+NAME
+----
+git-history - EXPERIMENTAL: Rewrite history of the current branch
+
+SYNOPSIS
+--------
+[synopsis]
+git history [<options>]
+
+DESCRIPTION
+-----------
+
+Rewrite history by rearranging or modifying specific commits in the
+history.
+
+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. You should use rebases if you want to reapply a range
+of commits onto a different base, or interactive rebases if you want to edit a
+range of commits.
+
+Note that this command does not (yet) work with histories that contain
+merges. You should use linkgit:git-rebase[1] with the `--rebase-merges`
+flag instead.
+
+COMMANDS
+--------
+
+Several commands are available to rewrite history in different ways:
+
+CONFIGURATION
+-------------
+
+include::includes/cmd-config-section-all.adoc[]
+
+GIT
+---
+Part of the linkgit:git[1] suite
diff --git a/Documentation/meson.build b/Documentation/meson.build
index f02dbc20cb..fd2e8cc02d 100644
--- a/Documentation/meson.build
+++ b/Documentation/meson.build
@@ -64,6 +64,7 @@ manpages = {
   'git-gui.adoc' : 1,
   'git-hash-object.adoc' : 1,
   'git-help.adoc' : 1,
+  'git-history.adoc' : 1,
   'git-hook.adoc' : 1,
   'git-http-backend.adoc' : 1,
   'git-http-fetch.adoc' : 1,
diff --git a/Makefile b/Makefile
index 809b094595..458841178b 100644
--- a/Makefile
+++ b/Makefile
@@ -1408,6 +1408,7 @@ BUILTIN_OBJS += builtin/get-tar-commit-id.o
 BUILTIN_OBJS += builtin/grep.o
 BUILTIN_OBJS += builtin/hash-object.o
 BUILTIN_OBJS += builtin/help.o
+BUILTIN_OBJS += builtin/history.o
 BUILTIN_OBJS += builtin/hook.o
 BUILTIN_OBJS += builtin/index-pack.o
 BUILTIN_OBJS += builtin/init-db.o
diff --git a/builtin.h b/builtin.h
index 1b35565fbd..93c91d07d4 100644
--- a/builtin.h
+++ b/builtin.h
@@ -172,6 +172,7 @@ int cmd_get_tar_commit_id(int argc, const char **argv, const char *prefix, struc
 int cmd_grep(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hash_object(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_help(int argc, const char **argv, const char *prefix, struct repository *repo);
+int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hook(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);
diff --git a/builtin/history.c b/builtin/history.c
new file mode 100644
index 0000000000..f6fe32610b
--- /dev/null
+++ b/builtin/history.c
@@ -0,0 +1,22 @@
+#include "builtin.h"
+#include "gettext.h"
+#include "parse-options.h"
+
+int cmd_history(int argc,
+		const char **argv,
+		const char *prefix,
+		struct repository *repo UNUSED)
+{
+	const char * const usage[] = {
+		N_("git history [<options>]"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc)
+		usagef("unrecognized argument: %s", argv[0]);
+	return 0;
+}
diff --git a/command-list.txt b/command-list.txt
index accd3d0c4b..f9005cf459 100644
--- a/command-list.txt
+++ b/command-list.txt
@@ -115,6 +115,7 @@ git-grep                                mainporcelain           info
 git-gui                                 mainporcelain
 git-hash-object                         plumbingmanipulators
 git-help                                ancillaryinterrogators          complete
+git-history                             mainporcelain           history
 git-hook                                purehelpers
 git-http-backend                        synchingrepositories
 git-http-fetch                          synchelpers
diff --git a/git.c b/git.c
index c5fad56813..744cb6527e 100644
--- a/git.c
+++ b/git.c
@@ -586,6 +586,7 @@ static struct cmd_struct commands[] = {
 	{ "grep", cmd_grep, RUN_SETUP_GENTLY },
 	{ "hash-object", cmd_hash_object },
 	{ "help", cmd_help },
+	{ "history", cmd_history, RUN_SETUP },
 	{ "hook", cmd_hook, RUN_SETUP },
 	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
 	{ "init", cmd_init_db },
diff --git a/meson.build b/meson.build
index 1878dff2b1..f07a308fb8 100644
--- a/meson.build
+++ b/meson.build
@@ -610,6 +610,7 @@ builtin_sources = [
   'builtin/grep.c',
   'builtin/hash-object.c',
   'builtin/help.c',
+  'builtin/history.c',
   'builtin/hook.c',
   'builtin/index-pack.c',
   'builtin/init-db.c',
diff --git a/t/meson.build b/t/meson.build
index 7c994d4643..62f5dca098 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -386,6 +386,7 @@ integration_tests = [
   't3436-rebase-more-options.sh',
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
+  't3450-history.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
new file mode 100755
index 0000000000..417c343d43
--- /dev/null
+++ b/t/t3450-history.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+test_description='tests for git-history command'
+
+. ./test-lib.sh
+
+test_expect_success 'does nothing without any arguments' '
+	git history >out 2>&1 &&
+	test_must_be_empty out
+'
+
+test_expect_success 'raises an error with unknown argument' '
+	test_must_fail git history garbage 2>err &&
+	test_grep "unrecognized argument: garbage" err
+'
+
+test_done
-- 
2.52.0.239.gd5f0c6e74e.dirty
Kristoffer Haugsbakk· Dec 22, 2025, 17:11 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v7 04/12] builtin: add new "history" command

On Wed, Dec 3, 2025, at 11:48, Patrick Steinhardt wrote:
Show 7 quoted lines
> When rewriting history via git-rebase(1) there are a few very common use
> cases:
>[snip]
> +CONFIGURATION
> +-------------
> +
> +include::includes/cmd-config-section-all.adoc[]

Now there are no config variables listed after `include::config/sequencer.adoc[]` was removed.

Show 5 quoted lines
> +
> +GIT
> +---
> +Part of the linkgit:git[1] suite
>[snip]
Patrick Steinhardt· Jan 6, 2026, 15:41 UTC · re: Kristoffer Haugsbakk · lore

Re: [PATCH v7 04/12] builtin: add new "history" command

On Mon, Dec 22, 2025 at 06:11:38PM +0100, Kristoffer Haugsbakk wrote:
Show 11 quoted lines
> On Wed, Dec 3, 2025, at 11:48, Patrick Steinhardt wrote:
> > When rewriting history via git-rebase(1) there are a few very common use
> > cases:
> >[snip]
> > +CONFIGURATION
> > +-------------
> > +
> > +include::includes/cmd-config-section-all.adoc[]
> 
> Now there are no config variables listed after
> `include::config/sequencer.adoc[]` was removed.
Oh, indeed, good catch! Will fix.
Patrick
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 05/12] builtin/history: implement "reword" subcommand

Implement a new "reword" subcommand for git-history(1). This subcommand is similar to the user performing an interactive rebase with a single commit changed to use the "reword" instruction.

The major difference is that we do not check out the commit that is to be reworded. This has the obvious benefit of being significantly faster compared to git-rebase(1), but even more importantly it allows the user to rewrite history even if there are local changes in the working tree or in the index.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |   7 +-
 builtin/history.c              | 372 ++++++++++++++++++++++++++++++++++++++++-
 t/meson.build                  |   1 +
 t/t3450-history.sh             |   6 +-
 t/t3451-history-reword.sh      | 236 ++++++++++++++++++++++++++
 5 files changed, 613 insertions(+), 9 deletions(-)
Show changes to 5 files +613 −9

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3450-history.sh, t/t3451-history-reword.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 67b8ce2040..160bf5d4d2 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -8,7 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history of the current branch
 SYNOPSIS
 --------
 [synopsis]
-git history [<options>]
+git history reword <commit>
 
 DESCRIPTION
 -----------
@@ -32,6 +32,11 @@ COMMANDS
 
 Several commands are available to rewrite history in different ways:
 
+`reword <commit>`::
+	Rewrite the commit message of the specified commit. All the other
+	details of this commit remain unchanged. This command will spawn an
+	editor with the current message of that commit.
+
 CONFIGURATION
 -------------
 
diff --git a/builtin/history.c b/builtin/history.c
index f6fe32610b..17bb150b95 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,22 +1,384 @@
+#define USE_THE_REPOSITORY_VARIABLE
+
 #include "builtin.h"
+#include "commit-reach.h"
+#include "commit.h"
+#include "config.h"
+#include "editor.h"
+#include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "parse-options.h"
+#include "refs.h"
+#include "replay.h"
+#include "reset.h"
+#include "revision.h"
+#include "sequencer.h"
+#include "strvec.h"
+#include "tree.h"
+#include "wt-status.h"
+
+#define GIT_HISTORY_REWORD_USAGE N_("git history reword <commit>")
+
+static int collect_commits(struct repository *repo,
+			   struct commit *old_commit,
+			   struct commit *new_commit,
+			   struct strvec *out)
+{
+	struct setup_revision_opt revision_opts = {
+		.assume_dashdash = 1,
+	};
+	struct strvec revisions = STRVEC_INIT;
+	struct commit *child;
+	struct rev_info rev = { 0 };
+	int ret;
+
+	repo_init_revisions(repo, &rev, NULL);
+	rev.reverse = 1;
+	strvec_push(&revisions, "");
+	strvec_push(&revisions, oid_to_hex(&new_commit->object.oid));
+	if (old_commit) {
+		strvec_pushf(&revisions, "^%s", oid_to_hex(&old_commit->object.oid));
+		strvec_pushf(&revisions, "--ancestry-path=%s", oid_to_hex(&old_commit->object.oid));
+	}
+
+	setup_revisions_from_strvec(&revisions, &rev, &revision_opts);
+	if (revisions.nr != 1 || prepare_revision_walk(&rev)) {
+		ret = error(_("revision walk setup failed"));
+		goto out;
+	}
+
+	while ((child = get_revision(&rev))) {
+		if (old_commit && !child->parents)
+			BUG("revision walk did not find child commit");
+		if (child->parents && child->parents->next) {
+			ret = error(_("cannot rearrange commit history with merges"));
+			goto out;
+		}
+
+		strvec_push(out, oid_to_hex(&child->object.oid));
+	}
+
+	ret = 0;
+
+out:
+	strvec_clear(&revisions);
+	release_revisions(&rev);
+	reset_revision_walk();
+	return ret;
+}
+
+static int gather_commits_between_head_and_revision(struct repository *repo,
+						    const char *revision,
+						    struct commit **original_commit,
+						    struct commit **parent_commit,
+						    struct commit **head,
+						    struct strvec *commits)
+{
+	struct commit_list *from_list = NULL;
+	int ret;
+
+	*original_commit = lookup_commit_reference_by_name(revision);
+	if (!*original_commit) {
+		ret = error(_("commit cannot be found: %s"), revision);
+		goto out;
+	}
+
+	*parent_commit = (*original_commit)->parents ? (*original_commit)->parents->item : NULL;
+	if (*parent_commit && repo_parse_commit(repo, *parent_commit)) {
+		ret = error(_("unable to parse commit %s"),
+			    oid_to_hex(&(*parent_commit)->object.oid));
+		goto out;
+	}
+
+	*head = lookup_commit_reference_by_name("HEAD");
+	if (!(*head)) {
+		ret = error(_("could not resolve HEAD to a commit"));
+		goto out;
+	}
+
+	commit_list_append(*original_commit, &from_list);
+	if (!repo_is_descendant_of(repo, *head, from_list)) {
+		ret = error(_("commit must be reachable from current HEAD commit"));
+		goto out;
+	}
+
+	/*
+	 * Collect the list of commits that we'll have to reapply now already.
+	 * This ensures that we'll abort early on in case the range of commits
+	 * contains merges, which we do not yet handle.
+	 */
+	ret = collect_commits(repo, *parent_commit, *head, commits);
+	if (ret < 0)
+		goto out;
+
+out:
+	free_commit_list(from_list);
+	return ret;
+}
+
+static void replace_commits(struct strvec *commits,
+			    const struct object_id *commit_to_replace,
+			    const struct object_id *replacements,
+			    size_t replacements_nr)
+{
+	char commit_to_replace_oid[GIT_MAX_HEXSZ + 1];
+	struct strvec replacement_oids = STRVEC_INIT;
+	bool found = false;
+
+	oid_to_hex_r(commit_to_replace_oid, commit_to_replace);
+	for (size_t i = 0; i < replacements_nr; i++)
+		strvec_push(&replacement_oids, oid_to_hex(&replacements[i]));
+
+	for (size_t i = 0; i < commits->nr; i++) {
+		if (strcmp(commits->v[i], commit_to_replace_oid))
+			continue;
+		strvec_splice(commits, i, 1, replacement_oids.v, replacement_oids.nr);
+		found = true;
+		break;
+	}
+	if (!found)
+		BUG("could not find commit to replace");
+
+	strvec_clear(&replacement_oids);
+}
+
+static int apply_commits(struct repository *repo,
+			 const struct strvec *commits,
+			 struct commit *onto,
+			 struct commit *orig_head,
+			 const char *action)
+{
+	struct reset_head_opts reset_opts = { 0 };
+	struct strbuf buf = STRBUF_INIT;
+	int ret;
+
+	for (size_t i = 0; i < commits->nr; i++) {
+		struct object_id commit_id;
+		struct commit *commit;
+		const char *end;
+
+		if (parse_oid_hex_algop(commits->v[i], &commit_id, &end,
+					repo->hash_algo)) {
+			ret = error(_("invalid object ID: %s"), commits->v[i]);
+			goto out;
+		}
+
+		commit = lookup_commit(repo, &commit_id);
+		if (!commit || repo_parse_commit(repo, commit)) {
+			ret = error(_("failed to look up commit: %s"), oid_to_hex(&commit_id));
+			goto out;
+		}
+
+		if (!onto) {
+			onto = commit;
+		} else {
+			struct tree *tree = repo_get_commit_tree(repo, commit);
+			onto = replay_create_commit(repo, tree, commit, onto);
+			if (!onto) {
+				ret = -1;
+				goto out;
+			}
+		}
+	}
+
+	reset_opts.oid = &onto->object.oid;
+	strbuf_addf(&buf, "%s: switch to rewritten %s", action, oid_to_hex(reset_opts.oid));
+	reset_opts.flags = RESET_HEAD_REFS_ONLY | RESET_ORIG_HEAD;
+	reset_opts.orig_head = &orig_head->object.oid;
+	reset_opts.default_reflog_action = action;
+	if (reset_head(repo, &reset_opts) < 0) {
+		ret = error(_("could not switch to %s"), oid_to_hex(reset_opts.oid));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	strbuf_release(&buf);
+	return ret;
+}
+
+static void change_data_free(void *util, const char *str UNUSED)
+{
+	struct wt_status_change_data *d = util;
+	free(d->rename_source);
+	free(d);
+}
+
+static int fill_commit_message(struct repository *repo,
+			       const struct object_id *old_tree,
+			       const struct object_id *new_tree,
+			       const char *default_message,
+			       const char *action,
+			       struct strbuf *out)
+{
+	const char *path = git_path_commit_editmsg();
+	const char *hint =
+		_("Please enter the commit message for the %s changes."
+		  " Lines starting\nwith '%s' will be ignored, and an"
+		  " empty message aborts the commit.\n");
+	struct wt_status s;
+
+	strbuf_addstr(out, default_message);
+	strbuf_addch(out, '\n');
+	strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
+	write_file_buf(path, out->buf, out->len);
+
+	wt_status_prepare(repo, &s);
+	FREE_AND_NULL(s.branch);
+	s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
+	s.commit_template = 1;
+	s.colopts = 0;
+	s.display_comment_prefix = 1;
+	s.hints = 0;
+	s.use_color = 0;
+	s.whence = FROM_COMMIT;
+	s.committable = 1;
+
+	s.fp = fopen(git_path_commit_editmsg(), "a");
+	if (!s.fp)
+		return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
+
+	wt_status_collect_changes_trees(&s, old_tree, new_tree);
+	wt_status_print(&s);
+	wt_status_collect_free_buffers(&s);
+	string_list_clear_func(&s.change, change_data_free);
+
+	strbuf_reset(out);
+	if (launch_editor(path, out, NULL)) {
+		fprintf(stderr, _("Aborting commit as launching the editor failed.\n"));
+		return -1;
+	}
+	strbuf_stripspace(out, comment_line_str);
+
+	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
+
+	if (!out->len) {
+		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
+		return -1;
+	}
+
+	return 0;
+}
+
+static int commit_tree_with_edited_message(struct repository *repo,
+					   const char *action,
+					   struct commit *original_commit,
+					   const struct object_id *new_tree_oid,
+					   const struct commit_list *parents,
+					   const struct object_id *parent_tree_oid,
+					   struct object_id *out)
+{
+	const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
+	const char *original_message, *original_body, *ptr;
+	struct commit_extra_header *original_extra_headers = NULL;
+	struct strbuf commit_message = STRBUF_INIT;
+	char *original_author = NULL;
+	size_t len;
+	int ret;
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original_commit, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+	find_commit_subject(original_message, &original_body);
+
+	ret = fill_commit_message(repo, parent_tree_oid, new_tree_oid,
+				  original_body, action, &commit_message);
+	if (ret < 0)
+		goto out;
+
+	original_extra_headers = read_commit_extra_headers(original_commit, exclude_gpgsig);
+
+	ret = commit_tree_extended(commit_message.buf, commit_message.len, new_tree_oid,
+				   parents, out, original_author, NULL, NULL,
+				   original_extra_headers);
+	if (ret < 0)
+		goto out;
+
+out:
+	free_commit_extra_headers(original_extra_headers);
+	strbuf_release(&commit_message);
+	free(original_author);
+	return ret;
+}
+
+static int cmd_history_reword(int argc,
+			      const char **argv,
+			      const char *prefix,
+			      struct repository *repo)
+{
+	const char * const usage[] = {
+		GIT_HISTORY_REWORD_USAGE,
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct commit *original_commit, *parent, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct object_id parent_tree_oid, original_commit_tree_oid;
+	struct object_id rewritten_commit;
+	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);
+
+	ret = gather_commits_between_head_and_revision(repo, argv[0], &original_commit,
+						       &parent, &head, &commits);
+	if (ret < 0)
+		goto out;
+
+	original_commit_tree_oid = repo_get_commit_tree(repo, original_commit)->object.oid;
+	if (parent)
+		parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
+	else
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+
+	/* We retain authorship of the original commit. */
+	ret = commit_tree_with_edited_message(repo, "reworded", original_commit,
+					      &original_commit_tree_oid,
+					      original_commit->parents, &parent_tree_oid,
+					      &rewritten_commit);
+	if (ret < 0) {
+		ret = error(_("failed writing reworded commit"));
+		goto out;
+	}
+
+	replace_commits(&commits, &original_commit->object.oid, &rewritten_commit, 1);
+
+	ret = apply_commits(repo, &commits, parent, head, "reword");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	strvec_clear(&commits);
+	return ret;
+}
 
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
-		struct repository *repo UNUSED)
+		struct repository *repo)
 {
 	const char * const usage[] = {
-		N_("git history [<options>]"),
+		GIT_HISTORY_REWORD_USAGE,
 		NULL,
 	};
+	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
+		OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
 		OPT_END(),
 	};
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
-	if (argc)
-		usagef("unrecognized argument: %s", argv[0]);
-	return 0;
+	return fn(argc, argv, prefix, repo);
 }
diff --git a/t/meson.build b/t/meson.build
index 62f5dca098..e187aef31e 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -387,6 +387,7 @@ integration_tests = [
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
+  't3451-history-reword.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
index 417c343d43..f513463b92 100755
--- a/t/t3450-history.sh
+++ b/t/t3450-history.sh
@@ -5,13 +5,13 @@ test_description='tests for git-history command'
 . ./test-lib.sh
 
 test_expect_success 'does nothing without any arguments' '
-	git history >out 2>&1 &&
-	test_must_be_empty out
+	test_must_fail git history 2>err &&
+	test_grep "need a subcommand" err
 '
 
 test_expect_success 'raises an error with unknown argument' '
 	test_must_fail git history garbage 2>err &&
-	test_grep "unrecognized argument: garbage" err
+	test_grep "unknown subcommand: .garbage." err
 '
 
 test_done
diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
new file mode 100755
index 0000000000..2a638d2378
--- /dev/null
+++ b/t/t3451-history-reword.sh
@@ -0,0 +1,236 @@
+#!/bin/sh
+
+test_description='tests for git-history reword subcommand'
+
+. ./test-lib.sh
+
+reword_with_message () {
+	cat >message &&
+	write_script fake-editor.sh <<-\EOF &&
+	cp message "$1"
+	EOF
+	test_set_editor "$(pwd)"/fake-editor.sh &&
+	git history reword "$@" &&
+	rm fake-editor.sh message
+}
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history reword HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err &&
+		test_must_fail git history reword HEAD 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work with unrelated commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		test_must_fail git history reword ours 2>err &&
+		test_grep "commit must be reachable from current HEAD commit" err
+	)
+'
+
+test_expect_success 'can reword tip of a branch' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		reword_with_message HEAD <<-EOF &&
+		third reworded
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		third reworded
+		second
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+
+		git reflog >reflog &&
+		test_grep "reword: updating HEAD" reflog
+	)
+'
+
+test_expect_success 'can reword commit in the middle' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		reword_with_message HEAD~ <<-EOF &&
+		second reworded
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'can reword root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		reword_with_message HEAD~2 <<-EOF &&
+		first reworded
+		EOF
+
+		cat >expect <<-EOF &&
+		third
+		second
+		first reworded
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'editor shows proper status' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		printf "\namend a comment\n" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+		git history reword HEAD &&
+
+		cat >expect <<-EOF &&
+		first
+
+		# Please enter the commit message for the reworded changes. Lines starting
+		# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+		# Changes to be committed:
+		#	new file:   first.t
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		test_commit_message HEAD <<-\EOF
+		first
+
+		amend a comment
+		EOF
+	)
+'
+
+# For now, git-history(1) does not yet execute any hooks. This is subject to
+# change in the future, and if it does this test here is expected to start
+# failing. In other words, this test is not an endorsement of the current
+# status quo.
+test_expect_success 'hooks are not executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		ORIG_PATH="$(pwd)" &&
+		export ORIG_PATH &&
+		for hook in prepare-commit-msg pre-commit post-commit post-rewrite commit-msg
+		do
+			write_script .git/hooks/$hook <<-\EOF || exit 1
+			touch "$ORIG_PATH/hooks.log
+			EOF
+		done &&
+
+		reword_with_message HEAD~ <<-EOF &&
+		second reworded
+		EOF
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+
+		test_path_is_missing hooks.log
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		! reword_with_message HEAD 2>err </dev/null &&
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_expect_success 'retains changes in the worktree and index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch a b &&
+		git add . &&
+		git commit -m "initial commit" &&
+		echo foo >a &&
+		echo bar >b &&
+		git add b &&
+		reword_with_message HEAD <<-EOF &&
+		message
+		EOF
+		cat >expect <<-\EOF &&
+		 M a
+		M  b
+		?? actual
+		?? expect
+		EOF
+		git status --porcelain >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.52.0.239.gd5f0c6e74e.dirty
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 06/12] add-patch: split out header from "add-interactive.h"

While we have a "add-patch.c" code file, its declarations are part of "add-interactive.h". This makes it somewhat harder than necessary to find relevant code and to identify clear boundaries between the two subsystems.

Split up concerns and move declarations that relate to "add-patch.c" into a new "add-patch.h" header.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.h | 23 +++--------------------
 add-patch.c       |  1 +
 add-patch.h       | 26 ++++++++++++++++++++++++++
 3 files changed, 30 insertions(+), 20 deletions(-)
Show changes to 3 files +30 −20

add-interactive.h, add-patch.c, add-patch.h

diff --git a/add-interactive.h b/add-interactive.h
index da49502b76..2e3d1d871d 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -1,14 +1,11 @@
 #ifndef ADD_INTERACTIVE_H
 #define ADD_INTERACTIVE_H
 
+#include "add-patch.h"
 #include "color.h"
 
-struct add_p_opt {
-	int context;
-	int interhunkcontext;
-};
-
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+struct pathspec;
+struct repository;
 
 struct add_i_state {
 	struct repository *r;
@@ -35,21 +32,7 @@ void init_add_i_state(struct add_i_state *s, struct repository *r,
 		      struct add_p_opt *add_p_opt);
 void clear_add_i_state(struct add_i_state *s);
 
-struct repository;
-struct pathspec;
 int run_add_i(struct repository *r, const struct pathspec *ps,
 	      struct add_p_opt *add_p_opt);
 
-enum add_p_mode {
-	ADD_P_ADD,
-	ADD_P_STASH,
-	ADD_P_RESET,
-	ADD_P_CHECKOUT,
-	ADD_P_WORKTREE,
-};
-
-int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
-	      const struct pathspec *ps);
-
 #endif
diff --git a/add-patch.c b/add-patch.c
index 173a53241e..5e3481083b 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -3,6 +3,7 @@
 
 #include "git-compat-util.h"
 #include "add-interactive.h"
+#include "add-patch.h"
 #include "advice.h"
 #include "editor.h"
 #include "environment.h"
diff --git a/add-patch.h b/add-patch.h
new file mode 100644
index 0000000000..4394c74107
--- /dev/null
+++ b/add-patch.h
@@ -0,0 +1,26 @@
+#ifndef ADD_PATCH_H
+#define ADD_PATCH_H
+
+struct pathspec;
+struct repository;
+
+struct add_p_opt {
+	int context;
+	int interhunkcontext;
+};
+
+#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+
+enum add_p_mode {
+	ADD_P_ADD,
+	ADD_P_STASH,
+	ADD_P_RESET,
+	ADD_P_CHECKOUT,
+	ADD_P_WORKTREE,
+};
+
+int run_add_p(struct repository *r, enum add_p_mode mode,
+	      struct add_p_opt *o, const char *revision,
+	      const struct pathspec *ps);
+
+#endif
-- 
2.52.0.239.gd5f0c6e74e.dirty
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 07/12] add-patch: split out `struct interactive_options`

The `struct add_p_opt` is reused both by our infra for "git add -p" and "git add -i". Users of `run_add_i()` for example are expected to pass `struct add_p_opt`. This is somewhat confusing and raises the question of which options apply to what part of the stack.

But things are even more confusing than that: while callers are expected to pass in `struct add_p_opt`, these options ultimately get used to initialize a `struct add_i_state` that is used by both subsystems. So we are basically going full circle here.

Refactor the code and split out a new `struct interactive_options` that hosts common options used by both. These options are then applied to a `struct interactive_config` that hosts common configuration.

This refactoring doesn't yet fully detangle the two subsystems from one another, as we still end up calling `init_add_i_state()` in the "git add -p" subsystem. This will be fixed in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.c  | 174 +++++++++++------------------------------------------
 add-interactive.h  |  23 +------
 add-patch.c        | 170 +++++++++++++++++++++++++++++++++++++++++++--------
 add-patch.h        |  36 ++++++++++-
 builtin/add.c      |  22 +++----
 builtin/checkout.c |   4 +-
 builtin/commit.c   |  16 ++---
 builtin/reset.c    |  16 ++---
 builtin/stash.c    |  46 +++++++-------
 commit.h           |   2 +-
 10 files changed, 270 insertions(+), 239 deletions(-)
Show changes to 10 files +270 −239

add-interactive.c, add-interactive.h, add-patch.c, add-patch.h, builtin/add.c, builtin/checkout.c, builtin/commit.c, builtin/reset.c, builtin/stash.c, commit.h

diff --git a/add-interactive.c b/add-interactive.c
index 68fc09547d..05d2e7eefe 100644
--- a/add-interactive.c
+++ b/add-interactive.c
@@ -3,7 +3,6 @@
 #include "git-compat-util.h"
 #include "add-interactive.h"
 #include "color.h"
-#include "config.h"
 #include "diffcore.h"
 #include "gettext.h"
 #include "hash.h"
@@ -20,119 +19,18 @@
 #include "prompt.h"
 #include "tree.h"
 
-static void init_color(struct repository *r, enum git_colorbool use_color,
-		       const char *section_and_slot, char *dst,
-		       const char *default_color)
-{
-	char *key = xstrfmt("color.%s", section_and_slot);
-	const char *value;
-
-	if (!want_color(use_color))
-		dst[0] = '\0';
-	else if (repo_config_get_value(r, key, &value) ||
-		 color_parse(value, dst))
-		strlcpy(dst, default_color, COLOR_MAXLEN);
-
-	free(key);
-}
-
-static enum git_colorbool check_color_config(struct repository *r, const char *var)
-{
-	const char *value;
-	enum git_colorbool ret;
-
-	if (repo_config_get_value(r, var, &value))
-		ret = GIT_COLOR_UNKNOWN;
-	else
-		ret = git_config_colorbool(var, value);
-
-	/*
-	 * Do not rely on want_color() to fall back to color.ui for us. It uses
-	 * the value parsed by git_color_config(), which may not have been
-	 * called by the main command.
-	 */
-	if (ret == GIT_COLOR_UNKNOWN &&
-	    !repo_config_get_value(r, "color.ui", &value))
-		ret = git_config_colorbool("color.ui", value);
-
-	return ret;
-}
-
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt)
+		      struct interactive_options *opts)
 {
 	s->r = r;
-	s->context = -1;
-	s->interhunkcontext = -1;
-
-	s->use_color_interactive = check_color_config(r, "color.interactive");
-
-	init_color(r, s->use_color_interactive, "interactive.header",
-		   s->header_color, GIT_COLOR_BOLD);
-	init_color(r, s->use_color_interactive, "interactive.help",
-		   s->help_color, GIT_COLOR_BOLD_RED);
-	init_color(r, s->use_color_interactive, "interactive.prompt",
-		   s->prompt_color, GIT_COLOR_BOLD_BLUE);
-	init_color(r, s->use_color_interactive, "interactive.error",
-		   s->error_color, GIT_COLOR_BOLD_RED);
-	strlcpy(s->reset_color_interactive,
-		want_color(s->use_color_interactive) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
-
-	s->use_color_diff = check_color_config(r, "color.diff");
-
-	init_color(r, s->use_color_diff, "diff.frag", s->fraginfo_color,
-		   diff_get_color(s->use_color_diff, DIFF_FRAGINFO));
-	init_color(r, s->use_color_diff, "diff.context", s->context_color,
-		   "fall back");
-	if (!strcmp(s->context_color, "fall back"))
-		init_color(r, s->use_color_diff, "diff.plain",
-			   s->context_color,
-			   diff_get_color(s->use_color_diff, DIFF_CONTEXT));
-	init_color(r, s->use_color_diff, "diff.old", s->file_old_color,
-		   diff_get_color(s->use_color_diff, DIFF_FILE_OLD));
-	init_color(r, s->use_color_diff, "diff.new", s->file_new_color,
-		   diff_get_color(s->use_color_diff, DIFF_FILE_NEW));
-	strlcpy(s->reset_color_diff,
-		want_color(s->use_color_diff) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
-
-	FREE_AND_NULL(s->interactive_diff_filter);
-	repo_config_get_string(r, "interactive.difffilter",
-			       &s->interactive_diff_filter);
-
-	FREE_AND_NULL(s->interactive_diff_algorithm);
-	repo_config_get_string(r, "diff.algorithm",
-			       &s->interactive_diff_algorithm);
-
-	if (!repo_config_get_int(r, "diff.context", &s->context))
-		if (s->context < 0)
-			die(_("%s cannot be negative"), "diff.context");
-	if (!repo_config_get_int(r, "diff.interHunkContext", &s->interhunkcontext))
-		if (s->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "diff.interHunkContext");
-
-	repo_config_get_bool(r, "interactive.singlekey", &s->use_single_key);
-	if (s->use_single_key)
-		setbuf(stdin, NULL);
-
-	if (add_p_opt->context != -1) {
-		if (add_p_opt->context < 0)
-			die(_("%s cannot be negative"), "--unified");
-		s->context = add_p_opt->context;
-	}
-	if (add_p_opt->interhunkcontext != -1) {
-		if (add_p_opt->interhunkcontext < 0)
-			die(_("%s cannot be negative"), "--inter-hunk-context");
-		s->interhunkcontext = add_p_opt->interhunkcontext;
-	}
+	interactive_config_init(&s->cfg, r, opts);
 }
 
 void clear_add_i_state(struct add_i_state *s)
 {
-	FREE_AND_NULL(s->interactive_diff_filter);
-	FREE_AND_NULL(s->interactive_diff_algorithm);
+	interactive_config_clear(&s->cfg);
 	memset(s, 0, sizeof(*s));
-	s->use_color_interactive = GIT_COLOR_UNKNOWN;
-	s->use_color_diff = GIT_COLOR_UNKNOWN;
+	interactive_config_clear(&s->cfg);
 }
 
 /*
@@ -286,7 +184,7 @@ static void list(struct add_i_state *s, struct string_list *list, int *selected,
 		return;
 
 	if (opts->header)
-		color_fprintf_ln(stdout, s->header_color,
+		color_fprintf_ln(stdout, s->cfg.header_color,
 				 "%s", opts->header);
 
 	for (i = 0; i < list->nr; i++) {
@@ -354,7 +252,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 		list(s, &items->items, items->selected, &opts->list_opts);
 
-		color_fprintf(stdout, s->prompt_color, "%s", opts->prompt);
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", opts->prompt);
 		fputs(singleton ? "> " : ">> ", stdout);
 		fflush(stdout);
 
@@ -432,7 +330,7 @@ static ssize_t list_and_choose(struct add_i_state *s,
 
 			if (from < 0 || from >= items->items.nr ||
 			    (singleton && from + 1 != to)) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("Huh (%s)?"), p);
 				break;
 			} else if (singleton) {
@@ -992,7 +890,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 				free(files->items.items[i].string);
 			} else if (item->index.unmerged ||
 				 item->worktree.unmerged) {
-				color_fprintf_ln(stderr, s->error_color,
+				color_fprintf_ln(stderr, s->cfg.error_color,
 						 _("ignoring unmerged: %s"),
 						 files->items.items[i].string);
 				free(item);
@@ -1014,9 +912,9 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 	opts->prompt = N_("Patch update");
 	count = list_and_choose(s, files, opts);
 	if (count > 0) {
-		struct add_p_opt add_p_opt = {
-			.context = s->context,
-			.interhunkcontext = s->interhunkcontext,
+		struct interactive_options opts = {
+			.context = s->cfg.context,
+			.interhunkcontext = s->cfg.interhunkcontext,
 		};
 		struct strvec args = STRVEC_INIT;
 		struct pathspec ps_selected = { 0 };
@@ -1028,7 +926,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 		parse_pathspec(&ps_selected,
 			       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,
 			       PATHSPEC_LITERAL_PATH, "", args.v);
-		res = run_add_p(s->r, ADD_P_ADD, &add_p_opt, NULL, &ps_selected);
+		res = run_add_p(s->r, ADD_P_ADD, &opts, NULL, &ps_selected);
 		strvec_clear(&args);
 		clear_pathspec(&ps_selected);
 	}
@@ -1064,10 +962,10 @@ static int run_diff(struct add_i_state *s, const struct pathspec *ps,
 		struct child_process cmd = CHILD_PROCESS_INIT;
 
 		strvec_pushl(&cmd.args, "git", "diff", "-p", "--cached", NULL);
-		if (s->context != -1)
-			strvec_pushf(&cmd.args, "--unified=%i", s->context);
-		if (s->interhunkcontext != -1)
-			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->interhunkcontext);
+		if (s->cfg.context != -1)
+			strvec_pushf(&cmd.args, "--unified=%i", s->cfg.context);
+		if (s->cfg.interhunkcontext != -1)
+			strvec_pushf(&cmd.args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
 		strvec_pushl(&cmd.args, oid_to_hex(!is_initial ? &oid :
 			     s->r->hash_algo->empty_tree), "--", NULL);
 		for (i = 0; i < files->items.nr; i++)
@@ -1085,17 +983,17 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 		    struct prefix_item_list *files UNUSED,
 		    struct list_and_choose_options *opts UNUSED)
 {
-	color_fprintf_ln(stdout, s->help_color, "status        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "status        - %s",
 			 _("show paths with changes"));
-	color_fprintf_ln(stdout, s->help_color, "update        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "update        - %s",
 			 _("add working tree state to the staged set of changes"));
-	color_fprintf_ln(stdout, s->help_color, "revert        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "revert        - %s",
 			 _("revert staged set of changes back to the HEAD version"));
-	color_fprintf_ln(stdout, s->help_color, "patch         - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "patch         - %s",
 			 _("pick hunks and update selectively"));
-	color_fprintf_ln(stdout, s->help_color, "diff          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "diff          - %s",
 			 _("view diff between HEAD and index"));
-	color_fprintf_ln(stdout, s->help_color, "add untracked - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "add untracked - %s",
 			 _("add contents of untracked files to the staged set of changes"));
 
 	return 0;
@@ -1103,21 +1001,21 @@ static int run_help(struct add_i_state *s, const struct pathspec *ps UNUSED,
 
 static void choose_prompt_help(struct add_i_state *s)
 {
-	color_fprintf_ln(stdout, s->help_color, "%s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "%s",
 			 _("Prompt help:"));
-	color_fprintf_ln(stdout, s->help_color, "1          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "1          - %s",
 			 _("select a single item"));
-	color_fprintf_ln(stdout, s->help_color, "3-5        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "3-5        - %s",
 			 _("select a range of items"));
-	color_fprintf_ln(stdout, s->help_color, "2-3,6-9    - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "2-3,6-9    - %s",
 			 _("select multiple ranges"));
-	color_fprintf_ln(stdout, s->help_color, "foo        - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "foo        - %s",
 			 _("select item based on unique prefix"));
-	color_fprintf_ln(stdout, s->help_color, "-...       - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "-...       - %s",
 			 _("unselect specified items"));
-	color_fprintf_ln(stdout, s->help_color, "*          - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "*          - %s",
 			 _("choose all items"));
-	color_fprintf_ln(stdout, s->help_color, "           - %s",
+	color_fprintf_ln(stdout, s->cfg.help_color, "           - %s",
 			 _("(empty) finish selecting"));
 }
 
@@ -1152,7 +1050,7 @@ static void print_command_item(int i, int selected UNUSED,
 
 static void command_prompt_help(struct add_i_state *s)
 {
-	const char *help_color = s->help_color;
+	const char *help_color = s->cfg.help_color;
 	color_fprintf_ln(stdout, help_color, "%s", _("Prompt help:"));
 	color_fprintf_ln(stdout, help_color, "1          - %s",
 			 _("select a numbered item"));
@@ -1163,7 +1061,7 @@ static void command_prompt_help(struct add_i_state *s)
 }
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt)
+	      struct interactive_options *interactive_opts)
 {
 	struct add_i_state s = { NULL };
 	struct print_command_item_data data = { "[", "]" };
@@ -1206,15 +1104,15 @@ int run_add_i(struct repository *r, const struct pathspec *ps,
 			->util = util;
 	}
 
-	init_add_i_state(&s, r, add_p_opt);
+	init_add_i_state(&s, r, interactive_opts);
 
 	/*
 	 * When color was asked for, use the prompt color for
 	 * highlighting, otherwise use square brackets.
 	 */
-	if (want_color(s.use_color_interactive)) {
-		data.color = s.prompt_color;
-		data.reset = s.reset_color_interactive;
+	if (want_color(s.cfg.use_color_interactive)) {
+		data.color = s.cfg.prompt_color;
+		data.reset = s.cfg.reset_color_interactive;
 	}
 	print_file_item_data.color = data.color;
 	print_file_item_data.reset = data.reset;
diff --git a/add-interactive.h b/add-interactive.h
index 2e3d1d871d..eefa2edc7c 100644
--- a/add-interactive.h
+++ b/add-interactive.h
@@ -2,37 +2,20 @@
 #define ADD_INTERACTIVE_H
 
 #include "add-patch.h"
-#include "color.h"
 
 struct pathspec;
 struct repository;
 
 struct add_i_state {
 	struct repository *r;
-	enum git_colorbool use_color_interactive;
-	enum git_colorbool use_color_diff;
-	char header_color[COLOR_MAXLEN];
-	char help_color[COLOR_MAXLEN];
-	char prompt_color[COLOR_MAXLEN];
-	char error_color[COLOR_MAXLEN];
-	char reset_color_interactive[COLOR_MAXLEN];
-
-	char fraginfo_color[COLOR_MAXLEN];
-	char context_color[COLOR_MAXLEN];
-	char file_old_color[COLOR_MAXLEN];
-	char file_new_color[COLOR_MAXLEN];
-	char reset_color_diff[COLOR_MAXLEN];
-
-	int use_single_key;
-	char *interactive_diff_filter, *interactive_diff_algorithm;
-	int context, interhunkcontext;
+	struct interactive_config cfg;
 };
 
 void init_add_i_state(struct add_i_state *s, struct repository *r,
-		      struct add_p_opt *add_p_opt);
+		      struct interactive_options *opts);
 void clear_add_i_state(struct add_i_state *s);
 
 int run_add_i(struct repository *r, const struct pathspec *ps,
-	      struct add_p_opt *add_p_opt);
+	      struct interactive_options *opts);
 
 #endif
diff --git a/add-patch.c b/add-patch.c
index 5e3481083b..6797d2f690 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -5,6 +5,8 @@
 #include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "config.h"
+#include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
@@ -279,6 +281,122 @@ struct add_p_state {
 	const char *revision;
 };
 
+static void init_color(struct repository *r,
+		       enum git_colorbool use_color,
+		       const char *section_and_slot, char *dst,
+		       const char *default_color)
+{
+	char *key = xstrfmt("color.%s", section_and_slot);
+	const char *value;
+
+	if (!want_color(use_color))
+		dst[0] = '\0';
+	else if (repo_config_get_value(r, key, &value) ||
+		 color_parse(value, dst))
+		strlcpy(dst, default_color, COLOR_MAXLEN);
+
+	free(key);
+}
+
+static enum git_colorbool check_color_config(struct repository *r, const char *var)
+{
+	const char *value;
+	enum git_colorbool ret;
+
+	if (repo_config_get_value(r, var, &value))
+		ret = GIT_COLOR_UNKNOWN;
+	else
+		ret = git_config_colorbool(var, value);
+
+	/*
+	 * Do not rely on want_color() to fall back to color.ui for us. It uses
+	 * the value parsed by git_color_config(), which may not have been
+	 * called by the main command.
+	 */
+	if (ret == GIT_COLOR_UNKNOWN &&
+	    !repo_config_get_value(r, "color.ui", &value))
+		ret = git_config_colorbool("color.ui", value);
+
+	return ret;
+}
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts)
+{
+	cfg->context = -1;
+	cfg->interhunkcontext = -1;
+
+	cfg->use_color_interactive = check_color_config(r, "color.interactive");
+
+	init_color(r, cfg->use_color_interactive, "interactive.header",
+		   cfg->header_color, GIT_COLOR_BOLD);
+	init_color(r, cfg->use_color_interactive, "interactive.help",
+		   cfg->help_color, GIT_COLOR_BOLD_RED);
+	init_color(r, cfg->use_color_interactive, "interactive.prompt",
+		   cfg->prompt_color, GIT_COLOR_BOLD_BLUE);
+	init_color(r, cfg->use_color_interactive, "interactive.error",
+		   cfg->error_color, GIT_COLOR_BOLD_RED);
+	strlcpy(cfg->reset_color_interactive,
+		want_color(cfg->use_color_interactive) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
+
+	cfg->use_color_diff = check_color_config(r, "color.diff");
+
+	init_color(r, cfg->use_color_diff, "diff.frag", cfg->fraginfo_color,
+		   diff_get_color(cfg->use_color_diff, DIFF_FRAGINFO));
+	init_color(r, cfg->use_color_diff, "diff.context", cfg->context_color,
+		   "fall back");
+	if (!strcmp(cfg->context_color, "fall back"))
+		init_color(r, cfg->use_color_diff, "diff.plain",
+			   cfg->context_color,
+			   diff_get_color(cfg->use_color_diff, DIFF_CONTEXT));
+	init_color(r, cfg->use_color_diff, "diff.old", cfg->file_old_color,
+		diff_get_color(cfg->use_color_diff, DIFF_FILE_OLD));
+	init_color(r, cfg->use_color_diff, "diff.new", cfg->file_new_color,
+		diff_get_color(cfg->use_color_diff, DIFF_FILE_NEW));
+	strlcpy(cfg->reset_color_diff,
+		want_color(cfg->use_color_diff) ? GIT_COLOR_RESET : "", COLOR_MAXLEN);
+
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	repo_config_get_string(r, "interactive.difffilter",
+			       &cfg->interactive_diff_filter);
+
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	repo_config_get_string(r, "diff.algorithm",
+			       &cfg->interactive_diff_algorithm);
+
+	if (!repo_config_get_int(r, "diff.context", &cfg->context))
+		if (cfg->context < 0)
+			die(_("%s cannot be negative"), "diff.context");
+	if (!repo_config_get_int(r, "diff.interHunkContext", &cfg->interhunkcontext))
+		if (cfg->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "diff.interHunkContext");
+
+	repo_config_get_bool(r, "interactive.singlekey", &cfg->use_single_key);
+	if (cfg->use_single_key)
+		setbuf(stdin, NULL);
+
+	if (opts->context != -1) {
+		if (opts->context < 0)
+			die(_("%s cannot be negative"), "--unified");
+		cfg->context = opts->context;
+	}
+	if (opts->interhunkcontext != -1) {
+		if (opts->interhunkcontext < 0)
+			die(_("%s cannot be negative"), "--inter-hunk-context");
+		cfg->interhunkcontext = opts->interhunkcontext;
+	}
+}
+
+void interactive_config_clear(struct interactive_config *cfg)
+{
+	FREE_AND_NULL(cfg->interactive_diff_filter);
+	FREE_AND_NULL(cfg->interactive_diff_algorithm);
+	memset(cfg, 0, sizeof(*cfg));
+	cfg->use_color_interactive = GIT_COLOR_UNKNOWN;
+	cfg->use_color_diff = GIT_COLOR_UNKNOWN;
+}
+
 static void add_p_state_clear(struct add_p_state *s)
 {
 	size_t i;
@@ -299,9 +417,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.error_color, stdout);
+	fputs(s->s.cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.reset_color_interactive);
+	puts(s->s.cfg.reset_color_interactive);
 	va_end(args);
 }
 
@@ -424,12 +542,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.context);
-	if (s->s.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.interhunkcontext);
-	if (s->s.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.interactive_diff_algorithm);
+	if (s->s.cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
+	if (s->s.cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
+	if (s->s.cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -458,9 +576,9 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	}
 	strbuf_complete_line(plain);
 
-	if (want_color_fd(1, s->s.use_color_diff)) {
+	if (want_color_fd(1, s->s.cfg.use_color_diff)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.interactive_diff_filter;
+		const char *diff_filter = s->s.cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -693,7 +811,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.fraginfo_color);
+			strbuf_addstr(out, s->s.cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -715,7 +833,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.reset_color_diff);
+			strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1104,12 +1222,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.file_old_color :
+			      s->s.cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.file_new_color :
-			      s->s.context_color);
+			      s->s.cfg.file_new_color :
+			      s->s.cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.reset_color_diff);
+		strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1238,7 +1356,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.use_single_key) {
+	if (s->s.cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1252,7 +1370,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1560,15 +1678,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.reset_color_interactive)
-			fputs(s->s.reset_color_interactive, stdout);
+		if (*s->s.cfg.reset_color_interactive)
+			fputs(s->s.cfg.reset_color_interactive, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF) {
 			quit = 1;
@@ -1731,7 +1849,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.header_color,
+				color_fprintf_ln(stdout, s->s.cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1749,7 +1867,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.help_color, "%s",
+			color_fprintf(stdout, s->s.cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1767,7 +1885,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.help_color,
+				color_fprintf_ln(stdout, s->s.cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1808,7 +1926,7 @@ static int patch_update_file(struct add_p_state *s,
 }
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
@@ -1816,7 +1934,7 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, o);
+	init_add_i_state(&s.s, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
diff --git a/add-patch.h b/add-patch.h
index 4394c74107..a4a05d9d14 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -1,15 +1,45 @@
 #ifndef ADD_PATCH_H
 #define ADD_PATCH_H
 
+#include "color.h"
+
 struct pathspec;
 struct repository;
 
-struct add_p_opt {
+struct interactive_options {
 	int context;
 	int interhunkcontext;
 };
 
-#define ADD_P_OPT_INIT { .context = -1, .interhunkcontext = -1 }
+#define INTERACTIVE_OPTIONS_INIT { \
+	.context = -1, \
+	.interhunkcontext = -1, \
+}
+
+struct interactive_config {
+	enum git_colorbool use_color_interactive;
+	enum git_colorbool use_color_diff;
+	char header_color[COLOR_MAXLEN];
+	char help_color[COLOR_MAXLEN];
+	char prompt_color[COLOR_MAXLEN];
+	char error_color[COLOR_MAXLEN];
+	char reset_color_interactive[COLOR_MAXLEN];
+
+	char fraginfo_color[COLOR_MAXLEN];
+	char context_color[COLOR_MAXLEN];
+	char file_old_color[COLOR_MAXLEN];
+	char file_new_color[COLOR_MAXLEN];
+	char reset_color_diff[COLOR_MAXLEN];
+
+	int use_single_key;
+	char *interactive_diff_filter, *interactive_diff_algorithm;
+	int context, interhunkcontext;
+};
+
+void interactive_config_init(struct interactive_config *cfg,
+			     struct repository *r,
+			     struct interactive_options *opts);
+void interactive_config_clear(struct interactive_config *cfg);
 
 enum add_p_mode {
 	ADD_P_ADD,
@@ -20,7 +50,7 @@ enum add_p_mode {
 };
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
-	      struct add_p_opt *o, const char *revision,
+	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
 #endif
diff --git a/builtin/add.c b/builtin/add.c
index 32709794b3..6f1e213052 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -31,7 +31,7 @@ static const char * const builtin_add_usage[] = {
 	NULL
 };
 static int patch_interactive, add_interactive, edit_interactive;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int take_worktree_changes;
 static int add_renormalize;
 static int pathspec_file_nul;
@@ -160,7 +160,7 @@ static int refresh(struct repository *repo, int verbose, const struct pathspec *
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt)
+		    int patch, struct interactive_options *interactive_opts)
 {
 	struct pathspec pathspec;
 	int ret;
@@ -172,9 +172,9 @@ int interactive_add(struct repository *repo,
 		       prefix, argv);
 
 	if (patch)
-		ret = !!run_add_p(repo, ADD_P_ADD, add_p_opt, NULL, &pathspec);
+		ret = !!run_add_p(repo, ADD_P_ADD, interactive_opts, NULL, &pathspec);
 	else
-		ret = !!run_add_i(repo, &pathspec, add_p_opt);
+		ret = !!run_add_i(repo, &pathspec, interactive_opts);
 
 	clear_pathspec(&pathspec);
 	return ret;
@@ -256,8 +256,8 @@ static struct option builtin_add_options[] = {
 	OPT_GROUP(""),
 	OPT_BOOL('i', "interactive", &add_interactive, N_("interactive picking")),
 	OPT_BOOL('p', "patch", &patch_interactive, N_("select hunks interactively")),
-	OPT_DIFF_UNIFIED(&add_p_opt.context),
-	OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+	OPT_DIFF_UNIFIED(&interactive_opts.context),
+	OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 	OPT_BOOL('e', "edit", &edit_interactive, N_("edit current diff and apply")),
 	OPT__FORCE(&ignored_too, N_("allow adding otherwise ignored files"), 0),
 	OPT_BOOL('u', "update", &take_worktree_changes, N_("update tracked files")),
@@ -400,9 +400,9 @@ int cmd_add(int argc,
 	prepare_repo_settings(repo);
 	repo->settings.command_requires_full_index = 0;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (patch_interactive)
@@ -412,11 +412,11 @@ int cmd_add(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--dry-run", "--interactive/--patch");
 		if (pathspec_from_file)
 			die(_("options '%s' and '%s' cannot be used together"), "--pathspec-from-file", "--interactive/--patch");
-		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &add_p_opt));
+		exit(interactive_add(repo, argv + 1, prefix, patch_interactive, &interactive_opts));
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
diff --git a/builtin/checkout.c b/builtin/checkout.c
index 66b69df6e6..530ae956ad 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -546,7 +546,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 
 	if (opts->patch_mode) {
 		enum add_p_mode patch_mode;
-		struct add_p_opt add_p_opt = {
+		struct interactive_options interactive_opts = {
 			.context = opts->patch_context,
 			.interhunkcontext = opts->patch_interhunk_context,
 		};
@@ -575,7 +575,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 		else
 			BUG("either flag must have been set, worktree=%d, index=%d",
 			    opts->checkout_worktree, opts->checkout_index);
-		return !!run_add_p(the_repository, patch_mode, &add_p_opt,
+		return !!run_add_p(the_repository, patch_mode, &interactive_opts,
 				   rev, &opts->pathspec);
 	}
 
diff --git a/builtin/commit.c b/builtin/commit.c
index 0243f17d53..640495cc57 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -123,7 +123,7 @@ static const char *edit_message, *use_message;
 static char *fixup_message, *fixup_commit, *squash_message;
 static const char *fixup_prefix;
 static int all, also, interactive, patch_interactive, only, amend, signoff;
-static struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+static struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 static int edit_flag = -1; /* unspecified */
 static int quiet, verbose, no_verify, allow_empty, dry_run, renew_authorship;
 static int config_commit_verbose = -1; /* unspecified */
@@ -356,9 +356,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 	const char *ret;
 	char *path = NULL;
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (is_status)
@@ -407,7 +407,7 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 		setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-		if (interactive_add(the_repository, argv, prefix, patch_interactive, &add_p_opt) != 0)
+		if (interactive_add(the_repository, argv, prefix, patch_interactive, &interactive_opts) != 0)
 			die(_("interactive add failed"));
 
 		the_repository->index_file = old_repo_index_file;
@@ -432,9 +432,9 @@ static const char *prepare_index(const char **argv, const char *prefix,
 		ret = get_lock_file_path(&index_lock);
 		goto out;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--interactive/--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--interactive/--patch");
 	}
 
@@ -1742,8 +1742,8 @@ int cmd_commit(int argc,
 		OPT_BOOL('i', "include", &also, N_("add specified files to index for commit")),
 		OPT_BOOL(0, "interactive", &interactive, N_("interactively add files")),
 		OPT_BOOL('p', "patch", &patch_interactive, N_("interactively add changes")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('o', "only", &only, N_("commit only specified files")),
 		OPT_BOOL('n', "no-verify", &no_verify, N_("bypass pre-commit and commit-msg hooks")),
 		OPT_BOOL(0, "dry-run", &dry_run, N_("show what would be committed")),
diff --git a/builtin/reset.c b/builtin/reset.c
index ed35802af1..088449e120 100644
--- a/builtin/reset.c
+++ b/builtin/reset.c
@@ -346,7 +346,7 @@ int cmd_reset(int argc,
 	struct object_id oid;
 	struct pathspec pathspec;
 	int intent_to_add = 0;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	const struct option options[] = {
 		OPT__QUIET(&quiet, N_("be quiet, only report errors")),
 		OPT_BOOL(0, "no-refresh", &no_refresh,
@@ -371,8 +371,8 @@ int cmd_reset(int argc,
 			       PARSE_OPT_OPTARG,
 			       option_parse_recurse_submodules_worktree_updater),
 		OPT_BOOL('p', "patch", &patch_mode, N_("select hunks interactively")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT_BOOL('N', "intent-to-add", &intent_to_add,
 				N_("record only the fact that removed paths will be added later")),
 		OPT_PATHSPEC_FROM_FILE(&pathspec_from_file),
@@ -423,9 +423,9 @@ int cmd_reset(int argc,
 		oidcpy(&oid, &tree->object.oid);
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	prepare_repo_settings(the_repository);
@@ -436,12 +436,12 @@ int cmd_reset(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--patch", "--{hard,mixed,soft}");
 		trace2_cmd_mode("patch-interactive");
 		update_ref_status = !!run_add_p(the_repository, ADD_P_RESET,
-						&add_p_opt, rev, &pathspec);
+						&interactive_opts, rev, &pathspec);
 		goto cleanup;
 	} else {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
diff --git a/builtin/stash.c b/builtin/stash.c
index 948eba06fb..3b50905233 100644
--- a/builtin/stash.c
+++ b/builtin/stash.c
@@ -1306,7 +1306,7 @@ static int stash_staged(struct stash_info *info, struct strbuf *out_patch,
 
 static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 		       struct strbuf *out_patch, int quiet,
-		       struct add_p_opt *add_p_opt)
+		       struct interactive_options *interactive_opts)
 {
 	int ret = 0;
 	struct child_process cp_read_tree = CHILD_PROCESS_INIT;
@@ -1331,7 +1331,7 @@ static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 	old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 	setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-	ret = !!run_add_p(the_repository, ADD_P_STASH, add_p_opt, NULL, ps);
+	ret = !!run_add_p(the_repository, ADD_P_STASH, interactive_opts, NULL, ps);
 
 	the_repository->index_file = old_repo_index_file;
 	if (old_index_env && *old_index_env)
@@ -1427,7 +1427,8 @@ static int stash_working_tree(struct stash_info *info, const struct pathspec *ps
 }
 
 static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_buf,
-			   int include_untracked, int patch_mode, struct add_p_opt *add_p_opt,
+			   int include_untracked, int patch_mode,
+			   struct interactive_options *interactive_opts,
 			   int only_staged, struct stash_info *info, struct strbuf *patch,
 			   int quiet)
 {
@@ -1509,7 +1510,7 @@ static int do_create_stash(const struct pathspec *ps, struct strbuf *stash_msg_b
 		untracked_commit_option = 1;
 	}
 	if (patch_mode) {
-		ret = stash_patch(info, ps, patch, quiet, add_p_opt);
+		ret = stash_patch(info, ps, patch, quiet, interactive_opts);
 		if (ret < 0) {
 			if (!quiet)
 				fprintf_ln(stderr, _("Cannot save the current "
@@ -1595,7 +1596,8 @@ static int create_stash(int argc, const char **argv, const char *prefix UNUSED,
 }
 
 static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int quiet,
-			 int keep_index, int patch_mode, struct add_p_opt *add_p_opt,
+			 int keep_index, int patch_mode,
+			 struct interactive_options *interactive_opts,
 			 int include_untracked, int only_staged)
 {
 	int ret = 0;
@@ -1667,7 +1669,7 @@ static int do_push_stash(const struct pathspec *ps, const char *stash_msg, int q
 	if (stash_msg)
 		strbuf_addstr(&stash_msg_buf, stash_msg);
 	if (do_create_stash(ps, &stash_msg_buf, include_untracked, patch_mode,
-			    add_p_opt, only_staged, &info, &patch, quiet)) {
+			    interactive_opts, only_staged, &info, &patch, quiet)) {
 		ret = -1;
 		goto done;
 	}
@@ -1841,7 +1843,7 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	char *pathspec_from_file = NULL;
 	struct pathspec ps;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1849,8 +1851,8 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1907,19 +1909,19 @@ static int push_stash(int argc, const char **argv, const char *prefix,
 	}
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index, patch_mode,
-			    &add_p_opt, include_untracked, only_staged);
+			    &interactive_opts, include_untracked, only_staged);
 
 	clear_pathspec(&ps);
 	free(pathspec_from_file);
@@ -1944,7 +1946,7 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 	const char *stash_msg = NULL;
 	struct pathspec ps;
 	struct strbuf stash_msg_buf = STRBUF_INIT;
-	struct add_p_opt add_p_opt = ADD_P_OPT_INIT;
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
 	struct option options[] = {
 		OPT_BOOL('k', "keep-index", &keep_index,
 			 N_("keep index")),
@@ -1952,8 +1954,8 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 			 N_("stash staged changes only")),
 		OPT_BOOL('p', "patch", &patch_mode,
 			 N_("stash in patch mode")),
-		OPT_DIFF_UNIFIED(&add_p_opt.context),
-		OPT_DIFF_INTERHUNK_CONTEXT(&add_p_opt.interhunkcontext),
+		OPT_DIFF_UNIFIED(&interactive_opts.context),
+		OPT_DIFF_INTERHUNK_CONTEXT(&interactive_opts.interhunkcontext),
 		OPT__QUIET(&quiet, N_("quiet mode")),
 		OPT_BOOL('u', "include-untracked", &include_untracked,
 			 N_("include untracked files in stash")),
@@ -1973,20 +1975,20 @@ static int save_stash(int argc, const char **argv, const char *prefix,
 
 	memset(&ps, 0, sizeof(ps));
 
-	if (add_p_opt.context < -1)
+	if (interactive_opts.context < -1)
 		die(_("'%s' cannot be negative"), "--unified");
-	if (add_p_opt.interhunkcontext < -1)
+	if (interactive_opts.interhunkcontext < -1)
 		die(_("'%s' cannot be negative"), "--inter-hunk-context");
 
 	if (!patch_mode) {
-		if (add_p_opt.context != -1)
+		if (interactive_opts.context != -1)
 			die(_("the option '%s' requires '%s'"), "--unified", "--patch");
-		if (add_p_opt.interhunkcontext != -1)
+		if (interactive_opts.interhunkcontext != -1)
 			die(_("the option '%s' requires '%s'"), "--inter-hunk-context", "--patch");
 	}
 
 	ret = do_push_stash(&ps, stash_msg, quiet, keep_index,
-			    patch_mode, &add_p_opt, include_untracked,
+			    patch_mode, &interactive_opts, include_untracked,
 			    only_staged);
 
 	strbuf_release(&stash_msg_buf);
diff --git a/commit.h b/commit.h
index 1d6e0c7518..7b6e59d6c1 100644
--- a/commit.h
+++ b/commit.h
@@ -258,7 +258,7 @@ int for_each_commit_graft(each_commit_graft_fn, void *);
 int interactive_add(struct repository *repo,
 		    const char **argv,
 		    const char *prefix,
-		    int patch, struct add_p_opt *add_p_opt);
+		    int patch, struct interactive_options *opts);
 
 struct commit_extra_header {
 	struct commit_extra_header *next;
-- 
2.52.0.239.gd5f0c6e74e.dirty
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 08/12] add-patch: remove dependency on "add-interactive" subsystem

With the preceding commit we have split out interactive configuration that is used by both "git add -p" and "git add -i". But we still initialize that configuration in the "add -p" subsystem by calling `init_add_i_state()`, even though we only do so to initialize the interactive configuration as well as a repository pointer.

Stop doing so and instead store and initialize the interactive configuration in `struct add_p_state` directly.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 70 ++++++++++++++++++++++++++++++++-----------------------------
 1 file changed, 37 insertions(+), 33 deletions(-)
Show changes to add-patch.c +37 −33
diff --git a/add-patch.c b/add-patch.c
index 6797d2f690..2780738153 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -2,7 +2,6 @@
 #define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
-#include "add-interactive.h"
 #include "add-patch.h"
 #include "advice.h"
 #include "config.h"
@@ -263,7 +262,8 @@ struct hunk {
 };
 
 struct add_p_state {
-	struct add_i_state s;
+	struct repository *r;
+	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
 	/* parsed diff */
@@ -408,7 +408,7 @@ static void add_p_state_clear(struct add_p_state *s)
 	for (i = 0; i < s->file_diff_nr; i++)
 		free(s->file_diff[i].hunk);
 	free(s->file_diff);
-	clear_add_i_state(&s->s);
+	interactive_config_clear(&s->cfg);
 }
 
 __attribute__((format (printf, 2, 3)))
@@ -417,9 +417,9 @@ static void err(struct add_p_state *s, const char *fmt, ...)
 	va_list args;
 
 	va_start(args, fmt);
-	fputs(s->s.cfg.error_color, stdout);
+	fputs(s->cfg.error_color, stdout);
 	vprintf(fmt, args);
-	puts(s->s.cfg.reset_color_interactive);
+	puts(s->cfg.reset_color_interactive);
 	va_end(args);
 }
 
@@ -437,7 +437,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->s.r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
 }
 
 static int parse_range(const char **p,
@@ -542,12 +542,12 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	int res;
 
 	strvec_pushv(&args, s->mode->diff_cmd);
-	if (s->s.cfg.context != -1)
-		strvec_pushf(&args, "--unified=%i", s->s.cfg.context);
-	if (s->s.cfg.interhunkcontext != -1)
-		strvec_pushf(&args, "--inter-hunk-context=%i", s->s.cfg.interhunkcontext);
-	if (s->s.cfg.interactive_diff_algorithm)
-		strvec_pushf(&args, "--diff-algorithm=%s", s->s.cfg.interactive_diff_algorithm);
+	if (s->cfg.context != -1)
+		strvec_pushf(&args, "--unified=%i", s->cfg.context);
+	if (s->cfg.interhunkcontext != -1)
+		strvec_pushf(&args, "--inter-hunk-context=%i", s->cfg.interhunkcontext);
+	if (s->cfg.interactive_diff_algorithm)
+		strvec_pushf(&args, "--diff-algorithm=%s", s->cfg.interactive_diff_algorithm);
 	if (s->revision) {
 		struct object_id oid;
 		strvec_push(&args,
@@ -576,9 +576,9 @@ static int parse_diff(struct add_p_state *s, const struct pathspec *ps)
 	}
 	strbuf_complete_line(plain);
 
-	if (want_color_fd(1, s->s.cfg.use_color_diff)) {
+	if (want_color_fd(1, s->cfg.use_color_diff)) {
 		struct child_process colored_cp = CHILD_PROCESS_INIT;
-		const char *diff_filter = s->s.cfg.interactive_diff_filter;
+		const char *diff_filter = s->cfg.interactive_diff_filter;
 
 		setup_child_process(s, &colored_cp, NULL);
 		xsnprintf((char *)args.v[color_arg_index], 8, "--color");
@@ -811,7 +811,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 				   hunk->colored_end - hunk->colored_start);
 			return;
 		} else {
-			strbuf_addstr(out, s->s.cfg.fraginfo_color);
+			strbuf_addstr(out, s->cfg.fraginfo_color);
 			p = s->colored.buf + header->colored_extra_start;
 			len = header->colored_extra_end
 				- header->colored_extra_start;
@@ -833,7 +833,7 @@ static void render_hunk(struct add_p_state *s, struct hunk *hunk,
 		if (len)
 			strbuf_add(out, p, len);
 		else if (colored)
-			strbuf_addf(out, "%s\n", s->s.cfg.reset_color_diff);
+			strbuf_addf(out, "%s\n", s->cfg.reset_color_diff);
 		else
 			strbuf_addch(out, '\n');
 	}
@@ -1222,12 +1222,12 @@ static void recolor_hunk(struct add_p_state *s, struct hunk *hunk)
 
 		strbuf_addstr(&s->colored,
 			      plain[current] == '-' ?
-			      s->s.cfg.file_old_color :
+			      s->cfg.file_old_color :
 			      plain[current] == '+' ?
-			      s->s.cfg.file_new_color :
-			      s->s.cfg.context_color);
+			      s->cfg.file_new_color :
+			      s->cfg.context_color);
 		strbuf_add(&s->colored, plain + current, eol - current);
-		strbuf_addstr(&s->colored, s->s.cfg.reset_color_diff);
+		strbuf_addstr(&s->colored, s->cfg.reset_color_diff);
 		if (next > eol)
 			strbuf_add(&s->colored, plain + eol, next - eol);
 		current = next;
@@ -1356,7 +1356,7 @@ static int run_apply_check(struct add_p_state *s,
 
 static int read_single_character(struct add_p_state *s)
 {
-	if (s->s.cfg.use_single_key) {
+	if (s->cfg.use_single_key) {
 		int res = read_key_without_echo(&s->answer);
 		printf("%s\n", res == EOF ? "" : s->answer.buf);
 		return res;
@@ -1370,7 +1370,7 @@ static int read_single_character(struct add_p_state *s)
 static int prompt_yesno(struct add_p_state *s, const char *prompt)
 {
 	for (;;) {
-		color_fprintf(stdout, s->s.cfg.prompt_color, "%s", _(prompt));
+		color_fprintf(stdout, s->cfg.prompt_color, "%s", _(prompt));
 		fflush(stdout);
 		if (read_single_character(s) == EOF)
 			return -1;
@@ -1678,15 +1678,15 @@ static int patch_update_file(struct add_p_state *s,
 		else
 			prompt_mode_type = PROMPT_HUNK;
 
-		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->s.cfg.prompt_color,
+		printf("%s(%"PRIuMAX"/%"PRIuMAX") ", s->cfg.prompt_color,
 			      (uintmax_t)hunk_index + 1,
 			      (uintmax_t)(file_diff->hunk_nr
 						? file_diff->hunk_nr
 						: 1));
 		printf(_(s->mode->prompt_mode[prompt_mode_type]),
 		       s->buf.buf);
-		if (*s->s.cfg.reset_color_interactive)
-			fputs(s->s.cfg.reset_color_interactive, stdout);
+		if (*s->cfg.reset_color_interactive)
+			fputs(s->cfg.reset_color_interactive, stdout);
 		fflush(stdout);
 		if (read_single_character(s) == EOF) {
 			quit = 1;
@@ -1849,7 +1849,7 @@ static int patch_update_file(struct add_p_state *s,
 				err(s, _("Sorry, cannot split this hunk"));
 			} else if (!split_hunk(s, file_diff,
 					     hunk - file_diff->hunk)) {
-				color_fprintf_ln(stdout, s->s.cfg.header_color,
+				color_fprintf_ln(stdout, s->cfg.header_color,
 						 _("Split into %d hunks."),
 						 (int)splittable_into);
 				rendered_hunk_index = -1;
@@ -1867,7 +1867,7 @@ static int patch_update_file(struct add_p_state *s,
 		} else if (s->answer.buf[0] == '?') {
 			const char *p = _(help_patch_remainder), *eol = p;
 
-			color_fprintf(stdout, s->s.cfg.help_color, "%s",
+			color_fprintf(stdout, s->cfg.help_color, "%s",
 				      _(s->mode->help_patch_text));
 
 			/*
@@ -1885,7 +1885,7 @@ static int patch_update_file(struct add_p_state *s,
 				if (*p != '?' && !strchr(s->buf.buf, *p))
 					continue;
 
-				color_fprintf_ln(stdout, s->s.cfg.help_color,
+				color_fprintf_ln(stdout, s->cfg.help_color,
 						 "%.*s", (int)(eol - p), p);
 			}
 		} else {
@@ -1905,7 +1905,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->s.r->index);
+		discard_index(s->r->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1916,8 +1916,8 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->s.r) >= 0)
-			repo_refresh_and_write_index(s->s.r, REFRESH_QUIET, 0,
+		if (repo_read_index(s->r) >= 0)
+			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
 	}
 
@@ -1930,11 +1930,15 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
-		{ r }, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT, STRBUF_INIT
+		.r = r,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
 	};
 	size_t i, binary_count = 0;
 
-	init_add_i_state(&s.s, r, opts);
+	interactive_config_init(&s.cfg, r, opts);
 
 	if (mode == ADD_P_STASH)
 		s.mode = &patch_mode_stash;
-- 
2.52.0.239.gd5f0c6e74e.dirty
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 09/12] add-patch: add support for in-memory index patching

With `run_add_p()` callers have the ability to apply changes from a specific revision to a repository's index. This infra supports several different modes, like for example applying changes to the index, working tree or both.

One feature that is missing though is the ability to apply changes to an in-memory index different from the repository's index. Add a new function `run_add_p_index()` to plug this gap.

This new function will be used in a subsequent commit.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-patch.c | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++---------
 add-patch.h |   8 ++++
 2 files changed, 128 insertions(+), 19 deletions(-)
Show changes to 2 files +128 −19

add-patch.c, add-patch.h

diff --git a/add-patch.c b/add-patch.c
index 2780738153..31d82a3e22 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -4,11 +4,13 @@
 #include "git-compat-util.h"
 #include "add-patch.h"
 #include "advice.h"
+#include "commit.h"
 #include "config.h"
 #include "diff.h"
 #include "editor.h"
 #include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "object-name.h"
 #include "pager.h"
 #include "read-cache-ll.h"
@@ -47,7 +49,7 @@ static struct patch_mode patch_mode_add = {
 		N_("Stage mode change [y,n,q,a,d%s,?]? "),
 		N_("Stage deletion [y,n,q,a,d%s,?]? "),
 		N_("Stage addition [y,n,q,a,d%s,?]? "),
-		N_("Stage this hunk [y,n,q,a,d%s,?]? ")
+		N_("Stage this hunk [y,n,q,a,d%s,?]? "),
 	},
 	.edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
 			     "will immediately be marked for staging."),
@@ -263,6 +265,8 @@ struct hunk {
 
 struct add_p_state {
 	struct repository *r;
+	struct index_state *index;
+	const char *index_file;
 	struct interactive_config cfg;
 	struct strbuf answer, buf;
 
@@ -437,7 +441,7 @@ static void setup_child_process(struct add_p_state *s,
 
 	cp->git_cmd = 1;
 	strvec_pushf(&cp->env,
-		     INDEX_ENVIRONMENT "=%s", s->r->index_file);
+		     INDEX_ENVIRONMENT "=%s", s->index_file);
 }
 
 static int parse_range(const char **p,
@@ -1905,7 +1909,7 @@ static int patch_update_file(struct add_p_state *s,
 		strbuf_reset(&s->buf);
 		reassemble_patch(s, file_diff, 0, &s->buf);
 
-		discard_index(s->r->index);
+		discard_index(s->index);
 		if (s->mode->apply_for_checkout)
 			apply_for_checkout(s, &s->buf,
 					   s->mode->is_reverse);
@@ -1916,27 +1920,54 @@ static int patch_update_file(struct add_p_state *s,
 					 NULL, 0, NULL, 0))
 				error(_("'git apply' failed"));
 		}
-		if (repo_read_index(s->r) >= 0)
+		if (read_index_from(s->index, s->index_file, s->r->gitdir) >= 0 &&
+		    s->index == s->r->index) {
 			repo_refresh_and_write_index(s->r, REFRESH_QUIET, 0,
 						     1, NULL, NULL, NULL);
+		}
 	}
 
 	putchar('\n');
 	return quit;
 }
 
+static int run_add_p_common(struct add_p_state *state,
+			    const struct pathspec *ps)
+{
+	size_t binary_count = 0;
+
+	if (parse_diff(state, ps) < 0)
+		return -1;
+
+	for (size_t i = 0; i < state->file_diff_nr; i++) {
+		if (state->file_diff[i].binary && !state->file_diff[i].hunk_nr)
+			binary_count++;
+		else if (patch_update_file(state, state->file_diff + i))
+			break;
+	}
+
+	if (state->file_diff_nr == 0)
+		err(state, _("No changes."));
+	else if (binary_count == state->file_diff_nr)
+		err(state, _("Only binary files changed."));
+
+	return 0;
+}
+
 int run_add_p(struct repository *r, enum add_p_mode mode,
 	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps)
 {
 	struct add_p_state s = {
 		.r = r,
+		.index = r->index,
+		.index_file = r->index_file,
 		.answer = STRBUF_INIT,
 		.buf = STRBUF_INIT,
 		.plain = STRBUF_INIT,
 		.colored = STRBUF_INIT,
 	};
-	size_t i, binary_count = 0;
+	int ret;
 
 	interactive_config_init(&s.cfg, r, opts);
 
@@ -1969,23 +2000,93 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	if (repo_read_index(r) < 0 ||
 	    (!s.mode->index_only &&
 	     repo_refresh_and_write_index(r, REFRESH_QUIET, 0, 1,
-					  NULL, NULL, NULL) < 0) ||
-	    parse_diff(&s, ps) < 0) {
-		add_p_state_clear(&s);
-		return -1;
+					  NULL, NULL, NULL) < 0)) {
+		ret = -1;
+		goto out;
 	}
 
-	for (i = 0; i < s.file_diff_nr; i++)
-		if (s.file_diff[i].binary && !s.file_diff[i].hunk_nr)
-			binary_count++;
-		else if (patch_update_file(&s, s.file_diff + i))
-			break;
+	ret = run_add_p_common(&s, ps);
+	if (ret < 0)
+		goto out;
 
-	if (s.file_diff_nr == 0)
-		err(&s, _("No changes."));
-	else if (binary_count == s.file_diff_nr)
-		err(&s, _("Only binary files changed."));
+	ret = 0;
 
+out:
 	add_p_state_clear(&s);
-	return 0;
+	return ret;
+}
+
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps)
+{
+	struct patch_mode mode = {
+		.apply_args = { "--cached", NULL },
+		.apply_check_args = { "--cached", NULL },
+		.prompt_mode = {
+			N_("Stage mode change [y,n,q,a,d%s,?]? "),
+			N_("Stage deletion [y,n,q,a,d%s,?]? "),
+			N_("Stage addition [y,n,q,a,d%s,?]? "),
+			N_("Stage this hunk [y,n,q,a,d%s,?]? ")
+		},
+		.edit_hunk_hint = N_("If the patch applies cleanly, the edited hunk "
+				     "will immediately be marked for staging."),
+		.help_patch_text =
+			N_("y - stage this hunk\n"
+			   "n - do not stage this hunk\n"
+			   "q - quit; do not stage this hunk or any of the remaining "
+				"ones\n"
+			   "a - stage this hunk and all later hunks in the file\n"
+			   "d - do not stage this hunk or any of the later hunks in "
+				"the file\n"),
+		.index_only = 1,
+	};
+	struct add_p_state s = {
+		.r = r,
+		.index = index,
+		.index_file = index_file,
+		.answer = STRBUF_INIT,
+		.buf = STRBUF_INIT,
+		.plain = STRBUF_INIT,
+		.colored = STRBUF_INIT,
+		.mode = &mode,
+		.revision = revision,
+	};
+	struct strbuf parent_revision = STRBUF_INIT;
+	char parent_tree_oid[GIT_MAX_HEXSZ + 1];
+	struct commit *commit;
+	int ret;
+
+	commit = lookup_commit_reference_by_name(revision);
+	if (!commit) {
+		err(&s, _("Revision does not refer to a commit"));
+		ret = -1;
+		goto out;
+	}
+
+	if (commit->parents)
+		oid_to_hex_r(parent_tree_oid, get_commit_tree_oid(commit->parents->item));
+	else
+		oid_to_hex_r(parent_tree_oid, r->hash_algo->empty_tree);
+
+	strbuf_addf(&parent_revision, "%s~", revision);
+	mode.diff_cmd[0] = "diff-tree";
+	mode.diff_cmd[1] = "-r";
+	mode.diff_cmd[2] = parent_tree_oid;
+
+	interactive_config_init(&s.cfg, r, opts);
+
+	ret = run_add_p_common(&s, ps);
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	strbuf_release(&parent_revision);
+	add_p_state_clear(&s);
+	return ret;
 }
diff --git a/add-patch.h b/add-patch.h
index a4a05d9d14..901c42fd7b 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -3,6 +3,7 @@
 
 #include "color.h"
 
+struct index_state;
 struct pathspec;
 struct repository;
 
@@ -53,4 +54,11 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 	      struct interactive_options *opts, const char *revision,
 	      const struct pathspec *ps);
 
+int run_add_p_index(struct repository *r,
+		    struct index_state *index,
+		    const char *index_file,
+		    struct interactive_options *opts,
+		    const char *revision,
+		    const struct pathspec *ps);
+
 #endif
-- 
2.52.0.239.gd5f0c6e74e.dirty
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 10/12] add-patch: allow disabling editing of hunks

The "add-patch" mode allows the user to edit hunks to apply custom changes. This is incompatible with a new `git history split` command that we're about to introduce in a subsequent commit, so we need a way to disable this mode.

Add a new flag to disable editing hunks.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 add-interactive.c  |  2 +-
 add-patch.c        | 21 +++++++++++++--------
 add-patch.h        | 11 +++++++++--
 builtin/add.c      |  2 +-
 builtin/checkout.c |  2 +-
 builtin/reset.c    |  2 +-
 builtin/stash.c    |  2 +-
 7 files changed, 27 insertions(+), 15 deletions(-)
Show changes to 7 files +27 −15

add-interactive.c, add-patch.c, add-patch.h, builtin/add.c, builtin/checkout.c, builtin/reset.c, builtin/stash.c

diff --git a/add-interactive.c b/add-interactive.c
index 05d2e7eefe..d41e039bc3 100644
--- a/add-interactive.c
+++ b/add-interactive.c
@@ -926,7 +926,7 @@ static int run_patch(struct add_i_state *s, const struct pathspec *ps,
 		parse_pathspec(&ps_selected,
 			       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,
 			       PATHSPEC_LITERAL_PATH, "", args.v);
-		res = run_add_p(s->r, ADD_P_ADD, &opts, NULL, &ps_selected);
+		res = run_add_p(s->r, ADD_P_ADD, &opts, NULL, &ps_selected, 0);
 		strvec_clear(&args);
 		clear_pathspec(&ps_selected);
 	}
diff --git a/add-patch.c b/add-patch.c
index 31d82a3e22..51a7b371a2 100644
--- a/add-patch.c
+++ b/add-patch.c
@@ -1565,7 +1565,8 @@ static bool get_first_undecided(const struct file_diff *file_diff, size_t *idx)
 }
 
 static int patch_update_file(struct add_p_state *s,
-			     struct file_diff *file_diff)
+			     struct file_diff *file_diff,
+			     unsigned flags)
 {
 	size_t hunk_index = 0;
 	ssize_t i, undecided_previous, undecided_next, rendered_hunk_index = -1;
@@ -1666,7 +1667,8 @@ static int patch_update_file(struct add_p_state *s,
 				permitted |= ALLOW_SPLIT;
 				strbuf_addstr(&s->buf, ",s");
 			}
-			if (hunk_index + 1 > file_diff->mode_change &&
+			if (!(flags & ADD_P_DISALLOW_EDIT) &&
+			    hunk_index + 1 > file_diff->mode_change &&
 			    !file_diff->deleted) {
 				permitted |= ALLOW_EDIT;
 				strbuf_addstr(&s->buf, ",e");
@@ -1932,7 +1934,8 @@ static int patch_update_file(struct add_p_state *s,
 }
 
 static int run_add_p_common(struct add_p_state *state,
-			    const struct pathspec *ps)
+			    const struct pathspec *ps,
+			    unsigned flags)
 {
 	size_t binary_count = 0;
 
@@ -1942,7 +1945,7 @@ static int run_add_p_common(struct add_p_state *state,
 	for (size_t i = 0; i < state->file_diff_nr; i++) {
 		if (state->file_diff[i].binary && !state->file_diff[i].hunk_nr)
 			binary_count++;
-		else if (patch_update_file(state, state->file_diff + i))
+		else if (patch_update_file(state, state->file_diff + i, flags))
 			break;
 	}
 
@@ -1956,7 +1959,8 @@ static int run_add_p_common(struct add_p_state *state,
 
 int run_add_p(struct repository *r, enum add_p_mode mode,
 	      struct interactive_options *opts, const char *revision,
-	      const struct pathspec *ps)
+	      const struct pathspec *ps,
+	      unsigned flags)
 {
 	struct add_p_state s = {
 		.r = r,
@@ -2005,7 +2009,7 @@ int run_add_p(struct repository *r, enum add_p_mode mode,
 		goto out;
 	}
 
-	ret = run_add_p_common(&s, ps);
+	ret = run_add_p_common(&s, ps, flags);
 	if (ret < 0)
 		goto out;
 
@@ -2021,7 +2025,8 @@ int run_add_p_index(struct repository *r,
 		    const char *index_file,
 		    struct interactive_options *opts,
 		    const char *revision,
-		    const struct pathspec *ps)
+		    const struct pathspec *ps,
+		    unsigned flags)
 {
 	struct patch_mode mode = {
 		.apply_args = { "--cached", NULL },
@@ -2079,7 +2084,7 @@ int run_add_p_index(struct repository *r,
 
 	interactive_config_init(&s.cfg, r, opts);
 
-	ret = run_add_p_common(&s, ps);
+	ret = run_add_p_common(&s, ps, flags);
 	if (ret < 0)
 		goto out;
 
diff --git a/add-patch.h b/add-patch.h
index 901c42fd7b..1facf19f96 100644
--- a/add-patch.h
+++ b/add-patch.h
@@ -50,15 +50,22 @@ enum add_p_mode {
 	ADD_P_WORKTREE,
 };
 
+enum add_p_flags {
+	/* Disallow "editing" hunks. */
+	ADD_P_DISALLOW_EDIT = (1 << 0),
+};
+
 int run_add_p(struct repository *r, enum add_p_mode mode,
 	      struct interactive_options *opts, const char *revision,
-	      const struct pathspec *ps);
+	      const struct pathspec *ps,
+	      unsigned flags);
 
 int run_add_p_index(struct repository *r,
 		    struct index_state *index,
 		    const char *index_file,
 		    struct interactive_options *opts,
 		    const char *revision,
-		    const struct pathspec *ps);
+		    const struct pathspec *ps,
+		    unsigned flags);
 
 #endif
diff --git a/builtin/add.c b/builtin/add.c
index 6f1e213052..dfe9636079 100644
--- a/builtin/add.c
+++ b/builtin/add.c
@@ -172,7 +172,7 @@ int interactive_add(struct repository *repo,
 		       prefix, argv);
 
 	if (patch)
-		ret = !!run_add_p(repo, ADD_P_ADD, interactive_opts, NULL, &pathspec);
+		ret = !!run_add_p(repo, ADD_P_ADD, interactive_opts, NULL, &pathspec, 0);
 	else
 		ret = !!run_add_i(repo, &pathspec, interactive_opts);
 
diff --git a/builtin/checkout.c b/builtin/checkout.c
index 530ae956ad..d325083ff3 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -576,7 +576,7 @@ static int checkout_paths(const struct checkout_opts *opts,
 			BUG("either flag must have been set, worktree=%d, index=%d",
 			    opts->checkout_worktree, opts->checkout_index);
 		return !!run_add_p(the_repository, patch_mode, &interactive_opts,
-				   rev, &opts->pathspec);
+				   rev, &opts->pathspec, 0);
 	}
 
 	repo_hold_locked_index(the_repository, &lock_file, LOCK_DIE_ON_ERROR);
diff --git a/builtin/reset.c b/builtin/reset.c
index 088449e120..008929bc7c 100644
--- a/builtin/reset.c
+++ b/builtin/reset.c
@@ -436,7 +436,7 @@ int cmd_reset(int argc,
 			die(_("options '%s' and '%s' cannot be used together"), "--patch", "--{hard,mixed,soft}");
 		trace2_cmd_mode("patch-interactive");
 		update_ref_status = !!run_add_p(the_repository, ADD_P_RESET,
-						&interactive_opts, rev, &pathspec);
+						&interactive_opts, rev, &pathspec, 0);
 		goto cleanup;
 	} else {
 		if (interactive_opts.context != -1)
diff --git a/builtin/stash.c b/builtin/stash.c
index 3b50905233..eb8142565e 100644
--- a/builtin/stash.c
+++ b/builtin/stash.c
@@ -1331,7 +1331,7 @@ static int stash_patch(struct stash_info *info, const struct pathspec *ps,
 	old_index_env = xstrdup_or_null(getenv(INDEX_ENVIRONMENT));
 	setenv(INDEX_ENVIRONMENT, the_repository->index_file, 1);
 
-	ret = !!run_add_p(the_repository, ADD_P_STASH, interactive_opts, NULL, ps);
+	ret = !!run_add_p(the_repository, ADD_P_STASH, interactive_opts, NULL, ps, 0);
 
 	the_repository->index_file = old_repo_index_file;
 	if (old_index_env && *old_index_env)
-- 
2.52.0.239.gd5f0c6e74e.dirty
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 11/12] cache-tree: allow writing in-memory index as tree

The function `write_in_core_index_as_tree()` takes a repository and writes its index into a tree object. What this function cannot do though is to take an _arbitrary_ in-memory index.

Introduce a new `struct index_state` parameter so that the caller can pass a different index than the one belonging to the repository. This will be used in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/checkout.c | 3 ++-
 cache-tree.c       | 4 ++--
 cache-tree.h       | 3 ++-
 3 files changed, 6 insertions(+), 4 deletions(-)
Show changes to 3 files +6 −4

builtin/checkout.c, cache-tree.c, cache-tree.h

diff --git a/builtin/checkout.c b/builtin/checkout.c
index d325083ff3..aa00f0f483 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -902,7 +902,8 @@ static int merge_working_tree(const struct checkout_opts *opts,
 					   0);
 			init_ui_merge_options(&o, the_repository);
 			o.verbosity = 0;
-			work = write_in_core_index_as_tree(the_repository);
+			work = write_in_core_index_as_tree(the_repository,
+							   the_repository->index);
 
 			ret = reset_tree(new_tree,
 					 opts, 1,
diff --git a/cache-tree.c b/cache-tree.c
index 2d8947b518..2976092270 100644
--- a/cache-tree.c
+++ b/cache-tree.c
@@ -723,11 +723,11 @@ static int write_index_as_tree_internal(struct object_id *oid,
 	return 0;
 }
 
-struct tree* write_in_core_index_as_tree(struct repository *repo) {
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state) {
 	struct object_id o;
 	int was_valid, ret;
 
-	struct index_state *index_state	= repo->index;
 	was_valid = index_state->cache_tree &&
 		    cache_tree_fully_valid(index_state->cache_tree);
 
diff --git a/cache-tree.h b/cache-tree.h
index b82c4963e7..f8bddae523 100644
--- a/cache-tree.h
+++ b/cache-tree.h
@@ -47,7 +47,8 @@ int cache_tree_verify(struct repository *, struct index_state *);
 #define WRITE_TREE_UNMERGED_INDEX (-2)
 #define WRITE_TREE_PREFIX_ERROR (-3)
 
-struct tree* write_in_core_index_as_tree(struct repository *repo);
+struct tree *write_in_core_index_as_tree(struct repository *repo,
+					 struct index_state *index_state);
 int write_index_as_tree(struct object_id *oid, struct index_state *index_state, const char *index_path, int flags, const char *prefix);
 void prime_cache_tree(struct repository *, struct index_state *, struct tree *);
 
-- 
2.52.0.239.gd5f0c6e74e.dirty
Patrick Steinhardt· Dec 3, 2025, 10:48 UTC · re: Patrick Steinhardt · lore

[PATCH v7 12/12] builtin/history: implement "split" subcommand

It is quite a common use case that one wants to split up one commit into multiple commits by moving parts of the changes of the original commit out into a separate commit. This is quite an involved operation though:

  1. Identify the commit in question that is to be dropped.
  2. Perform an interactive rebase on top of that commit's parent.
  3. Modify the instruction sheet to "edit" the commit that is to be
     split up.
  4. Drop the commit via "git reset HEAD~".
  5. Stage changes that should go into the first commit and commit it.
  6. Stage changes that should go into the second commit and commit it.
  7. Finalize the rebase.

This is quite complex, and overall I would claim that most people who are not experts in Git would struggle with this flow.

Introduce a new "split" subcommand for git-history(1) to make this way easier. All the user needs to do is to say `git history split $COMMIT`. From hereon, Git asks the user which parts of the commit shall be moved out into a separate commit and, once done, asks the user for the commit message. Git then creates that split-out commit and applies the original commit on top of it.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  62 ++++++
 builtin/history.c              | 174 ++++++++++++++++
 t/meson.build                  |   1 +
 t/t3452-history-split.sh       | 452 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 689 insertions(+)
Show changes to 4 files +687 −0

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3452-history-split.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 160bf5d4d2..f1252baa2f 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -9,6 +9,7 @@ SYNOPSIS
 --------
 [synopsis]
 git history reword <commit>
+git history split <commit> [--] [<pathspec>...]
 
 DESCRIPTION
 -----------
@@ -37,11 +38,72 @@ Several commands are available to rewrite history in different ways:
 	details of this commit remain unchanged. This command will spawn an
 	editor with the current message of that commit.
 
+`split <commit> [--] [<pathspec>...]`::
+	Interactively split up <commit> into two commits by choosing
+	hunks introduced by it that will be moved into the new split-out
+	commit. These hunks will then be written into a new commit that
+	becomes the parent of the previous commit. The original commit
+	stays intact, except that its parent will be the newly split-out
+	commit.
++
+The commit messages of the split-up commits will be asked for by launching
+the configured editor. Authorship of the commit will be the same as for the
+original commit.
++
+If passed, _<pathspec>_ can be used to limit which changes shall be split out
+of the original commit. Files not matching any of the pathspecs will remain
+part of the original commit. For more details, see the 'pathspec' entry in
+linkgit:gitglossary[7].
++
+It is invalid to select either all or no hunks, as that would lead to
+one of the commits becoming empty.
+
 CONFIGURATION
 -------------
 
 include::includes/cmd-config-section-all.adoc[]
 
+EXAMPLES
+--------
+
+Split a commit
+~~~~~~~~~~~~~~
+
+----------
+$ git log --stat --oneline
+3f81232 (HEAD -> main) original
+ bar | 1 +
+ foo | 1 +
+ 2 files changed, 2 insertions(+)
+
+$ git history split HEAD
+diff --git a/bar b/bar
+new file mode 100644
+index 0000000..5716ca5
+--- /dev/null
++++ b/bar
+@@ -0,0 +1 @@
++bar
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? y
+
+diff --git a/foo b/foo
+new file mode 100644
+index 0000000..257cc56
+--- /dev/null
++++ b/foo
+@@ -0,0 +1 @@
++foo
+(1/1) Stage addition [y,n,q,a,d,e,p,?]? n
+
+$ git log --stat --oneline
+7cebe64 (HEAD -> main) original
+ foo | 1 +
+ 1 file changed, 1 insertion(+)
+d1582f3 split-out commit
+ bar | 1 +
+ 1 file changed, 1 insertion(+)
+----------
+
 GIT
 ---
 Part of the linkgit:git[1] suite
diff --git a/builtin/history.c b/builtin/history.c
index 17bb150b95..c5262d964a 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,6 +1,7 @@
 #define USE_THE_REPOSITORY_VARIABLE
 
 #include "builtin.h"
+#include "cache-tree.h"
 #include "commit-reach.h"
 #include "commit.h"
 #include "config.h"
@@ -8,17 +9,22 @@
 #include "environment.h"
 #include "gettext.h"
 #include "hex.h"
+#include "oidmap.h"
 #include "parse-options.h"
+#include "path.h"
+#include "read-cache.h"
 #include "refs.h"
 #include "replay.h"
 #include "reset.h"
 #include "revision.h"
+#include "run-command.h"
 #include "sequencer.h"
 #include "strvec.h"
 #include "tree.h"
 #include "wt-status.h"
 
 #define GIT_HISTORY_REWORD_USAGE N_("git history reword <commit>")
+#define GIT_HISTORY_SPLIT_USAGE  N_("git history split <commit> [--] [<pathspec>...]")
 
 static int collect_commits(struct repository *repo,
 			   struct commit *old_commit,
@@ -364,6 +370,172 @@ static int cmd_history_reword(int argc,
 	return ret;
 }
 
+static int split_commit(struct repository *repo,
+			struct commit *original_commit,
+			struct pathspec *pathspec,
+			struct object_id *out)
+{
+	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
+	struct strbuf index_file = STRBUF_INIT;
+	struct child_process read_tree_cmd = CHILD_PROCESS_INIT;
+	struct index_state index = INDEX_STATE_INIT(repo);
+	struct object_id original_commit_tree_oid, parent_tree_oid;
+	char original_commit_oid[GIT_MAX_HEXSZ + 1];
+	struct commit_list *parents = NULL;
+	struct commit *first_commit;
+	struct tree *split_tree;
+	int ret;
+
+	if (original_commit->parents)
+		parent_tree_oid = *get_commit_tree_oid(original_commit->parents->item);
+	else
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	original_commit_tree_oid = *get_commit_tree_oid(original_commit);
+
+	/*
+	* Construct the first commit. This is done by taking the original
+	* commit parent's tree and selectively patching changes from the diff
+	* between that parent and its child.
+	*/
+	repo_git_path_replace(repo, &index_file, "%s", "history-split.index");
+
+	read_tree_cmd.git_cmd = 1;
+	strvec_pushf(&read_tree_cmd.env, "GIT_INDEX_FILE=%s", index_file.buf);
+	strvec_push(&read_tree_cmd.args, "read-tree");
+	strvec_push(&read_tree_cmd.args, oid_to_hex(&parent_tree_oid));
+	ret = run_command(&read_tree_cmd);
+	if (ret < 0)
+		goto out;
+
+	ret = read_index_from(&index, index_file.buf, repo->gitdir);
+	if (ret < 0) {
+		ret = error(_("failed reading temporary index"));
+		goto out;
+	}
+
+	oid_to_hex_r(original_commit_oid, &original_commit->object.oid);
+	ret = run_add_p_index(repo, &index, index_file.buf, &interactive_opts,
+			      original_commit_oid, pathspec, ADD_P_DISALLOW_EDIT);
+	if (ret < 0)
+		goto out;
+
+	split_tree = write_in_core_index_as_tree(repo, &index);
+	if (!split_tree) {
+		ret = error(_("failed split tree"));
+		goto out;
+	}
+
+	unlink(index_file.buf);
+
+	/*
+	* We disallow the cases where either the split-out commit or the
+	* original commit would become empty. Consequently, if we see that the
+	* new tree ID matches either of those trees we abort.
+	*/
+	if (oideq(&split_tree->object.oid, &parent_tree_oid)) {
+		ret = error(_("split commit is empty"));
+		goto out;
+	} else if (oideq(&split_tree->object.oid, &original_commit_tree_oid)) {
+		ret = error(_("split commit tree matches original commit"));
+		goto out;
+	}
+
+	/*
+	 * 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(repo, "split-out", original_commit,
+					      &split_tree->object.oid,
+					      original_commit->parents, &parent_tree_oid, &out[0]);
+	if (ret < 0) {
+		ret = error(_("failed writing split-out commit"));
+		goto out;
+	}
+
+	/*
+	* The second commit is constructed from the original tree. The base to
+	* diff against and the parent in this case is the first split-out
+	* commit.
+	*/
+	first_commit = lookup_commit_reference(repo, &out[0]);
+	commit_list_append(first_commit, &parents);
+
+	ret = commit_tree_with_edited_message(repo, "split-out", original_commit,
+					      &original_commit_tree_oid,
+					      parents, get_commit_tree_oid(first_commit), &out[1]);
+	if (ret < 0) {
+		ret = error(_("failed writing split-out commit"));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	if (index_file.len)
+		unlink(index_file.buf);
+	strbuf_release(&index_file);
+	free_commit_list(parents);
+	release_index(&index);
+	return ret;
+}
+
+static int cmd_history_split(int argc,
+			     const char **argv,
+			     const char *prefix,
+			     struct repository *repo)
+{
+	const char * const usage[] = {
+		GIT_HISTORY_SPLIT_USAGE,
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+	struct commit *original_commit, *parent, *head;
+	struct strvec commits = STRVEC_INIT;
+	struct object_id split_commits[2];
+	struct pathspec pathspec = { 0 };
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc < 1) {
+		ret = error(_("command expects a revision"));
+		goto out;
+	}
+	repo_config(repo, git_default_config, NULL);
+
+	parse_pathspec(&pathspec, 0,
+		PATHSPEC_PREFER_FULL | PATHSPEC_SYMLINK_LEADING_PATH | PATHSPEC_PREFIX_ORIGIN,
+		prefix, argv + 1);
+
+	ret = gather_commits_between_head_and_revision(repo, argv[0], &original_commit,
+						       &parent, &head, &commits);
+	if (ret < 0)
+		goto out;
+
+	/*
+	 * Then we split up the commit and replace the original commit with the
+	 * new ones.
+	 */
+	ret = split_commit(repo, original_commit, &pathspec, split_commits);
+	if (ret < 0)
+		goto out;
+
+	replace_commits(&commits, &original_commit->object.oid,
+			split_commits, ARRAY_SIZE(split_commits));
+
+	ret = apply_commits(repo, &commits, parent, head, "split");
+	if (ret < 0)
+		goto out;
+
+	ret = 0;
+
+out:
+	clear_pathspec(&pathspec);
+	strvec_clear(&commits);
+	return ret;
+}
+
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
@@ -371,11 +543,13 @@ int cmd_history(int argc,
 {
 	const char * const usage[] = {
 		GIT_HISTORY_REWORD_USAGE,
+		GIT_HISTORY_SPLIT_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
 		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 e187aef31e..1eef433207 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -388,6 +388,7 @@ integration_tests = [
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
   't3451-history-reword.sh',
+  't3452-history-split.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3452-history-split.sh b/t/t3452-history-split.sh
new file mode 100755
index 0000000000..02ae86a7e6
--- /dev/null
+++ b/t/t3452-history-split.sh
@@ -0,0 +1,452 @@
+#!/bin/sh
+
+test_description='tests for git-history split subcommand'
+
+. ./test-lib.sh
+
+# The fake editor takes multiple arguments, each of which represents a commit
+# message. Subsequent invocations of the editor will then yield those messages
+# in order.
+#
+set_fake_editor () {
+	printf "%s\n" "$@" >fake-input &&
+	write_script fake-editor.sh <<-\EOF &&
+	head -n1 fake-input >"$1"
+	sed 1d fake-input >fake-input.trimmed &&
+	mv fake-input.trimmed fake-input
+	EOF
+	test_set_editor "$(pwd)"/fake-editor.sh
+}
+
+expect_log () {
+	git log --format="%s" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+expect_tree_entries () {
+	git ls-tree --name-only "$1" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+test_expect_success 'refuses to work with merge commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+		test_must_fail git history split HEAD 2>err &&
+		test_grep "cannot rearrange commit history with merges" err &&
+		test_must_fail git history split HEAD~ 2>err &&
+		test_grep "cannot rearrange commit history with merges" err
+	)
+'
+
+test_expect_success 'refuses to work with unrelated commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		test_must_fail git history split ours 2>err &&
+		test_grep "commit must be reachable from current HEAD commit" err
+	)
+'
+
+test_expect_success 'can split up tip commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		git symbolic-ref HEAD >expect &&
+		set_fake_editor "first" "second" &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		expect_log <<-EOF &&
+		second
+		first
+		initial
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF &&
+		bar
+		foo
+		initial.t
+		EOF
+
+		git reflog >reflog &&
+		test_grep "split: updating HEAD" reflog
+	)
+'
+
+test_expect_success 'can split up root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m root &&
+		test_commit tip &&
+
+		set_fake_editor "first" "second" &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		second
+		first
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can split up in-between commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit initial &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+		test_commit tip &&
+
+		set_fake_editor "first" "second" &&
+		git history split HEAD~ <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		tip
+		second
+		first
+		initial
+		EOF
+
+		expect_tree_entries HEAD~2 <<-EOF &&
+		bar
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		initial.t
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		initial.t
+		tip.t
+		EOF
+	)
+'
+
+test_expect_success 'can pick multiple hunks' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar baz foo qux &&
+		git add . &&
+		git commit -m split-me &&
+
+		set_fake_editor "first" "second" &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		y
+		n
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		bar
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		baz
+		foo
+		qux
+		EOF
+	)
+'
+
+
+test_expect_success 'can use only last hunk' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		set_fake_editor "first" "second" &&
+		git history split HEAD <<-EOF &&
+		n
+		y
+		EOF
+
+		expect_log <<-EOF &&
+		second
+		first
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		set_fake_editor "" &&
+		test_must_fail git history split HEAD <<-EOF 2>err &&
+		y
+		n
+		EOF
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_expect_success 'commit message editor sees split-out changes' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cat "$1" >>MESSAGES &&
+		echo "some commit message" >"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		# Note that we expect to see the messages twice, once for each
+		# of the commits. The committed files are different though.
+		cat >expect <<-EOF &&
+		split-me
+
+		# Please enter the commit message for the split-out changes. Lines starting
+		# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+		# Changes to be committed:
+		#	new file:   bar
+		#
+		split-me
+
+		# Please enter the commit message for the split-out changes. Lines starting
+		# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+		# Changes to be committed:
+		#	new file:   foo
+		#
+		EOF
+		test_cmp expect MESSAGES &&
+
+		expect_log <<-EOF
+		some commit message
+		some commit message
+		EOF
+	)
+'
+
+test_expect_success 'can use pathspec to limit what gets split' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		set_fake_editor "first" "second" &&
+		git history split HEAD -- foo <<-EOF &&
+		y
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		foo
+		EOF
+
+		expect_tree_entries HEAD <<-EOF
+		bar
+		foo
+		EOF
+	)
+'
+
+test_expect_success 'refuses to create empty split-out commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		n
+		n
+		EOF
+		test_grep "split commit is empty" err
+	)
+'
+
+test_expect_success 'hooks are not executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+		old_head=$(git rev-parse HEAD) &&
+
+		ORIG_PATH="$(pwd)" &&
+		export ORIG_PATH &&
+		for hook in prepare-commit-msg pre-commit post-commit post-rewrite commit-msg
+		do
+			write_script .git/hooks/$hook <<-\EOF || exit 1
+			touch "$ORIG_PATH/hooks.log
+			EOF
+		done &&
+
+		set_fake_editor "first" "second" &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_log <<-EOF &&
+		second
+		first
+		EOF
+
+		test_path_is_missing hooks.log
+	)
+'
+
+test_expect_success 'refuses to create empty original commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch bar foo &&
+		git add . &&
+		git commit -m split-me &&
+
+		test_must_fail git history split HEAD 2>err <<-EOF &&
+		y
+		y
+		EOF
+		test_grep "split commit tree matches original commit" err
+	)
+'
+
+test_expect_success 'retains changes in the worktree and index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		echo a >a &&
+		echo b >b &&
+		git add . &&
+		git commit -m "initial commit" &&
+		echo a-modified >a &&
+		echo b-modified >b &&
+		git add b &&
+		set_fake_editor "a-only" "remainder" &&
+		git history split HEAD <<-EOF &&
+		y
+		n
+		EOF
+
+		expect_tree_entries HEAD~ <<-EOF &&
+		a
+		EOF
+		expect_tree_entries HEAD <<-EOF &&
+		a
+		b
+		EOF
+
+		cat >expect <<-\EOF &&
+		 M a
+		M  b
+		?? actual
+		?? expect
+		?? fake-editor.sh
+		?? fake-input
+		EOF
+		git status --porcelain >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.52.0.239.gd5f0c6e74e.dirty
Patrick Steinhardt· Jan 9, 2026, 08:35 UTC · re: Patrick Steinhardt · lore

[PATCH v9 0/7] Introduce git-history(1) command for easy history editing

Hi,

over recent months I've been playing around with Jujutsu quite frequently. While I still prefer using Git, there's been a couple features in it that I really like and that I'd like to have in Git, as well.

A copule of these features relate to history editing. Most importantly, I really dig the following commands:

  - jj-abandon(1) to drop a specific commit from your history.
  - jj-absorb(1) to take some changes and automatically apply them to
    commits in your history that last modified the respective hunks.
  - jj-split(1) to split a commit into two.
  - jj-new(1) to insert a new commit after or before a specific other
    commit.

Not all of these commands can be ported directly into Git. jj-new(1) for example doesn't really make a ton of sense for us, I'd claim. But some of these commands _do_ make sense.

This patch series is a starting point for such a command. For now, it only implements rewording of commits. I'll continue introducing new commands once this patch series has been merged though.

Changes in v9:
  - Rename `struct replay_ref_updates` to `struct replay_result` to make
    its semantics less focussed on ref updates, only.
  - Clarify and fix return codes of git-replay(1) so that we return 1 on
    conflict, 128 on an error and 0 on success.
  - The usual small improvements to commit messages.
  - Link to v8: https://lore.kernel.org/r/20260107-b4-pks-history-builtin-v8-0-18e9779e3a26@pks.im
Changes in v8:
  - The series is now built on top of e0bfec3dfc (The 15th batch,
    2026-01-06) with kh/replay-invalid-onto-advance at 56b77a687e
    (t3650: add more regression tests for failure conditions,
    2026-01-05) merged into it. The latter is expected to hit 'next'
    soonish.
  - Another biggish revamp to build almost completely on top of the
    git-replay(1) logic.
  - We now advance all branches descending from the rewritten commit by
    default.
  - I've dropped the patches introducing `git history split` and will
    send this as a follow-up patch series once this once has been
    merged. This was done to focus attention on the underlying mechanics
    as much as possible (and to keep my own sanity with the frequent
    revamps).
  - Link to v7: https://lore.kernel.org/r/20251203-b4-pks-history-builtin-v7-0-9e9f849bfd0e@pks.im
Changes in v7:
  - I've rebased the patch series on top of f0ef5b6d9b (The fifth batch,
    2025-11-30) with pw/replay-exclude-gpgsig-fix at 9f3a115087 (replay:
    do not copy "gpgsign-sha256" header, 2025-11-26) merged into it to
    fix some conflicts.
  - I refactored a bunch of code to be shared between split and reword.
  - I am now using "--ancestry-path" and "--reverse" to enumerate
    commits.
  - `git history split` now asks for commit messages for both commits.
  - `git history split` no longer allows the user to edit hunks so that
    conflicts become impossible. This may be reintroduced at a later
    point in time.
  - A bunch of test improvements.
  - Several commit message and documentation improvements.
  - Link to v6: https://lore.kernel.org/r/20251027-b4-pks-history-builtin-v6-0-407dd3f57ad3@pks.im
Changes in v6:
  - I've rebased the patch series again to pull in the latest updates
    from sa/replay-atomic-ref-updates and fix conflicts. It is now based
    on 4e98b730f1 (The twenty-fourth batch, 2025-10-24) with ab661bb1bb
    (replay: add replay.refAction config option, 2025-10-23) merged into
    it.
  - I've dropped the "-m" options for now, so commit messages are always
    asked for via the editor. These can be introduced in a subsequent
    patch series once discussion around them has settled.
  - We don't use the merge machinery anymore to pick the commits.
  - Drop the commit to parse commits in the replay machinery. It didn't
    seem to be necessary in v5 anymore, and now that we don't use the
    merge machinery at all we don't ever take that code path in the
    first place.
  - Link to v5:
    https://lore.kernel.org/r/20251021-b4-pks-history-builtin-v5-0-78d23f578fe6@pks.im
Changes in v5:
  - I've changed the patch series to be based on top of 133d151831 (The
    twenty-first batch, 2025-10-20) with sa/replay-atomic-ref-updates at
    a1c22e627e (SQAUASH??? t0450 band-aid, 2025-10-14) merged into it.
    This is one the one hand to fix a conflict, but also to get some of
    the CI updates to make GitLab CI work again.
  - Some slight commit message improvements.
  - Deduplicate subcommand usage strings by using defines.
  - Fix the desendancy checks to properly verify that HEAD is a
    descendant of the commit to be rewritten. Also add some tests for
    this.
  - Fix the hint that mentions that lines starting with the comment
    character will be tripped after having written the commit message.
  - Move an include to the correct commit.
  - Link to v4: https://lore.kernel.org/r/20251001-b4-pks-history-builtin-v4-0-8e61ddb86317@pks.im
Changes in v4:
  - I've rebuilt the patch series. It is now based on 821f583da6 (The
    thirteenth batcn, 2025-09-29) with sa/replay-atomic-ref-updates
    at 665c66a743 (replay: make atomic ref updates the default behavior,
    2025-09-27) merged into it. This should fix all conflicts with seen.
  - I've reworked this patch series to use the same infra as
    git-replay(1), as discussed during the Contributor's Summit.
  - I've slimmed down the patch series to only tackle those commands
    that cannot result in a conflict to keep it simple. I also learned
    that Elijah has been working on a "git replay edit" command, so I
    dropped that command so that we can instead use his version.
  - During the Contributor's Summit we have agreed that for now, we
    won't care about hook execution just yet. This may be backfilled at
    a later point in time.
  - I dropped "commit.verbose" handling for now, as my understanding of
    it was wrong at first. This is something we should backfill.
  - Link to v3: https://lore.kernel.org/r/20250904-b4-pks-history-builtin-v3-0-509053514755@pks.im
Changes in v3:
  - Add logic to drive the "post-rewrite" hook and add tests to verify
    that all hooks are executed as expected.
  - Deduplicate logic to turn a replay action into a todo command.
  - Move the addition of tests for the top-level git-history(1) command
    to the correct commit.
  - Some smaller commit message fixes.
  - Honor "commit.verbose".
  - Fix copy-paste error with an error message.
  - Link to v2: https://lore.kernel.org/r/20250824-b4-pks-history-builtin-v2-0-964ac12f65bd@pks.im
Changes in v2:
  - Add a new "reword" subcommand.
  - List git-history(1) in "command-list.txt".
  - Add some missing error handling.
  - Simplify calling convention of `apply_commits()` to handle root
    commits internally instead of requiring every caller to do so.
  - Add tests to verify that git-history(1) refuses to work with changes
    in the worktree or index.
  - Mark git-history(1) as experimental.
  - Introduce commands to manage interrupted history edits.
  - A bunch of improvements to the manpage.
  - Link to v1: https://lore.kernel.org/r/20250819-b4-pks-history-builtin-v1-0-9b77c32688fe@pks.im
Thanks!
Patrick
---
Patrick Steinhardt (7):
      builtin/replay: extract core logic to replay revisions
      builtin/replay: move core logic into "libgit.a"
      replay: small set of cleanups
      replay: yield the object ID of the final rewritten commit
      wt-status: provide function to expose status for trees
      builtin: add new "history" command
      builtin/history: implement "reword" subcommand
 .gitignore                     |   1 +
 Documentation/git-history.adoc |  73 ++++++++
 Documentation/meson.build      |   1 +
 Makefile                       |   2 +
 builtin.h                      |   1 +
 builtin/history.c              | 404 +++++++++++++++++++++++++++++++++++++++++
 builtin/replay.c               | 380 ++++----------------------------------
 command-list.txt               |   1 +
 git.c                          |   1 +
 meson.build                    |   2 +
 replay.c                       | 355 ++++++++++++++++++++++++++++++++++++
 replay.h                       |  80 ++++++++
 t/meson.build                  |   2 +
 t/t3450-history.sh             |  17 ++
 t/t3451-history-reword.sh      | 344 +++++++++++++++++++++++++++++++++++
 wt-status.c                    |  24 +++
 wt-status.h                    |   9 +
 17 files changed, 1352 insertions(+), 345 deletions(-)
Range-diff versus v8:
1:  4b58ceb517 ! 1:  5f3123028a builtin/replay: extract core logic to replay revisions
    @@ builtin/replay.c: static enum ref_action_mode get_ref_action_mode(struct reposit
     +	int contained;
     +};
     +
    -+struct replay_ref_updates {
    ++struct replay_result {
     +	struct replay_ref_update {
     +		char *refname;
     +		struct object_id old_oid;
     +		struct object_id new_oid;
    -+	} *items;
    -+	size_t nr, alloc;
    ++	} *updates;
    ++	size_t updates_nr, updates_alloc;
    ++
    ++	bool merge_conflict;
     +};
     +
    -+static void replay_ref_updates_release(struct replay_ref_updates *updates)
    ++static void replay_result_release(struct replay_result *result)
    ++{
    ++	for (size_t i = 0; i < result->updates_nr; i++)
    ++		free(result->updates[i].refname);
    ++	free(result->updates);
    ++}
    ++
    ++static void replay_result_queue_update(struct replay_result *result,
    ++				       const char *refname,
    ++				       const struct object_id *old_oid,
    ++				       const struct object_id *new_oid)
     +{
    -+	for (size_t i = 0; i < updates->nr; i++)
    -+		free(updates->items[i].refname);
    -+	free(updates->items);
    ++	ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
    ++	result->updates[result->updates_nr].refname = xstrdup(refname);
    ++	result->updates[result->updates_nr].old_oid = *old_oid;
    ++	result->updates[result->updates_nr].new_oid = *new_oid;
    ++	result->updates_nr++;
     +}
     +
     +static int replay_revisions(struct repository *repo, struct rev_info *revs,
     +			    struct replay_revisions_options *opts,
    -+			    struct replay_ref_updates *updates)
    ++			    struct replay_result *out)
     +{
     +	kh_oid_map_t *replayed_commits = NULL;
     +	struct strset *update_refs = NULL;
    @@ builtin/replay.c: static enum ref_action_mode get_ref_action_mode(struct reposit
     +			if (decoration->type == DECORATION_REF_LOCAL &&
     +			    (opts->contained || strset_contains(update_refs,
     +								decoration->name))) {
    -+				ALLOC_GROW(updates->items, updates->nr + 1, updates->alloc);
    -+				updates->items[updates->nr].refname = xstrdup(decoration->name);
    -+				updates->items[updates->nr].old_oid = commit->object.oid;
    -+				updates->items[updates->nr].new_oid = last_commit->object.oid;
    -+				updates->nr++;
    ++				replay_result_queue_update(out, decoration->name,
    ++							   &commit->object.oid,
    ++							   &last_commit->object.oid);
     +			}
     +			decoration = decoration->next;
     +		}
     +	}
     +
     +	if (!result.clean) {
    ++		out->merge_conflict = true;
     +		ret = -1;
     +		goto out;
     +	}
     +
     +	/* In --advance mode, advance the target ref */
    -+	if (advance) {
    -+		ALLOC_GROW(updates->items, updates->nr + 1, updates->alloc);
    -+		updates->items[updates->nr].refname = xstrdup(advance);
    -+		updates->items[updates->nr].old_oid = onto->object.oid;
    -+		updates->items[updates->nr].new_oid = last_commit->object.oid;
    -+		updates->nr++;
    -+	}
    ++	if (advance)
    ++		replay_result_queue_update(out, advance,
    ++					   &onto->object.oid,
    ++					   &last_commit->object.oid);
     +
     +	ret = 0;
     +
    @@ builtin/replay.c: int cmd_replay(int argc,
     -	const char *onto_name = NULL;
     -	int contained = 0;
     +	struct replay_revisions_options opts = { 0 };
    -+	struct replay_ref_updates updates = { 0 };
    ++	struct replay_result result = { 0 };
      	const char *ref_action = NULL;
      	enum ref_action_mode ref_mode;
     -
    @@ builtin/replay.c: int cmd_replay(int argc,
     -			   &onto, &update_refs);
     -
     -	/* FIXME: Should allow replaying commits with the first as a root commit */
    -+	ret = replay_revisions(repo, &revs, &opts, &updates);
    ++	ret = replay_revisions(repo, &revs, &opts, &result);
     +	if (ret)
     +		goto cleanup;
      
    @@ builtin/replay.c: int cmd_replay(int argc,
     -				      &onto->object.oid,
     -				      reflog_msg.buf,
     -				      &transaction_err) < 0) {
    -+	for (size_t i = 0; i < updates.nr; i++) {
    -+		ret = handle_ref_update(ref_mode, transaction, updates.items[i].refname,
    -+					&updates.items[i].new_oid, &updates.items[i].old_oid,
    ++	for (size_t i = 0; i < result.updates_nr; i++) {
    ++		ret = handle_ref_update(ref_mode, transaction, result.updates[i].refname,
    ++					&result.updates[i].new_oid, &result.updates[i].old_oid,
     +					reflog_msg.buf, &transaction_err);
     +		if (ret) {
      			ret = error(_("failed to update ref '%s': %s"),
     -				    advance_name, transaction_err.buf);
    -+				    updates.items[i].refname, transaction_err.buf);
    ++				    result.updates[i].refname, transaction_err.buf);
      			goto cleanup;
      		}
      	}
    @@ builtin/replay.c: int cmd_replay(int argc,
      cleanup:
      	if (transaction)
      		ref_transaction_free(transaction);
    -+	replay_ref_updates_release(&updates);
    ++	replay_result_release(&result);
      	strbuf_release(&transaction_err);
      	strbuf_release(&reflog_msg);
      	release_revisions(&revs);
    @@ builtin/replay.c: int cmd_replay(int argc,
     -	if (ret < 0)
     -		exit(128);
     -	return ret ? 0 : 1;
    -+	return ret ? 1 : 0;
    ++	if (ret) {
    ++		if (result.merge_conflict)
    ++			return 1;
    ++		return 128;
    ++	}
    ++
    ++	return 0;
      }
2:  2b46a91628 ! 2:  fd63cca82f builtin/replay: move core logic into "libgit.a"
    @@ builtin/replay.c: static enum ref_action_mode get_ref_action_mode(struct reposit
     -	int contained;
     -};
     -
    --struct replay_ref_updates {
    +-struct replay_result {
     -	struct replay_ref_update {
     -		char *refname;
     -		struct object_id old_oid;
     -		struct object_id new_oid;
    --	} *items;
    --	size_t nr, alloc;
    +-	} *updates;
    +-	size_t updates_nr, updates_alloc;
    +-
    +-	bool merge_conflict;
     -};
     -
    --static void replay_ref_updates_release(struct replay_ref_updates *updates)
    +-static void replay_result_release(struct replay_result *result)
     -{
    --	for (size_t i = 0; i < updates->nr; i++)
    --		free(updates->items[i].refname);
    --	free(updates->items);
    +-	for (size_t i = 0; i < result->updates_nr; i++)
    +-		free(result->updates[i].refname);
    +-	free(result->updates);
    +-}
    +-
    +-static void replay_result_queue_update(struct replay_result *result,
    +-				       const char *refname,
    +-				       const struct object_id *old_oid,
    +-				       const struct object_id *new_oid)
    +-{
    +-	ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
    +-	result->updates[result->updates_nr].refname = xstrdup(refname);
    +-	result->updates[result->updates_nr].old_oid = *old_oid;
    +-	result->updates[result->updates_nr].new_oid = *new_oid;
    +-	result->updates_nr++;
     -}
     -
     -static int replay_revisions(struct repository *repo, struct rev_info *revs,
     -			    struct replay_revisions_options *opts,
    --			    struct replay_ref_updates *updates)
    +-			    struct replay_result *out)
     -{
     -	kh_oid_map_t *replayed_commits = NULL;
     -	struct strset *update_refs = NULL;
    @@ builtin/replay.c: static enum ref_action_mode get_ref_action_mode(struct reposit
     -			if (decoration->type == DECORATION_REF_LOCAL &&
     -			    (opts->contained || strset_contains(update_refs,
     -								decoration->name))) {
    --				ALLOC_GROW(updates->items, updates->nr + 1, updates->alloc);
    --				updates->items[updates->nr].refname = xstrdup(decoration->name);
    --				updates->items[updates->nr].old_oid = commit->object.oid;
    --				updates->items[updates->nr].new_oid = last_commit->object.oid;
    --				updates->nr++;
    +-				replay_result_queue_update(out, decoration->name,
    +-							   &commit->object.oid,
    +-							   &last_commit->object.oid);
     -			}
     -			decoration = decoration->next;
     -		}
     -	}
     -
     -	if (!result.clean) {
    +-		out->merge_conflict = true;
     -		ret = -1;
     -		goto out;
     -	}
     -
     -	/* In --advance mode, advance the target ref */
    --	if (advance) {
    --		ALLOC_GROW(updates->items, updates->nr + 1, updates->alloc);
    --		updates->items[updates->nr].refname = xstrdup(advance);
    --		updates->items[updates->nr].old_oid = onto->object.oid;
    --		updates->items[updates->nr].new_oid = last_commit->object.oid;
    --		updates->nr++;
    --	}
    +-	if (advance)
    +-		replay_result_queue_update(out, advance,
    +-					   &onto->object.oid,
    +-					   &last_commit->object.oid);
     -
     -	ret = 0;
     -
    @@ replay.c (new)
     +	return create_commit(repo, result->tree, pickme, replayed_base);
     +}
     +
    -+void replay_ref_updates_release(struct replay_ref_updates *updates)
    ++void replay_result_release(struct replay_result *result)
     +{
    -+	for (size_t i = 0; i < updates->nr; i++)
    -+		free(updates->items[i].refname);
    -+	free(updates->items);
    ++	for (size_t i = 0; i < result->updates_nr; i++)
    ++		free(result->updates[i].refname);
    ++	free(result->updates);
    ++}
    ++
    ++static void replay_result_queue_update(struct replay_result *result,
    ++				       const char *refname,
    ++				       const struct object_id *old_oid,
    ++				       const struct object_id *new_oid)
    ++{
    ++	ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
    ++	result->updates[result->updates_nr].refname = xstrdup(refname);
    ++	result->updates[result->updates_nr].old_oid = *old_oid;
    ++	result->updates[result->updates_nr].new_oid = *new_oid;
    ++	result->updates_nr++;
     +}
     +
     +int replay_revisions(struct repository *repo, struct rev_info *revs,
     +		     struct replay_revisions_options *opts,
    -+		     struct replay_ref_updates *updates)
    ++		     struct replay_result *out)
     +{
     +	kh_oid_map_t *replayed_commits = NULL;
     +	struct strset *update_refs = NULL;
    @@ replay.c (new)
     +			if (decoration->type == DECORATION_REF_LOCAL &&
     +			    (opts->contained || strset_contains(update_refs,
     +								decoration->name))) {
    -+				ALLOC_GROW(updates->items, updates->nr + 1, updates->alloc);
    -+				updates->items[updates->nr].refname = xstrdup(decoration->name);
    -+				updates->items[updates->nr].old_oid = commit->object.oid;
    -+				updates->items[updates->nr].new_oid = last_commit->object.oid;
    -+				updates->nr++;
    ++				replay_result_queue_update(out, decoration->name,
    ++							   &commit->object.oid,
    ++							   &last_commit->object.oid);
     +			}
     +			decoration = decoration->next;
     +		}
     +	}
     +
     +	if (!result.clean) {
    ++		out->merge_conflict = true;
     +		ret = -1;
     +		goto out;
     +	}
     +
     +	/* In --advance mode, advance the target ref */
    -+	if (advance) {
    -+		ALLOC_GROW(updates->items, updates->nr + 1, updates->alloc);
    -+		updates->items[updates->nr].refname = xstrdup(advance);
    -+		updates->items[updates->nr].old_oid = onto->object.oid;
    -+		updates->items[updates->nr].new_oid = last_commit->object.oid;
    -+		updates->nr++;
    -+	}
    ++	if (advance)
    ++		replay_result_queue_update(out, advance,
    ++					   &onto->object.oid,
    ++					   &last_commit->object.oid);
     +
     +	ret = 0;
     +
    @@ replay.h (new)
     +};
     +
     +/* This struct is used as an out-parameter by `replay_revisions()`. */
    -+struct replay_ref_updates {
    ++struct replay_result {
     +	/*
     +	 * The set of reference updates that are caused by replaying the
     +	 * commits.
    @@ replay.h (new)
     +		char *refname;
     +		struct object_id old_oid;
     +		struct object_id new_oid;
    -+	} *items;
    -+	size_t nr, alloc;
    ++	} *updates;
    ++	size_t updates_nr, updates_alloc;
    ++
    ++	/* Set to true in case the replay failed with a merge conflict. */
    ++	bool merge_conflict;
     +};
     +
    -+void replay_ref_updates_release(struct replay_ref_updates *updates);
    ++void replay_result_release(struct replay_result *result);
     +
     +/*
     + * Replay a set of commits onto a new location. Leaves both the working tree,
    @@ replay.h (new)
     + */
     +int replay_revisions(struct repository *repo, struct rev_info *revs,
     +		     struct replay_revisions_options *opts,
    -+		     struct replay_ref_updates *updates);
    ++		     struct replay_result *out);
     +
     +#endif
3:  881988bcfa = 3:  eb455cade1 replay: small set of cleanups
4:  197fa292c4 ! 4:  ccd323f6db replay: yield the object ID of the final rewritten commit
    @@ Commit message
     
      ## replay.c ##
     @@ replay.c: int replay_revisions(struct repository *repo, struct rev_info *revs,
    - 		updates->nr++;
    - 	}
    + 					   &onto->object.oid,
    + 					   &last_commit->object.oid);
      
    -+	updates->final_oid = last_commit->object.oid;
    ++	out->final_oid = last_commit->object.oid;
     +
      	ret = 0;
      
      out:
     
      ## replay.h ##
    -@@ replay.h: struct replay_ref_updates {
    - 		struct object_id new_oid;
    - 	} *items;
    - 	size_t nr, alloc;
    +@@ replay.h: struct replay_result {
    + 
    + 	/* Set to true in case the replay failed with a merge conflict. */
    + 	bool merge_conflict;
     +
     +	/*
     +	 * The final object ID that was rewritten. Note that this field has
    @@ replay.h: struct replay_ref_updates {
     +	struct object_id final_oid;
      };
      
    - void replay_ref_updates_release(struct replay_ref_updates *updates);
    + void replay_result_release(struct replay_result *result);
5:  91df8f3813 = 5:  a40034bcc1 wt-status: provide function to expose status for trees
6:  025e13874e ! 6:  4d566d6c43 builtin: add new "history" command
    @@ Commit message
         splitting up a commit into two are way more involved than that and
         require a whole series of commands.
     
    -    Another problem that rebases have is that dependent branches are not
    -    being updated. The use of stacked branches has grown quite common with
    -    competiting version control systems like Jujutsu though, so it clearly
    -    is a need that users have. While rebases _can_ serve this use case if
    -    one always works on the latest stacked branch, it is somewhat awkward
    -    and very easy to get wrong.
    +    Rebases also do not update dependent branches. The use of stacked
    +    branches has grown quite common with competing version control systems
    +    like Jujutsu though, so it clearly is a need that users have. While
    +    rebases _can_ serve this use case if one always works on the latest
    +    stacked branch, it is somewhat awkward and very easy to get wrong.
     
         Add a new "history" command to plug these gaps. This command will have
         several different subcommands to imperatively rewrite history for common
7:  b5cfe7da4e ! 7:  666fec826c builtin/history: implement "reword" subcommand
    @@ builtin/history.c
     +{
     +	const struct name_decoration *decoration;
     +	struct replay_revisions_options opts = { 0 };
    -+	struct replay_ref_updates updates = {
    ++	struct replay_result result = {
     +		.final_oid = rewritten->object.oid,
     +	};
     +	struct ref_transaction *transaction = NULL;
    @@ builtin/history.c
     +
     +	opts.onto = oid_to_hex_r(hex, &rewritten->object.oid);
     +
    -+	ret = replay_revisions(repo, &revs, &opts, &updates);
    ++	ret = replay_revisions(repo, &revs, &opts, &result);
     +	if (ret)
     +		goto out;
     +
    @@ builtin/history.c
     +			goto out;
     +		}
     +
    -+		for (size_t i = 0; i < updates.nr; i++) {
    ++		for (size_t i = 0; i < result.updates_nr; i++) {
     +			ret = ref_transaction_update(transaction,
    -+						     updates.items[i].refname,
    -+						     &updates.items[i].new_oid,
    -+						     &updates.items[i].old_oid,
    ++						     result.updates[i].refname,
    ++						     &result.updates[i].new_oid,
    ++						     &result.updates[i].old_oid,
     +						     NULL, NULL, 0, reflog_msg, &err);
     +			if (ret) {
     +				ret = error(_("failed to update ref '%s': %s"),
    -+					    updates.items[i].refname, err.buf);
    ++					    result.updates[i].refname, err.buf);
     +				goto out;
     +			}
     +		}
    @@ builtin/history.c
     +		break;
     +	case REF_ACTION_HEAD:
     +		ret = refs_update_ref(get_main_ref_store(repo), reflog_msg, "HEAD",
    -+				      &updates.final_oid, &head->object.oid, 0,
    ++				      &result.final_oid, &head->object.oid, 0,
     +				      UPDATE_REFS_MSG_ON_ERR);
     +		if (ret)
     +			goto out;
     +		break;
     +	case REF_ACTION_PRINT:
    -+		for (size_t i = 0; i < updates.nr; i++)
    ++		for (size_t i = 0; i < result.updates_nr; i++)
     +			printf("update %s %s %s\n",
    -+			       updates.items[i].refname,
    -+			       oid_to_hex(&updates.items[i].new_oid),
    -+			       oid_to_hex(&updates.items[i].old_oid));
    ++			       result.updates[i].refname,
    ++			       oid_to_hex(&result.updates[i].new_oid),
    ++			       oid_to_hex(&result.updates[i].old_oid));
     +		break;
     +	default:
     +		BUG("unsupported ref action %d", action);
    @@ builtin/history.c
     +	ret = 0;
     +
     +out:
    -+	replay_ref_updates_release(&updates);
     +	ref_transaction_free(transaction);
    ++	replay_result_release(&result);
     +	release_revisions(&revs);
     +	strbuf_release(&err);
     +	strvec_clear(&args);

--- base-commit: 02bdc12118c43e0a65258eb742492965859bffb6 change-id: 20250819-b4-pks-history-builtin-83398f9a05f0

Patrick Steinhardt· Jan 9, 2026, 08:35 UTC · re: Patrick Steinhardt · lore

[PATCH v9 1/7] builtin/replay: extract core logic to replay revisions

We're about to move the core logic used to replay revisions onto a new base into the "libgit.a" library. Prepare for this by pulling out the logic into a new function `replay_revisions()` that:

  1. Takes a set of revisions to replay and some options that tell it how
     it ought to replay the revisions.
  2. Replays the commits.
  3. Records any reference updates that would be caused by replaying the
     commits in a structure that is owned by the caller.

The logic itself will be moved into a separate file in the next commit. This change is not expected to cause user-visible change in behaviour.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/replay.c | 274 +++++++++++++++++++++++++++++++++----------------------
 1 file changed, 165 insertions(+), 109 deletions(-)
Show changes to builtin/replay.c +165 −109
diff --git a/builtin/replay.c b/builtin/replay.c
index 1960bbbee8..df3b32a52d 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -278,6 +278,137 @@ static enum ref_action_mode get_ref_action_mode(struct repository *repo, const c
 	return REF_ACTION_UPDATE;
 }
 
+struct replay_revisions_options {
+	const char *advance;
+	const char *onto;
+	int contained;
+};
+
+struct replay_result {
+	struct replay_ref_update {
+		char *refname;
+		struct object_id old_oid;
+		struct object_id new_oid;
+	} *updates;
+	size_t updates_nr, updates_alloc;
+
+	bool merge_conflict;
+};
+
+static void replay_result_release(struct replay_result *result)
+{
+	for (size_t i = 0; i < result->updates_nr; i++)
+		free(result->updates[i].refname);
+	free(result->updates);
+}
+
+static void replay_result_queue_update(struct replay_result *result,
+				       const char *refname,
+				       const struct object_id *old_oid,
+				       const struct object_id *new_oid)
+{
+	ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
+	result->updates[result->updates_nr].refname = xstrdup(refname);
+	result->updates[result->updates_nr].old_oid = *old_oid;
+	result->updates[result->updates_nr].new_oid = *new_oid;
+	result->updates_nr++;
+}
+
+static int replay_revisions(struct repository *repo, struct rev_info *revs,
+			    struct replay_revisions_options *opts,
+			    struct replay_result *out)
+{
+	kh_oid_map_t *replayed_commits = NULL;
+	struct strset *update_refs = NULL;
+	struct commit *last_commit = NULL;
+	struct commit *commit;
+	struct commit *onto = NULL;
+	struct merge_options merge_opt;
+	struct merge_result result;
+	char *advance;
+	int ret;
+
+	advance = xstrdup_or_null(opts->advance);
+	set_up_replay_mode(repo, &revs->cmdline, opts->onto, &advance,
+			   &onto, &update_refs);
+
+	/* FIXME: Should allow replaying commits with the first as a root commit */
+
+	if (prepare_revision_walk(revs) < 0) {
+		ret = error(_("error preparing revisions"));
+		goto out;
+	}
+
+	init_basic_merge_options(&merge_opt, repo);
+	memset(&result, 0, sizeof(result));
+	merge_opt.show_rename_progress = 0;
+	last_commit = onto;
+	replayed_commits = kh_init_oid_map();
+	while ((commit = get_revision(revs))) {
+		const struct name_decoration *decoration;
+		khint_t pos;
+		int hr;
+
+		if (!commit->parents)
+			die(_("replaying down from root commit is not supported yet!"));
+		if (commit->parents->next)
+			die(_("replaying merge commits is not supported yet!"));
+
+		last_commit = pick_regular_commit(repo, commit, replayed_commits,
+						  onto, &merge_opt, &result);
+		if (!last_commit)
+			break;
+
+		/* Record commit -> last_commit mapping */
+		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
+		if (hr == 0)
+			BUG("Duplicate rewritten commit: %s\n",
+			    oid_to_hex(&commit->object.oid));
+		kh_value(replayed_commits, pos) = last_commit;
+
+		/* Update any necessary branches */
+		if (advance)
+			continue;
+		decoration = get_name_decoration(&commit->object);
+		if (!decoration)
+			continue;
+		while (decoration) {
+			if (decoration->type == DECORATION_REF_LOCAL &&
+			    (opts->contained || strset_contains(update_refs,
+								decoration->name))) {
+				replay_result_queue_update(out, decoration->name,
+							   &commit->object.oid,
+							   &last_commit->object.oid);
+			}
+			decoration = decoration->next;
+		}
+	}
+
+	if (!result.clean) {
+		out->merge_conflict = true;
+		ret = -1;
+		goto out;
+	}
+
+	/* In --advance mode, advance the target ref */
+	if (advance)
+		replay_result_queue_update(out, advance,
+					   &onto->object.oid,
+					   &last_commit->object.oid);
+
+	ret = 0;
+
+out:
+	if (update_refs) {
+		strset_clear(update_refs);
+		free(update_refs);
+	}
+	kh_destroy_oid_map(replayed_commits);
+	merge_finalize(&merge_opt, &result);
+	free(advance);
+	return ret;
+}
+
 static int handle_ref_update(enum ref_action_mode mode,
 			     struct ref_transaction *transaction,
 			     const char *refname,
@@ -306,21 +437,11 @@ int cmd_replay(int argc,
 	       const char *prefix,
 	       struct repository *repo)
 {
-	const char *advance_name_opt = NULL;
-	char *advance_name = NULL;
-	struct commit *onto = NULL;
-	const char *onto_name = NULL;
-	int contained = 0;
+	struct replay_revisions_options opts = { 0 };
+	struct replay_result result = { 0 };
 	const char *ref_action = NULL;
 	enum ref_action_mode ref_mode;
-
 	struct rev_info revs;
-	struct commit *last_commit = NULL;
-	struct commit *commit;
-	struct merge_options merge_opt;
-	struct merge_result result;
-	struct strset *update_refs = NULL;
-	kh_oid_map_t *replayed_commits;
 	struct ref_transaction *transaction = NULL;
 	struct strbuf transaction_err = STRBUF_INIT;
 	struct strbuf reflog_msg = STRBUF_INIT;
@@ -333,13 +454,13 @@ int cmd_replay(int argc,
 		NULL
 	};
 	struct option replay_options[] = {
-		OPT_STRING(0, "advance", &advance_name_opt,
+		OPT_STRING(0, "advance", &opts.advance,
 			   N_("branch"),
 			   N_("make replay advance given branch")),
-		OPT_STRING(0, "onto", &onto_name,
+		OPT_STRING(0, "onto", &opts.onto,
 			   N_("revision"),
 			   N_("replay onto given commit")),
-		OPT_BOOL(0, "contained", &contained,
+		OPT_BOOL(0, "contained", &opts.contained,
 			 N_("update all branches that point at commits in <revision-range>")),
 		OPT_STRING(0, "ref-action", &ref_action,
 			   N_("mode"),
@@ -350,19 +471,17 @@ int cmd_replay(int argc,
 	argc = parse_options(argc, argv, prefix, replay_options, replay_usage,
 			     PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);
 
-	if (!onto_name && !advance_name_opt) {
+	if (!opts.onto && !opts.advance) {
 		error(_("option --onto or --advance is mandatory"));
 		usage_with_options(replay_usage, replay_options);
 	}
 
-	die_for_incompatible_opt2(!!advance_name_opt, "--advance",
-				  contained, "--contained");
+	die_for_incompatible_opt2(!!opts.advance, "--advance",
+				  opts.contained, "--contained");
 
 	/* Parse ref action mode from command line or config */
 	ref_mode = get_ref_action_mode(repo, ref_action);
 
-	advance_name = xstrdup_or_null(advance_name_opt);
-
 	repo_init_revisions(repo, &revs, prefix);
 
 	/*
@@ -414,18 +533,19 @@ int cmd_replay(int argc,
 		revs.simplify_history = 0;
 	}
 
-	set_up_replay_mode(repo, &revs.cmdline,
-			   onto_name, &advance_name,
-			   &onto, &update_refs);
-
-	/* FIXME: Should allow replaying commits with the first as a root commit */
+	ret = replay_revisions(repo, &revs, &opts, &result);
+	if (ret)
+		goto cleanup;
 
 	/* Build reflog message */
-	if (advance_name_opt)
-		strbuf_addf(&reflog_msg, "replay --advance %s", advance_name_opt);
-	else
-		strbuf_addf(&reflog_msg, "replay --onto %s",
-			    oid_to_hex(&onto->object.oid));
+	if (opts.advance) {
+		strbuf_addf(&reflog_msg, "replay --advance %s", opts.advance);
+	} else {
+		struct object_id oid;
+		if (repo_get_oid_committish(repo, opts.onto, &oid))
+			BUG("--onto commit should have been resolved beforehand already");
+		strbuf_addf(&reflog_msg, "replay --onto %s", oid_to_hex(&oid));
+	}
 
 	/* Initialize ref transaction if using update mode */
 	if (ref_mode == REF_ACTION_UPDATE) {
@@ -438,78 +558,19 @@ int cmd_replay(int argc,
 		}
 	}
 
-	if (prepare_revision_walk(&revs) < 0) {
-		ret = error(_("error preparing revisions"));
-		goto cleanup;
-	}
-
-	init_basic_merge_options(&merge_opt, repo);
-	memset(&result, 0, sizeof(result));
-	merge_opt.show_rename_progress = 0;
-	last_commit = onto;
-	replayed_commits = kh_init_oid_map();
-	while ((commit = get_revision(&revs))) {
-		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
-
-		if (!commit->parents)
-			die(_("replaying down from root commit is not supported yet!"));
-		if (commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
-
-		last_commit = pick_regular_commit(repo, commit, replayed_commits,
-						  onto, &merge_opt, &result);
-		if (!last_commit)
-			break;
-
-		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
-
-		/* Update any necessary branches */
-		if (advance_name)
-			continue;
-		decoration = get_name_decoration(&commit->object);
-		if (!decoration)
-			continue;
-		while (decoration) {
-			if (decoration->type == DECORATION_REF_LOCAL &&
-			    (contained || strset_contains(update_refs,
-							  decoration->name))) {
-				if (handle_ref_update(ref_mode, transaction,
-						      decoration->name,
-						      &last_commit->object.oid,
-						      &commit->object.oid,
-						      reflog_msg.buf,
-						      &transaction_err) < 0) {
-					ret = error(_("failed to update ref '%s': %s"),
-						    decoration->name, transaction_err.buf);
-					goto cleanup;
-				}
-			}
-			decoration = decoration->next;
-		}
-	}
-
-	/* In --advance mode, advance the target ref */
-	if (result.clean == 1 && advance_name) {
-		if (handle_ref_update(ref_mode, transaction, advance_name,
-				      &last_commit->object.oid,
-				      &onto->object.oid,
-				      reflog_msg.buf,
-				      &transaction_err) < 0) {
+	for (size_t i = 0; i < result.updates_nr; i++) {
+		ret = handle_ref_update(ref_mode, transaction, result.updates[i].refname,
+					&result.updates[i].new_oid, &result.updates[i].old_oid,
+					reflog_msg.buf, &transaction_err);
+		if (ret) {
 			ret = error(_("failed to update ref '%s': %s"),
-				    advance_name, transaction_err.buf);
+				    result.updates[i].refname, transaction_err.buf);
 			goto cleanup;
 		}
 	}
 
 	/* Commit the ref transaction if we have one */
-	if (transaction && result.clean == 1) {
+	if (transaction) {
 		if (ref_transaction_commit(transaction, &transaction_err)) {
 			ret = error(_("failed to commit ref transaction: %s"),
 				    transaction_err.buf);
@@ -517,24 +578,19 @@ int cmd_replay(int argc,
 		}
 	}
 
-	merge_finalize(&merge_opt, &result);
-	kh_destroy_oid_map(replayed_commits);
-	if (update_refs) {
-		strset_clear(update_refs);
-		free(update_refs);
-	}
-	ret = result.clean;
-
 cleanup:
 	if (transaction)
 		ref_transaction_free(transaction);
+	replay_result_release(&result);
 	strbuf_release(&transaction_err);
 	strbuf_release(&reflog_msg);
 	release_revisions(&revs);
-	free(advance_name);
 
-	/* Return */
-	if (ret < 0)
-		exit(128);
-	return ret ? 0 : 1;
+	if (ret) {
+		if (result.merge_conflict)
+			return 1;
+		return 128;
+	}
+
+	return 0;
 }
-- 
2.52.0.542.g9473a8513b.dirty
Elijah Newren· Jan 10, 2026, 01:14 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v9 1/7] builtin/replay: extract core logic to replay revisions

On Fri, Jan 9, 2026 at 12:35 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 68 quoted lines
>
> We're about to move the core logic used to replay revisions onto a new
> base into the "libgit.a" library. Prepare for this by pulling out the
> logic into a new function `replay_revisions()` that:
>
>   1. Takes a set of revisions to replay and some options that tell it how
>      it ought to replay the revisions.
>
>   2. Replays the commits.
>
>   3. Records any reference updates that would be caused by replaying the
>      commits in a structure that is owned by the caller.
>
> The logic itself will be moved into a separate file in the next commit.
> This change is not expected to cause user-visible change in behaviour.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  builtin/replay.c | 274 +++++++++++++++++++++++++++++++++----------------------
>  1 file changed, 165 insertions(+), 109 deletions(-)
>
> diff --git a/builtin/replay.c b/builtin/replay.c
> index 1960bbbee8..df3b32a52d 100644
> --- a/builtin/replay.c
> +++ b/builtin/replay.c
> @@ -278,6 +278,137 @@ static enum ref_action_mode get_ref_action_mode(struct repository *repo, const c
>         return REF_ACTION_UPDATE;
>  }
>
> +struct replay_revisions_options {
> +       const char *advance;
> +       const char *onto;
> +       int contained;
> +};
> +
> +struct replay_result {
> +       struct replay_ref_update {
> +               char *refname;
> +               struct object_id old_oid;
> +               struct object_id new_oid;
> +       } *updates;
> +       size_t updates_nr, updates_alloc;
> +
> +       bool merge_conflict;
> +};
> +
> +static void replay_result_release(struct replay_result *result)
> +{
> +       for (size_t i = 0; i < result->updates_nr; i++)
> +               free(result->updates[i].refname);
> +       free(result->updates);
> +}
> +
> +static void replay_result_queue_update(struct replay_result *result,
> +                                      const char *refname,
> +                                      const struct object_id *old_oid,
> +                                      const struct object_id *new_oid)
> +{
> +       ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
> +       result->updates[result->updates_nr].refname = xstrdup(refname);
> +       result->updates[result->updates_nr].old_oid = *old_oid;
> +       result->updates[result->updates_nr].new_oid = *new_oid;
> +       result->updates_nr++;
> +}
> +
> +static int replay_revisions(struct repository *repo, struct rev_info *revs,
> +                           struct replay_revisions_options *opts,
> +                           struct replay_result *out)
Why have both repo & revs?  Can't we get repo from revs->repo?
Show 71 quoted lines
> +{
> +       kh_oid_map_t *replayed_commits = NULL;
> +       struct strset *update_refs = NULL;
> +       struct commit *last_commit = NULL;
> +       struct commit *commit;
> +       struct commit *onto = NULL;
> +       struct merge_options merge_opt;
> +       struct merge_result result;
> +       char *advance;
> +       int ret;
> +
> +       advance = xstrdup_or_null(opts->advance);
> +       set_up_replay_mode(repo, &revs->cmdline, opts->onto, &advance,
> +                          &onto, &update_refs);
> +
> +       /* FIXME: Should allow replaying commits with the first as a root commit */
> +
> +       if (prepare_revision_walk(revs) < 0) {
> +               ret = error(_("error preparing revisions"));
> +               goto out;
> +       }
> +
> +       init_basic_merge_options(&merge_opt, repo);
> +       memset(&result, 0, sizeof(result));
> +       merge_opt.show_rename_progress = 0;
> +       last_commit = onto;
> +       replayed_commits = kh_init_oid_map();
> +       while ((commit = get_revision(revs))) {
> +               const struct name_decoration *decoration;
> +               khint_t pos;
> +               int hr;
> +
> +               if (!commit->parents)
> +                       die(_("replaying down from root commit is not supported yet!"));
> +               if (commit->parents->next)
> +                       die(_("replaying merge commits is not supported yet!"));
> +
> +               last_commit = pick_regular_commit(repo, commit, replayed_commits,
> +                                                 onto, &merge_opt, &result);
> +               if (!last_commit)
> +                       break;
> +
> +               /* Record commit -> last_commit mapping */
> +               pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
> +               if (hr == 0)
> +                       BUG("Duplicate rewritten commit: %s\n",
> +                           oid_to_hex(&commit->object.oid));
> +               kh_value(replayed_commits, pos) = last_commit;
> +
> +               /* Update any necessary branches */
> +               if (advance)
> +                       continue;
> +               decoration = get_name_decoration(&commit->object);
> +               if (!decoration)
> +                       continue;
> +               while (decoration) {
> +                       if (decoration->type == DECORATION_REF_LOCAL &&
> +                           (opts->contained || strset_contains(update_refs,
> +                                                               decoration->name))) {
> +                               replay_result_queue_update(out, decoration->name,
> +                                                          &commit->object.oid,
> +                                                          &last_commit->object.oid);
> +                       }
> +                       decoration = decoration->next;
> +               }
> +       }
> +
> +       if (!result.clean) {
> +               out->merge_conflict = true;
> +               ret = -1;
> +               goto out;
What?  In the replay code before this series:
        ret = result.clean;
        [...]
        /* Return */
        if (ret < 0)
                exit(128);
        return ret ? 0 : 1;
So, !result.clean should result in a return code of 1, not -1.
Show 232 quoted lines
> +       }
> +
> +       /* In --advance mode, advance the target ref */
> +       if (advance)
> +               replay_result_queue_update(out, advance,
> +                                          &onto->object.oid,
> +                                          &last_commit->object.oid);
> +
> +       ret = 0;
> +
> +out:
> +       if (update_refs) {
> +               strset_clear(update_refs);
> +               free(update_refs);
> +       }
> +       kh_destroy_oid_map(replayed_commits);
> +       merge_finalize(&merge_opt, &result);
> +       free(advance);
> +       return ret;
> +}
> +
>  static int handle_ref_update(enum ref_action_mode mode,
>                              struct ref_transaction *transaction,
>                              const char *refname,
> @@ -306,21 +437,11 @@ int cmd_replay(int argc,
>                const char *prefix,
>                struct repository *repo)
>  {
> -       const char *advance_name_opt = NULL;
> -       char *advance_name = NULL;
> -       struct commit *onto = NULL;
> -       const char *onto_name = NULL;
> -       int contained = 0;
> +       struct replay_revisions_options opts = { 0 };
> +       struct replay_result result = { 0 };
>         const char *ref_action = NULL;
>         enum ref_action_mode ref_mode;
> -
>         struct rev_info revs;
> -       struct commit *last_commit = NULL;
> -       struct commit *commit;
> -       struct merge_options merge_opt;
> -       struct merge_result result;
> -       struct strset *update_refs = NULL;
> -       kh_oid_map_t *replayed_commits;
>         struct ref_transaction *transaction = NULL;
>         struct strbuf transaction_err = STRBUF_INIT;
>         struct strbuf reflog_msg = STRBUF_INIT;
> @@ -333,13 +454,13 @@ int cmd_replay(int argc,
>                 NULL
>         };
>         struct option replay_options[] = {
> -               OPT_STRING(0, "advance", &advance_name_opt,
> +               OPT_STRING(0, "advance", &opts.advance,
>                            N_("branch"),
>                            N_("make replay advance given branch")),
> -               OPT_STRING(0, "onto", &onto_name,
> +               OPT_STRING(0, "onto", &opts.onto,
>                            N_("revision"),
>                            N_("replay onto given commit")),
> -               OPT_BOOL(0, "contained", &contained,
> +               OPT_BOOL(0, "contained", &opts.contained,
>                          N_("update all branches that point at commits in <revision-range>")),
>                 OPT_STRING(0, "ref-action", &ref_action,
>                            N_("mode"),
> @@ -350,19 +471,17 @@ int cmd_replay(int argc,
>         argc = parse_options(argc, argv, prefix, replay_options, replay_usage,
>                              PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);
>
> -       if (!onto_name && !advance_name_opt) {
> +       if (!opts.onto && !opts.advance) {
>                 error(_("option --onto or --advance is mandatory"));
>                 usage_with_options(replay_usage, replay_options);
>         }
>
> -       die_for_incompatible_opt2(!!advance_name_opt, "--advance",
> -                                 contained, "--contained");
> +       die_for_incompatible_opt2(!!opts.advance, "--advance",
> +                                 opts.contained, "--contained");
>
>         /* Parse ref action mode from command line or config */
>         ref_mode = get_ref_action_mode(repo, ref_action);
>
> -       advance_name = xstrdup_or_null(advance_name_opt);
> -
>         repo_init_revisions(repo, &revs, prefix);
>
>         /*
> @@ -414,18 +533,19 @@ int cmd_replay(int argc,
>                 revs.simplify_history = 0;
>         }
>
> -       set_up_replay_mode(repo, &revs.cmdline,
> -                          onto_name, &advance_name,
> -                          &onto, &update_refs);
> -
> -       /* FIXME: Should allow replaying commits with the first as a root commit */
> +       ret = replay_revisions(repo, &revs, &opts, &result);
> +       if (ret)
> +               goto cleanup;
>
>         /* Build reflog message */
> -       if (advance_name_opt)
> -               strbuf_addf(&reflog_msg, "replay --advance %s", advance_name_opt);
> -       else
> -               strbuf_addf(&reflog_msg, "replay --onto %s",
> -                           oid_to_hex(&onto->object.oid));
> +       if (opts.advance) {
> +               strbuf_addf(&reflog_msg, "replay --advance %s", opts.advance);
> +       } else {
> +               struct object_id oid;
> +               if (repo_get_oid_committish(repo, opts.onto, &oid))
> +                       BUG("--onto commit should have been resolved beforehand already");
> +               strbuf_addf(&reflog_msg, "replay --onto %s", oid_to_hex(&oid));
> +       }
>
>         /* Initialize ref transaction if using update mode */
>         if (ref_mode == REF_ACTION_UPDATE) {
> @@ -438,78 +558,19 @@ int cmd_replay(int argc,
>                 }
>         }
>
> -       if (prepare_revision_walk(&revs) < 0) {
> -               ret = error(_("error preparing revisions"));
> -               goto cleanup;
> -       }
> -
> -       init_basic_merge_options(&merge_opt, repo);
> -       memset(&result, 0, sizeof(result));
> -       merge_opt.show_rename_progress = 0;
> -       last_commit = onto;
> -       replayed_commits = kh_init_oid_map();
> -       while ((commit = get_revision(&revs))) {
> -               const struct name_decoration *decoration;
> -               khint_t pos;
> -               int hr;
> -
> -               if (!commit->parents)
> -                       die(_("replaying down from root commit is not supported yet!"));
> -               if (commit->parents->next)
> -                       die(_("replaying merge commits is not supported yet!"));
> -
> -               last_commit = pick_regular_commit(repo, commit, replayed_commits,
> -                                                 onto, &merge_opt, &result);
> -               if (!last_commit)
> -                       break;
> -
> -               /* Record commit -> last_commit mapping */
> -               pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
> -               if (hr == 0)
> -                       BUG("Duplicate rewritten commit: %s\n",
> -                           oid_to_hex(&commit->object.oid));
> -               kh_value(replayed_commits, pos) = last_commit;
> -
> -               /* Update any necessary branches */
> -               if (advance_name)
> -                       continue;
> -               decoration = get_name_decoration(&commit->object);
> -               if (!decoration)
> -                       continue;
> -               while (decoration) {
> -                       if (decoration->type == DECORATION_REF_LOCAL &&
> -                           (contained || strset_contains(update_refs,
> -                                                         decoration->name))) {
> -                               if (handle_ref_update(ref_mode, transaction,
> -                                                     decoration->name,
> -                                                     &last_commit->object.oid,
> -                                                     &commit->object.oid,
> -                                                     reflog_msg.buf,
> -                                                     &transaction_err) < 0) {
> -                                       ret = error(_("failed to update ref '%s': %s"),
> -                                                   decoration->name, transaction_err.buf);
> -                                       goto cleanup;
> -                               }
> -                       }
> -                       decoration = decoration->next;
> -               }
> -       }
> -
> -       /* In --advance mode, advance the target ref */
> -       if (result.clean == 1 && advance_name) {
> -               if (handle_ref_update(ref_mode, transaction, advance_name,
> -                                     &last_commit->object.oid,
> -                                     &onto->object.oid,
> -                                     reflog_msg.buf,
> -                                     &transaction_err) < 0) {
> +       for (size_t i = 0; i < result.updates_nr; i++) {
> +               ret = handle_ref_update(ref_mode, transaction, result.updates[i].refname,
> +                                       &result.updates[i].new_oid, &result.updates[i].old_oid,
> +                                       reflog_msg.buf, &transaction_err);
> +               if (ret) {
>                         ret = error(_("failed to update ref '%s': %s"),
> -                                   advance_name, transaction_err.buf);
> +                                   result.updates[i].refname, transaction_err.buf);
>                         goto cleanup;
>                 }
>         }
>
>         /* Commit the ref transaction if we have one */
> -       if (transaction && result.clean == 1) {
> +       if (transaction) {
>                 if (ref_transaction_commit(transaction, &transaction_err)) {
>                         ret = error(_("failed to commit ref transaction: %s"),
>                                     transaction_err.buf);
> @@ -517,24 +578,19 @@ int cmd_replay(int argc,
>                 }
>         }
>
> -       merge_finalize(&merge_opt, &result);
> -       kh_destroy_oid_map(replayed_commits);
> -       if (update_refs) {
> -               strset_clear(update_refs);
> -               free(update_refs);
> -       }
> -       ret = result.clean;
> -
>  cleanup:
>         if (transaction)
>                 ref_transaction_free(transaction);
> +       replay_result_release(&result);
>         strbuf_release(&transaction_err);
>         strbuf_release(&reflog_msg);
>         release_revisions(&revs);
> -       free(advance_name);
>
> -       /* Return */
> -       if (ret < 0)
> -               exit(128);
> -       return ret ? 0 : 1;
> +       if (ret) {
> +               if (result.merge_conflict)
> +                       return 1;

This feels like a rather Rube-Goldberg way to get this. Above where I highlighted where you set ret to -1 incorrectly, can we just set it to 1 there and then lose all this special logic in favor of "return ret;" here? Or am I overlooking something?

Show 8 quoted lines
> +               return 128;
> +       }
> +
> +       return 0;
>  }
>
> --
> 2.52.0.542.g9473a8513b.dirty
Patrick Steinhardt· Jan 12, 2026, 13:02 UTC · re: Elijah Newren · lore

Re: [PATCH v9 1/7] builtin/replay: extract core logic to replay revisions

On Fri, Jan 09, 2026 at 05:14:48PM -0800, Elijah Newren wrote:
Show 50 quoted lines
> On Fri, Jan 9, 2026 at 12:35 AM Patrick Steinhardt <ps@pks.im> wrote:
> > diff --git a/builtin/replay.c b/builtin/replay.c
> > index 1960bbbee8..df3b32a52d 100644
> > --- a/builtin/replay.c
> > +++ b/builtin/replay.c
> > @@ -278,6 +278,137 @@ static enum ref_action_mode get_ref_action_mode(struct repository *repo, const c
> >         return REF_ACTION_UPDATE;
> >  }
> >
> > +struct replay_revisions_options {
> > +       const char *advance;
> > +       const char *onto;
> > +       int contained;
> > +};
> > +
> > +struct replay_result {
> > +       struct replay_ref_update {
> > +               char *refname;
> > +               struct object_id old_oid;
> > +               struct object_id new_oid;
> > +       } *updates;
> > +       size_t updates_nr, updates_alloc;
> > +
> > +       bool merge_conflict;
> > +};
> > +
> > +static void replay_result_release(struct replay_result *result)
> > +{
> > +       for (size_t i = 0; i < result->updates_nr; i++)
> > +               free(result->updates[i].refname);
> > +       free(result->updates);
> > +}
> > +
> > +static void replay_result_queue_update(struct replay_result *result,
> > +                                      const char *refname,
> > +                                      const struct object_id *old_oid,
> > +                                      const struct object_id *new_oid)
> > +{
> > +       ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
> > +       result->updates[result->updates_nr].refname = xstrdup(refname);
> > +       result->updates[result->updates_nr].old_oid = *old_oid;
> > +       result->updates[result->updates_nr].new_oid = *new_oid;
> > +       result->updates_nr++;
> > +}
> > +
> > +static int replay_revisions(struct repository *repo, struct rev_info *revs,
> > +                           struct replay_revisions_options *opts,
> > +                           struct replay_result *out)
> 
> Why have both repo & revs?  Can't we get repo from revs->repo?
True indeed, will change.
Show 33 quoted lines
> > @@ -517,24 +578,19 @@ int cmd_replay(int argc,
> >                 }
> >         }
> >
> > -       merge_finalize(&merge_opt, &result);
> > -       kh_destroy_oid_map(replayed_commits);
> > -       if (update_refs) {
> > -               strset_clear(update_refs);
> > -               free(update_refs);
> > -       }
> > -       ret = result.clean;
> > -
> >  cleanup:
> >         if (transaction)
> >                 ref_transaction_free(transaction);
> > +       replay_result_release(&result);
> >         strbuf_release(&transaction_err);
> >         strbuf_release(&reflog_msg);
> >         release_revisions(&revs);
> > -       free(advance_name);
> >
> > -       /* Return */
> > -       if (ret < 0)
> > -               exit(128);
> > -       return ret ? 0 : 1;
> > +       if (ret) {
> > +               if (result.merge_conflict)
> > +                       return 1;
> 
> This feels like a rather Rube-Goldberg way to get this.  Above where I
> highlighted where you set ret to -1 incorrectly, can we just set it to
> 1 there and then lose all this special logic in favor of "return ret;"
> here?  Or am I overlooking something?

You're not really overlooking anything, no. The reason I did it this way here is to make the logic more explicit -- a simple return value is very fragile, and I broke this in v8 of this patch series already.

Furthermore, this is preparing for us to move this code into a separate library. Not all callers may want to handle conflicts the exact same, so I think it's sensible to explicitly tell the caller about such error cases. One way to do this is to use e.g. an enum to surface the info, but that is somewhat limiting. We may for example tell the caller later on what the conflicting commit was, and that's becoming way easier by having the info in `sturct replay_result`.

Patrick
Patrick Steinhardt· Jan 9, 2026, 08:35 UTC · re: Patrick Steinhardt · lore

[PATCH v9 2/7] builtin/replay: move core logic into "libgit.a"

Move the core logic used to replay commits into "libgit.a" so that it can be easily reused by other commands. It will be used in a subsequent commit where we're about to introduce a new git-history(1) command.

Note that with this change we have no sign-comparison warnings anymore, and neither do we depend on `the_repository`.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Makefile         |   1 +
 builtin/replay.c | 368 +------------------------------------------------------
 meson.build      |   1 +
 replay.c         | 355 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 replay.h         |  64 ++++++++++
 5 files changed, 422 insertions(+), 367 deletions(-)
Show changes to 5 files +422 −367

Makefile, builtin/replay.c, meson.build, replay.c, replay.h

diff --git a/Makefile b/Makefile
index b7eba509c6..1c64a5d2ae 100644
--- a/Makefile
+++ b/Makefile
@@ -1285,6 +1285,7 @@ LIB_OBJS += repack-geometry.o
 LIB_OBJS += repack-midx.o
 LIB_OBJS += repack-promisor.o
 LIB_OBJS += replace-object.o
+LIB_OBJS += replay.o
 LIB_OBJS += repo-settings.o
 LIB_OBJS += repository.o
 LIB_OBJS += rerere.o
diff --git a/builtin/replay.c b/builtin/replay.c
index df3b32a52d..da8b7202f6 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -2,257 +2,22 @@
  * "git replay" builtin command
  */
 
-#define USE_THE_REPOSITORY_VARIABLE
-#define DISABLE_SIGN_COMPARE_WARNINGS
-
 #include "git-compat-util.h"
 
 #include "builtin.h"
 #include "config.h"
-#include "environment.h"
 #include "hex.h"
-#include "lockfile.h"
-#include "merge-ort.h"
 #include "object-name.h"
 #include "parse-options.h"
 #include "refs.h"
+#include "replay.h"
 #include "revision.h"
-#include "strmap.h"
-#include <oidset.h>
-#include <tree.h>
 
 enum ref_action_mode {
 	REF_ACTION_UPDATE,
 	REF_ACTION_PRINT,
 };
 
-static const char *short_commit_name(struct repository *repo,
-				     struct commit *commit)
-{
-	return repo_find_unique_abbrev(repo, &commit->object.oid,
-				       DEFAULT_ABBREV);
-}
-
-static struct commit *peel_committish(struct repository *repo,
-				      const char *name,
-				      const char *mode)
-{
-	struct object *obj;
-	struct object_id oid;
-
-	if (repo_get_oid(repo, name, &oid))
-		die(_("'%s' is not a valid commit-ish for %s"), name, mode);
-	obj = parse_object_or_die(repo, &oid, name);
-	return (struct commit *)repo_peel_to_type(repo, name, 0, obj,
-						  OBJ_COMMIT);
-}
-
-static char *get_author(const char *message)
-{
-	size_t len;
-	const char *a;
-
-	a = find_commit_header(message, "author", &len);
-	if (a)
-		return xmemdupz(a, len);
-
-	return NULL;
-}
-
-static struct commit *create_commit(struct repository *repo,
-				    struct tree *tree,
-				    struct commit *based_on,
-				    struct commit *parent)
-{
-	struct object_id ret;
-	struct object *obj = NULL;
-	struct commit_list *parents = NULL;
-	char *author;
-	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
-	struct commit_extra_header *extra = NULL;
-	struct strbuf msg = STRBUF_INIT;
-	const char *out_enc = get_commit_output_encoding();
-	const char *message = repo_logmsg_reencode(repo, based_on,
-						   NULL, out_enc);
-	const char *orig_message = NULL;
-	const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
-
-	commit_list_insert(parent, &parents);
-	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
-	find_commit_subject(message, &orig_message);
-	strbuf_addstr(&msg, orig_message);
-	author = get_author(message);
-	reset_ident_date();
-	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
-				 &ret, author, NULL, sign_commit, extra)) {
-		error(_("failed to write commit object"));
-		goto out;
-	}
-
-	obj = parse_object(repo, &ret);
-
-out:
-	repo_unuse_commit_buffer(the_repository, based_on, message);
-	free_commit_extra_headers(extra);
-	free_commit_list(parents);
-	strbuf_release(&msg);
-	free(author);
-	return (struct commit *)obj;
-}
-
-struct ref_info {
-	struct commit *onto;
-	struct strset positive_refs;
-	struct strset negative_refs;
-	int positive_refexprs;
-	int negative_refexprs;
-};
-
-static void get_ref_information(struct repository *repo,
-				struct rev_cmdline_info *cmd_info,
-				struct ref_info *ref_info)
-{
-	int i;
-
-	ref_info->onto = NULL;
-	strset_init(&ref_info->positive_refs);
-	strset_init(&ref_info->negative_refs);
-	ref_info->positive_refexprs = 0;
-	ref_info->negative_refexprs = 0;
-
-	/*
-	 * When the user specifies e.g.
-	 *   git replay origin/main..mybranch
-	 *   git replay ^origin/next mybranch1 mybranch2
-	 * we want to be able to determine where to replay the commits.  In
-	 * these examples, the branches are probably based on an old version
-	 * of either origin/main or origin/next, so we want to replay on the
-	 * newest version of that branch.  In contrast we would want to error
-	 * out if they ran
-	 *   git replay ^origin/master ^origin/next mybranch
-	 *   git replay mybranch~2..mybranch
-	 * the first of those because there's no unique base to choose, and
-	 * the second because they'd likely just be replaying commits on top
-	 * of the same commit and not making any difference.
-	 */
-	for (i = 0; i < cmd_info->nr; i++) {
-		struct rev_cmdline_entry *e = cmd_info->rev + i;
-		struct object_id oid;
-		const char *refexpr = e->name;
-		char *fullname = NULL;
-		int can_uniquely_dwim = 1;
-
-		if (*refexpr == '^')
-			refexpr++;
-		if (repo_dwim_ref(repo, refexpr, strlen(refexpr), &oid, &fullname, 0) != 1)
-			can_uniquely_dwim = 0;
-
-		if (e->flags & BOTTOM) {
-			if (can_uniquely_dwim)
-				strset_add(&ref_info->negative_refs, fullname);
-			if (!ref_info->negative_refexprs)
-				ref_info->onto = lookup_commit_reference_gently(repo,
-										&e->item->oid, 1);
-			ref_info->negative_refexprs++;
-		} else {
-			if (can_uniquely_dwim)
-				strset_add(&ref_info->positive_refs, fullname);
-			ref_info->positive_refexprs++;
-		}
-
-		free(fullname);
-	}
-}
-
-static void set_up_replay_mode(struct repository *repo,
-			       struct rev_cmdline_info *cmd_info,
-			       const char *onto_name,
-			       char **advance_name,
-			       struct commit **onto,
-			       struct strset **update_refs)
-{
-	struct ref_info rinfo;
-
-	get_ref_information(repo, cmd_info, &rinfo);
-	if (!rinfo.positive_refexprs)
-		die(_("need some commits to replay"));
-
-	die_for_incompatible_opt2(!!onto_name, "--onto",
-				  !!*advance_name, "--advance");
-	if (onto_name) {
-		*onto = peel_committish(repo, onto_name, "--onto");
-		if (rinfo.positive_refexprs <
-		    strset_get_size(&rinfo.positive_refs))
-			die(_("all positive revisions given must be references"));
-		*update_refs = xcalloc(1, sizeof(**update_refs));
-		**update_refs = rinfo.positive_refs;
-		memset(&rinfo.positive_refs, 0, sizeof(**update_refs));
-	} else {
-		struct object_id oid;
-		char *fullname = NULL;
-
-		if (!*advance_name)
-			BUG("expected either onto_name or *advance_name in this function");
-
-		if (repo_dwim_ref(repo, *advance_name, strlen(*advance_name),
-			     &oid, &fullname, 0) == 1) {
-			free(*advance_name);
-			*advance_name = fullname;
-		} else {
-			die(_("argument to --advance must be a reference"));
-		}
-		*onto = peel_committish(repo, *advance_name, "--advance");
-		if (rinfo.positive_refexprs > 1)
-			die(_("cannot advance target with multiple sources because ordering would be ill-defined"));
-	}
-	strset_clear(&rinfo.negative_refs);
-	strset_clear(&rinfo.positive_refs);
-}
-
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
-{
-	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
-	if (pos == kh_end(replayed_commits))
-		return fallback;
-	return kh_value(replayed_commits, pos);
-}
-
-static struct commit *pick_regular_commit(struct repository *repo,
-					  struct commit *pickme,
-					  kh_oid_map_t *replayed_commits,
-					  struct commit *onto,
-					  struct merge_options *merge_opt,
-					  struct merge_result *result)
-{
-	struct commit *base, *replayed_base;
-	struct tree *pickme_tree, *base_tree;
-
-	base = pickme->parents->item;
-	replayed_base = mapped_commit(replayed_commits, base, onto);
-
-	result->tree = repo_get_commit_tree(repo, replayed_base);
-	pickme_tree = repo_get_commit_tree(repo, pickme);
-	base_tree = repo_get_commit_tree(repo, base);
-
-	merge_opt->branch1 = short_commit_name(repo, replayed_base);
-	merge_opt->branch2 = short_commit_name(repo, pickme);
-	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
-
-	merge_incore_nonrecursive(merge_opt,
-				  base_tree,
-				  result->tree,
-				  pickme_tree,
-				  result);
-
-	free((char*)merge_opt->ancestor);
-	merge_opt->ancestor = NULL;
-	if (!result->clean)
-		return NULL;
-	return create_commit(repo, result->tree, pickme, replayed_base);
-}
-
 static enum ref_action_mode parse_ref_action_mode(const char *ref_action, const char *source)
 {
 	if (!ref_action || !strcmp(ref_action, "update"))
@@ -278,137 +43,6 @@ static enum ref_action_mode get_ref_action_mode(struct repository *repo, const c
 	return REF_ACTION_UPDATE;
 }
 
-struct replay_revisions_options {
-	const char *advance;
-	const char *onto;
-	int contained;
-};
-
-struct replay_result {
-	struct replay_ref_update {
-		char *refname;
-		struct object_id old_oid;
-		struct object_id new_oid;
-	} *updates;
-	size_t updates_nr, updates_alloc;
-
-	bool merge_conflict;
-};
-
-static void replay_result_release(struct replay_result *result)
-{
-	for (size_t i = 0; i < result->updates_nr; i++)
-		free(result->updates[i].refname);
-	free(result->updates);
-}
-
-static void replay_result_queue_update(struct replay_result *result,
-				       const char *refname,
-				       const struct object_id *old_oid,
-				       const struct object_id *new_oid)
-{
-	ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
-	result->updates[result->updates_nr].refname = xstrdup(refname);
-	result->updates[result->updates_nr].old_oid = *old_oid;
-	result->updates[result->updates_nr].new_oid = *new_oid;
-	result->updates_nr++;
-}
-
-static int replay_revisions(struct repository *repo, struct rev_info *revs,
-			    struct replay_revisions_options *opts,
-			    struct replay_result *out)
-{
-	kh_oid_map_t *replayed_commits = NULL;
-	struct strset *update_refs = NULL;
-	struct commit *last_commit = NULL;
-	struct commit *commit;
-	struct commit *onto = NULL;
-	struct merge_options merge_opt;
-	struct merge_result result;
-	char *advance;
-	int ret;
-
-	advance = xstrdup_or_null(opts->advance);
-	set_up_replay_mode(repo, &revs->cmdline, opts->onto, &advance,
-			   &onto, &update_refs);
-
-	/* FIXME: Should allow replaying commits with the first as a root commit */
-
-	if (prepare_revision_walk(revs) < 0) {
-		ret = error(_("error preparing revisions"));
-		goto out;
-	}
-
-	init_basic_merge_options(&merge_opt, repo);
-	memset(&result, 0, sizeof(result));
-	merge_opt.show_rename_progress = 0;
-	last_commit = onto;
-	replayed_commits = kh_init_oid_map();
-	while ((commit = get_revision(revs))) {
-		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
-
-		if (!commit->parents)
-			die(_("replaying down from root commit is not supported yet!"));
-		if (commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
-
-		last_commit = pick_regular_commit(repo, commit, replayed_commits,
-						  onto, &merge_opt, &result);
-		if (!last_commit)
-			break;
-
-		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
-
-		/* Update any necessary branches */
-		if (advance)
-			continue;
-		decoration = get_name_decoration(&commit->object);
-		if (!decoration)
-			continue;
-		while (decoration) {
-			if (decoration->type == DECORATION_REF_LOCAL &&
-			    (opts->contained || strset_contains(update_refs,
-								decoration->name))) {
-				replay_result_queue_update(out, decoration->name,
-							   &commit->object.oid,
-							   &last_commit->object.oid);
-			}
-			decoration = decoration->next;
-		}
-	}
-
-	if (!result.clean) {
-		out->merge_conflict = true;
-		ret = -1;
-		goto out;
-	}
-
-	/* In --advance mode, advance the target ref */
-	if (advance)
-		replay_result_queue_update(out, advance,
-					   &onto->object.oid,
-					   &last_commit->object.oid);
-
-	ret = 0;
-
-out:
-	if (update_refs) {
-		strset_clear(update_refs);
-		free(update_refs);
-	}
-	kh_destroy_oid_map(replayed_commits);
-	merge_finalize(&merge_opt, &result);
-	free(advance);
-	return ret;
-}
-
 static int handle_ref_update(enum ref_action_mode mode,
 			     struct ref_transaction *transaction,
 			     const char *refname,
diff --git a/meson.build b/meson.build
index dd52efd1c8..a5a4e99b25 100644
--- a/meson.build
+++ b/meson.build
@@ -471,6 +471,7 @@ libgit_sources = [
   'repack-midx.c',
   'repack-promisor.c',
   'replace-object.c',
+  'replay.c',
   'repo-settings.c',
   'repository.c',
   'rerere.c',
diff --git a/replay.c b/replay.c
new file mode 100644
index 0000000000..fc7186ef09
--- /dev/null
+++ b/replay.c
@@ -0,0 +1,355 @@
+#define USE_THE_REPOSITORY_VARIABLE
+#define DISABLE_SIGN_COMPARE_WARNINGS
+
+#include "git-compat-util.h"
+#include "environment.h"
+#include "hex.h"
+#include "merge-ort.h"
+#include "object-name.h"
+#include "oidset.h"
+#include "parse-options.h"
+#include "refs.h"
+#include "replay.h"
+#include "revision.h"
+#include "tree.h"
+
+static const char *short_commit_name(struct repository *repo,
+				     struct commit *commit)
+{
+	return repo_find_unique_abbrev(repo, &commit->object.oid,
+				       DEFAULT_ABBREV);
+}
+
+static struct commit *peel_committish(struct repository *repo,
+				      const char *name,
+				      const char *mode)
+{
+	struct object *obj;
+	struct object_id oid;
+
+	if (repo_get_oid(repo, name, &oid))
+		die(_("'%s' is not a valid commit-ish for %s"), name, mode);
+	obj = parse_object_or_die(repo, &oid, name);
+	return (struct commit *)repo_peel_to_type(repo, name, 0, obj,
+						  OBJ_COMMIT);
+}
+
+static char *get_author(const char *message)
+{
+	size_t len;
+	const char *a;
+
+	a = find_commit_header(message, "author", &len);
+	if (a)
+		return xmemdupz(a, len);
+
+	return NULL;
+}
+
+static struct commit *create_commit(struct repository *repo,
+				    struct tree *tree,
+				    struct commit *based_on,
+				    struct commit *parent)
+{
+	struct object_id ret;
+	struct object *obj = NULL;
+	struct commit_list *parents = NULL;
+	char *author;
+	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
+	struct commit_extra_header *extra = NULL;
+	struct strbuf msg = STRBUF_INIT;
+	const char *out_enc = get_commit_output_encoding();
+	const char *message = repo_logmsg_reencode(repo, based_on,
+						   NULL, out_enc);
+	const char *orig_message = NULL;
+	const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
+
+	commit_list_insert(parent, &parents);
+	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
+	find_commit_subject(message, &orig_message);
+	strbuf_addstr(&msg, orig_message);
+	author = get_author(message);
+	reset_ident_date();
+	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
+				 &ret, author, NULL, sign_commit, extra)) {
+		error(_("failed to write commit object"));
+		goto out;
+	}
+
+	obj = parse_object(repo, &ret);
+
+out:
+	repo_unuse_commit_buffer(the_repository, based_on, message);
+	free_commit_extra_headers(extra);
+	free_commit_list(parents);
+	strbuf_release(&msg);
+	free(author);
+	return (struct commit *)obj;
+}
+
+struct ref_info {
+	struct commit *onto;
+	struct strset positive_refs;
+	struct strset negative_refs;
+	int positive_refexprs;
+	int negative_refexprs;
+};
+
+static void get_ref_information(struct repository *repo,
+				struct rev_cmdline_info *cmd_info,
+				struct ref_info *ref_info)
+{
+	int i;
+
+	ref_info->onto = NULL;
+	strset_init(&ref_info->positive_refs);
+	strset_init(&ref_info->negative_refs);
+	ref_info->positive_refexprs = 0;
+	ref_info->negative_refexprs = 0;
+
+	/*
+	 * When the user specifies e.g.
+	 *   git replay origin/main..mybranch
+	 *   git replay ^origin/next mybranch1 mybranch2
+	 * we want to be able to determine where to replay the commits.  In
+	 * these examples, the branches are probably based on an old version
+	 * of either origin/main or origin/next, so we want to replay on the
+	 * newest version of that branch.  In contrast we would want to error
+	 * out if they ran
+	 *   git replay ^origin/master ^origin/next mybranch
+	 *   git replay mybranch~2..mybranch
+	 * the first of those because there's no unique base to choose, and
+	 * the second because they'd likely just be replaying commits on top
+	 * of the same commit and not making any difference.
+	 */
+	for (i = 0; i < cmd_info->nr; i++) {
+		struct rev_cmdline_entry *e = cmd_info->rev + i;
+		struct object_id oid;
+		const char *refexpr = e->name;
+		char *fullname = NULL;
+		int can_uniquely_dwim = 1;
+
+		if (*refexpr == '^')
+			refexpr++;
+		if (repo_dwim_ref(repo, refexpr, strlen(refexpr), &oid, &fullname, 0) != 1)
+			can_uniquely_dwim = 0;
+
+		if (e->flags & BOTTOM) {
+			if (can_uniquely_dwim)
+				strset_add(&ref_info->negative_refs, fullname);
+			if (!ref_info->negative_refexprs)
+				ref_info->onto = lookup_commit_reference_gently(repo,
+										&e->item->oid, 1);
+			ref_info->negative_refexprs++;
+		} else {
+			if (can_uniquely_dwim)
+				strset_add(&ref_info->positive_refs, fullname);
+			ref_info->positive_refexprs++;
+		}
+
+		free(fullname);
+	}
+}
+
+static void set_up_replay_mode(struct repository *repo,
+			       struct rev_cmdline_info *cmd_info,
+			       const char *onto_name,
+			       char **advance_name,
+			       struct commit **onto,
+			       struct strset **update_refs)
+{
+	struct ref_info rinfo;
+
+	get_ref_information(repo, cmd_info, &rinfo);
+	if (!rinfo.positive_refexprs)
+		die(_("need some commits to replay"));
+
+	die_for_incompatible_opt2(!!onto_name, "--onto",
+				  !!*advance_name, "--advance");
+	if (onto_name) {
+		*onto = peel_committish(repo, onto_name, "--onto");
+		if (rinfo.positive_refexprs <
+		    strset_get_size(&rinfo.positive_refs))
+			die(_("all positive revisions given must be references"));
+		*update_refs = xcalloc(1, sizeof(**update_refs));
+		**update_refs = rinfo.positive_refs;
+		memset(&rinfo.positive_refs, 0, sizeof(**update_refs));
+	} else {
+		struct object_id oid;
+		char *fullname = NULL;
+
+		if (!*advance_name)
+			BUG("expected either onto_name or *advance_name in this function");
+
+		if (repo_dwim_ref(repo, *advance_name, strlen(*advance_name),
+			     &oid, &fullname, 0) == 1) {
+			free(*advance_name);
+			*advance_name = fullname;
+		} else {
+			die(_("argument to --advance must be a reference"));
+		}
+		*onto = peel_committish(repo, *advance_name, "--advance");
+		if (rinfo.positive_refexprs > 1)
+			die(_("cannot advance target with multiple sources because ordering would be ill-defined"));
+	}
+	strset_clear(&rinfo.negative_refs);
+	strset_clear(&rinfo.positive_refs);
+}
+
+static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
+				    struct commit *commit,
+				    struct commit *fallback)
+{
+	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
+	if (pos == kh_end(replayed_commits))
+		return fallback;
+	return kh_value(replayed_commits, pos);
+}
+
+static struct commit *pick_regular_commit(struct repository *repo,
+					  struct commit *pickme,
+					  kh_oid_map_t *replayed_commits,
+					  struct commit *onto,
+					  struct merge_options *merge_opt,
+					  struct merge_result *result)
+{
+	struct commit *base, *replayed_base;
+	struct tree *pickme_tree, *base_tree;
+
+	base = pickme->parents->item;
+	replayed_base = mapped_commit(replayed_commits, base, onto);
+
+	result->tree = repo_get_commit_tree(repo, replayed_base);
+	pickme_tree = repo_get_commit_tree(repo, pickme);
+	base_tree = repo_get_commit_tree(repo, base);
+
+	merge_opt->branch1 = short_commit_name(repo, replayed_base);
+	merge_opt->branch2 = short_commit_name(repo, pickme);
+	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
+
+	merge_incore_nonrecursive(merge_opt,
+				  base_tree,
+				  result->tree,
+				  pickme_tree,
+				  result);
+
+	free((char*)merge_opt->ancestor);
+	merge_opt->ancestor = NULL;
+	if (!result->clean)
+		return NULL;
+	return create_commit(repo, result->tree, pickme, replayed_base);
+}
+
+void replay_result_release(struct replay_result *result)
+{
+	for (size_t i = 0; i < result->updates_nr; i++)
+		free(result->updates[i].refname);
+	free(result->updates);
+}
+
+static void replay_result_queue_update(struct replay_result *result,
+				       const char *refname,
+				       const struct object_id *old_oid,
+				       const struct object_id *new_oid)
+{
+	ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
+	result->updates[result->updates_nr].refname = xstrdup(refname);
+	result->updates[result->updates_nr].old_oid = *old_oid;
+	result->updates[result->updates_nr].new_oid = *new_oid;
+	result->updates_nr++;
+}
+
+int replay_revisions(struct repository *repo, struct rev_info *revs,
+		     struct replay_revisions_options *opts,
+		     struct replay_result *out)
+{
+	kh_oid_map_t *replayed_commits = NULL;
+	struct strset *update_refs = NULL;
+	struct commit *last_commit = NULL;
+	struct commit *commit;
+	struct commit *onto = NULL;
+	struct merge_options merge_opt;
+	struct merge_result result;
+	char *advance;
+	int ret;
+
+	advance = xstrdup_or_null(opts->advance);
+	set_up_replay_mode(repo, &revs->cmdline, opts->onto, &advance,
+			   &onto, &update_refs);
+
+	/* FIXME: Should allow replaying commits with the first as a root commit */
+
+	if (prepare_revision_walk(revs) < 0) {
+		ret = error(_("error preparing revisions"));
+		goto out;
+	}
+
+	init_basic_merge_options(&merge_opt, repo);
+	memset(&result, 0, sizeof(result));
+	merge_opt.show_rename_progress = 0;
+	last_commit = onto;
+	replayed_commits = kh_init_oid_map();
+	while ((commit = get_revision(revs))) {
+		const struct name_decoration *decoration;
+		khint_t pos;
+		int hr;
+
+		if (!commit->parents)
+			die(_("replaying down from root commit is not supported yet!"));
+		if (commit->parents->next)
+			die(_("replaying merge commits is not supported yet!"));
+
+		last_commit = pick_regular_commit(repo, commit, replayed_commits,
+						  onto, &merge_opt, &result);
+		if (!last_commit)
+			break;
+
+		/* Record commit -> last_commit mapping */
+		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
+		if (hr == 0)
+			BUG("Duplicate rewritten commit: %s\n",
+			    oid_to_hex(&commit->object.oid));
+		kh_value(replayed_commits, pos) = last_commit;
+
+		/* Update any necessary branches */
+		if (advance)
+			continue;
+		decoration = get_name_decoration(&commit->object);
+		if (!decoration)
+			continue;
+		while (decoration) {
+			if (decoration->type == DECORATION_REF_LOCAL &&
+			    (opts->contained || strset_contains(update_refs,
+								decoration->name))) {
+				replay_result_queue_update(out, decoration->name,
+							   &commit->object.oid,
+							   &last_commit->object.oid);
+			}
+			decoration = decoration->next;
+		}
+	}
+
+	if (!result.clean) {
+		out->merge_conflict = true;
+		ret = -1;
+		goto out;
+	}
+
+	/* In --advance mode, advance the target ref */
+	if (advance)
+		replay_result_queue_update(out, advance,
+					   &onto->object.oid,
+					   &last_commit->object.oid);
+
+	ret = 0;
+
+out:
+	if (update_refs) {
+		strset_clear(update_refs);
+		free(update_refs);
+	}
+	kh_destroy_oid_map(replayed_commits);
+	merge_finalize(&merge_opt, &result);
+	free(advance);
+	return ret;
+}
diff --git a/replay.h b/replay.h
new file mode 100644
index 0000000000..84bc8a7a5b
--- /dev/null
+++ b/replay.h
@@ -0,0 +1,64 @@
+#ifndef REPLAY_H
+#define REPLAY_H
+
+#include "hash.h"
+
+struct repository;
+struct rev_info;
+
+/*
+ * A set of options that can be passed to `replay_revisions()`.
+ */
+struct replay_revisions_options {
+	/*
+	 * Starting point at which to create the new commits; must be a branch
+	 * name. The branch will be updated to point to the rewritten commits.
+	 * This option is mutually exclusive with `onto`.
+	 */
+	const char *advance;
+
+	/*
+	 * Starting point at which to create the new commits; must be a
+	 * committish. References pointing at decendants of `onto` will be
+	 * updated to point to the new commits.
+	 */
+	 const char *onto;
+
+	/*
+	 * Update branches that point at commits in the given revision range.
+	 * Requires `onto` to be set.
+	 */
+	int contained;
+};
+
+/* This struct is used as an out-parameter by `replay_revisions()`. */
+struct replay_result {
+	/*
+	 * The set of reference updates that are caused by replaying the
+	 * commits.
+	 */
+	struct replay_ref_update {
+		char *refname;
+		struct object_id old_oid;
+		struct object_id new_oid;
+	} *updates;
+	size_t updates_nr, updates_alloc;
+
+	/* Set to true in case the replay failed with a merge conflict. */
+	bool merge_conflict;
+};
+
+void replay_result_release(struct replay_result *result);
+
+/*
+ * Replay a set of commits onto a new location. Leaves both the working tree,
+ * index and references untouched. Reference updates caused by the replay will
+ * be recorded in the `updates` out pointer.
+ *
+ * Returns 0 on success, a negative error code otherwise.
+ */
+int replay_revisions(struct repository *repo, struct rev_info *revs,
+		     struct replay_revisions_options *opts,
+		     struct replay_result *out);
+
+#endif
-- 
2.52.0.542.g9473a8513b.dirty
Elijah Newren· Jan 10, 2026, 01:16 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v9 2/7] builtin/replay: move core logic into "libgit.a"

On Fri, Jan 9, 2026 at 12:35 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 457 quoted lines
>
> Move the core logic used to replay commits into "libgit.a" so that it
> can be easily reused by other commands. It will be used in a subsequent
> commit where we're about to introduce a new git-history(1) command.
>
> Note that with this change we have no sign-comparison warnings anymore,
> and neither do we depend on `the_repository`.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  Makefile         |   1 +
>  builtin/replay.c | 368 +------------------------------------------------------
>  meson.build      |   1 +
>  replay.c         | 355 +++++++++++++++++++++++++++++++++++++++++++++++++++++
>  replay.h         |  64 ++++++++++
>  5 files changed, 422 insertions(+), 367 deletions(-)
>
> diff --git a/Makefile b/Makefile
> index b7eba509c6..1c64a5d2ae 100644
> --- a/Makefile
> +++ b/Makefile
> @@ -1285,6 +1285,7 @@ LIB_OBJS += repack-geometry.o
>  LIB_OBJS += repack-midx.o
>  LIB_OBJS += repack-promisor.o
>  LIB_OBJS += replace-object.o
> +LIB_OBJS += replay.o
>  LIB_OBJS += repo-settings.o
>  LIB_OBJS += repository.o
>  LIB_OBJS += rerere.o
> diff --git a/builtin/replay.c b/builtin/replay.c
> index df3b32a52d..da8b7202f6 100644
> --- a/builtin/replay.c
> +++ b/builtin/replay.c
> @@ -2,257 +2,22 @@
>   * "git replay" builtin command
>   */
>
> -#define USE_THE_REPOSITORY_VARIABLE
> -#define DISABLE_SIGN_COMPARE_WARNINGS
> -
>  #include "git-compat-util.h"
>
>  #include "builtin.h"
>  #include "config.h"
> -#include "environment.h"
>  #include "hex.h"
> -#include "lockfile.h"
> -#include "merge-ort.h"
>  #include "object-name.h"
>  #include "parse-options.h"
>  #include "refs.h"
> +#include "replay.h"
>  #include "revision.h"
> -#include "strmap.h"
> -#include <oidset.h>
> -#include <tree.h>
>
>  enum ref_action_mode {
>         REF_ACTION_UPDATE,
>         REF_ACTION_PRINT,
>  };
>
> -static const char *short_commit_name(struct repository *repo,
> -                                    struct commit *commit)
> -{
> -       return repo_find_unique_abbrev(repo, &commit->object.oid,
> -                                      DEFAULT_ABBREV);
> -}
> -
> -static struct commit *peel_committish(struct repository *repo,
> -                                     const char *name,
> -                                     const char *mode)
> -{
> -       struct object *obj;
> -       struct object_id oid;
> -
> -       if (repo_get_oid(repo, name, &oid))
> -               die(_("'%s' is not a valid commit-ish for %s"), name, mode);
> -       obj = parse_object_or_die(repo, &oid, name);
> -       return (struct commit *)repo_peel_to_type(repo, name, 0, obj,
> -                                                 OBJ_COMMIT);
> -}
> -
> -static char *get_author(const char *message)
> -{
> -       size_t len;
> -       const char *a;
> -
> -       a = find_commit_header(message, "author", &len);
> -       if (a)
> -               return xmemdupz(a, len);
> -
> -       return NULL;
> -}
> -
> -static struct commit *create_commit(struct repository *repo,
> -                                   struct tree *tree,
> -                                   struct commit *based_on,
> -                                   struct commit *parent)
> -{
> -       struct object_id ret;
> -       struct object *obj = NULL;
> -       struct commit_list *parents = NULL;
> -       char *author;
> -       char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
> -       struct commit_extra_header *extra = NULL;
> -       struct strbuf msg = STRBUF_INIT;
> -       const char *out_enc = get_commit_output_encoding();
> -       const char *message = repo_logmsg_reencode(repo, based_on,
> -                                                  NULL, out_enc);
> -       const char *orig_message = NULL;
> -       const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
> -
> -       commit_list_insert(parent, &parents);
> -       extra = read_commit_extra_headers(based_on, exclude_gpgsig);
> -       find_commit_subject(message, &orig_message);
> -       strbuf_addstr(&msg, orig_message);
> -       author = get_author(message);
> -       reset_ident_date();
> -       if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
> -                                &ret, author, NULL, sign_commit, extra)) {
> -               error(_("failed to write commit object"));
> -               goto out;
> -       }
> -
> -       obj = parse_object(repo, &ret);
> -
> -out:
> -       repo_unuse_commit_buffer(the_repository, based_on, message);
> -       free_commit_extra_headers(extra);
> -       free_commit_list(parents);
> -       strbuf_release(&msg);
> -       free(author);
> -       return (struct commit *)obj;
> -}
> -
> -struct ref_info {
> -       struct commit *onto;
> -       struct strset positive_refs;
> -       struct strset negative_refs;
> -       int positive_refexprs;
> -       int negative_refexprs;
> -};
> -
> -static void get_ref_information(struct repository *repo,
> -                               struct rev_cmdline_info *cmd_info,
> -                               struct ref_info *ref_info)
> -{
> -       int i;
> -
> -       ref_info->onto = NULL;
> -       strset_init(&ref_info->positive_refs);
> -       strset_init(&ref_info->negative_refs);
> -       ref_info->positive_refexprs = 0;
> -       ref_info->negative_refexprs = 0;
> -
> -       /*
> -        * When the user specifies e.g.
> -        *   git replay origin/main..mybranch
> -        *   git replay ^origin/next mybranch1 mybranch2
> -        * we want to be able to determine where to replay the commits.  In
> -        * these examples, the branches are probably based on an old version
> -        * of either origin/main or origin/next, so we want to replay on the
> -        * newest version of that branch.  In contrast we would want to error
> -        * out if they ran
> -        *   git replay ^origin/master ^origin/next mybranch
> -        *   git replay mybranch~2..mybranch
> -        * the first of those because there's no unique base to choose, and
> -        * the second because they'd likely just be replaying commits on top
> -        * of the same commit and not making any difference.
> -        */
> -       for (i = 0; i < cmd_info->nr; i++) {
> -               struct rev_cmdline_entry *e = cmd_info->rev + i;
> -               struct object_id oid;
> -               const char *refexpr = e->name;
> -               char *fullname = NULL;
> -               int can_uniquely_dwim = 1;
> -
> -               if (*refexpr == '^')
> -                       refexpr++;
> -               if (repo_dwim_ref(repo, refexpr, strlen(refexpr), &oid, &fullname, 0) != 1)
> -                       can_uniquely_dwim = 0;
> -
> -               if (e->flags & BOTTOM) {
> -                       if (can_uniquely_dwim)
> -                               strset_add(&ref_info->negative_refs, fullname);
> -                       if (!ref_info->negative_refexprs)
> -                               ref_info->onto = lookup_commit_reference_gently(repo,
> -                                                                               &e->item->oid, 1);
> -                       ref_info->negative_refexprs++;
> -               } else {
> -                       if (can_uniquely_dwim)
> -                               strset_add(&ref_info->positive_refs, fullname);
> -                       ref_info->positive_refexprs++;
> -               }
> -
> -               free(fullname);
> -       }
> -}
> -
> -static void set_up_replay_mode(struct repository *repo,
> -                              struct rev_cmdline_info *cmd_info,
> -                              const char *onto_name,
> -                              char **advance_name,
> -                              struct commit **onto,
> -                              struct strset **update_refs)
> -{
> -       struct ref_info rinfo;
> -
> -       get_ref_information(repo, cmd_info, &rinfo);
> -       if (!rinfo.positive_refexprs)
> -               die(_("need some commits to replay"));
> -
> -       die_for_incompatible_opt2(!!onto_name, "--onto",
> -                                 !!*advance_name, "--advance");
> -       if (onto_name) {
> -               *onto = peel_committish(repo, onto_name, "--onto");
> -               if (rinfo.positive_refexprs <
> -                   strset_get_size(&rinfo.positive_refs))
> -                       die(_("all positive revisions given must be references"));
> -               *update_refs = xcalloc(1, sizeof(**update_refs));
> -               **update_refs = rinfo.positive_refs;
> -               memset(&rinfo.positive_refs, 0, sizeof(**update_refs));
> -       } else {
> -               struct object_id oid;
> -               char *fullname = NULL;
> -
> -               if (!*advance_name)
> -                       BUG("expected either onto_name or *advance_name in this function");
> -
> -               if (repo_dwim_ref(repo, *advance_name, strlen(*advance_name),
> -                            &oid, &fullname, 0) == 1) {
> -                       free(*advance_name);
> -                       *advance_name = fullname;
> -               } else {
> -                       die(_("argument to --advance must be a reference"));
> -               }
> -               *onto = peel_committish(repo, *advance_name, "--advance");
> -               if (rinfo.positive_refexprs > 1)
> -                       die(_("cannot advance target with multiple sources because ordering would be ill-defined"));
> -       }
> -       strset_clear(&rinfo.negative_refs);
> -       strset_clear(&rinfo.positive_refs);
> -}
> -
> -static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
> -                                   struct commit *commit,
> -                                   struct commit *fallback)
> -{
> -       khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
> -       if (pos == kh_end(replayed_commits))
> -               return fallback;
> -       return kh_value(replayed_commits, pos);
> -}
> -
> -static struct commit *pick_regular_commit(struct repository *repo,
> -                                         struct commit *pickme,
> -                                         kh_oid_map_t *replayed_commits,
> -                                         struct commit *onto,
> -                                         struct merge_options *merge_opt,
> -                                         struct merge_result *result)
> -{
> -       struct commit *base, *replayed_base;
> -       struct tree *pickme_tree, *base_tree;
> -
> -       base = pickme->parents->item;
> -       replayed_base = mapped_commit(replayed_commits, base, onto);
> -
> -       result->tree = repo_get_commit_tree(repo, replayed_base);
> -       pickme_tree = repo_get_commit_tree(repo, pickme);
> -       base_tree = repo_get_commit_tree(repo, base);
> -
> -       merge_opt->branch1 = short_commit_name(repo, replayed_base);
> -       merge_opt->branch2 = short_commit_name(repo, pickme);
> -       merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
> -
> -       merge_incore_nonrecursive(merge_opt,
> -                                 base_tree,
> -                                 result->tree,
> -                                 pickme_tree,
> -                                 result);
> -
> -       free((char*)merge_opt->ancestor);
> -       merge_opt->ancestor = NULL;
> -       if (!result->clean)
> -               return NULL;
> -       return create_commit(repo, result->tree, pickme, replayed_base);
> -}
> -
>  static enum ref_action_mode parse_ref_action_mode(const char *ref_action, const char *source)
>  {
>         if (!ref_action || !strcmp(ref_action, "update"))
> @@ -278,137 +43,6 @@ static enum ref_action_mode get_ref_action_mode(struct repository *repo, const c
>         return REF_ACTION_UPDATE;
>  }
>
> -struct replay_revisions_options {
> -       const char *advance;
> -       const char *onto;
> -       int contained;
> -};
> -
> -struct replay_result {
> -       struct replay_ref_update {
> -               char *refname;
> -               struct object_id old_oid;
> -               struct object_id new_oid;
> -       } *updates;
> -       size_t updates_nr, updates_alloc;
> -
> -       bool merge_conflict;
> -};
> -
> -static void replay_result_release(struct replay_result *result)
> -{
> -       for (size_t i = 0; i < result->updates_nr; i++)
> -               free(result->updates[i].refname);
> -       free(result->updates);
> -}
> -
> -static void replay_result_queue_update(struct replay_result *result,
> -                                      const char *refname,
> -                                      const struct object_id *old_oid,
> -                                      const struct object_id *new_oid)
> -{
> -       ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
> -       result->updates[result->updates_nr].refname = xstrdup(refname);
> -       result->updates[result->updates_nr].old_oid = *old_oid;
> -       result->updates[result->updates_nr].new_oid = *new_oid;
> -       result->updates_nr++;
> -}
> -
> -static int replay_revisions(struct repository *repo, struct rev_info *revs,
> -                           struct replay_revisions_options *opts,
> -                           struct replay_result *out)
> -{
> -       kh_oid_map_t *replayed_commits = NULL;
> -       struct strset *update_refs = NULL;
> -       struct commit *last_commit = NULL;
> -       struct commit *commit;
> -       struct commit *onto = NULL;
> -       struct merge_options merge_opt;
> -       struct merge_result result;
> -       char *advance;
> -       int ret;
> -
> -       advance = xstrdup_or_null(opts->advance);
> -       set_up_replay_mode(repo, &revs->cmdline, opts->onto, &advance,
> -                          &onto, &update_refs);
> -
> -       /* FIXME: Should allow replaying commits with the first as a root commit */
> -
> -       if (prepare_revision_walk(revs) < 0) {
> -               ret = error(_("error preparing revisions"));
> -               goto out;
> -       }
> -
> -       init_basic_merge_options(&merge_opt, repo);
> -       memset(&result, 0, sizeof(result));
> -       merge_opt.show_rename_progress = 0;
> -       last_commit = onto;
> -       replayed_commits = kh_init_oid_map();
> -       while ((commit = get_revision(revs))) {
> -               const struct name_decoration *decoration;
> -               khint_t pos;
> -               int hr;
> -
> -               if (!commit->parents)
> -                       die(_("replaying down from root commit is not supported yet!"));
> -               if (commit->parents->next)
> -                       die(_("replaying merge commits is not supported yet!"));
> -
> -               last_commit = pick_regular_commit(repo, commit, replayed_commits,
> -                                                 onto, &merge_opt, &result);
> -               if (!last_commit)
> -                       break;
> -
> -               /* Record commit -> last_commit mapping */
> -               pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
> -               if (hr == 0)
> -                       BUG("Duplicate rewritten commit: %s\n",
> -                           oid_to_hex(&commit->object.oid));
> -               kh_value(replayed_commits, pos) = last_commit;
> -
> -               /* Update any necessary branches */
> -               if (advance)
> -                       continue;
> -               decoration = get_name_decoration(&commit->object);
> -               if (!decoration)
> -                       continue;
> -               while (decoration) {
> -                       if (decoration->type == DECORATION_REF_LOCAL &&
> -                           (opts->contained || strset_contains(update_refs,
> -                                                               decoration->name))) {
> -                               replay_result_queue_update(out, decoration->name,
> -                                                          &commit->object.oid,
> -                                                          &last_commit->object.oid);
> -                       }
> -                       decoration = decoration->next;
> -               }
> -       }
> -
> -       if (!result.clean) {
> -               out->merge_conflict = true;
> -               ret = -1;
> -               goto out;
> -       }
> -
> -       /* In --advance mode, advance the target ref */
> -       if (advance)
> -               replay_result_queue_update(out, advance,
> -                                          &onto->object.oid,
> -                                          &last_commit->object.oid);
> -
> -       ret = 0;
> -
> -out:
> -       if (update_refs) {
> -               strset_clear(update_refs);
> -               free(update_refs);
> -       }
> -       kh_destroy_oid_map(replayed_commits);
> -       merge_finalize(&merge_opt, &result);
> -       free(advance);
> -       return ret;
> -}
> -
>  static int handle_ref_update(enum ref_action_mode mode,
>                              struct ref_transaction *transaction,
>                              const char *refname,
> diff --git a/meson.build b/meson.build
> index dd52efd1c8..a5a4e99b25 100644
> --- a/meson.build
> +++ b/meson.build
> @@ -471,6 +471,7 @@ libgit_sources = [
>    'repack-midx.c',
>    'repack-promisor.c',
>    'replace-object.c',
> +  'replay.c',
>    'repo-settings.c',
>    'repository.c',
>    'rerere.c',
> diff --git a/replay.c b/replay.c
> new file mode 100644
> index 0000000000..fc7186ef09
> --- /dev/null
> +++ b/replay.c
> @@ -0,0 +1,355 @@
> +#define USE_THE_REPOSITORY_VARIABLE
> +#define DISABLE_SIGN_COMPARE_WARNINGS
> +
> +#include "git-compat-util.h"
> +#include "environment.h"
> +#include "hex.h"
> +#include "merge-ort.h"
> +#include "object-name.h"
> +#include "oidset.h"

I don't think oidset is used here? Although, come to think of it, I'm not sure it was used in replay to begin with. Looks like I added this include during the switch from t/helper/test-fast-rebase, but never used it even then. I must have thought I was going to use it, and added it, but never did. Anyway, maybe this is a good time to get rid of it?

> +#include "parse-options.h"

Why would parse-options be included here? Oh, die_for_incompatible_opt2()? Feels a little weird that we have option parsing logic outside of builtin/, but...maybe it's all fine?

> +#include "refs.h"
> +#include "replay.h"
> +#include "revision.h"

Shouldn't strmap.h (for strset) also be included? I think we get it as a side-effect of something else, but since we use it directly, it'd make sense to include directly.

Show 411 quoted lines
> +#include "tree.h"
> +
> +static const char *short_commit_name(struct repository *repo,
> +                                    struct commit *commit)
> +{
> +       return repo_find_unique_abbrev(repo, &commit->object.oid,
> +                                      DEFAULT_ABBREV);
> +}
> +
> +static struct commit *peel_committish(struct repository *repo,
> +                                     const char *name,
> +                                     const char *mode)
> +{
> +       struct object *obj;
> +       struct object_id oid;
> +
> +       if (repo_get_oid(repo, name, &oid))
> +               die(_("'%s' is not a valid commit-ish for %s"), name, mode);
> +       obj = parse_object_or_die(repo, &oid, name);
> +       return (struct commit *)repo_peel_to_type(repo, name, 0, obj,
> +                                                 OBJ_COMMIT);
> +}
> +
> +static char *get_author(const char *message)
> +{
> +       size_t len;
> +       const char *a;
> +
> +       a = find_commit_header(message, "author", &len);
> +       if (a)
> +               return xmemdupz(a, len);
> +
> +       return NULL;
> +}
> +
> +static struct commit *create_commit(struct repository *repo,
> +                                   struct tree *tree,
> +                                   struct commit *based_on,
> +                                   struct commit *parent)
> +{
> +       struct object_id ret;
> +       struct object *obj = NULL;
> +       struct commit_list *parents = NULL;
> +       char *author;
> +       char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
> +       struct commit_extra_header *extra = NULL;
> +       struct strbuf msg = STRBUF_INIT;
> +       const char *out_enc = get_commit_output_encoding();
> +       const char *message = repo_logmsg_reencode(repo, based_on,
> +                                                  NULL, out_enc);
> +       const char *orig_message = NULL;
> +       const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
> +
> +       commit_list_insert(parent, &parents);
> +       extra = read_commit_extra_headers(based_on, exclude_gpgsig);
> +       find_commit_subject(message, &orig_message);
> +       strbuf_addstr(&msg, orig_message);
> +       author = get_author(message);
> +       reset_ident_date();
> +       if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
> +                                &ret, author, NULL, sign_commit, extra)) {
> +               error(_("failed to write commit object"));
> +               goto out;
> +       }
> +
> +       obj = parse_object(repo, &ret);
> +
> +out:
> +       repo_unuse_commit_buffer(the_repository, based_on, message);
> +       free_commit_extra_headers(extra);
> +       free_commit_list(parents);
> +       strbuf_release(&msg);
> +       free(author);
> +       return (struct commit *)obj;
> +}
> +
> +struct ref_info {
> +       struct commit *onto;
> +       struct strset positive_refs;
> +       struct strset negative_refs;
> +       int positive_refexprs;
> +       int negative_refexprs;
> +};
> +
> +static void get_ref_information(struct repository *repo,
> +                               struct rev_cmdline_info *cmd_info,
> +                               struct ref_info *ref_info)
> +{
> +       int i;
> +
> +       ref_info->onto = NULL;
> +       strset_init(&ref_info->positive_refs);
> +       strset_init(&ref_info->negative_refs);
> +       ref_info->positive_refexprs = 0;
> +       ref_info->negative_refexprs = 0;
> +
> +       /*
> +        * When the user specifies e.g.
> +        *   git replay origin/main..mybranch
> +        *   git replay ^origin/next mybranch1 mybranch2
> +        * we want to be able to determine where to replay the commits.  In
> +        * these examples, the branches are probably based on an old version
> +        * of either origin/main or origin/next, so we want to replay on the
> +        * newest version of that branch.  In contrast we would want to error
> +        * out if they ran
> +        *   git replay ^origin/master ^origin/next mybranch
> +        *   git replay mybranch~2..mybranch
> +        * the first of those because there's no unique base to choose, and
> +        * the second because they'd likely just be replaying commits on top
> +        * of the same commit and not making any difference.
> +        */
> +       for (i = 0; i < cmd_info->nr; i++) {
> +               struct rev_cmdline_entry *e = cmd_info->rev + i;
> +               struct object_id oid;
> +               const char *refexpr = e->name;
> +               char *fullname = NULL;
> +               int can_uniquely_dwim = 1;
> +
> +               if (*refexpr == '^')
> +                       refexpr++;
> +               if (repo_dwim_ref(repo, refexpr, strlen(refexpr), &oid, &fullname, 0) != 1)
> +                       can_uniquely_dwim = 0;
> +
> +               if (e->flags & BOTTOM) {
> +                       if (can_uniquely_dwim)
> +                               strset_add(&ref_info->negative_refs, fullname);
> +                       if (!ref_info->negative_refexprs)
> +                               ref_info->onto = lookup_commit_reference_gently(repo,
> +                                                                               &e->item->oid, 1);
> +                       ref_info->negative_refexprs++;
> +               } else {
> +                       if (can_uniquely_dwim)
> +                               strset_add(&ref_info->positive_refs, fullname);
> +                       ref_info->positive_refexprs++;
> +               }
> +
> +               free(fullname);
> +       }
> +}
> +
> +static void set_up_replay_mode(struct repository *repo,
> +                              struct rev_cmdline_info *cmd_info,
> +                              const char *onto_name,
> +                              char **advance_name,
> +                              struct commit **onto,
> +                              struct strset **update_refs)
> +{
> +       struct ref_info rinfo;
> +
> +       get_ref_information(repo, cmd_info, &rinfo);
> +       if (!rinfo.positive_refexprs)
> +               die(_("need some commits to replay"));
> +
> +       die_for_incompatible_opt2(!!onto_name, "--onto",
> +                                 !!*advance_name, "--advance");
> +       if (onto_name) {
> +               *onto = peel_committish(repo, onto_name, "--onto");
> +               if (rinfo.positive_refexprs <
> +                   strset_get_size(&rinfo.positive_refs))
> +                       die(_("all positive revisions given must be references"));
> +               *update_refs = xcalloc(1, sizeof(**update_refs));
> +               **update_refs = rinfo.positive_refs;
> +               memset(&rinfo.positive_refs, 0, sizeof(**update_refs));
> +       } else {
> +               struct object_id oid;
> +               char *fullname = NULL;
> +
> +               if (!*advance_name)
> +                       BUG("expected either onto_name or *advance_name in this function");
> +
> +               if (repo_dwim_ref(repo, *advance_name, strlen(*advance_name),
> +                            &oid, &fullname, 0) == 1) {
> +                       free(*advance_name);
> +                       *advance_name = fullname;
> +               } else {
> +                       die(_("argument to --advance must be a reference"));
> +               }
> +               *onto = peel_committish(repo, *advance_name, "--advance");
> +               if (rinfo.positive_refexprs > 1)
> +                       die(_("cannot advance target with multiple sources because ordering would be ill-defined"));
> +       }
> +       strset_clear(&rinfo.negative_refs);
> +       strset_clear(&rinfo.positive_refs);
> +}
> +
> +static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
> +                                   struct commit *commit,
> +                                   struct commit *fallback)
> +{
> +       khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
> +       if (pos == kh_end(replayed_commits))
> +               return fallback;
> +       return kh_value(replayed_commits, pos);
> +}
> +
> +static struct commit *pick_regular_commit(struct repository *repo,
> +                                         struct commit *pickme,
> +                                         kh_oid_map_t *replayed_commits,
> +                                         struct commit *onto,
> +                                         struct merge_options *merge_opt,
> +                                         struct merge_result *result)
> +{
> +       struct commit *base, *replayed_base;
> +       struct tree *pickme_tree, *base_tree;
> +
> +       base = pickme->parents->item;
> +       replayed_base = mapped_commit(replayed_commits, base, onto);
> +
> +       result->tree = repo_get_commit_tree(repo, replayed_base);
> +       pickme_tree = repo_get_commit_tree(repo, pickme);
> +       base_tree = repo_get_commit_tree(repo, base);
> +
> +       merge_opt->branch1 = short_commit_name(repo, replayed_base);
> +       merge_opt->branch2 = short_commit_name(repo, pickme);
> +       merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
> +
> +       merge_incore_nonrecursive(merge_opt,
> +                                 base_tree,
> +                                 result->tree,
> +                                 pickme_tree,
> +                                 result);
> +
> +       free((char*)merge_opt->ancestor);
> +       merge_opt->ancestor = NULL;
> +       if (!result->clean)
> +               return NULL;
> +       return create_commit(repo, result->tree, pickme, replayed_base);
> +}
> +
> +void replay_result_release(struct replay_result *result)
> +{
> +       for (size_t i = 0; i < result->updates_nr; i++)
> +               free(result->updates[i].refname);
> +       free(result->updates);
> +}
> +
> +static void replay_result_queue_update(struct replay_result *result,
> +                                      const char *refname,
> +                                      const struct object_id *old_oid,
> +                                      const struct object_id *new_oid)
> +{
> +       ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
> +       result->updates[result->updates_nr].refname = xstrdup(refname);
> +       result->updates[result->updates_nr].old_oid = *old_oid;
> +       result->updates[result->updates_nr].new_oid = *new_oid;
> +       result->updates_nr++;
> +}
> +
> +int replay_revisions(struct repository *repo, struct rev_info *revs,
> +                    struct replay_revisions_options *opts,
> +                    struct replay_result *out)
> +{
> +       kh_oid_map_t *replayed_commits = NULL;
> +       struct strset *update_refs = NULL;
> +       struct commit *last_commit = NULL;
> +       struct commit *commit;
> +       struct commit *onto = NULL;
> +       struct merge_options merge_opt;
> +       struct merge_result result;
> +       char *advance;
> +       int ret;
> +
> +       advance = xstrdup_or_null(opts->advance);
> +       set_up_replay_mode(repo, &revs->cmdline, opts->onto, &advance,
> +                          &onto, &update_refs);
> +
> +       /* FIXME: Should allow replaying commits with the first as a root commit */
> +
> +       if (prepare_revision_walk(revs) < 0) {
> +               ret = error(_("error preparing revisions"));
> +               goto out;
> +       }
> +
> +       init_basic_merge_options(&merge_opt, repo);
> +       memset(&result, 0, sizeof(result));
> +       merge_opt.show_rename_progress = 0;
> +       last_commit = onto;
> +       replayed_commits = kh_init_oid_map();
> +       while ((commit = get_revision(revs))) {
> +               const struct name_decoration *decoration;
> +               khint_t pos;
> +               int hr;
> +
> +               if (!commit->parents)
> +                       die(_("replaying down from root commit is not supported yet!"));
> +               if (commit->parents->next)
> +                       die(_("replaying merge commits is not supported yet!"));
> +
> +               last_commit = pick_regular_commit(repo, commit, replayed_commits,
> +                                                 onto, &merge_opt, &result);
> +               if (!last_commit)
> +                       break;
> +
> +               /* Record commit -> last_commit mapping */
> +               pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
> +               if (hr == 0)
> +                       BUG("Duplicate rewritten commit: %s\n",
> +                           oid_to_hex(&commit->object.oid));
> +               kh_value(replayed_commits, pos) = last_commit;
> +
> +               /* Update any necessary branches */
> +               if (advance)
> +                       continue;
> +               decoration = get_name_decoration(&commit->object);
> +               if (!decoration)
> +                       continue;
> +               while (decoration) {
> +                       if (decoration->type == DECORATION_REF_LOCAL &&
> +                           (opts->contained || strset_contains(update_refs,
> +                                                               decoration->name))) {
> +                               replay_result_queue_update(out, decoration->name,
> +                                                          &commit->object.oid,
> +                                                          &last_commit->object.oid);
> +                       }
> +                       decoration = decoration->next;
> +               }
> +       }
> +
> +       if (!result.clean) {
> +               out->merge_conflict = true;
> +               ret = -1;
> +               goto out;
> +       }
> +
> +       /* In --advance mode, advance the target ref */
> +       if (advance)
> +               replay_result_queue_update(out, advance,
> +                                          &onto->object.oid,
> +                                          &last_commit->object.oid);
> +
> +       ret = 0;
> +
> +out:
> +       if (update_refs) {
> +               strset_clear(update_refs);
> +               free(update_refs);
> +       }
> +       kh_destroy_oid_map(replayed_commits);
> +       merge_finalize(&merge_opt, &result);
> +       free(advance);
> +       return ret;
> +}
> diff --git a/replay.h b/replay.h
> new file mode 100644
> index 0000000000..84bc8a7a5b
> --- /dev/null
> +++ b/replay.h
> @@ -0,0 +1,64 @@
> +#ifndef REPLAY_H
> +#define REPLAY_H
> +
> +#include "hash.h"
> +
> +struct repository;
> +struct rev_info;
> +
> +/*
> + * A set of options that can be passed to `replay_revisions()`.
> + */
> +struct replay_revisions_options {
> +       /*
> +        * Starting point at which to create the new commits; must be a branch
> +        * name. The branch will be updated to point to the rewritten commits.
> +        * This option is mutually exclusive with `onto`.
> +        */
> +       const char *advance;
> +
> +       /*
> +        * Starting point at which to create the new commits; must be a
> +        * committish. References pointing at decendants of `onto` will be
> +        * updated to point to the new commits.
> +        */
> +        const char *onto;
> +
> +       /*
> +        * Update branches that point at commits in the given revision range.
> +        * Requires `onto` to be set.
> +        */
> +       int contained;
> +};
> +
> +/* This struct is used as an out-parameter by `replay_revisions()`. */
> +struct replay_result {
> +       /*
> +        * The set of reference updates that are caused by replaying the
> +        * commits.
> +        */
> +       struct replay_ref_update {
> +               char *refname;
> +               struct object_id old_oid;
> +               struct object_id new_oid;
> +       } *updates;
> +       size_t updates_nr, updates_alloc;
> +
> +       /* Set to true in case the replay failed with a merge conflict. */
> +       bool merge_conflict;
> +};
> +
> +void replay_result_release(struct replay_result *result);
> +
> +/*
> + * Replay a set of commits onto a new location. Leaves both the working tree,
> + * index and references untouched. Reference updates caused by the replay will
> + * be recorded in the `updates` out pointer.
> + *
> + * Returns 0 on success, a negative error code otherwise.
> + */
> +int replay_revisions(struct repository *repo, struct rev_info *revs,
> +                    struct replay_revisions_options *opts,
> +                    struct replay_result *out);
> +
stray extra line?
> +#endif

It feels duplicative to have replay_result include a merge_conflict field and to have replay_revisions() return an int which signifies whether there's a conflict. Can we remove one of the two? (Perhaps the merge_conflict field was only a workaround to the weird ret setting from the previous patch?)

Patrick Steinhardt· Jan 12, 2026, 13:02 UTC · re: Elijah Newren · lore

Re: [PATCH v9 2/7] builtin/replay: move core logic into "libgit.a"

On Fri, Jan 09, 2026 at 05:16:41PM -0800, Elijah Newren wrote:
Show 23 quoted lines
> On Fri, Jan 9, 2026 at 12:35 AM Patrick Steinhardt <ps@pks.im> wrote:
> > diff --git a/replay.c b/replay.c
> > new file mode 100644
> > index 0000000000..fc7186ef09
> > --- /dev/null
> > +++ b/replay.c
> > @@ -0,0 +1,355 @@
> > +#define USE_THE_REPOSITORY_VARIABLE
> > +#define DISABLE_SIGN_COMPARE_WARNINGS
> > +
> > +#include "git-compat-util.h"
> > +#include "environment.h"
> > +#include "hex.h"
> > +#include "merge-ort.h"
> > +#include "object-name.h"
> > +#include "oidset.h"
> 
> I don't think oidset is used here?   Although, come to think of it,
> I'm not sure it was used in replay to begin with.  Looks like I added
> this include during the switch from t/helper/test-fast-rebase, but
> never used it even then.  I must have thought I was going to use it,
> and added it, but never did.  Anyway, maybe this is a good time to get
> rid of it?
Yeah, let's.
Show 5 quoted lines
> > +#include "parse-options.h"
> 
> Why would parse-options be included here?  Oh,
> die_for_incompatible_opt2()?  Feels a little weird that we have option
> parsing logic outside of builtin/, but...maybe it's all fine?

I'm not a huge fan of it, either. We could for example change this so that we `BUG()` here and instead have the option in "builtin/replay.c"? Something like this:

Show changes to builtin/replay.c +5 −5
diff --git a/builtin/replay.c b/builtin/replay.c
index 4a11ef0f1b..649c93200e 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -177,8 +177,9 @@ static void set_up_replay_mode(struct repository *repo,
 	if (!rinfo.positive_refexprs)
 		die(_("need some commits to replay"));
 
-	die_for_incompatible_opt2(!!onto_name, "--onto",
-				  !!*advance_name, "--advance");
+	if (!(!!onto_name ^ !!*advance_name))
+		BUG("expected either onto_name or *advance_name in this function");
+
 	if (onto_name) {
 		*onto = peel_committish(repo, onto_name, "--onto");
 		if (rinfo.positive_refexprs <
@@ -191,9 +192,6 @@ static void set_up_replay_mode(struct repository *repo,
 		struct object_id oid;
 		char *fullname = NULL;
 
-		if (!*advance_name)
-			BUG("expected either onto_name or *advance_name in this function");
-
 		if (repo_dwim_ref(repo, *advance_name, strlen(*advance_name),
 			     &oid, &fullname, 0) == 1) {
 			free(*advance_name);
@@ -478,6 +476,8 @@ int cmd_replay(int argc,
 
 	die_for_incompatible_opt2(!!opts.advance, "--advance",
 				  opts.contained, "--contained");
+	die_for_incompatible_opt2(!!opts.advance, "--advance",
+				  !!opts.onto, "--onto");
 
 	/* Parse ref action mode from command line or config */
 	ref_mode = get_ref_action_mode(repo, ref_action);

Yeha, I think that's cleaner, even if it repeats some of the
conditionals.

> > +#include "refs.h"
> > +#include "replay.h"
> > +#include "revision.h"
> 
> Shouldn't strmap.h (for strset) also be included?  I think we get it
> as a side-effect of something else, but since we use it directly, it'd
> make sense to include directly.

Yup, will add.

> > diff --git a/replay.h b/replay.h
> > new file mode 100644
> > index 0000000000..84bc8a7a5b
> > --- /dev/null
> > +++ b/replay.h
> > @@ -0,0 +1,64 @@
> > +#ifndef REPLAY_H
> > +#define REPLAY_H
> > +
> > +#include "hash.h"
> > +
> > +struct repository;
> > +struct rev_info;
> > +
> > +/*
> > + * A set of options that can be passed to `replay_revisions()`.
> > + */
> > +struct replay_revisions_options {
> > +       /*
> > +        * Starting point at which to create the new commits; must be a branch
> > +        * name. The branch will be updated to point to the rewritten commits.
> > +        * This option is mutually exclusive with `onto`.
> > +        */
> > +       const char *advance;
> > +
> > +       /*
> > +        * Starting point at which to create the new commits; must be a
> > +        * committish. References pointing at decendants of `onto` will be
> > +        * updated to point to the new commits.
> > +        */
> > +        const char *onto;
> > +
> > +       /*
> > +        * Update branches that point at commits in the given revision range.
> > +        * Requires `onto` to be set.
> > +        */
> > +       int contained;
> > +};
> > +
> > +/* This struct is used as an out-parameter by `replay_revisions()`. */
> > +struct replay_result {
> > +       /*
> > +        * The set of reference updates that are caused by replaying the
> > +        * commits.
> > +        */
> > +       struct replay_ref_update {
> > +               char *refname;
> > +               struct object_id old_oid;
> > +               struct object_id new_oid;
> > +       } *updates;
> > +       size_t updates_nr, updates_alloc;
> > +
> > +       /* Set to true in case the replay failed with a merge conflict. */
> > +       bool merge_conflict;
> > +};
> > +
> > +void replay_result_release(struct replay_result *result);
> > +
> > +/*
> > + * Replay a set of commits onto a new location. Leaves both the working tree,
> > + * index and references untouched. Reference updates caused by the replay will
> > + * be recorded in the `updates` out pointer.
> > + *
> > + * Returns 0 on success, a negative error code otherwise.
> > + */
> > +int replay_revisions(struct repository *repo, struct rev_info *revs,
> > +                    struct replay_revisions_options *opts,
> > +                    struct replay_result *out);
> > +
> 
> stray extra line?

We typically have an empty line between the last declaration and the
`#endif` in our headers.

> > +#endif
> 
> It feels duplicative to have replay_result include a merge_conflict
> field and to have replay_revisions() return an int which signifies
> whether there's a conflict.  Can we remove one of the two?  (Perhaps
> the merge_conflict field was only a workaround to the weird ret
> setting from the previous patch?)

The idea here is that we have a generic error code that tells the caller
that _something_ happened, whereas `struct replay_result` gives the
caller a bit more context around what exactly has happened. This allows
callers to handle merge conflicts differently from any other error and
makes the different failure modes a lot more explicit.

Some context: at GitLab we actually have the use case to surface more
information around what commits have conflicted, so there will be a
follow-up patch series that extends `struct replay_result` to return
more information about the actual conflict. I'm already planning ahead a
bit in this patch series.

Patrick
Elijah Newren· Jan 13, 2026, 06:00 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v9 2/7] builtin/replay: move core logic into "libgit.a"

On Mon, Jan 12, 2026 at 5:02 AM Patrick Steinhardt <ps@pks.im> wrote:
>
> On Fri, Jan 09, 2026 at 05:16:41PM -0800, Elijah Newren wrote:
[...]
Show 9 quoted lines
> > > +int replay_revisions(struct repository *repo, struct rev_info *revs,
> > > +                    struct replay_revisions_options *opts,
> > > +                    struct replay_result *out);
> > > +
> >
> > stray extra line?
>
> We typically have an empty line between the last declaration and the
> `#endif` in our headers.
Yeah, I was just confused; sorry.
Show 16 quoted lines
> > > +#endif
> >
> > It feels duplicative to have replay_result include a merge_conflict
> > field and to have replay_revisions() return an int which signifies
> > whether there's a conflict.  Can we remove one of the two?  (Perhaps
> > the merge_conflict field was only a workaround to the weird ret
> > setting from the previous patch?)
>
> The idea here is that we have a generic error code that tells the caller
> that _something_ happened, whereas `struct replay_result` gives the
> caller a bit more context around what exactly has happened. This allows
> callers to handle merge conflicts differently from any other error and
> makes the different failure modes a lot more explicit.
>
> Some context: at GitLab we actually have the use case to surface more
> information around what commits have conflicted, so there will be a

Interesting, but doesn't answering that question presume first-class conflict handling? How do you determine which commits conflict without that? Or, is the first commit we process that hits a conflict sufficient information and you don't really need the commits that conflict, just one of them? Or, do you presume that all unprocessed commits after the first one that conflicted would have also conflicted (even if it touched files that are conflict-free so far, so that commit would not have contributed to the conflicts)? If this is done without first-class conflict handling, there may also be an assumption here about linear single-branch history, or else some kind of attempt to continue processing whichever commits don't have an ancestor that has conflicted, so that we can enumerate "commits [which] have conflicted".

> follow-up patch series that extends `struct replay_result` to return
> more information about the actual conflict. I'm already planning ahead a
> bit in this patch series.

Wait, above you said you wanted to know the "commits [which] have conflicted", here you seem to be suggesting you want to know about "the actual conflict" which might mean you only care about the first commit that conflicted but you want details about what conflicted within it. Or is it perhaps the set of files that would have had conflicts across replaying the whole sequence of patches (in which case a bunch of the previous questions are still valid)?

Patrick Steinhardt· Jan 13, 2026, 07:31 UTC · re: Elijah Newren · lore

Re: [PATCH v9 2/7] builtin/replay: move core logic into "libgit.a"

On Mon, Jan 12, 2026 at 10:00:16PM -0800, Elijah Newren wrote:
Show 32 quoted lines
> On Mon, Jan 12, 2026 at 5:02 AM Patrick Steinhardt <ps@pks.im> wrote:
> >
> > On Fri, Jan 09, 2026 at 05:16:41PM -0800, Elijah Newren wrote:
> [...]
> > > It feels duplicative to have replay_result include a merge_conflict
> > > field and to have replay_revisions() return an int which signifies
> > > whether there's a conflict.  Can we remove one of the two?  (Perhaps
> > > the merge_conflict field was only a workaround to the weird ret
> > > setting from the previous patch?)
> >
> > The idea here is that we have a generic error code that tells the caller
> > that _something_ happened, whereas `struct replay_result` gives the
> > caller a bit more context around what exactly has happened. This allows
> > callers to handle merge conflicts differently from any other error and
> > makes the different failure modes a lot more explicit.
> >
> > Some context: at GitLab we actually have the use case to surface more
> > information around what commits have conflicted, so there will be a
> 
> Interesting, but doesn't answering that question presume first-class
> conflict handling?  How do you determine which commits conflict
> without that?  Or, is the first commit we process that hits a conflict
> sufficient information and you don't really need the commits that
> conflict, just one of them?  Or, do you presume that all unprocessed
> commits after the first one that conflicted would have also conflicted
> (even if it touched files that are conflict-free so far, so that
> commit would not have contributed to the conflicts)?  If this is done
> without first-class conflict handling, there may also be an assumption
> here about linear single-branch history, or else some kind of attempt
> to continue processing whichever commits don't have an ancestor that
> has conflicted, so that we can enumerate "commits [which] have
> conflicted".

Yup, knowing about the first commit that conflicts is sufficient for our use case.

Show 11 quoted lines
> > follow-up patch series that extends `struct replay_result` to return
> > more information about the actual conflict. I'm already planning ahead a
> > bit in this patch series.
> 
> Wait, above you said you wanted to know the "commits [which] have
> conflicted", here you seem to be suggesting you want to know about
> "the actual conflict" which might mean you only care about the first
> commit that conflicted but you want details about what conflicted
> within it.  Or is it perhaps the set of files that would have had
> conflicts across replaying the whole sequence of patches (in which
> case a bunch of the previous questions are still valid)?

I was simply being inaccurate there, we only care about the first conflicting commit, no plural.

Thanks!
Patrick
Patrick Steinhardt· Jan 9, 2026, 08:35 UTC · re: Patrick Steinhardt · lore

[PATCH v9 3/7] replay: small set of cleanups

Perform a small set of cleanups so that the "replay" logic compiles with "-Wsign-compare" and doesn't use `the_repository` anymore. Note that there are still some implicit dependencies on `the_repository`, e.g. because we use `get_commit_output_encoding()`.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 replay.c | 11 ++++-------
 1 file changed, 4 insertions(+), 7 deletions(-)
Show changes to replay.c +4 −7
diff --git a/replay.c b/replay.c
index fc7186ef09..313fb2a768 100644
--- a/replay.c
+++ b/replay.c
@@ -1,5 +1,4 @@
 #define USE_THE_REPOSITORY_VARIABLE
-#define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
 #include "environment.h"
@@ -79,7 +78,7 @@ static struct commit *create_commit(struct repository *repo,
 	obj = parse_object(repo, &ret);
 
 out:
-	repo_unuse_commit_buffer(the_repository, based_on, message);
+	repo_unuse_commit_buffer(repo, based_on, message);
 	free_commit_extra_headers(extra);
 	free_commit_list(parents);
 	strbuf_release(&msg);
@@ -91,16 +90,14 @@ struct ref_info {
 	struct commit *onto;
 	struct strset positive_refs;
 	struct strset negative_refs;
-	int positive_refexprs;
-	int negative_refexprs;
+	size_t positive_refexprs;
+	size_t negative_refexprs;
 };
 
 static void get_ref_information(struct repository *repo,
 				struct rev_cmdline_info *cmd_info,
 				struct ref_info *ref_info)
 {
-	int i;
-
 	ref_info->onto = NULL;
 	strset_init(&ref_info->positive_refs);
 	strset_init(&ref_info->negative_refs);
@@ -122,7 +119,7 @@ static void get_ref_information(struct repository *repo,
 	 * the second because they'd likely just be replaying commits on top
 	 * of the same commit and not making any difference.
 	 */
-	for (i = 0; i < cmd_info->nr; i++) {
+	for (size_t i = 0; i < cmd_info->nr; i++) {
 		struct rev_cmdline_entry *e = cmd_info->rev + i;
 		struct object_id oid;
 		const char *refexpr = e->name;
-- 
2.52.0.542.g9473a8513b.dirty
Patrick Steinhardt· Jan 9, 2026, 08:35 UTC · re: Patrick Steinhardt · lore

[PATCH v9 4/7] replay: yield the object ID of the final rewritten commit

In a subsequent commit we'll introduce a new git-history(1) command that uses the replay machinery to rewrite commits. One of its supported modes will only want to update the "HEAD" reference, but that is not currently supported by the replay machinery.

Allow implementing this use case by exposing a `final_oid` field for the reference updates. This field will be set to the last commit that was rewritten, which is sufficient information for us to implement this mode in git-history(1).

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 replay.c |  2 ++
 replay.h | 16 ++++++++++++++++
 2 files changed, 18 insertions(+)
Show changes to 2 files +18 −0

replay.c, replay.h

diff --git a/replay.c b/replay.c
index 313fb2a768..20560e792b 100644
--- a/replay.c
+++ b/replay.c
@@ -338,6 +338,8 @@ int replay_revisions(struct repository *repo, struct rev_info *revs,
 					   &onto->object.oid,
 					   &last_commit->object.oid);
 
+	out->final_oid = last_commit->object.oid;
+
 	ret = 0;
 
 out:
diff --git a/replay.h b/replay.h
index 84bc8a7a5b..f8f9889112 100644
--- a/replay.h
+++ b/replay.h
@@ -46,6 +46,22 @@ struct replay_result {
 
 	/* Set to true in case the replay failed with a merge conflict. */
 	bool merge_conflict;
+
+	/*
+	 * The final object ID that was rewritten. Note that this field has
+	 * somewhat special semantics and may or may not be what you want:
+	 *
+	 *   - If no commits were rewritten it will remain uninitialized.
+	 *
+	 *   - If a thicket of branches is rewritten it is undefined in which
+	 *     order those branches will be rewritten, and thus the final object
+	 *     ID may point to a different commit than you'd expect.
+	 *
+	 * That being said, this field can still be useful when you know that
+	 * you only replay a single strand of commits. In that case, the final
+	 * commit will point to the tip of the rewritten strand of commits.
+	 */
+	struct object_id final_oid;
 };
 
 void replay_result_release(struct replay_result *result);
-- 
2.52.0.542.g9473a8513b.dirty
Elijah Newren· Jan 10, 2026, 01:17 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v9 4/7] replay: yield the object ID of the final rewritten commit

On Fri, Jan 9, 2026 at 12:35 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 5 quoted lines
>
> In a subsequent commit we'll introduce a new git-history(1) command that
> uses the replay machinery to rewrite commits. One of its supported modes
> will only want to update the "HEAD" reference, but that is not currently
> supported by the replay machinery.

I'm a little surprised. In that case replay_result->updates_nr would be 1 and you'd just use replay_result->updates[0], right?

Show 49 quoted lines
> Allow implementing this use case by exposing a `final_oid` field for the
> reference updates. This field will be set to the last commit that was
> rewritten, which is sufficient information for us to implement this mode
> in git-history(1).
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  replay.c |  2 ++
>  replay.h | 16 ++++++++++++++++
>  2 files changed, 18 insertions(+)
>
> diff --git a/replay.c b/replay.c
> index 313fb2a768..20560e792b 100644
> --- a/replay.c
> +++ b/replay.c
> @@ -338,6 +338,8 @@ int replay_revisions(struct repository *repo, struct rev_info *revs,
>                                            &onto->object.oid,
>                                            &last_commit->object.oid);
>
> +       out->final_oid = last_commit->object.oid;
> +
>         ret = 0;
>
>  out:
> diff --git a/replay.h b/replay.h
> index 84bc8a7a5b..f8f9889112 100644
> --- a/replay.h
> +++ b/replay.h
> @@ -46,6 +46,22 @@ struct replay_result {
>
>         /* Set to true in case the replay failed with a merge conflict. */
>         bool merge_conflict;
> +
> +       /*
> +        * The final object ID that was rewritten. Note that this field has
> +        * somewhat special semantics and may or may not be what you want:
> +        *
> +        *   - If no commits were rewritten it will remain uninitialized.
> +        *
> +        *   - If a thicket of branches is rewritten it is undefined in which
> +        *     order those branches will be rewritten, and thus the final object
> +        *     ID may point to a different commit than you'd expect.
> +        *
> +        * That being said, this field can still be useful when you know that
> +        * you only replay a single strand of commits. In that case, the final
> +        * commit will point to the tip of the rewritten strand of commits.
> +        */
> +       struct object_id final_oid;
>  };

I don't understand why this is needed for the usecase you provide. Are you perhaps trying to rewrite a set of commits whose tip is not a branch or something (directly contradicting your first paragraph of the commit message)? That's the only case I can think of where this would be useful, unless I'm missing something?

Patrick Steinhardt· Jan 12, 2026, 13:03 UTC · re: Elijah Newren · lore

Re: [PATCH v9 4/7] replay: yield the object ID of the final rewritten commit

On Fri, Jan 09, 2026 at 05:17:02PM -0800, Elijah Newren wrote:
Show 32 quoted lines
> On Fri, Jan 9, 2026 at 12:35 AM Patrick Steinhardt <ps@pks.im> wrote:
> > diff --git a/replay.h b/replay.h
> > index 84bc8a7a5b..f8f9889112 100644
> > --- a/replay.h
> > +++ b/replay.h
> > @@ -46,6 +46,22 @@ struct replay_result {
> >
> >         /* Set to true in case the replay failed with a merge conflict. */
> >         bool merge_conflict;
> > +
> > +       /*
> > +        * The final object ID that was rewritten. Note that this field has
> > +        * somewhat special semantics and may or may not be what you want:
> > +        *
> > +        *   - If no commits were rewritten it will remain uninitialized.
> > +        *
> > +        *   - If a thicket of branches is rewritten it is undefined in which
> > +        *     order those branches will be rewritten, and thus the final object
> > +        *     ID may point to a different commit than you'd expect.
> > +        *
> > +        * That being said, this field can still be useful when you know that
> > +        * you only replay a single strand of commits. In that case, the final
> > +        * commit will point to the tip of the rewritten strand of commits.
> > +        */
> > +       struct object_id final_oid;
> >  };
> 
> I don't understand why this is needed for the usecase you provide.
> Are you perhaps trying to rewrite a set of commits whose tip is not a
> branch or something (directly contradicting your first paragraph of
> the commit message)?  That's the only case I can think of where this
> would be useful, unless I'm missing something?

It's the case where you're rewriting a detached HEAD. But I'll discard this commit in favor of what you've posted.

Thanks!
Patrick
Patrick Steinhardt· Jan 9, 2026, 08:35 UTC · re: Patrick Steinhardt · lore

[PATCH v9 5/7] wt-status: provide function to expose status for trees

The "wt-status" subsystem is responsible for printing status information around the current state of the working tree. This most importantly includes information around whether the working tree or the index have any changes.

We're about to introduce a new command where the changes in neither of them are actually relevant to us. Instead, what we want is to format the changes between two different trees. While it is a little bit of a stretch to add this as functionality to _working tree_ status, it doesn't make any sense to open-code this functionality, either.

Implement a new function `wt_status_collect_changes_trees()` that diffs two trees and formats the status accordingly. This function is not yet used, but will be in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 wt-status.c | 24 ++++++++++++++++++++++++
 wt-status.h |  9 +++++++++
 2 files changed, 33 insertions(+)
Show changes to 2 files +33 −0

wt-status.c, wt-status.h

diff --git a/wt-status.c b/wt-status.c
index e12adb26b9..95942399f8 100644
--- a/wt-status.c
+++ b/wt-status.c
@@ -612,6 +612,30 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q,
 	}
 }
 
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish)
+{
+	struct diff_options opts = { 0 };
+
+	repo_diff_setup(s->repo, &opts);
+	opts.output_format = DIFF_FORMAT_CALLBACK;
+	opts.format_callback = wt_status_collect_updated_cb;
+	opts.format_callback_data = s;
+	opts.detect_rename = s->detect_rename >= 0 ? s->detect_rename : opts.detect_rename;
+	opts.rename_limit = s->rename_limit >= 0 ? s->rename_limit : opts.rename_limit;
+	opts.rename_score = s->rename_score >= 0 ? s->rename_score : opts.rename_score;
+	opts.flags.recursive = 1;
+	diff_setup_done(&opts);
+
+	diff_tree_oid(old_treeish, new_treeish, "", &opts);
+	diffcore_std(&opts);
+	diff_flush(&opts);
+	wt_status_get_state(s->repo, &s->state, 0);
+
+	diff_free(&opts);
+}
+
 static void wt_status_collect_changes_worktree(struct wt_status *s)
 {
 	struct rev_info rev;
diff --git a/wt-status.h b/wt-status.h
index e40a27214a..e9fe32e98c 100644
--- a/wt-status.h
+++ b/wt-status.h
@@ -153,6 +153,15 @@ void wt_status_add_cut_line(struct wt_status *s);
 void wt_status_prepare(struct repository *r, struct wt_status *s);
 void wt_status_print(struct wt_status *s);
 void wt_status_collect(struct wt_status *s);
+
+/*
+ * Collect all changes between the two trees. Changes will be displayed as if
+ * they were staged into the index.
+ */
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish);
+
 /*
  * Frees the buffers allocated by wt_status_collect.
  */
-- 
2.52.0.542.g9473a8513b.dirty
Patrick Steinhardt· Jan 9, 2026, 08:35 UTC · re: Patrick Steinhardt · lore

[PATCH v9 6/7] builtin: add new "history" command

When rewriting history via git-rebase(1) there are a few very common use cases:

  - The ordering of two commits should be reversed.
  - A commit should be split up into two commits.
  - A commit should be dropped from the history completely.
  - Multiple commits should be squashed into one.
  - Editing an existing commit that is not the tip of the current
    branch.

While these operations are all doable, it often feels needlessly kludgey to do so by doing an interactive rebase, using the editor to say what one wants, and then perform the actions. Also, some operations like splitting up a commit into two are way more involved than that and require a whole series of commands.

Rebases also do not update dependent branches. The use of stacked branches has grown quite common with competing version control systems like Jujutsu though, so it clearly is a need that users have. While rebases _can_ serve this use case if one always works on the latest stacked branch, it is somewhat awkward and very easy to get wrong.

Add a new "history" command to plug these gaps. This command will have several different subcommands to imperatively rewrite history for common use cases like the above.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 .gitignore                     |  1 +
 Documentation/git-history.adoc | 56 ++++++++++++++++++++++++++++++++++++++++++
 Documentation/meson.build      |  1 +
 Makefile                       |  1 +
 builtin.h                      |  1 +
 builtin/history.c              | 22 +++++++++++++++++
 command-list.txt               |  1 +
 git.c                          |  1 +
 meson.build                    |  1 +
 t/meson.build                  |  1 +
 t/t3450-history.sh             | 17 +++++++++++++
 11 files changed, 103 insertions(+)
Show changes to 11 files +103 −0

.gitignore, Documentation/git-history.adoc, Documentation/meson.build, Makefile, builtin.h, builtin/history.c, command-list.txt, git.c, meson.build, t/meson.build, t/t3450-history.sh

diff --git a/.gitignore b/.gitignore
index 78a45cb5be..24635cf2d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,6 +79,7 @@
 /git-grep
 /git-hash-object
 /git-help
+/git-history
 /git-hook
 /git-http-backend
 /git-http-fetch
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
new file mode 100644
index 0000000000..5a9d931efc
--- /dev/null
+++ b/Documentation/git-history.adoc
@@ -0,0 +1,56 @@
+git-history(1)
+==============
+
+NAME
+----
+git-history - EXPERIMENTAL: Rewrite history
+
+SYNOPSIS
+--------
+[synopsis]
+git history [<options>]
+
+DESCRIPTION
+-----------
+
+Rewrite history by rearranging or modifying specific commits in the
+history.
+
+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.
+* 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
+  of the original commit to point to the rewritten commit.
+
+Overall, linkgit:git-history[1] aims to provide a more opinionated way to modify
+your commit history that is simpler to use compared to linkgit:git-rebase[1] in
+general.
+
+If you want to reapply a range of commits onto a different base, or interactive
+rebases if you want to edit a range of commits.
+
+LIMITATIONS
+-----------
+
+This command does not (yet) work with histories that contain merges. You
+should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
+
+Furthermore, the command does not support operations that can result in merge
+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.
+
+COMMANDS
+--------
+
+Several commands are available to rewrite history in different ways:
+
+GIT
+---
+Part of the linkgit:git[1] suite
diff --git a/Documentation/meson.build b/Documentation/meson.build
index f02dbc20cb..fd2e8cc02d 100644
--- a/Documentation/meson.build
+++ b/Documentation/meson.build
@@ -64,6 +64,7 @@ manpages = {
   'git-gui.adoc' : 1,
   'git-hash-object.adoc' : 1,
   'git-help.adoc' : 1,
+  'git-history.adoc' : 1,
   'git-hook.adoc' : 1,
   'git-http-backend.adoc' : 1,
   'git-http-fetch.adoc' : 1,
diff --git a/Makefile b/Makefile
index 1c64a5d2ae..c0569ed8e4 100644
--- a/Makefile
+++ b/Makefile
@@ -1418,6 +1418,7 @@ BUILTIN_OBJS += builtin/get-tar-commit-id.o
 BUILTIN_OBJS += builtin/grep.o
 BUILTIN_OBJS += builtin/hash-object.o
 BUILTIN_OBJS += builtin/help.o
+BUILTIN_OBJS += builtin/history.o
 BUILTIN_OBJS += builtin/hook.o
 BUILTIN_OBJS += builtin/index-pack.o
 BUILTIN_OBJS += builtin/init-db.o
diff --git a/builtin.h b/builtin.h
index 1b35565fbd..93c91d07d4 100644
--- a/builtin.h
+++ b/builtin.h
@@ -172,6 +172,7 @@ int cmd_get_tar_commit_id(int argc, const char **argv, const char *prefix, struc
 int cmd_grep(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hash_object(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_help(int argc, const char **argv, const char *prefix, struct repository *repo);
+int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hook(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);
diff --git a/builtin/history.c b/builtin/history.c
new file mode 100644
index 0000000000..f6fe32610b
--- /dev/null
+++ b/builtin/history.c
@@ -0,0 +1,22 @@
+#include "builtin.h"
+#include "gettext.h"
+#include "parse-options.h"
+
+int cmd_history(int argc,
+		const char **argv,
+		const char *prefix,
+		struct repository *repo UNUSED)
+{
+	const char * const usage[] = {
+		N_("git history [<options>]"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc)
+		usagef("unrecognized argument: %s", argv[0]);
+	return 0;
+}
diff --git a/command-list.txt b/command-list.txt
index accd3d0c4b..f9005cf459 100644
--- a/command-list.txt
+++ b/command-list.txt
@@ -115,6 +115,7 @@ git-grep                                mainporcelain           info
 git-gui                                 mainporcelain
 git-hash-object                         plumbingmanipulators
 git-help                                ancillaryinterrogators          complete
+git-history                             mainporcelain           history
 git-hook                                purehelpers
 git-http-backend                        synchingrepositories
 git-http-fetch                          synchelpers
diff --git a/git.c b/git.c
index c5fad56813..744cb6527e 100644
--- a/git.c
+++ b/git.c
@@ -586,6 +586,7 @@ static struct cmd_struct commands[] = {
 	{ "grep", cmd_grep, RUN_SETUP_GENTLY },
 	{ "hash-object", cmd_hash_object },
 	{ "help", cmd_help },
+	{ "history", cmd_history, RUN_SETUP },
 	{ "hook", cmd_hook, RUN_SETUP },
 	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
 	{ "init", cmd_init_db },
diff --git a/meson.build b/meson.build
index a5a4e99b25..3a1d12caa4 100644
--- a/meson.build
+++ b/meson.build
@@ -610,6 +610,7 @@ builtin_sources = [
   'builtin/grep.c',
   'builtin/hash-object.c',
   'builtin/help.c',
+  'builtin/history.c',
   'builtin/hook.c',
   'builtin/index-pack.c',
   'builtin/init-db.c',
diff --git a/t/meson.build b/t/meson.build
index 459c52a489..73006b095a 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -387,6 +387,7 @@ integration_tests = [
   't3436-rebase-more-options.sh',
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
+  't3450-history.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
new file mode 100755
index 0000000000..417c343d43
--- /dev/null
+++ b/t/t3450-history.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+test_description='tests for git-history command'
+
+. ./test-lib.sh
+
+test_expect_success 'does nothing without any arguments' '
+	git history >out 2>&1 &&
+	test_must_be_empty out
+'
+
+test_expect_success 'raises an error with unknown argument' '
+	test_must_fail git history garbage 2>err &&
+	test_grep "unrecognized argument: garbage" err
+'
+
+test_done
-- 
2.52.0.542.g9473a8513b.dirty
Elijah Newren· Jan 10, 2026, 01:17 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v9 6/7] builtin: add new "history" command

On Fri, Jan 9, 2026 at 12:35 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 100 quoted lines
>
> When rewriting history via git-rebase(1) there are a few very common use
> cases:
>
>   - The ordering of two commits should be reversed.
>
>   - A commit should be split up into two commits.
>
>   - A commit should be dropped from the history completely.
>
>   - Multiple commits should be squashed into one.
>
>   - Editing an existing commit that is not the tip of the current
>     branch.
>
> While these operations are all doable, it often feels needlessly kludgey
> to do so by doing an interactive rebase, using the editor to say what
> one wants, and then perform the actions. Also, some operations like
> splitting up a commit into two are way more involved than that and
> require a whole series of commands.
>
> Rebases also do not update dependent branches. The use of stacked
> branches has grown quite common with competing version control systems
> like Jujutsu though, so it clearly is a need that users have. While
> rebases _can_ serve this use case if one always works on the latest
> stacked branch, it is somewhat awkward and very easy to get wrong.
>
> Add a new "history" command to plug these gaps. This command will have
> several different subcommands to imperatively rewrite history for common
> use cases like the above.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  .gitignore                     |  1 +
>  Documentation/git-history.adoc | 56 ++++++++++++++++++++++++++++++++++++++++++
>  Documentation/meson.build      |  1 +
>  Makefile                       |  1 +
>  builtin.h                      |  1 +
>  builtin/history.c              | 22 +++++++++++++++++
>  command-list.txt               |  1 +
>  git.c                          |  1 +
>  meson.build                    |  1 +
>  t/meson.build                  |  1 +
>  t/t3450-history.sh             | 17 +++++++++++++
>  11 files changed, 103 insertions(+)
>
> diff --git a/.gitignore b/.gitignore
> index 78a45cb5be..24635cf2d6 100644
> --- a/.gitignore
> +++ b/.gitignore
> @@ -79,6 +79,7 @@
>  /git-grep
>  /git-hash-object
>  /git-help
> +/git-history
>  /git-hook
>  /git-http-backend
>  /git-http-fetch
> diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> new file mode 100644
> index 0000000000..5a9d931efc
> --- /dev/null
> +++ b/Documentation/git-history.adoc
> @@ -0,0 +1,56 @@
> +git-history(1)
> +==============
> +
> +NAME
> +----
> +git-history - EXPERIMENTAL: Rewrite history
> +
> +SYNOPSIS
> +--------
> +[synopsis]
> +git history [<options>]
> +
> +DESCRIPTION
> +-----------
> +
> +Rewrite history by rearranging or modifying specific commits in the
> +history.
> +
> +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.
> +* 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
> +  of the original commit to point to the rewritten commit.
> +
> +Overall, linkgit:git-history[1] aims to provide a more opinionated way to modify
> +your commit history that is simpler to use compared to linkgit:git-rebase[1] in
> +general.
> +
> +If you want to reapply a range of commits onto a different base, or interactive
> +rebases if you want to edit a range of commits.
"If ..." with no "then ..." ?  This sentence isn't complete.
Show 15 quoted lines
> +LIMITATIONS
> +-----------
> +
> +This command does not (yet) work with histories that contain merges. You
> +should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
> +
> +Furthermore, the command does not support operations that can result in merge
> +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.
> +
> +COMMANDS
> +--------
> +
> +Several commands are available to rewrite history in different ways:
Um, what are they?
Show 66 quoted lines
> +
> +GIT
> +---
> +Part of the linkgit:git[1] suite
> diff --git a/Documentation/meson.build b/Documentation/meson.build
> index f02dbc20cb..fd2e8cc02d 100644
> --- a/Documentation/meson.build
> +++ b/Documentation/meson.build
> @@ -64,6 +64,7 @@ manpages = {
>    'git-gui.adoc' : 1,
>    'git-hash-object.adoc' : 1,
>    'git-help.adoc' : 1,
> +  'git-history.adoc' : 1,
>    'git-hook.adoc' : 1,
>    'git-http-backend.adoc' : 1,
>    'git-http-fetch.adoc' : 1,
> diff --git a/Makefile b/Makefile
> index 1c64a5d2ae..c0569ed8e4 100644
> --- a/Makefile
> +++ b/Makefile
> @@ -1418,6 +1418,7 @@ BUILTIN_OBJS += builtin/get-tar-commit-id.o
>  BUILTIN_OBJS += builtin/grep.o
>  BUILTIN_OBJS += builtin/hash-object.o
>  BUILTIN_OBJS += builtin/help.o
> +BUILTIN_OBJS += builtin/history.o
>  BUILTIN_OBJS += builtin/hook.o
>  BUILTIN_OBJS += builtin/index-pack.o
>  BUILTIN_OBJS += builtin/init-db.o
> diff --git a/builtin.h b/builtin.h
> index 1b35565fbd..93c91d07d4 100644
> --- a/builtin.h
> +++ b/builtin.h
> @@ -172,6 +172,7 @@ int cmd_get_tar_commit_id(int argc, const char **argv, const char *prefix, struc
>  int cmd_grep(int argc, const char **argv, const char *prefix, struct repository *repo);
>  int cmd_hash_object(int argc, const char **argv, const char *prefix, struct repository *repo);
>  int cmd_help(int argc, const char **argv, const char *prefix, struct repository *repo);
> +int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo);
>  int cmd_hook(int argc, const char **argv, const char *prefix, struct repository *repo);
>  int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);
>  int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);
> diff --git a/builtin/history.c b/builtin/history.c
> new file mode 100644
> index 0000000000..f6fe32610b
> --- /dev/null
> +++ b/builtin/history.c
> @@ -0,0 +1,22 @@
> +#include "builtin.h"
> +#include "gettext.h"
> +#include "parse-options.h"
> +
> +int cmd_history(int argc,
> +               const char **argv,
> +               const char *prefix,
> +               struct repository *repo UNUSED)
> +{
> +       const char * const usage[] = {
> +               N_("git history [<options>]"),
> +               NULL,
> +       };
> +       struct option options[] = {
> +               OPT_END(),
> +       };
> +
> +       argc = parse_options(argc, argv, prefix, options, usage, 0);
> +       if (argc)
> +               usagef("unrecognized argument: %s", argv[0]);

Oh, the manual is just a placeholder that you'll extend later? That makes sense, but it would be less confusing if it started out with "No subcommands are yet supported, but will be added later", with that sentence removed in the next commit when you add a subcommand.

Show 76 quoted lines
> +       return 0;
> +}
> diff --git a/command-list.txt b/command-list.txt
> index accd3d0c4b..f9005cf459 100644
> --- a/command-list.txt
> +++ b/command-list.txt
> @@ -115,6 +115,7 @@ git-grep                                mainporcelain           info
>  git-gui                                 mainporcelain
>  git-hash-object                         plumbingmanipulators
>  git-help                                ancillaryinterrogators          complete
> +git-history                             mainporcelain           history
>  git-hook                                purehelpers
>  git-http-backend                        synchingrepositories
>  git-http-fetch                          synchelpers
> diff --git a/git.c b/git.c
> index c5fad56813..744cb6527e 100644
> --- a/git.c
> +++ b/git.c
> @@ -586,6 +586,7 @@ static struct cmd_struct commands[] = {
>         { "grep", cmd_grep, RUN_SETUP_GENTLY },
>         { "hash-object", cmd_hash_object },
>         { "help", cmd_help },
> +       { "history", cmd_history, RUN_SETUP },
>         { "hook", cmd_hook, RUN_SETUP },
>         { "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
>         { "init", cmd_init_db },
> diff --git a/meson.build b/meson.build
> index a5a4e99b25..3a1d12caa4 100644
> --- a/meson.build
> +++ b/meson.build
> @@ -610,6 +610,7 @@ builtin_sources = [
>    'builtin/grep.c',
>    'builtin/hash-object.c',
>    'builtin/help.c',
> +  'builtin/history.c',
>    'builtin/hook.c',
>    'builtin/index-pack.c',
>    'builtin/init-db.c',
> diff --git a/t/meson.build b/t/meson.build
> index 459c52a489..73006b095a 100644
> --- a/t/meson.build
> +++ b/t/meson.build
> @@ -387,6 +387,7 @@ integration_tests = [
>    't3436-rebase-more-options.sh',
>    't3437-rebase-fixup-options.sh',
>    't3438-rebase-broken-files.sh',
> +  't3450-history.sh',
>    't3500-cherry.sh',
>    't3501-revert-cherry-pick.sh',
>    't3502-cherry-pick-merge.sh',
> diff --git a/t/t3450-history.sh b/t/t3450-history.sh
> new file mode 100755
> index 0000000000..417c343d43
> --- /dev/null
> +++ b/t/t3450-history.sh
> @@ -0,0 +1,17 @@
> +#!/bin/sh
> +
> +test_description='tests for git-history command'
> +
> +. ./test-lib.sh
> +
> +test_expect_success 'does nothing without any arguments' '
> +       git history >out 2>&1 &&
> +       test_must_be_empty out
> +'
> +
> +test_expect_success 'raises an error with unknown argument' '
> +       test_must_fail git history garbage 2>err &&
> +       test_grep "unrecognized argument: garbage" err
> +'
> +
> +test_done
>
> --
> 2.52.0.542.g9473a8513b.dirty
Patrick Steinhardt· Jan 12, 2026, 13:02 UTC · re: Elijah Newren · lore

Re: [PATCH v9 6/7] builtin: add new "history" command

On Fri, Jan 09, 2026 at 05:17:25PM -0800, Elijah Newren wrote:
Show 45 quoted lines
> On Fri, Jan 9, 2026 at 12:35 AM Patrick Steinhardt <ps@pks.im> wrote:
> > diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
> > new file mode 100644
> > index 0000000000..5a9d931efc
> > --- /dev/null
> > +++ b/Documentation/git-history.adoc
> > @@ -0,0 +1,56 @@
> > +git-history(1)
> > +==============
> > +
> > +NAME
> > +----
> > +git-history - EXPERIMENTAL: Rewrite history
> > +
> > +SYNOPSIS
> > +--------
> > +[synopsis]
> > +git history [<options>]
> > +
> > +DESCRIPTION
> > +-----------
> > +
> > +Rewrite history by rearranging or modifying specific commits in the
> > +history.
> > +
> > +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.
> > +* 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
> > +  of the original commit to point to the rewritten commit.
> > +
> > +Overall, linkgit:git-history[1] aims to provide a more opinionated way to modify
> > +your commit history that is simpler to use compared to linkgit:git-rebase[1] in
> > +general.
> > +
> > +If you want to reapply a range of commits onto a different base, or interactive
> > +rebases if you want to edit a range of commits.
> 
> "If ..." with no "then ..." ?  This sentence isn't complete.

Oh, true. I noticed this error, but only fixed it in the next commit. Will move around.

Show 31 quoted lines
> > diff --git a/builtin/history.c b/builtin/history.c
> > new file mode 100644
> > index 0000000000..f6fe32610b
> > --- /dev/null
> > +++ b/builtin/history.c
> > @@ -0,0 +1,22 @@
> > +#include "builtin.h"
> > +#include "gettext.h"
> > +#include "parse-options.h"
> > +
> > +int cmd_history(int argc,
> > +               const char **argv,
> > +               const char *prefix,
> > +               struct repository *repo UNUSED)
> > +{
> > +       const char * const usage[] = {
> > +               N_("git history [<options>]"),
> > +               NULL,
> > +       };
> > +       struct option options[] = {
> > +               OPT_END(),
> > +       };
> > +
> > +       argc = parse_options(argc, argv, prefix, options, usage, 0);
> > +       if (argc)
> > +               usagef("unrecognized argument: %s", argv[0]);
> 
> Oh, the manual is just a placeholder that you'll extend later?  That
> makes sense, but it would be less confusing if it started out with "No
> subcommands are yet supported, but will be added later", with that
> sentence removed in the next commit when you add a subcommand.
Yup. Will change.
Patrick
Patrick Steinhardt· Jan 9, 2026, 08:35 UTC · re: Patrick Steinhardt · lore

[PATCH v9 7/7] builtin/history: implement "reword" subcommand

Implement a new "reword" subcommand for git-history(1). This subcommand is similar to the user performing an interactive rebase with a single commit changed to use the "reword" instruction.

The "reword" subcommand is built on top of the replay subsystem instead of the sequencer. This leads to some major differences compared to git-rebase(1):

  - We do not check out the commit that is to be reworded and instead
    perform the operation in-memory. This has the obvious benefit of
    being significantly faster compared to git-rebase(1), but even more
    importantly it allows the user to rewrite history even if there are
    local changes in the working tree or in the index.
  - We do not execute any hooks, even though we leave some room for
    changing this in the future.
  - By default, all local branches that contain the commit will be
    rewritten. This especially helps with workflows that use stacked
    branches.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  23 ++-
 builtin/history.c              | 392 ++++++++++++++++++++++++++++++++++++++++-
 replay.c                       |   5 +-
 t/meson.build                  |   1 +
 t/t3450-history.sh             |   6 +-
 t/t3451-history-reword.sh      | 344 ++++++++++++++++++++++++++++++++++++
 6 files changed, 758 insertions(+), 13 deletions(-)
Show changes to 6 files +758 −13

Documentation/git-history.adoc, builtin/history.c, replay.c, t/meson.build, t/t3450-history.sh, t/t3451-history-reword.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 5a9d931efc..4eea317e5c 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -8,7 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history
 SYNOPSIS
 --------
 [synopsis]
-git history [<options>]
+git history reword <commit> [--ref-action=(branches|head|print)]
 
 DESCRIPTION
 -----------
@@ -32,8 +32,9 @@ Overall, linkgit:git-history[1] aims to provide a more opinionated way to modify
 your commit history that is simpler to use compared to linkgit:git-rebase[1] in
 general.
 
-If you want to reapply a range of commits onto a different base, or interactive
-rebases if you want to edit a range of commits.
+Use linkgit:git-rebase[1] if you want to reapply a range of commits onto a
+different base, or interactive rebases if you want to edit a range of commits
+at once.
 
 LIMITATIONS
 -----------
@@ -51,6 +52,22 @@ COMMANDS
 
 Several commands are available to rewrite history in different ways:
 
+`reword <commit>`::
+	Rewrite the commit message of the specified commit. All the other
+	details of this commit remain unchanged. This command will spawn an
+	editor with the current message of that commit.
+
+OPTIONS
+-------
+
+`--ref-action=(branches|head|print)`::
+	Control which references will be updated by the command, if any. With
+	`branches`, all local branches that point to commits which are
+	decendants of the original commit will be rewritten. With `head`, only
+	the current `HEAD` reference will be rewritten. With `print`, all
+	updates as they would be performed with `branches` are printed in a
+	format that can be consumed by linkgit:git-update-ref[1].
+
 GIT
 ---
 Part of the linkgit:git[1] suite
diff --git a/builtin/history.c b/builtin/history.c
index f6fe32610b..28db6fc5b9 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,22 +1,404 @@
+#define USE_THE_REPOSITORY_VARIABLE
+
 #include "builtin.h"
+#include "commit.h"
+#include "commit-reach.h"
+#include "config.h"
+#include "editor.h"
+#include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "parse-options.h"
+#include "refs.h"
+#include "replay.h"
+#include "revision.h"
+#include "sequencer.h"
+#include "strvec.h"
+#include "tree.h"
+#include "wt-status.h"
+
+#define GIT_HISTORY_REWORD_USAGE \
+	N_("git history reword <commit> [--ref-action=(branches|head|print)]")
+
+static void change_data_free(void *util, const char *str UNUSED)
+{
+	struct wt_status_change_data *d = util;
+	free(d->rename_source);
+	free(d);
+}
+
+static int fill_commit_message(struct repository *repo,
+			       const struct object_id *old_tree,
+			       const struct object_id *new_tree,
+			       const char *default_message,
+			       const char *action,
+			       struct strbuf *out)
+{
+	const char *path = git_path_commit_editmsg();
+	const char *hint =
+		_("Please enter the commit message for the %s changes."
+		  " Lines starting\nwith '%s' will be ignored, and an"
+		  " empty message aborts the commit.\n");
+	struct wt_status s;
+
+	strbuf_addstr(out, default_message);
+	strbuf_addch(out, '\n');
+	strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
+	write_file_buf(path, out->buf, out->len);
+
+	wt_status_prepare(repo, &s);
+	FREE_AND_NULL(s.branch);
+	s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
+	s.commit_template = 1;
+	s.colopts = 0;
+	s.display_comment_prefix = 1;
+	s.hints = 0;
+	s.use_color = 0;
+	s.whence = FROM_COMMIT;
+	s.committable = 1;
+
+	s.fp = fopen(git_path_commit_editmsg(), "a");
+	if (!s.fp)
+		return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
+
+	wt_status_collect_changes_trees(&s, old_tree, new_tree);
+	wt_status_print(&s);
+	wt_status_collect_free_buffers(&s);
+	string_list_clear_func(&s.change, change_data_free);
+
+	strbuf_reset(out);
+	if (launch_editor(path, out, NULL)) {
+		fprintf(stderr, _("Aborting commit as launching the editor failed.\n"));
+		return -1;
+	}
+	strbuf_stripspace(out, comment_line_str);
+
+	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
+
+	if (!out->len) {
+		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
+		return -1;
+	}
+
+	return 0;
+}
+
+static int commit_tree_with_edited_message(struct repository *repo,
+					   const char *action,
+					   struct commit *original,
+					   struct commit **out)
+{
+	const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
+	const char *original_message, *original_body, *ptr;
+	struct commit_extra_header *original_extra_headers = NULL;
+	struct strbuf commit_message = STRBUF_INIT;
+	struct object_id rewritten_commit_oid;
+	struct object_id original_tree_oid;
+	struct object_id parent_tree_oid;
+	char *original_author = NULL;
+	struct commit *parent;
+	size_t len;
+	int ret;
+
+	original_tree_oid = repo_get_commit_tree(repo, original)->object.oid;
+
+	parent = original->parents ? original->parents->item : NULL;
+	if (parent) {
+		if (repo_parse_commit(repo, parent)) {
+			ret = error(_("unable to parse parent commit %s"),
+				    oid_to_hex(&parent->object.oid));
+			goto out;
+		}
+
+		parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
+	} else {
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	}
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+	find_commit_subject(original_message, &original_body);
+
+	ret = fill_commit_message(repo, &parent_tree_oid, &original_tree_oid,
+				  original_body, action, &commit_message);
+	if (ret < 0)
+		goto out;
+
+	original_extra_headers = read_commit_extra_headers(original, exclude_gpgsig);
+
+	ret = commit_tree_extended(commit_message.buf, commit_message.len, &original_tree_oid,
+				   original->parents, &rewritten_commit_oid, original_author,
+				   NULL, NULL, original_extra_headers);
+	if (ret < 0)
+		goto out;
+
+	*out = lookup_commit_or_die(&rewritten_commit_oid, "rewritten commit");
+
+out:
+	free_commit_extra_headers(original_extra_headers);
+	strbuf_release(&commit_message);
+	free(original_author);
+	return ret;
+}
+
+enum ref_action {
+	REF_ACTION_DEFAULT,
+	REF_ACTION_BRANCHES,
+	REF_ACTION_HEAD,
+	REF_ACTION_PRINT,
+};
+
+static int parse_ref_action(const struct option *opt, const char *value, int unset)
+{
+	enum ref_action *action = opt->value;
+
+	BUG_ON_OPT_NEG_NOARG(unset, value);
+	if (!strcmp(value, "branches")) {
+		*action = REF_ACTION_BRANCHES;
+	} else if (!strcmp(value, "head")) {
+		*action = REF_ACTION_HEAD;
+	} else if (!strcmp(value, "print")) {
+		*action = REF_ACTION_PRINT;
+	} else {
+		return error(_("%s expects one of 'branches', 'head' or 'print'"),
+			     opt->long_name);
+	}
+
+	return 0;
+}
+
+static int handle_reference_updates(enum ref_action action,
+				    struct repository *repo,
+				    struct commit *original,
+				    struct commit *rewritten,
+				    const char *reflog_msg)
+{
+	const struct name_decoration *decoration;
+	struct replay_revisions_options opts = { 0 };
+	struct replay_result result = {
+		.final_oid = rewritten->object.oid,
+	};
+	struct ref_transaction *transaction = NULL;
+	struct strvec args = STRVEC_INIT;
+	struct strbuf err = STRBUF_INIT;
+	struct commit *head = NULL;
+	struct rev_info revs;
+	char hex[GIT_MAX_HEXSZ + 1];
+	int ret;
+
+	repo_init_revisions(repo, &revs, NULL);
+	strvec_push(&args, "ignored");
+	strvec_push(&args, "--reverse");
+	strvec_push(&args, "--topo-order");
+	strvec_push(&args, "--full-history");
+
+	/* We only want to see commits that are descendants of the old commit. */
+	strvec_pushf(&args, "--ancestry-path=%s",
+		     oid_to_hex(&original->object.oid));
+
+	/*
+	 * Ancestry path may also show ancestors of the old commit, but we
+	 * don't want to see those, either.
+	 */
+	strvec_pushf(&args, "^%s", oid_to_hex(&original->object.oid));
+
+	/*
+	 * When we're asked to update HEAD we need to verify that the commit
+	 * that we want to rewrite is actually an ancestor of it and, if so,
+	 * update it. Otherwise we'll update (or print) all descendant
+	 * branches.
+	 */
+	if (action == REF_ACTION_HEAD) {
+		struct commit_list *from_list = NULL;
+
+		head = lookup_commit_reference_by_name("HEAD");
+		if (!head) {
+			ret = error(_("cannot look up HEAD"));
+			goto out;
+		}
+
+		commit_list_insert(original, &from_list);
+		ret = repo_is_descendant_of(repo, head, from_list);
+		free_commit_list(from_list);
+
+		if (ret < 0) {
+			ret = error(_("cannot determine descendance"));
+			goto out;
+		} else if (!ret) {
+			ret = error(_("rewritten commit must be an ancestor "
+				      "of HEAD when using --ref-action=head"));
+			goto out;
+		}
+
+		strvec_push(&args, oid_to_hex(&head->object.oid));
+	} else {
+		strvec_push(&args, "--branches");
+	}
+
+	setup_revisions_from_strvec(&args, &revs, NULL);
+	if (revs.nr)
+		BUG("revisions were set up with invalid argument '%s'", args.v[0]);
+
+	opts.onto = oid_to_hex_r(hex, &rewritten->object.oid);
+
+	ret = replay_revisions(repo, &revs, &opts, &result);
+	if (ret)
+		goto out;
+
+	switch (action) {
+	case REF_ACTION_DEFAULT:
+	case REF_ACTION_BRANCHES:
+		transaction = ref_store_transaction_begin(get_main_ref_store(repo), 0, &err);
+		if (!transaction) {
+			ret = error(_("failed to begin ref transaction: %s"), err.buf);
+			goto out;
+		}
+
+		for (size_t i = 0; i < result.updates_nr; i++) {
+			ret = ref_transaction_update(transaction,
+						     result.updates[i].refname,
+						     &result.updates[i].new_oid,
+						     &result.updates[i].old_oid,
+						     NULL, NULL, 0, reflog_msg, &err);
+			if (ret) {
+				ret = error(_("failed to update ref '%s': %s"),
+					    result.updates[i].refname, err.buf);
+				goto out;
+			}
+		}
+
+		/*
+		 * `replay_revisions()` only updates references that are
+		 * ancestors of `rewritten`, so we need to manually
+		 * handle updating references that point to `original`.
+		 */
+		for (decoration = get_name_decoration(&original->object);
+		     decoration;
+		     decoration = decoration->next)
+		{
+			if (decoration->type != DECORATION_REF_LOCAL)
+				continue;
+
+			ret = ref_transaction_update(transaction,
+						     decoration->name,
+						     &rewritten->object.oid,
+						     &original->object.oid,
+						     NULL, NULL, 0, reflog_msg, &err);
+			if (ret) {
+				ret = error(_("failed to update ref '%s': %s"),
+					    decoration->name, err.buf);
+				goto out;
+			}
+		}
+
+		if (ref_transaction_commit(transaction, &err)) {
+			ret = error(_("failed to commit ref transaction: %s"), err.buf);
+			goto out;
+		}
+
+		break;
+	case REF_ACTION_HEAD:
+		ret = refs_update_ref(get_main_ref_store(repo), reflog_msg, "HEAD",
+				      &result.final_oid, &head->object.oid, 0,
+				      UPDATE_REFS_MSG_ON_ERR);
+		if (ret)
+			goto out;
+		break;
+	case REF_ACTION_PRINT:
+		for (size_t i = 0; i < result.updates_nr; i++)
+			printf("update %s %s %s\n",
+			       result.updates[i].refname,
+			       oid_to_hex(&result.updates[i].new_oid),
+			       oid_to_hex(&result.updates[i].old_oid));
+		break;
+	default:
+		BUG("unsupported ref action %d", action);
+	}
+
+	ret = 0;
+
+out:
+	ref_transaction_free(transaction);
+	replay_result_release(&result);
+	release_revisions(&revs);
+	strbuf_release(&err);
+	strvec_clear(&args);
+	return ret;
+}
+
+static int cmd_history_reword(int argc,
+			      const char **argv,
+			      const char *prefix,
+			      struct repository *repo)
+{
+	const char * const usage[] = {
+		GIT_HISTORY_REWORD_USAGE,
+		NULL,
+	};
+	enum ref_action action = REF_ACTION_DEFAULT;
+	struct option options[] = {
+		OPT_CALLBACK_F(0, "ref-action", &action, N_("<action>"),
+			       N_("control ref update behavior (branches|head|print)"),
+			       PARSE_OPT_NONEG, parse_ref_action),
+		OPT_END(),
+	};
+	struct strbuf reflog_msg = STRBUF_INIT;
+	struct commit *original, *rewritten;
+	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);
+
+	original = lookup_commit_reference_by_name(argv[0]);
+	if (!original) {
+		ret = error(_("commit cannot be found: %s"), argv[0]);
+		goto out;
+	}
+
+	ret = commit_tree_with_edited_message(repo, "reworded", original, &rewritten);
+	if (ret < 0) {
+		ret = error(_("failed writing reworded commit"));
+		goto out;
+	}
+
+	strbuf_addf(&reflog_msg, "reword: updating %s", argv[0]);
+
+	ret = handle_reference_updates(action, repo, original, rewritten,
+				       reflog_msg.buf);
+	if (ret < 0) {
+		ret = error(_("failed replaying descendants"));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	strbuf_release(&reflog_msg);
+	return ret;
+}
 
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
-		struct repository *repo UNUSED)
+		struct repository *repo)
 {
 	const char * const usage[] = {
-		N_("git history [<options>]"),
+		GIT_HISTORY_REWORD_USAGE,
 		NULL,
 	};
+	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
+		OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
 		OPT_END(),
 	};
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
-	if (argc)
-		usagef("unrecognized argument: %s", argv[0]);
-	return 0;
+	return fn(argc, argv, prefix, repo);
 }
diff --git a/replay.c b/replay.c
index 20560e792b..74e45ed27a 100644
--- a/replay.c
+++ b/replay.c
@@ -266,7 +266,9 @@ int replay_revisions(struct repository *repo, struct rev_info *revs,
 	struct commit *commit;
 	struct commit *onto = NULL;
 	struct merge_options merge_opt;
-	struct merge_result result;
+	struct merge_result result = {
+		.clean = 1,
+	};
 	char *advance;
 	int ret;
 
@@ -282,7 +284,6 @@ int replay_revisions(struct repository *repo, struct rev_info *revs,
 	}
 
 	init_basic_merge_options(&merge_opt, repo);
-	memset(&result, 0, sizeof(result));
 	merge_opt.show_rename_progress = 0;
 	last_commit = onto;
 	replayed_commits = kh_init_oid_map();
diff --git a/t/meson.build b/t/meson.build
index 73006b095a..c9f92450dc 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -388,6 +388,7 @@ integration_tests = [
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
+  't3451-history-reword.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
index 417c343d43..f513463b92 100755
--- a/t/t3450-history.sh
+++ b/t/t3450-history.sh
@@ -5,13 +5,13 @@ test_description='tests for git-history command'
 . ./test-lib.sh
 
 test_expect_success 'does nothing without any arguments' '
-	git history >out 2>&1 &&
-	test_must_be_empty out
+	test_must_fail git history 2>err &&
+	test_grep "need a subcommand" err
 '
 
 test_expect_success 'raises an error with unknown argument' '
 	test_must_fail git history garbage 2>err &&
-	test_grep "unrecognized argument: garbage" err
+	test_grep "unknown subcommand: .garbage." err
 '
 
 test_done
diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
new file mode 100755
index 0000000000..cd5883051d
--- /dev/null
+++ b/t/t3451-history-reword.sh
@@ -0,0 +1,344 @@
+#!/bin/sh
+
+test_description='tests for git-history reword subcommand'
+
+. ./test-lib.sh
+. "$TEST_DIRECTORY/lib-log-graph.sh"
+
+reword_with_message () {
+	cat >message &&
+	write_script fake-editor.sh <<-\EOF &&
+	cp message "$1"
+	EOF
+	test_set_editor "$(pwd)"/fake-editor.sh &&
+	git history reword "$@" &&
+	rm fake-editor.sh message
+}
+
+expect_graph () {
+	cat >expect &&
+	lib_test_cmp_graph --graph --format=%s "$@"
+}
+
+expect_log () {
+	git log --format="%s" "$@" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+test_expect_success 'can reword tip of a branch' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		reword_with_message HEAD <<-EOF &&
+		third reworded
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		expect_log <<-\EOF &&
+		third reworded
+		second
+		first
+		EOF
+
+		git reflog >reflog &&
+		test_grep "reword: updating HEAD" reflog
+	)
+'
+
+test_expect_success 'can reword commit in the middle' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		reword_with_message HEAD~ <<-EOF &&
+		second reworded
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		expect_log <<-\EOF
+		third
+		second reworded
+		first
+		EOF
+	)
+'
+
+test_expect_success 'can reword root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		reword_with_message HEAD~2 <<-EOF &&
+		first reworded
+		EOF
+
+		expect_log <<-\EOF
+		third
+		second
+		first reworded
+		EOF
+	)
+'
+
+test_expect_success 'can reword in a bare repo' '
+	test_when_finished "rm -rf repo repo.git" &&
+	git init repo &&
+	test_commit -C repo first &&
+	git clone --bare repo repo.git &&
+	(
+		cd repo.git &&
+		reword_with_message HEAD <<-EOF &&
+		reworded
+		EOF
+
+		expect_log <<-\EOF
+		reworded
+		EOF
+	)
+'
+
+test_expect_success 'can reword a 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 &&
+
+		git rev-parse ours >ours-before &&
+		reword_with_message theirs <<-EOF &&
+		Reworded theirs
+		EOF
+		git rev-parse ours >ours-after &&
+		test_cmp ours-before ours-after &&
+
+		expect_graph --branches <<-\EOF
+		* Reworded theirs
+		| * ours
+		|/
+		* base
+		EOF
+	)
+'
+
+test_expect_success 'can reword a merge commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+
+		# It is not possible to replay merge commits embedded in the
+		# history (yet).
+		test_must_fail git history reword HEAD~ 2>err &&
+		test_grep "replaying merge commits is not supported yet" err &&
+
+		# But it is possible to reword a merge commit directly.
+		reword_with_message HEAD <<-EOF &&
+		Reworded merge commit
+		EOF
+		expect_graph <<-\EOF
+		*   Reworded merge commit
+		|\
+		| * theirs
+		* | ours
+		|/
+		* base
+		EOF
+	)
+'
+
+test_expect_success '--ref-action=print 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 ours &&
+		git switch branch &&
+		test_commit theirs &&
+
+		git refs list >refs-expect &&
+		reword_with_message --ref-action=print base >updates <<-\EOF &&
+		reworded commit
+		EOF
+		git refs list >refs-actual &&
+		test_cmp refs-expect refs-actual &&
+
+		test_grep "update refs/heads/branch" updates &&
+		test_grep "update refs/heads/main" updates &&
+		git update-ref --stdin <updates &&
+		expect_log --branches <<-\EOF
+		theirs
+		ours
+		reworded commit
+		EOF
+	)
+'
+
+test_expect_success '--ref-action=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 theirs &&
+		git switch branch &&
+		test_commit ours &&
+
+		# When told to update HEAD, only, the command will refuse to
+		# rewrite commits that are not an ancestor of HEAD.
+		test_must_fail git history reword --ref-action=head theirs 2>err &&
+		test_grep "rewritten commit must be an ancestor of HEAD" err &&
+
+		reword_with_message --ref-action=head base >updates <<-\EOF &&
+		reworded base
+		EOF
+		expect_log HEAD <<-\EOF &&
+		ours
+		reworded base
+		EOF
+		expect_log main <<-\EOF
+		theirs
+		base
+		EOF
+	)
+'
+
+test_expect_success 'editor shows proper status' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		printf "\namend a comment\n" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+		git history reword HEAD &&
+
+		cat >expect <<-EOF &&
+		first
+
+		# Please enter the commit message for the reworded changes. Lines starting
+		# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+		# Changes to be committed:
+		#	new file:   first.t
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		test_commit_message HEAD <<-\EOF
+		first
+
+		amend a comment
+		EOF
+	)
+'
+
+# For now, git-history(1) does not yet execute any hooks. This is subject to
+# change in the future, and if it does this test here is expected to start
+# failing. In other words, this test is not an endorsement of the current
+# status quo.
+test_expect_success 'hooks are not executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		ORIG_PATH="$(pwd)" &&
+		export ORIG_PATH &&
+		for hook in prepare-commit-msg pre-commit post-commit post-rewrite commit-msg
+		do
+			write_script .git/hooks/$hook <<-\EOF || exit 1
+			touch "$ORIG_PATH/hooks.log
+			EOF
+		done &&
+
+		reword_with_message HEAD~ <<-EOF &&
+		second reworded
+		EOF
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+
+		test_path_is_missing hooks.log
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		! reword_with_message HEAD 2>err </dev/null &&
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_expect_success 'retains changes in the worktree and index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch a b &&
+		git add . &&
+		git commit -m "initial commit" &&
+		echo foo >a &&
+		echo bar >b &&
+		git add b &&
+		reword_with_message HEAD <<-EOF &&
+		message
+		EOF
+		cat >expect <<-\EOF &&
+		 M a
+		M  b
+		?? actual
+		?? expect
+		EOF
+		git status --porcelain >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.52.0.542.g9473a8513b.dirty
Elijah Newren· Jan 10, 2026, 01:26 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v9 0/7] Introduce git-history(1) command for easy history editing

On Fri, Jan 9, 2026 at 12:35 AM Patrick Steinhardt <ps@pks.im> wrote:
Show 7 quoted lines
> Changes in v9:
>   - Rename `struct replay_ref_updates` to `struct replay_result` to make
>     its semantics less focussed on ref updates, only.
>   - Clarify and fix return codes of git-replay(1) so that we return 1 on
>     conflict, 128 on an error and 0 on success.
>   - The usual small improvements to commit messages.
>   - Link to v8: https://lore.kernel.org/r/20260107-b4-pks-history-builtin-v8-0-18e9779e3a26@pks.im

I read through this series in detail; it's forming into shape nicely. I think there's still a number of small implementation things to fix up (see my comments on the individual patches), but the design looks good to me now. I suspect we'll be ready to merge before long.

Thanks for your patience and hard work on this!
SZEDER Gábor· Jan 10, 2026, 17:14 UTC · re: Elijah Newren · lore

Re: [PATCH v9 0/7] Introduce git-history(1) command for easy history editing

On Fri, Jan 09, 2026 at 05:26:48PM -0800, Elijah Newren wrote:
Show 13 quoted lines
> On Fri, Jan 9, 2026 at 12:35 AM Patrick Steinhardt <ps@pks.im> wrote:
> > Changes in v9:
> >   - Rename `struct replay_ref_updates` to `struct replay_result` to make
> >     its semantics less focussed on ref updates, only.
> >   - Clarify and fix return codes of git-replay(1) so that we return 1 on
> >     conflict, 128 on an error and 0 on success.
> >   - The usual small improvements to commit messages.
> >   - Link to v8: https://lore.kernel.org/r/20260107-b4-pks-history-builtin-v8-0-18e9779e3a26@pks.im
> 
> I read through this series in detail; it's forming into shape nicely.
> I think there's still a number of small implementation things to fix
> up (see my comments on the individual patches), but the design looks
> good to me now.  I suspect we'll be ready to merge before long.

I don't think this should be merged until we have clear and feasible plans for future subcommands that will cause conflicts, to avoid painting ourselves into a corner, like when we couldn't change the not well thought out details of 'git switch/restore' anymore.

Patrick Steinhardt· Jan 12, 2026, 13:03 UTC · re: SZEDER Gábor · lore

Re: [PATCH v9 0/7] Introduce git-history(1) command for easy history editing

On Sat, Jan 10, 2026 at 06:14:00PM +0100, SZEDER Gábor wrote:
Show 19 quoted lines
> On Fri, Jan 09, 2026 at 05:26:48PM -0800, Elijah Newren wrote:
> > On Fri, Jan 9, 2026 at 12:35 AM Patrick Steinhardt <ps@pks.im> wrote:
> > > Changes in v9:
> > >   - Rename `struct replay_ref_updates` to `struct replay_result` to make
> > >     its semantics less focussed on ref updates, only.
> > >   - Clarify and fix return codes of git-replay(1) so that we return 1 on
> > >     conflict, 128 on an error and 0 on success.
> > >   - The usual small improvements to commit messages.
> > >   - Link to v8: https://lore.kernel.org/r/20260107-b4-pks-history-builtin-v8-0-18e9779e3a26@pks.im
> > 
> > I read through this series in detail; it's forming into shape nicely.
> > I think there's still a number of small implementation things to fix
> > up (see my comments on the individual patches), but the design looks
> > good to me now.  I suspect we'll be ready to merge before long.
> 
> I don't think this should be merged until we have clear and feasible
> plans for future subcommands that will cause conflicts, to avoid
> painting ourselves into a corner, like when we couldn't change the not
> well thought out details of 'git switch/restore' anymore.

I agree that we need to think a bit about how we'll handle conflicts eventually. But I don't agree that this means we cannot already merge this series for subcommands that _cannot_ have conflicts as I don't really see how design decisions around handling conflicts would have an influence on subcommands that never have any conflicts in the first place.

The intent of this command is that it should allow users to easily handle certain common operations, without them having to worry about the underpinnings. So even if we eventually figure out that the replay subsystem is not a good fit for e.g. a potential `git history edit` command I don't think that this means that the whole status quo of `git history reword` would be impacted. And even if we eventuall _do_ decide to switch back to for example the sequencer subsystem I wouldn't expect that to impact the overall design of `git history reword` either.

So yes, I think we should have a discussion around how we can:
  - Handle conflicts in replay-based commands in general.
  - Support stacked branches workflows with conflicting commits.

From my point of view this leads into the direction of first-class conflicts in Git, and I think Elijah also leans into that direction. But it feels like a separate discussion to be had that is independent from the current proposed subcommands.

Also, with the new semantics that we have aligned on with dependent branches being rewritten I also think that we'll want to get the inital infrastructure out there to facilitate user feedback. Such feedback can also help us align on a better design for git-history(1) going forward.

Thanks!
Patrick
Elijah Newren· Jan 11, 2026, 05:58 UTC · re: Patrick Steinhardt · lore

[PATCH 0/2] Some

I've been waiting for the history series to consolidate before upstream some local GitHub changes to replay and adding some others of my own.

So, I figured sending some of my suggestions from my reviews as a "SQUASH ME" patch that Patrick could split up and squash into his series might be helpful.

Plus, I noticed a bug (pre-dating his series) with handling of detached heads in replay that has propagated with his history stuff, so I added a second patch to fix that bug in both replay and history..

Elijah Newren (2):
  SQUASH ME: Fixups
  history: fix detached HEAD handling
 Documentation/git-history.adoc |  2 +-
 builtin/history.c              | 31 ++++++++++++----------
 builtin/replay.c               | 10 ++------
 replay.c                       | 28 +++++++++++++-------
 replay.h                       | 21 +--------------
 t/t3451-history-reword.sh      | 47 ++++++++++++++++++++++++++++++++++
 6 files changed, 87 insertions(+), 52 deletions(-)
-- 
2.52.0.410.g227cdbe8e36
Elijah Newren· Jan 11, 2026, 05:58 UTC · re: Elijah Newren · lore

[PATCH v9 8/7] SQUASH ME: Fixups

This includes several fixes I highlighted in my review and needs to be split up and squashed into the relevant previous patches.

Signed-off-by: Elijah Newren <newren@gmail.com>
---
 Documentation/git-history.adoc |  2 +-
 builtin/history.c              | 16 ++++------------
 builtin/replay.c               | 10 ++--------
 replay.c                       | 10 ++++------
 replay.h                       | 21 +--------------------
 5 files changed, 12 insertions(+), 47 deletions(-)
Show changes to 5 files +12 −47

Documentation/git-history.adoc, builtin/history.c, builtin/replay.c, replay.c, replay.h

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 4eea317e5cb..457a1314a51 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -63,7 +63,7 @@ OPTIONS
 `--ref-action=(branches|head|print)`::
 	Control which references will be updated by the command, if any. With
 	`branches`, all local branches that point to commits which are
-	decendants of the original commit will be rewritten. With `head`, only
+	descendants of the original commit will be rewritten. With `head`, only
 	the current `HEAD` reference will be rewritten. With `print`, all
 	updates as they would be performed with `branches` are printed in a
 	format that can be consumed by linkgit:git-update-ref[1].
diff --git a/builtin/history.c b/builtin/history.c
index 28db6fc5b9e..60c5b5c5c76 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -178,9 +178,7 @@ static int handle_reference_updates(enum ref_action action,
 {
 	const struct name_decoration *decoration;
 	struct replay_revisions_options opts = { 0 };
-	struct replay_result result = {
-		.final_oid = rewritten->object.oid,
-	};
+	struct replay_result result = { 0 };
 	struct ref_transaction *transaction = NULL;
 	struct strvec args = STRVEC_INIT;
 	struct strbuf err = STRBUF_INIT;
@@ -233,7 +231,7 @@ static int handle_reference_updates(enum ref_action action,
 			goto out;
 		}
 
-		strvec_push(&args, oid_to_hex(&head->object.oid));
+		strvec_push(&args, "HEAD");
 	} else {
 		strvec_push(&args, "--branches");
 	}
@@ -244,13 +242,14 @@ static int handle_reference_updates(enum ref_action action,
 
 	opts.onto = oid_to_hex_r(hex, &rewritten->object.oid);
 
-	ret = replay_revisions(repo, &revs, &opts, &result);
+	ret = replay_revisions(&revs, &opts, &result);
 	if (ret)
 		goto out;
 
 	switch (action) {
 	case REF_ACTION_DEFAULT:
 	case REF_ACTION_BRANCHES:
+	case REF_ACTION_HEAD:
 		transaction = ref_store_transaction_begin(get_main_ref_store(repo), 0, &err);
 		if (!transaction) {
 			ret = error(_("failed to begin ref transaction: %s"), err.buf);
@@ -300,13 +299,6 @@ static int handle_reference_updates(enum ref_action action,
 		}
 
 		break;
-	case REF_ACTION_HEAD:
-		ret = refs_update_ref(get_main_ref_store(repo), reflog_msg, "HEAD",
-				      &result.final_oid, &head->object.oid, 0,
-				      UPDATE_REFS_MSG_ON_ERR);
-		if (ret)
-			goto out;
-		break;
 	case REF_ACTION_PRINT:
 		for (size_t i = 0; i < result.updates_nr; i++)
 			printf("update %s %s %s\n",
diff --git a/builtin/replay.c b/builtin/replay.c
index da8b7202f6a..f66e1a1fcd3 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -167,7 +167,7 @@ int cmd_replay(int argc,
 		revs.simplify_history = 0;
 	}
 
-	ret = replay_revisions(repo, &revs, &opts, &result);
+	ret = replay_revisions(&revs, &opts, &result);
 	if (ret)
 		goto cleanup;
 
@@ -220,11 +220,5 @@ int cmd_replay(int argc,
 	strbuf_release(&reflog_msg);
 	release_revisions(&revs);
 
-	if (ret) {
-		if (result.merge_conflict)
-			return 1;
-		return 128;
-	}
-
-	return 0;
+	return ret;
 }
diff --git a/replay.c b/replay.c
index 74e45ed27a2..ea300e3c36c 100644
--- a/replay.c
+++ b/replay.c
@@ -5,11 +5,11 @@
 #include "hex.h"
 #include "merge-ort.h"
 #include "object-name.h"
-#include "oidset.h"
 #include "parse-options.h"
 #include "refs.h"
 #include "replay.h"
 #include "revision.h"
+#include "strmap.h"
 #include "tree.h"
 
 static const char *short_commit_name(struct repository *repo,
@@ -256,7 +256,7 @@ static void replay_result_queue_update(struct replay_result *result,
 	result->updates_nr++;
 }
 
-int replay_revisions(struct repository *repo, struct rev_info *revs,
+int replay_revisions(struct rev_info *revs,
 		     struct replay_revisions_options *opts,
 		     struct replay_result *out)
 {
@@ -265,6 +265,7 @@ int replay_revisions(struct repository *repo, struct rev_info *revs,
 	struct commit *last_commit = NULL;
 	struct commit *commit;
 	struct commit *onto = NULL;
+	struct repository *repo = revs->repo;
 	struct merge_options merge_opt;
 	struct merge_result result = {
 		.clean = 1,
@@ -328,8 +329,7 @@ int replay_revisions(struct repository *repo, struct rev_info *revs,
 	}
 
 	if (!result.clean) {
-		out->merge_conflict = true;
-		ret = -1;
+		ret = 1;
 		goto out;
 	}
 
@@ -339,8 +339,6 @@ int replay_revisions(struct repository *repo, struct rev_info *revs,
 					   &onto->object.oid,
 					   &last_commit->object.oid);
 
-	out->final_oid = last_commit->object.oid;
-
 	ret = 0;
 
 out:
diff --git a/replay.h b/replay.h
index f8f9889112e..9b31c85dd9b 100644
--- a/replay.h
+++ b/replay.h
@@ -43,25 +43,6 @@ struct replay_result {
 		struct object_id new_oid;
 	} *updates;
 	size_t updates_nr, updates_alloc;
-
-	/* Set to true in case the replay failed with a merge conflict. */
-	bool merge_conflict;
-
-	/*
-	 * The final object ID that was rewritten. Note that this field has
-	 * somewhat special semantics and may or may not be what you want:
-	 *
-	 *   - If no commits were rewritten it will remain uninitialized.
-	 *
-	 *   - If a thicket of branches is rewritten it is undefined in which
-	 *     order those branches will be rewritten, and thus the final object
-	 *     ID may point to a different commit than you'd expect.
-	 *
-	 * That being said, this field can still be useful when you know that
-	 * you only replay a single strand of commits. In that case, the final
-	 * commit will point to the tip of the rewritten strand of commits.
-	 */
-	struct object_id final_oid;
 };
 
 void replay_result_release(struct replay_result *result);
@@ -73,7 +54,7 @@ void replay_result_release(struct replay_result *result);
  *
  * Returns 0 on success, a negative error code otherwise.
  */
-int replay_revisions(struct repository *repo, struct rev_info *revs,
+int replay_revisions(struct rev_info *revs,
 		     struct replay_revisions_options *opts,
 		     struct replay_result *out);
 
-- 
2.52.0.410.g227cdbe8e36
Elijah Newren· Jan 11, 2026, 05:58 UTC · re: Elijah Newren · lore

[PATCH v9 9/7] history: fix detached HEAD handling

The default behavior for history is to work on all local branches. When HEAD is detached, it should be treated like a local branch as well. The primary fix for this is just to make sure that in addition to passing --branches to the revision machinery that we pass HEAD as well.

However, that doesn't quite do the trick, because we also process the "decorations" that point at commits that we have processed, and we do this in two places -- in replay_revisions() as we replay commits, and in handle_reference_updates() when there are no commits to replay because the commit at the tip of the revision range was the one edited. In both cases, we previously keyed off of DECORATION_REF_LOCAL to make sure we only looked at local branches. Now, we need to also pay attention to DECORATION_REF_HEAD. However, in order to avoid doing two updates to the same branch (which will the ref transaction framework would throw an error on), we need to only pay attention to DECORATION_REF_HEAD when we have a detached HEAD.

Signed-off-by: Elijah Newren <newren@gmail.com>
---
 builtin/history.c         | 15 +++++++++++--
 replay.c                  | 18 ++++++++++++---
 t/t3451-history-reword.sh | 47 +++++++++++++++++++++++++++++++++++++++
 3 files changed, 75 insertions(+), 5 deletions(-)
Show changes to 3 files +75 −5

builtin/history.c, replay.c, t/t3451-history-reword.sh

diff --git a/builtin/history.c b/builtin/history.c
index 60c5b5c5c76..dd0df89c94e 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -183,10 +183,18 @@ static int handle_reference_updates(enum ref_action action,
 	struct strvec args = STRVEC_INIT;
 	struct strbuf err = STRBUF_INIT;
 	struct commit *head = NULL;
+	char *head_ref = NULL;
+	bool detached_head = false;
 	struct rev_info revs;
 	char hex[GIT_MAX_HEXSZ + 1];
 	int ret;
 
+	head_ref = refs_resolve_refdup(get_main_ref_store(repo), "HEAD",
+				       RESOLVE_REF_READING, NULL, NULL);
+	if (!strcmp(head_ref, "HEAD"))
+		detached_head = true;
+	free(head_ref);
+
 	repo_init_revisions(repo, &revs, NULL);
 	strvec_push(&args, "ignored");
 	strvec_push(&args, "--reverse");
@@ -234,6 +242,7 @@ static int handle_reference_updates(enum ref_action action,
 		strvec_push(&args, "HEAD");
 	} else {
 		strvec_push(&args, "--branches");
+		strvec_push(&args, "HEAD");
 	}
 
 	setup_revisions_from_strvec(&args, &revs, NULL);
@@ -278,9 +287,11 @@ static int handle_reference_updates(enum ref_action action,
 		     decoration;
 		     decoration = decoration->next)
 		{
-			if (decoration->type != DECORATION_REF_LOCAL)
+			if ((decoration->type != DECORATION_REF_HEAD ||
+			     (action != REF_ACTION_HEAD && !detached_head)) &&
+			    (decoration->type != DECORATION_REF_LOCAL ||
+			     action == REF_ACTION_HEAD))
 				continue;
-
 			ret = ref_transaction_update(transaction,
 						     decoration->name,
 						     &rewritten->object.oid,
diff --git a/replay.c b/replay.c
index ea300e3c36c..7c98b1d1044 100644
--- a/replay.c
+++ b/replay.c
@@ -151,11 +151,20 @@ static void get_ref_information(struct repository *repo,
 static void set_up_replay_mode(struct repository *repo,
 			       struct rev_cmdline_info *cmd_info,
 			       const char *onto_name,
+			       bool *detached_head,
 			       char **advance_name,
 			       struct commit **onto,
 			       struct strset **update_refs)
 {
 	struct ref_info rinfo;
+	char *head_ref;
+
+	*detached_head = false;
+	head_ref = refs_resolve_refdup(get_main_ref_store(repo), "HEAD",
+				       RESOLVE_REF_READING, NULL, NULL);
+	if (!strcmp(head_ref, "HEAD"))
+		*detached_head = true;
+	free(head_ref);
 
 	get_ref_information(repo, cmd_info, &rinfo);
 	if (!rinfo.positive_refexprs)
@@ -271,11 +280,12 @@ int replay_revisions(struct rev_info *revs,
 		.clean = 1,
 	};
 	char *advance;
+	bool detached_head;
 	int ret;
 
 	advance = xstrdup_or_null(opts->advance);
-	set_up_replay_mode(repo, &revs->cmdline, opts->onto, &advance,
-			   &onto, &update_refs);
+	set_up_replay_mode(repo, &revs->cmdline, opts->onto,
+			   &detached_head, &advance, &onto, &update_refs);
 
 	/* FIXME: Should allow replaying commits with the first as a root commit */
 
@@ -317,7 +327,9 @@ int replay_revisions(struct rev_info *revs,
 		if (!decoration)
 			continue;
 		while (decoration) {
-			if (decoration->type == DECORATION_REF_LOCAL &&
+			if ((decoration->type == DECORATION_REF_LOCAL ||
+			     (decoration->type == DECORATION_REF_HEAD &&
+			      detached_head)) &&
 			    (opts->contained || strset_contains(update_refs,
 								decoration->name))) {
 				replay_result_queue_update(out, decoration->name,
diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
index cd5883051d6..7ddbab8e676 100755
--- a/t/t3451-history-reword.sh
+++ b/t/t3451-history-reword.sh
@@ -77,6 +77,53 @@ test_expect_success 'can reword commit in the middle' '
 	)
 '
 
+test_expect_success 'can reword commit in the middle even on detached head' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third_on_main &&
+		git checkout --detach HEAD^ &&
+		test_commit third_on_head &&
+
+		reword_with_message HEAD~ <<-EOF &&
+		second reworded
+		EOF
+
+		expect_log HEAD --branches --graph <<-\EOF
+		* third_on_head
+		| * third_on_main
+		|/  
+		* second reworded
+		* first
+		EOF
+	)
+'
+
+test_expect_success 'can reword the detached head' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		git checkout --detach HEAD &&
+		test_commit third &&
+
+		reword_with_message HEAD <<-EOF &&
+		third reworded
+		EOF
+
+		expect_log <<-\EOF
+		third reworded
+		second
+		first
+		EOF
+	)
+'
+
 test_expect_success 'can reword root commit' '
 	test_when_finished "rm -rf repo" &&
 	git init repo &&
-- 
2.52.0.410.g227cdbe8e36
Elijah Newren· Jan 11, 2026, 06:15 UTC · re: Elijah Newren · lore

Re: [PATCH 0/2] Some

The subject should have been "Some fixes for ps/history and replay".  Oops.
On Sat, Jan 10, 2026 at 9:59 PM Elijah Newren <newren@gmail.com> wrote:
Show 28 quoted lines
>
> I've been waiting for the history series to consolidate before
> upstream some local GitHub changes to replay and adding some others of
> my own.
>
> So, I figured sending some of my suggestions from my reviews as a
> "SQUASH ME" patch that Patrick could split up and squash into his series
> might be helpful.
>
> Plus, I noticed a bug (pre-dating his series) with handling of detached
> heads in replay that has propagated with his history stuff, so I added a
> second patch to fix that bug in both replay and history..
>
> Elijah Newren (2):
>   SQUASH ME: Fixups
>   history: fix detached HEAD handling
>
>  Documentation/git-history.adoc |  2 +-
>  builtin/history.c              | 31 ++++++++++++----------
>  builtin/replay.c               | 10 ++------
>  replay.c                       | 28 +++++++++++++-------
>  replay.h                       | 21 +--------------
>  t/t3451-history-reword.sh      | 47 ++++++++++++++++++++++++++++++++++
>  6 files changed, 87 insertions(+), 52 deletions(-)
>
> --
> 2.52.0.410.g227cdbe8e36
>
Patrick Steinhardt· Jan 12, 2026, 13:02 UTC · re: Elijah Newren · lore

Re: [PATCH 0/2] Some

On Sat, Jan 10, 2026 at 09:58:57PM -0800, Elijah Newren wrote:
Show 11 quoted lines
> I've been waiting for the history series to consolidate before
> upstream some local GitHub changes to replay and adding some others of
> my own.
> 
> So, I figured sending some of my suggestions from my reviews as a
> "SQUASH ME" patch that Patrick could split up and squash into his series
> might be helpful.
> 
> Plus, I noticed a bug (pre-dating his series) with handling of detached
> heads in replay that has propagated with his history stuff, so I added a
> second patch to fix that bug in both replay and history..
Perfect, I'll work these patches into my series. Thanks!
Patrick
Patrick Steinhardt· Jan 13, 2026, 09:54 UTC · re: Patrick Steinhardt · lore

[PATCH v11 0/8] Introduce git-history(1) command for easy history editing

Hi,

over recent months I've been playing around with Jujutsu quite frequently. While I still prefer using Git, there's been a couple features in it that I really like and that I'd like to have in Git, as well.

A copule of these features relate to history editing. Most importantly, I really dig the following commands:

  - jj-abandon(1) to drop a specific commit from your history.
  - jj-absorb(1) to take some changes and automatically apply them to
    commits in your history that last modified the respective hunks.
  - jj-split(1) to split a commit into two.
  - jj-new(1) to insert a new commit after or before a specific other
    commit.

Not all of these commands can be ported directly into Git. jj-new(1) for example doesn't really make a ton of sense for us, I'd claim. But some of these commands _do_ make sense.

This patch series is a starting point for such a command. For now, it only implements rewording of commits. I'll continue introducing new commands once this patch series has been merged though.

Changes in v11:
  - Fix overly clever `BUG()` condition.
  - Drop the `struct replay_result::merge_conflict` field.
  - Return a positive value from `replay_revisions()` in case there was
    a conflict.
  - Improve a commit message.
  - Fix check for whether `setup_revisions_from_strvec()` was
    successful.
  - Link to v10: https://lore.kernel.org/r/20260112-b4-pks-history-builtin-v10-0-e3c6aa5b4cec@pks.im
Changes in v10:
  - Split out changes that make `replay_revisions()` handle empty
    revision ranges properly.
  - Drop redundant `struct repository` parameter in
    `replay_revisions()`.
  - Move around `die_for_incompatible_opt2()`.
  - Fix up some of the headers in "replay.c".
  - Split out the changes that make `replay_revisions()` handle empty
    revision ranges properly.
  - Create a separate commit that fixes updating detached HEAD.
  - Strip "encoding" header as we're reencoding commit messages.
  - Merge the logic to update refs in in `REF_ACTION_HEAD` and
    `REF_ACTION_BRANCHES` modes.
  - Link to v9: https://lore.kernel.org/r/20260109-b4-pks-history-builtin-v9-0-8766101814c6@pks.im
Changes in v9:
  - Rename `struct replay_ref_updates` to `struct replay_result` to make
    its semantics less focussed on ref updates, only.
  - Clarify and fix return codes of git-replay(1) so that we return 1 on
    conflict, 128 on an error and 0 on success.
  - The usual small improvements to commit messages.
  - Link to v8: https://lore.kernel.org/r/20260107-b4-pks-history-builtin-v8-0-18e9779e3a26@pks.im
Changes in v8:
  - The series is now built on top of e0bfec3dfc (The 15th batch,
    2026-01-06) with kh/replay-invalid-onto-advance at 56b77a687e
    (t3650: add more regression tests for failure conditions,
    2026-01-05) merged into it. The latter is expected to hit 'next'
    soonish.
  - Another biggish revamp to build almost completely on top of the
    git-replay(1) logic.
  - We now advance all branches descending from the rewritten commit by
    default.
  - I've dropped the patches introducing `git history split` and will
    send this as a follow-up patch series once this once has been
    merged. This was done to focus attention on the underlying mechanics
    as much as possible (and to keep my own sanity with the frequent
    revamps).
  - Link to v7: https://lore.kernel.org/r/20251203-b4-pks-history-builtin-v7-0-9e9f849bfd0e@pks.im
Changes in v7:
  - I've rebased the patch series on top of f0ef5b6d9b (The fifth batch,
    2025-11-30) with pw/replay-exclude-gpgsig-fix at 9f3a115087 (replay:
    do not copy "gpgsign-sha256" header, 2025-11-26) merged into it to
    fix some conflicts.
  - I refactored a bunch of code to be shared between split and reword.
  - I am now using "--ancestry-path" and "--reverse" to enumerate
    commits.
  - `git history split` now asks for commit messages for both commits.
  - `git history split` no longer allows the user to edit hunks so that
    conflicts become impossible. This may be reintroduced at a later
    point in time.
  - A bunch of test improvements.
  - Several commit message and documentation improvements.
  - Link to v6: https://lore.kernel.org/r/20251027-b4-pks-history-builtin-v6-0-407dd3f57ad3@pks.im
Changes in v6:
  - I've rebased the patch series again to pull in the latest updates
    from sa/replay-atomic-ref-updates and fix conflicts. It is now based
    on 4e98b730f1 (The twenty-fourth batch, 2025-10-24) with ab661bb1bb
    (replay: add replay.refAction config option, 2025-10-23) merged into
    it.
  - I've dropped the "-m" options for now, so commit messages are always
    asked for via the editor. These can be introduced in a subsequent
    patch series once discussion around them has settled.
  - We don't use the merge machinery anymore to pick the commits.
  - Drop the commit to parse commits in the replay machinery. It didn't
    seem to be necessary in v5 anymore, and now that we don't use the
    merge machinery at all we don't ever take that code path in the
    first place.
  - Link to v5:
    https://lore.kernel.org/r/20251021-b4-pks-history-builtin-v5-0-78d23f578fe6@pks.im
Changes in v5:
  - I've changed the patch series to be based on top of 133d151831 (The
    twenty-first batch, 2025-10-20) with sa/replay-atomic-ref-updates at
    a1c22e627e (SQAUASH??? t0450 band-aid, 2025-10-14) merged into it.
    This is one the one hand to fix a conflict, but also to get some of
    the CI updates to make GitLab CI work again.
  - Some slight commit message improvements.
  - Deduplicate subcommand usage strings by using defines.
  - Fix the desendancy checks to properly verify that HEAD is a
    descendant of the commit to be rewritten. Also add some tests for
    this.
  - Fix the hint that mentions that lines starting with the comment
    character will be tripped after having written the commit message.
  - Move an include to the correct commit.
  - Link to v4: https://lore.kernel.org/r/20251001-b4-pks-history-builtin-v4-0-8e61ddb86317@pks.im
Changes in v4:
  - I've rebuilt the patch series. It is now based on 821f583da6 (The
    thirteenth batcn, 2025-09-29) with sa/replay-atomic-ref-updates
    at 665c66a743 (replay: make atomic ref updates the default behavior,
    2025-09-27) merged into it. This should fix all conflicts with seen.
  - I've reworked this patch series to use the same infra as
    git-replay(1), as discussed during the Contributor's Summit.
  - I've slimmed down the patch series to only tackle those commands
    that cannot result in a conflict to keep it simple. I also learned
    that Elijah has been working on a "git replay edit" command, so I
    dropped that command so that we can instead use his version.
  - During the Contributor's Summit we have agreed that for now, we
    won't care about hook execution just yet. This may be backfilled at
    a later point in time.
  - I dropped "commit.verbose" handling for now, as my understanding of
    it was wrong at first. This is something we should backfill.
  - Link to v3: https://lore.kernel.org/r/20250904-b4-pks-history-builtin-v3-0-509053514755@pks.im
Changes in v3:
  - Add logic to drive the "post-rewrite" hook and add tests to verify
    that all hooks are executed as expected.
  - Deduplicate logic to turn a replay action into a todo command.
  - Move the addition of tests for the top-level git-history(1) command
    to the correct commit.
  - Some smaller commit message fixes.
  - Honor "commit.verbose".
  - Fix copy-paste error with an error message.
  - Link to v2: https://lore.kernel.org/r/20250824-b4-pks-history-builtin-v2-0-964ac12f65bd@pks.im
Changes in v2:
  - Add a new "reword" subcommand.
  - List git-history(1) in "command-list.txt".
  - Add some missing error handling.
  - Simplify calling convention of `apply_commits()` to handle root
    commits internally instead of requiring every caller to do so.
  - Add tests to verify that git-history(1) refuses to work with changes
    in the worktree or index.
  - Mark git-history(1) as experimental.
  - Introduce commands to manage interrupted history edits.
  - A bunch of improvements to the manpage.
  - Link to v1: https://lore.kernel.org/r/20250819-b4-pks-history-builtin-v1-0-9b77c32688fe@pks.im
Thanks!
Patrick
---
Patrick Steinhardt (8):
      builtin/replay: extract core logic to replay revisions
      builtin/replay: move core logic into "libgit.a"
      replay: small set of cleanups
      replay: support empty commit ranges
      replay: support updating detached HEAD
      wt-status: provide function to expose status for trees
      builtin: add new "history" command
      builtin/history: implement "reword" subcommand
 .gitignore                     |   1 +
 Documentation/git-history.adoc |  73 +++++++
 Documentation/meson.build      |   1 +
 Makefile                       |   2 +
 builtin.h                      |   1 +
 builtin/history.c              | 427 +++++++++++++++++++++++++++++++++++++++++
 builtin/replay.c               | 373 +++--------------------------------
 command-list.txt               |   1 +
 git.c                          |   1 +
 meson.build                    |   2 +
 replay.c                       | 371 +++++++++++++++++++++++++++++++++++
 replay.h                       |  61 ++++++
 t/meson.build                  |   2 +
 t/t3450-history.sh             |  17 ++
 t/t3451-history-reword.sh      | 391 +++++++++++++++++++++++++++++++++++++
 t/t3650-replay-basics.sh       |   9 +
 wt-status.c                    |  24 +++
 wt-status.h                    |   9 +
 18 files changed, 1425 insertions(+), 341 deletions(-)
Range-diff versus v10:
1:  f02aa5abd1 ! 1:  bdefcad12d builtin/replay: extract core logic to replay revisions
    @@ builtin/replay.c: static void set_up_replay_mode(struct repository *repo,
      
     -	die_for_incompatible_opt2(!!onto_name, "--onto",
     -				  !!*advance_name, "--advance");
    -+	if (!(!!onto_name ^ !!*advance_name))
    -+		BUG("expected either onto_name or *advance_name in this function");
    ++	if (!onto_name == !*advance_name)
    ++		BUG("one and only one of onto_name and *advance_name must be given");
     +
      	if (onto_name) {
      		*onto = peel_committish(repo, onto_name, "--onto");
    @@ builtin/replay.c: static struct commit *pick_regular_commit(struct repository *r
     +		struct object_id new_oid;
     +	} *updates;
     +	size_t updates_nr, updates_alloc;
    -+
    -+	bool merge_conflict;
     +};
     +
     +static void replay_result_release(struct replay_result *result)
    @@ builtin/replay.c: static struct commit *pick_regular_commit(struct repository *r
     +	}
     +
     +	if (!result.clean) {
    -+		out->merge_conflict = true;
    -+		ret = -1;
    ++		ret = 1;
     +		goto out;
     +	}
     +
    @@ builtin/replay.c: int cmd_replay(int argc,
     -		free(update_refs);
     -	}
     -	ret = result.clean;
    --
    ++	ret = 0;
    + 
      cleanup:
      	if (transaction)
      		ref_transaction_free(transaction);
    @@ builtin/replay.c: int cmd_replay(int argc,
      	release_revisions(&revs);
     -	free(advance_name);
      
    --	/* Return */
    --	if (ret < 0)
    --		exit(128);
    + 	/* Return */
    + 	if (ret < 0)
    + 		exit(128);
     -	return ret ? 0 : 1;
    -+	if (ret) {
    -+		if (result.merge_conflict)
    -+			return 1;
    -+		return 128;
    -+	}
    -+
    -+	return 0;
    ++	return ret;
      }
2:  078d713391 ! 2:  80eaf3f9fd builtin/replay: move core logic into "libgit.a"
    @@ builtin/replay.c
     -	if (!rinfo.positive_refexprs)
     -		die(_("need some commits to replay"));
     -
    --	if (!(!!onto_name ^ !!*advance_name))
    --		BUG("expected either onto_name or *advance_name in this function");
    +-	if (!onto_name == !*advance_name)
    +-		BUG("one and only one of onto_name and *advance_name must be given");
     -
     -	if (onto_name) {
     -		*onto = peel_committish(repo, onto_name, "--onto");
    @@ builtin/replay.c
     -		struct object_id new_oid;
     -	} *updates;
     -	size_t updates_nr, updates_alloc;
    --
    --	bool merge_conflict;
     -};
     -
     -static void replay_result_release(struct replay_result *result)
    @@ builtin/replay.c
     -	}
     -
     -	if (!result.clean) {
    --		out->merge_conflict = true;
    --		ret = -1;
    +-		ret = 1;
     -		goto out;
     -	}
     -
    @@ replay.c (new)
     +	if (!rinfo.positive_refexprs)
     +		die(_("need some commits to replay"));
     +
    -+	if (!(!!onto_name ^ !!*advance_name))
    -+		BUG("expected either onto_name or *advance_name in this function");
    ++	if (!onto_name == !*advance_name)
    ++		BUG("one and only one of onto_name and *advance_name must be given");
     +
     +	if (onto_name) {
     +		*onto = peel_committish(repo, onto_name, "--onto");
    @@ replay.c (new)
     +	}
     +
     +	if (!result.clean) {
    -+		out->merge_conflict = true;
    -+		ret = -1;
    ++		ret = 1;
     +		goto out;
     +	}
     +
    @@ replay.h (new)
     +		struct object_id new_oid;
     +	} *updates;
     +	size_t updates_nr, updates_alloc;
    -+
    -+	/* Set to true in case the replay failed with a merge conflict. */
    -+	bool merge_conflict;
     +};
     +
     +void replay_result_release(struct replay_result *result);
    @@ replay.h (new)
     + * index and references untouched. Reference updates caused by the replay will
     + * be recorded in the `updates` out pointer.
     + *
    -+ * Returns 0 on success, a negative error code otherwise.
    ++ * Returns 0 on success, 1 on conflict and a negative error code otherwise.
     + */
     +int replay_revisions(struct rev_info *revs,
     +		     struct replay_revisions_options *opts,
3:  44f96f6b93 = 3:  bd6783570b replay: small set of cleanups
4:  0c861b1a37 ! 4:  e927f66224 replay: support empty commit ranges
    @@ Commit message
     
         In a subsequent commit we're about to introduce a new user of the replay
         subsystem. With that new user, the range of commits that we'll want to
    -    replay will be identified implicitly via "HEAD". With such implicit
    -    ranges it becomes likely that the range of revisions that we're asked to
    -    replay becomes empty. This case does not make sense with git-replay(1),
    -    but with the new command it will.
    +    replay will be identified implicitly via a single commit, and will
    +    include all descendants of that commit to any branch. If that commit has
    +    no descendants (because it's the tip of some branch), then the range of
    +    revisions that we're asked to replay becomes empty. This case does not
    +    make sense with git-replay(1), but with the new command it will.
     
         This case is not currently supported by `replay_revisions()` though
         because we zero-initialize `struct merge_result`. This includes its
5:  03a64727ab = 5:  c8ed32bfae replay: support updating detached HEAD
6:  453a5769a6 = 6:  79f8bf7613 wt-status: provide function to expose status for trees
7:  f73ad9a468 = 7:  d0d56cc4fc builtin: add new "history" command
8:  e7af330353 ! 8:  464f5467ab builtin/history: implement "reword" subcommand
    @@ builtin/history.c
     +	}
     +
     +	setup_revisions_from_strvec(&args, &revs, NULL);
    -+	if (revs.nr)
    -+		BUG("revisions were set up with invalid argument '%s'", args.v[0]);
    ++	if (args.nr != 1)
    ++		BUG("revisions were set up with invalid argument");
     +
     +	opts.onto = oid_to_hex_r(hex, &rewritten->object.oid);
     +

--- base-commit: 02bdc12118c43e0a65258eb742492965859bffb6 change-id: 20250819-b4-pks-history-builtin-83398f9a05f0

Patrick Steinhardt· Jan 13, 2026, 09:54 UTC · re: Patrick Steinhardt · lore

[PATCH v11 1/8] builtin/replay: extract core logic to replay revisions

We're about to move the core logic used to replay revisions onto a new base into the "libgit.a" library. Prepare for this by pulling out the logic into a new function `replay_revisions()` that:

  1. Takes a set of revisions to replay and some options that tell it how
     it ought to replay the revisions.
  2. Replays the commits.
  3. Records any reference updates that would be caused by replaying the
     commits in a structure that is owned by the caller.

The logic itself will be moved into a separate file in the next commit. This change is not expected to cause user-visible change in behaviour.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/replay.c | 269 +++++++++++++++++++++++++++++++++----------------------
 1 file changed, 162 insertions(+), 107 deletions(-)
Show changes to builtin/replay.c +162 −107
diff --git a/builtin/replay.c b/builtin/replay.c
index 1960bbbee8..daf907668c 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -177,8 +177,9 @@ static void set_up_replay_mode(struct repository *repo,
 	if (!rinfo.positive_refexprs)
 		die(_("need some commits to replay"));
 
-	die_for_incompatible_opt2(!!onto_name, "--onto",
-				  !!*advance_name, "--advance");
+	if (!onto_name == !*advance_name)
+		BUG("one and only one of onto_name and *advance_name must be given");
+
 	if (onto_name) {
 		*onto = peel_committish(repo, onto_name, "--onto");
 		if (rinfo.positive_refexprs <
@@ -253,6 +254,134 @@ static struct commit *pick_regular_commit(struct repository *repo,
 	return create_commit(repo, result->tree, pickme, replayed_base);
 }
 
+struct replay_revisions_options {
+	const char *advance;
+	const char *onto;
+	int contained;
+};
+
+struct replay_result {
+	struct replay_ref_update {
+		char *refname;
+		struct object_id old_oid;
+		struct object_id new_oid;
+	} *updates;
+	size_t updates_nr, updates_alloc;
+};
+
+static void replay_result_release(struct replay_result *result)
+{
+	for (size_t i = 0; i < result->updates_nr; i++)
+		free(result->updates[i].refname);
+	free(result->updates);
+}
+
+static void replay_result_queue_update(struct replay_result *result,
+				       const char *refname,
+				       const struct object_id *old_oid,
+				       const struct object_id *new_oid)
+{
+	ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
+	result->updates[result->updates_nr].refname = xstrdup(refname);
+	result->updates[result->updates_nr].old_oid = *old_oid;
+	result->updates[result->updates_nr].new_oid = *new_oid;
+	result->updates_nr++;
+}
+
+static int replay_revisions(struct rev_info *revs,
+			    struct replay_revisions_options *opts,
+			    struct replay_result *out)
+{
+	kh_oid_map_t *replayed_commits = NULL;
+	struct strset *update_refs = NULL;
+	struct commit *last_commit = NULL;
+	struct commit *commit;
+	struct commit *onto = NULL;
+	struct merge_options merge_opt;
+	struct merge_result result;
+	char *advance;
+	int ret;
+
+	advance = xstrdup_or_null(opts->advance);
+	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto, &advance,
+			   &onto, &update_refs);
+
+	/* FIXME: Should allow replaying commits with the first as a root commit */
+
+	if (prepare_revision_walk(revs) < 0) {
+		ret = error(_("error preparing revisions"));
+		goto out;
+	}
+
+	init_basic_merge_options(&merge_opt, revs->repo);
+	memset(&result, 0, sizeof(result));
+	merge_opt.show_rename_progress = 0;
+	last_commit = onto;
+	replayed_commits = kh_init_oid_map();
+	while ((commit = get_revision(revs))) {
+		const struct name_decoration *decoration;
+		khint_t pos;
+		int hr;
+
+		if (!commit->parents)
+			die(_("replaying down from root commit is not supported yet!"));
+		if (commit->parents->next)
+			die(_("replaying merge commits is not supported yet!"));
+
+		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
+						  onto, &merge_opt, &result);
+		if (!last_commit)
+			break;
+
+		/* Record commit -> last_commit mapping */
+		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
+		if (hr == 0)
+			BUG("Duplicate rewritten commit: %s\n",
+			    oid_to_hex(&commit->object.oid));
+		kh_value(replayed_commits, pos) = last_commit;
+
+		/* Update any necessary branches */
+		if (advance)
+			continue;
+		decoration = get_name_decoration(&commit->object);
+		if (!decoration)
+			continue;
+		while (decoration) {
+			if (decoration->type == DECORATION_REF_LOCAL &&
+			    (opts->contained || strset_contains(update_refs,
+								decoration->name))) {
+				replay_result_queue_update(out, decoration->name,
+							   &commit->object.oid,
+							   &last_commit->object.oid);
+			}
+			decoration = decoration->next;
+		}
+	}
+
+	if (!result.clean) {
+		ret = 1;
+		goto out;
+	}
+
+	/* In --advance mode, advance the target ref */
+	if (advance)
+		replay_result_queue_update(out, advance,
+					   &onto->object.oid,
+					   &last_commit->object.oid);
+
+	ret = 0;
+
+out:
+	if (update_refs) {
+		strset_clear(update_refs);
+		free(update_refs);
+	}
+	kh_destroy_oid_map(replayed_commits);
+	merge_finalize(&merge_opt, &result);
+	free(advance);
+	return ret;
+}
+
 static enum ref_action_mode parse_ref_action_mode(const char *ref_action, const char *source)
 {
 	if (!ref_action || !strcmp(ref_action, "update"))
@@ -306,21 +435,11 @@ int cmd_replay(int argc,
 	       const char *prefix,
 	       struct repository *repo)
 {
-	const char *advance_name_opt = NULL;
-	char *advance_name = NULL;
-	struct commit *onto = NULL;
-	const char *onto_name = NULL;
-	int contained = 0;
+	struct replay_revisions_options opts = { 0 };
+	struct replay_result result = { 0 };
 	const char *ref_action = NULL;
 	enum ref_action_mode ref_mode;
-
 	struct rev_info revs;
-	struct commit *last_commit = NULL;
-	struct commit *commit;
-	struct merge_options merge_opt;
-	struct merge_result result;
-	struct strset *update_refs = NULL;
-	kh_oid_map_t *replayed_commits;
 	struct ref_transaction *transaction = NULL;
 	struct strbuf transaction_err = STRBUF_INIT;
 	struct strbuf reflog_msg = STRBUF_INIT;
@@ -333,13 +452,13 @@ int cmd_replay(int argc,
 		NULL
 	};
 	struct option replay_options[] = {
-		OPT_STRING(0, "advance", &advance_name_opt,
+		OPT_STRING(0, "advance", &opts.advance,
 			   N_("branch"),
 			   N_("make replay advance given branch")),
-		OPT_STRING(0, "onto", &onto_name,
+		OPT_STRING(0, "onto", &opts.onto,
 			   N_("revision"),
 			   N_("replay onto given commit")),
-		OPT_BOOL(0, "contained", &contained,
+		OPT_BOOL(0, "contained", &opts.contained,
 			 N_("update all branches that point at commits in <revision-range>")),
 		OPT_STRING(0, "ref-action", &ref_action,
 			   N_("mode"),
@@ -350,19 +469,19 @@ int cmd_replay(int argc,
 	argc = parse_options(argc, argv, prefix, replay_options, replay_usage,
 			     PARSE_OPT_KEEP_ARGV0 | PARSE_OPT_KEEP_UNKNOWN_OPT);
 
-	if (!onto_name && !advance_name_opt) {
+	if (!opts.onto && !opts.advance) {
 		error(_("option --onto or --advance is mandatory"));
 		usage_with_options(replay_usage, replay_options);
 	}
 
-	die_for_incompatible_opt2(!!advance_name_opt, "--advance",
-				  contained, "--contained");
+	die_for_incompatible_opt2(!!opts.advance, "--advance",
+				  opts.contained, "--contained");
+	die_for_incompatible_opt2(!!opts.advance, "--advance",
+				  !!opts.onto, "--onto");
 
 	/* Parse ref action mode from command line or config */
 	ref_mode = get_ref_action_mode(repo, ref_action);
 
-	advance_name = xstrdup_or_null(advance_name_opt);
-
 	repo_init_revisions(repo, &revs, prefix);
 
 	/*
@@ -414,18 +533,19 @@ int cmd_replay(int argc,
 		revs.simplify_history = 0;
 	}
 
-	set_up_replay_mode(repo, &revs.cmdline,
-			   onto_name, &advance_name,
-			   &onto, &update_refs);
-
-	/* FIXME: Should allow replaying commits with the first as a root commit */
+	ret = replay_revisions(&revs, &opts, &result);
+	if (ret)
+		goto cleanup;
 
 	/* Build reflog message */
-	if (advance_name_opt)
-		strbuf_addf(&reflog_msg, "replay --advance %s", advance_name_opt);
-	else
-		strbuf_addf(&reflog_msg, "replay --onto %s",
-			    oid_to_hex(&onto->object.oid));
+	if (opts.advance) {
+		strbuf_addf(&reflog_msg, "replay --advance %s", opts.advance);
+	} else {
+		struct object_id oid;
+		if (repo_get_oid_committish(repo, opts.onto, &oid))
+			BUG("--onto commit should have been resolved beforehand already");
+		strbuf_addf(&reflog_msg, "replay --onto %s", oid_to_hex(&oid));
+	}
 
 	/* Initialize ref transaction if using update mode */
 	if (ref_mode == REF_ACTION_UPDATE) {
@@ -438,78 +558,19 @@ int cmd_replay(int argc,
 		}
 	}
 
-	if (prepare_revision_walk(&revs) < 0) {
-		ret = error(_("error preparing revisions"));
-		goto cleanup;
-	}
-
-	init_basic_merge_options(&merge_opt, repo);
-	memset(&result, 0, sizeof(result));
-	merge_opt.show_rename_progress = 0;
-	last_commit = onto;
-	replayed_commits = kh_init_oid_map();
-	while ((commit = get_revision(&revs))) {
-		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
-
-		if (!commit->parents)
-			die(_("replaying down from root commit is not supported yet!"));
-		if (commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
-
-		last_commit = pick_regular_commit(repo, commit, replayed_commits,
-						  onto, &merge_opt, &result);
-		if (!last_commit)
-			break;
-
-		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
-
-		/* Update any necessary branches */
-		if (advance_name)
-			continue;
-		decoration = get_name_decoration(&commit->object);
-		if (!decoration)
-			continue;
-		while (decoration) {
-			if (decoration->type == DECORATION_REF_LOCAL &&
-			    (contained || strset_contains(update_refs,
-							  decoration->name))) {
-				if (handle_ref_update(ref_mode, transaction,
-						      decoration->name,
-						      &last_commit->object.oid,
-						      &commit->object.oid,
-						      reflog_msg.buf,
-						      &transaction_err) < 0) {
-					ret = error(_("failed to update ref '%s': %s"),
-						    decoration->name, transaction_err.buf);
-					goto cleanup;
-				}
-			}
-			decoration = decoration->next;
-		}
-	}
-
-	/* In --advance mode, advance the target ref */
-	if (result.clean == 1 && advance_name) {
-		if (handle_ref_update(ref_mode, transaction, advance_name,
-				      &last_commit->object.oid,
-				      &onto->object.oid,
-				      reflog_msg.buf,
-				      &transaction_err) < 0) {
+	for (size_t i = 0; i < result.updates_nr; i++) {
+		ret = handle_ref_update(ref_mode, transaction, result.updates[i].refname,
+					&result.updates[i].new_oid, &result.updates[i].old_oid,
+					reflog_msg.buf, &transaction_err);
+		if (ret) {
 			ret = error(_("failed to update ref '%s': %s"),
-				    advance_name, transaction_err.buf);
+				    result.updates[i].refname, transaction_err.buf);
 			goto cleanup;
 		}
 	}
 
 	/* Commit the ref transaction if we have one */
-	if (transaction && result.clean == 1) {
+	if (transaction) {
 		if (ref_transaction_commit(transaction, &transaction_err)) {
 			ret = error(_("failed to commit ref transaction: %s"),
 				    transaction_err.buf);
@@ -517,24 +578,18 @@ int cmd_replay(int argc,
 		}
 	}
 
-	merge_finalize(&merge_opt, &result);
-	kh_destroy_oid_map(replayed_commits);
-	if (update_refs) {
-		strset_clear(update_refs);
-		free(update_refs);
-	}
-	ret = result.clean;
+	ret = 0;
 
 cleanup:
 	if (transaction)
 		ref_transaction_free(transaction);
+	replay_result_release(&result);
 	strbuf_release(&transaction_err);
 	strbuf_release(&reflog_msg);
 	release_revisions(&revs);
-	free(advance_name);
 
 	/* Return */
 	if (ret < 0)
 		exit(128);
-	return ret ? 0 : 1;
+	return ret;
 }
-- 
2.52.0.590.g1f87b77810.dirty
Patrick Steinhardt· Jan 13, 2026, 09:54 UTC · re: Patrick Steinhardt · lore

[PATCH v11 2/8] builtin/replay: move core logic into "libgit.a"

Move the core logic used to replay commits into "libgit.a" so that it can be easily reused by other commands. It will be used in a subsequent commit where we're about to introduce a new git-history(1) command.

Note that with this change we have no sign-comparison warnings anymore, and neither do we depend on `the_repository`.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Makefile         |   1 +
 builtin/replay.c | 366 +------------------------------------------------------
 meson.build      |   1 +
 replay.c         | 354 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 replay.h         |  61 ++++++++++
 5 files changed, 418 insertions(+), 365 deletions(-)
Show changes to 5 files +418 −365

Makefile, builtin/replay.c, meson.build, replay.c, replay.h

diff --git a/Makefile b/Makefile
index b7eba509c6..1c64a5d2ae 100644
--- a/Makefile
+++ b/Makefile
@@ -1285,6 +1285,7 @@ LIB_OBJS += repack-geometry.o
 LIB_OBJS += repack-midx.o
 LIB_OBJS += repack-promisor.o
 LIB_OBJS += replace-object.o
+LIB_OBJS += replay.o
 LIB_OBJS += repo-settings.o
 LIB_OBJS += repository.o
 LIB_OBJS += rerere.o
diff --git a/builtin/replay.c b/builtin/replay.c
index daf907668c..2cdde830a8 100644
--- a/builtin/replay.c
+++ b/builtin/replay.c
@@ -2,386 +2,22 @@
  * "git replay" builtin command
  */
 
-#define USE_THE_REPOSITORY_VARIABLE
-#define DISABLE_SIGN_COMPARE_WARNINGS
-
 #include "git-compat-util.h"
 
 #include "builtin.h"
 #include "config.h"
-#include "environment.h"
 #include "hex.h"
-#include "lockfile.h"
-#include "merge-ort.h"
 #include "object-name.h"
 #include "parse-options.h"
 #include "refs.h"
+#include "replay.h"
 #include "revision.h"
-#include "strmap.h"
-#include <oidset.h>
-#include <tree.h>
 
 enum ref_action_mode {
 	REF_ACTION_UPDATE,
 	REF_ACTION_PRINT,
 };
 
-static const char *short_commit_name(struct repository *repo,
-				     struct commit *commit)
-{
-	return repo_find_unique_abbrev(repo, &commit->object.oid,
-				       DEFAULT_ABBREV);
-}
-
-static struct commit *peel_committish(struct repository *repo,
-				      const char *name,
-				      const char *mode)
-{
-	struct object *obj;
-	struct object_id oid;
-
-	if (repo_get_oid(repo, name, &oid))
-		die(_("'%s' is not a valid commit-ish for %s"), name, mode);
-	obj = parse_object_or_die(repo, &oid, name);
-	return (struct commit *)repo_peel_to_type(repo, name, 0, obj,
-						  OBJ_COMMIT);
-}
-
-static char *get_author(const char *message)
-{
-	size_t len;
-	const char *a;
-
-	a = find_commit_header(message, "author", &len);
-	if (a)
-		return xmemdupz(a, len);
-
-	return NULL;
-}
-
-static struct commit *create_commit(struct repository *repo,
-				    struct tree *tree,
-				    struct commit *based_on,
-				    struct commit *parent)
-{
-	struct object_id ret;
-	struct object *obj = NULL;
-	struct commit_list *parents = NULL;
-	char *author;
-	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
-	struct commit_extra_header *extra = NULL;
-	struct strbuf msg = STRBUF_INIT;
-	const char *out_enc = get_commit_output_encoding();
-	const char *message = repo_logmsg_reencode(repo, based_on,
-						   NULL, out_enc);
-	const char *orig_message = NULL;
-	const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
-
-	commit_list_insert(parent, &parents);
-	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
-	find_commit_subject(message, &orig_message);
-	strbuf_addstr(&msg, orig_message);
-	author = get_author(message);
-	reset_ident_date();
-	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
-				 &ret, author, NULL, sign_commit, extra)) {
-		error(_("failed to write commit object"));
-		goto out;
-	}
-
-	obj = parse_object(repo, &ret);
-
-out:
-	repo_unuse_commit_buffer(the_repository, based_on, message);
-	free_commit_extra_headers(extra);
-	free_commit_list(parents);
-	strbuf_release(&msg);
-	free(author);
-	return (struct commit *)obj;
-}
-
-struct ref_info {
-	struct commit *onto;
-	struct strset positive_refs;
-	struct strset negative_refs;
-	int positive_refexprs;
-	int negative_refexprs;
-};
-
-static void get_ref_information(struct repository *repo,
-				struct rev_cmdline_info *cmd_info,
-				struct ref_info *ref_info)
-{
-	int i;
-
-	ref_info->onto = NULL;
-	strset_init(&ref_info->positive_refs);
-	strset_init(&ref_info->negative_refs);
-	ref_info->positive_refexprs = 0;
-	ref_info->negative_refexprs = 0;
-
-	/*
-	 * When the user specifies e.g.
-	 *   git replay origin/main..mybranch
-	 *   git replay ^origin/next mybranch1 mybranch2
-	 * we want to be able to determine where to replay the commits.  In
-	 * these examples, the branches are probably based on an old version
-	 * of either origin/main or origin/next, so we want to replay on the
-	 * newest version of that branch.  In contrast we would want to error
-	 * out if they ran
-	 *   git replay ^origin/master ^origin/next mybranch
-	 *   git replay mybranch~2..mybranch
-	 * the first of those because there's no unique base to choose, and
-	 * the second because they'd likely just be replaying commits on top
-	 * of the same commit and not making any difference.
-	 */
-	for (i = 0; i < cmd_info->nr; i++) {
-		struct rev_cmdline_entry *e = cmd_info->rev + i;
-		struct object_id oid;
-		const char *refexpr = e->name;
-		char *fullname = NULL;
-		int can_uniquely_dwim = 1;
-
-		if (*refexpr == '^')
-			refexpr++;
-		if (repo_dwim_ref(repo, refexpr, strlen(refexpr), &oid, &fullname, 0) != 1)
-			can_uniquely_dwim = 0;
-
-		if (e->flags & BOTTOM) {
-			if (can_uniquely_dwim)
-				strset_add(&ref_info->negative_refs, fullname);
-			if (!ref_info->negative_refexprs)
-				ref_info->onto = lookup_commit_reference_gently(repo,
-										&e->item->oid, 1);
-			ref_info->negative_refexprs++;
-		} else {
-			if (can_uniquely_dwim)
-				strset_add(&ref_info->positive_refs, fullname);
-			ref_info->positive_refexprs++;
-		}
-
-		free(fullname);
-	}
-}
-
-static void set_up_replay_mode(struct repository *repo,
-			       struct rev_cmdline_info *cmd_info,
-			       const char *onto_name,
-			       char **advance_name,
-			       struct commit **onto,
-			       struct strset **update_refs)
-{
-	struct ref_info rinfo;
-
-	get_ref_information(repo, cmd_info, &rinfo);
-	if (!rinfo.positive_refexprs)
-		die(_("need some commits to replay"));
-
-	if (!onto_name == !*advance_name)
-		BUG("one and only one of onto_name and *advance_name must be given");
-
-	if (onto_name) {
-		*onto = peel_committish(repo, onto_name, "--onto");
-		if (rinfo.positive_refexprs <
-		    strset_get_size(&rinfo.positive_refs))
-			die(_("all positive revisions given must be references"));
-		*update_refs = xcalloc(1, sizeof(**update_refs));
-		**update_refs = rinfo.positive_refs;
-		memset(&rinfo.positive_refs, 0, sizeof(**update_refs));
-	} else {
-		struct object_id oid;
-		char *fullname = NULL;
-
-		if (!*advance_name)
-			BUG("expected either onto_name or *advance_name in this function");
-
-		if (repo_dwim_ref(repo, *advance_name, strlen(*advance_name),
-			     &oid, &fullname, 0) == 1) {
-			free(*advance_name);
-			*advance_name = fullname;
-		} else {
-			die(_("argument to --advance must be a reference"));
-		}
-		*onto = peel_committish(repo, *advance_name, "--advance");
-		if (rinfo.positive_refexprs > 1)
-			die(_("cannot advance target with multiple sources because ordering would be ill-defined"));
-	}
-	strset_clear(&rinfo.negative_refs);
-	strset_clear(&rinfo.positive_refs);
-}
-
-static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
-				    struct commit *commit,
-				    struct commit *fallback)
-{
-	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
-	if (pos == kh_end(replayed_commits))
-		return fallback;
-	return kh_value(replayed_commits, pos);
-}
-
-static struct commit *pick_regular_commit(struct repository *repo,
-					  struct commit *pickme,
-					  kh_oid_map_t *replayed_commits,
-					  struct commit *onto,
-					  struct merge_options *merge_opt,
-					  struct merge_result *result)
-{
-	struct commit *base, *replayed_base;
-	struct tree *pickme_tree, *base_tree;
-
-	base = pickme->parents->item;
-	replayed_base = mapped_commit(replayed_commits, base, onto);
-
-	result->tree = repo_get_commit_tree(repo, replayed_base);
-	pickme_tree = repo_get_commit_tree(repo, pickme);
-	base_tree = repo_get_commit_tree(repo, base);
-
-	merge_opt->branch1 = short_commit_name(repo, replayed_base);
-	merge_opt->branch2 = short_commit_name(repo, pickme);
-	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
-
-	merge_incore_nonrecursive(merge_opt,
-				  base_tree,
-				  result->tree,
-				  pickme_tree,
-				  result);
-
-	free((char*)merge_opt->ancestor);
-	merge_opt->ancestor = NULL;
-	if (!result->clean)
-		return NULL;
-	return create_commit(repo, result->tree, pickme, replayed_base);
-}
-
-struct replay_revisions_options {
-	const char *advance;
-	const char *onto;
-	int contained;
-};
-
-struct replay_result {
-	struct replay_ref_update {
-		char *refname;
-		struct object_id old_oid;
-		struct object_id new_oid;
-	} *updates;
-	size_t updates_nr, updates_alloc;
-};
-
-static void replay_result_release(struct replay_result *result)
-{
-	for (size_t i = 0; i < result->updates_nr; i++)
-		free(result->updates[i].refname);
-	free(result->updates);
-}
-
-static void replay_result_queue_update(struct replay_result *result,
-				       const char *refname,
-				       const struct object_id *old_oid,
-				       const struct object_id *new_oid)
-{
-	ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
-	result->updates[result->updates_nr].refname = xstrdup(refname);
-	result->updates[result->updates_nr].old_oid = *old_oid;
-	result->updates[result->updates_nr].new_oid = *new_oid;
-	result->updates_nr++;
-}
-
-static int replay_revisions(struct rev_info *revs,
-			    struct replay_revisions_options *opts,
-			    struct replay_result *out)
-{
-	kh_oid_map_t *replayed_commits = NULL;
-	struct strset *update_refs = NULL;
-	struct commit *last_commit = NULL;
-	struct commit *commit;
-	struct commit *onto = NULL;
-	struct merge_options merge_opt;
-	struct merge_result result;
-	char *advance;
-	int ret;
-
-	advance = xstrdup_or_null(opts->advance);
-	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto, &advance,
-			   &onto, &update_refs);
-
-	/* FIXME: Should allow replaying commits with the first as a root commit */
-
-	if (prepare_revision_walk(revs) < 0) {
-		ret = error(_("error preparing revisions"));
-		goto out;
-	}
-
-	init_basic_merge_options(&merge_opt, revs->repo);
-	memset(&result, 0, sizeof(result));
-	merge_opt.show_rename_progress = 0;
-	last_commit = onto;
-	replayed_commits = kh_init_oid_map();
-	while ((commit = get_revision(revs))) {
-		const struct name_decoration *decoration;
-		khint_t pos;
-		int hr;
-
-		if (!commit->parents)
-			die(_("replaying down from root commit is not supported yet!"));
-		if (commit->parents->next)
-			die(_("replaying merge commits is not supported yet!"));
-
-		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
-						  onto, &merge_opt, &result);
-		if (!last_commit)
-			break;
-
-		/* Record commit -> last_commit mapping */
-		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
-		if (hr == 0)
-			BUG("Duplicate rewritten commit: %s\n",
-			    oid_to_hex(&commit->object.oid));
-		kh_value(replayed_commits, pos) = last_commit;
-
-		/* Update any necessary branches */
-		if (advance)
-			continue;
-		decoration = get_name_decoration(&commit->object);
-		if (!decoration)
-			continue;
-		while (decoration) {
-			if (decoration->type == DECORATION_REF_LOCAL &&
-			    (opts->contained || strset_contains(update_refs,
-								decoration->name))) {
-				replay_result_queue_update(out, decoration->name,
-							   &commit->object.oid,
-							   &last_commit->object.oid);
-			}
-			decoration = decoration->next;
-		}
-	}
-
-	if (!result.clean) {
-		ret = 1;
-		goto out;
-	}
-
-	/* In --advance mode, advance the target ref */
-	if (advance)
-		replay_result_queue_update(out, advance,
-					   &onto->object.oid,
-					   &last_commit->object.oid);
-
-	ret = 0;
-
-out:
-	if (update_refs) {
-		strset_clear(update_refs);
-		free(update_refs);
-	}
-	kh_destroy_oid_map(replayed_commits);
-	merge_finalize(&merge_opt, &result);
-	free(advance);
-	return ret;
-}
-
 static enum ref_action_mode parse_ref_action_mode(const char *ref_action, const char *source)
 {
 	if (!ref_action || !strcmp(ref_action, "update"))
diff --git a/meson.build b/meson.build
index dd52efd1c8..a5a4e99b25 100644
--- a/meson.build
+++ b/meson.build
@@ -471,6 +471,7 @@ libgit_sources = [
   'repack-midx.c',
   'repack-promisor.c',
   'replace-object.c',
+  'replay.c',
   'repo-settings.c',
   'repository.c',
   'rerere.c',
diff --git a/replay.c b/replay.c
new file mode 100644
index 0000000000..c580a9caa2
--- /dev/null
+++ b/replay.c
@@ -0,0 +1,354 @@
+#define USE_THE_REPOSITORY_VARIABLE
+#define DISABLE_SIGN_COMPARE_WARNINGS
+
+#include "git-compat-util.h"
+#include "environment.h"
+#include "hex.h"
+#include "merge-ort.h"
+#include "object-name.h"
+#include "refs.h"
+#include "replay.h"
+#include "revision.h"
+#include "strmap.h"
+#include "tree.h"
+
+static const char *short_commit_name(struct repository *repo,
+				     struct commit *commit)
+{
+	return repo_find_unique_abbrev(repo, &commit->object.oid,
+				       DEFAULT_ABBREV);
+}
+
+static struct commit *peel_committish(struct repository *repo,
+				      const char *name,
+				      const char *mode)
+{
+	struct object *obj;
+	struct object_id oid;
+
+	if (repo_get_oid(repo, name, &oid))
+		die(_("'%s' is not a valid commit-ish for %s"), name, mode);
+	obj = parse_object_or_die(repo, &oid, name);
+	return (struct commit *)repo_peel_to_type(repo, name, 0, obj,
+						  OBJ_COMMIT);
+}
+
+static char *get_author(const char *message)
+{
+	size_t len;
+	const char *a;
+
+	a = find_commit_header(message, "author", &len);
+	if (a)
+		return xmemdupz(a, len);
+
+	return NULL;
+}
+
+static struct commit *create_commit(struct repository *repo,
+				    struct tree *tree,
+				    struct commit *based_on,
+				    struct commit *parent)
+{
+	struct object_id ret;
+	struct object *obj = NULL;
+	struct commit_list *parents = NULL;
+	char *author;
+	char *sign_commit = NULL; /* FIXME: cli users might want to sign again */
+	struct commit_extra_header *extra = NULL;
+	struct strbuf msg = STRBUF_INIT;
+	const char *out_enc = get_commit_output_encoding();
+	const char *message = repo_logmsg_reencode(repo, based_on,
+						   NULL, out_enc);
+	const char *orig_message = NULL;
+	const char *exclude_gpgsig[] = { "gpgsig", "gpgsig-sha256", NULL };
+
+	commit_list_insert(parent, &parents);
+	extra = read_commit_extra_headers(based_on, exclude_gpgsig);
+	find_commit_subject(message, &orig_message);
+	strbuf_addstr(&msg, orig_message);
+	author = get_author(message);
+	reset_ident_date();
+	if (commit_tree_extended(msg.buf, msg.len, &tree->object.oid, parents,
+				 &ret, author, NULL, sign_commit, extra)) {
+		error(_("failed to write commit object"));
+		goto out;
+	}
+
+	obj = parse_object(repo, &ret);
+
+out:
+	repo_unuse_commit_buffer(the_repository, based_on, message);
+	free_commit_extra_headers(extra);
+	free_commit_list(parents);
+	strbuf_release(&msg);
+	free(author);
+	return (struct commit *)obj;
+}
+
+struct ref_info {
+	struct commit *onto;
+	struct strset positive_refs;
+	struct strset negative_refs;
+	int positive_refexprs;
+	int negative_refexprs;
+};
+
+static void get_ref_information(struct repository *repo,
+				struct rev_cmdline_info *cmd_info,
+				struct ref_info *ref_info)
+{
+	int i;
+
+	ref_info->onto = NULL;
+	strset_init(&ref_info->positive_refs);
+	strset_init(&ref_info->negative_refs);
+	ref_info->positive_refexprs = 0;
+	ref_info->negative_refexprs = 0;
+
+	/*
+	 * When the user specifies e.g.
+	 *   git replay origin/main..mybranch
+	 *   git replay ^origin/next mybranch1 mybranch2
+	 * we want to be able to determine where to replay the commits.  In
+	 * these examples, the branches are probably based on an old version
+	 * of either origin/main or origin/next, so we want to replay on the
+	 * newest version of that branch.  In contrast we would want to error
+	 * out if they ran
+	 *   git replay ^origin/master ^origin/next mybranch
+	 *   git replay mybranch~2..mybranch
+	 * the first of those because there's no unique base to choose, and
+	 * the second because they'd likely just be replaying commits on top
+	 * of the same commit and not making any difference.
+	 */
+	for (i = 0; i < cmd_info->nr; i++) {
+		struct rev_cmdline_entry *e = cmd_info->rev + i;
+		struct object_id oid;
+		const char *refexpr = e->name;
+		char *fullname = NULL;
+		int can_uniquely_dwim = 1;
+
+		if (*refexpr == '^')
+			refexpr++;
+		if (repo_dwim_ref(repo, refexpr, strlen(refexpr), &oid, &fullname, 0) != 1)
+			can_uniquely_dwim = 0;
+
+		if (e->flags & BOTTOM) {
+			if (can_uniquely_dwim)
+				strset_add(&ref_info->negative_refs, fullname);
+			if (!ref_info->negative_refexprs)
+				ref_info->onto = lookup_commit_reference_gently(repo,
+										&e->item->oid, 1);
+			ref_info->negative_refexprs++;
+		} else {
+			if (can_uniquely_dwim)
+				strset_add(&ref_info->positive_refs, fullname);
+			ref_info->positive_refexprs++;
+		}
+
+		free(fullname);
+	}
+}
+
+static void set_up_replay_mode(struct repository *repo,
+			       struct rev_cmdline_info *cmd_info,
+			       const char *onto_name,
+			       char **advance_name,
+			       struct commit **onto,
+			       struct strset **update_refs)
+{
+	struct ref_info rinfo;
+
+	get_ref_information(repo, cmd_info, &rinfo);
+	if (!rinfo.positive_refexprs)
+		die(_("need some commits to replay"));
+
+	if (!onto_name == !*advance_name)
+		BUG("one and only one of onto_name and *advance_name must be given");
+
+	if (onto_name) {
+		*onto = peel_committish(repo, onto_name, "--onto");
+		if (rinfo.positive_refexprs <
+		    strset_get_size(&rinfo.positive_refs))
+			die(_("all positive revisions given must be references"));
+		*update_refs = xcalloc(1, sizeof(**update_refs));
+		**update_refs = rinfo.positive_refs;
+		memset(&rinfo.positive_refs, 0, sizeof(**update_refs));
+	} else {
+		struct object_id oid;
+		char *fullname = NULL;
+
+		if (!*advance_name)
+			BUG("expected either onto_name or *advance_name in this function");
+
+		if (repo_dwim_ref(repo, *advance_name, strlen(*advance_name),
+			     &oid, &fullname, 0) == 1) {
+			free(*advance_name);
+			*advance_name = fullname;
+		} else {
+			die(_("argument to --advance must be a reference"));
+		}
+		*onto = peel_committish(repo, *advance_name, "--advance");
+		if (rinfo.positive_refexprs > 1)
+			die(_("cannot advance target with multiple sources because ordering would be ill-defined"));
+	}
+	strset_clear(&rinfo.negative_refs);
+	strset_clear(&rinfo.positive_refs);
+}
+
+static struct commit *mapped_commit(kh_oid_map_t *replayed_commits,
+				    struct commit *commit,
+				    struct commit *fallback)
+{
+	khint_t pos = kh_get_oid_map(replayed_commits, commit->object.oid);
+	if (pos == kh_end(replayed_commits))
+		return fallback;
+	return kh_value(replayed_commits, pos);
+}
+
+static struct commit *pick_regular_commit(struct repository *repo,
+					  struct commit *pickme,
+					  kh_oid_map_t *replayed_commits,
+					  struct commit *onto,
+					  struct merge_options *merge_opt,
+					  struct merge_result *result)
+{
+	struct commit *base, *replayed_base;
+	struct tree *pickme_tree, *base_tree;
+
+	base = pickme->parents->item;
+	replayed_base = mapped_commit(replayed_commits, base, onto);
+
+	result->tree = repo_get_commit_tree(repo, replayed_base);
+	pickme_tree = repo_get_commit_tree(repo, pickme);
+	base_tree = repo_get_commit_tree(repo, base);
+
+	merge_opt->branch1 = short_commit_name(repo, replayed_base);
+	merge_opt->branch2 = short_commit_name(repo, pickme);
+	merge_opt->ancestor = xstrfmt("parent of %s", merge_opt->branch2);
+
+	merge_incore_nonrecursive(merge_opt,
+				  base_tree,
+				  result->tree,
+				  pickme_tree,
+				  result);
+
+	free((char*)merge_opt->ancestor);
+	merge_opt->ancestor = NULL;
+	if (!result->clean)
+		return NULL;
+	return create_commit(repo, result->tree, pickme, replayed_base);
+}
+
+void replay_result_release(struct replay_result *result)
+{
+	for (size_t i = 0; i < result->updates_nr; i++)
+		free(result->updates[i].refname);
+	free(result->updates);
+}
+
+static void replay_result_queue_update(struct replay_result *result,
+				       const char *refname,
+				       const struct object_id *old_oid,
+				       const struct object_id *new_oid)
+{
+	ALLOC_GROW(result->updates, result->updates_nr + 1, result->updates_alloc);
+	result->updates[result->updates_nr].refname = xstrdup(refname);
+	result->updates[result->updates_nr].old_oid = *old_oid;
+	result->updates[result->updates_nr].new_oid = *new_oid;
+	result->updates_nr++;
+}
+
+int replay_revisions(struct rev_info *revs,
+		     struct replay_revisions_options *opts,
+		     struct replay_result *out)
+{
+	kh_oid_map_t *replayed_commits = NULL;
+	struct strset *update_refs = NULL;
+	struct commit *last_commit = NULL;
+	struct commit *commit;
+	struct commit *onto = NULL;
+	struct merge_options merge_opt;
+	struct merge_result result;
+	char *advance;
+	int ret;
+
+	advance = xstrdup_or_null(opts->advance);
+	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto, &advance,
+			   &onto, &update_refs);
+
+	/* FIXME: Should allow replaying commits with the first as a root commit */
+
+	if (prepare_revision_walk(revs) < 0) {
+		ret = error(_("error preparing revisions"));
+		goto out;
+	}
+
+	init_basic_merge_options(&merge_opt, revs->repo);
+	memset(&result, 0, sizeof(result));
+	merge_opt.show_rename_progress = 0;
+	last_commit = onto;
+	replayed_commits = kh_init_oid_map();
+	while ((commit = get_revision(revs))) {
+		const struct name_decoration *decoration;
+		khint_t pos;
+		int hr;
+
+		if (!commit->parents)
+			die(_("replaying down from root commit is not supported yet!"));
+		if (commit->parents->next)
+			die(_("replaying merge commits is not supported yet!"));
+
+		last_commit = pick_regular_commit(revs->repo, commit, replayed_commits,
+						  onto, &merge_opt, &result);
+		if (!last_commit)
+			break;
+
+		/* Record commit -> last_commit mapping */
+		pos = kh_put_oid_map(replayed_commits, commit->object.oid, &hr);
+		if (hr == 0)
+			BUG("Duplicate rewritten commit: %s\n",
+			    oid_to_hex(&commit->object.oid));
+		kh_value(replayed_commits, pos) = last_commit;
+
+		/* Update any necessary branches */
+		if (advance)
+			continue;
+		decoration = get_name_decoration(&commit->object);
+		if (!decoration)
+			continue;
+		while (decoration) {
+			if (decoration->type == DECORATION_REF_LOCAL &&
+			    (opts->contained || strset_contains(update_refs,
+								decoration->name))) {
+				replay_result_queue_update(out, decoration->name,
+							   &commit->object.oid,
+							   &last_commit->object.oid);
+			}
+			decoration = decoration->next;
+		}
+	}
+
+	if (!result.clean) {
+		ret = 1;
+		goto out;
+	}
+
+	/* In --advance mode, advance the target ref */
+	if (advance)
+		replay_result_queue_update(out, advance,
+					   &onto->object.oid,
+					   &last_commit->object.oid);
+
+	ret = 0;
+
+out:
+	if (update_refs) {
+		strset_clear(update_refs);
+		free(update_refs);
+	}
+	kh_destroy_oid_map(replayed_commits);
+	merge_finalize(&merge_opt, &result);
+	free(advance);
+	return ret;
+}
diff --git a/replay.h b/replay.h
new file mode 100644
index 0000000000..d8407dc7f7
--- /dev/null
+++ b/replay.h
@@ -0,0 +1,61 @@
+#ifndef REPLAY_H
+#define REPLAY_H
+
+#include "hash.h"
+
+struct repository;
+struct rev_info;
+
+/*
+ * A set of options that can be passed to `replay_revisions()`.
+ */
+struct replay_revisions_options {
+	/*
+	 * Starting point at which to create the new commits; must be a branch
+	 * name. The branch will be updated to point to the rewritten commits.
+	 * This option is mutually exclusive with `onto`.
+	 */
+	const char *advance;
+
+	/*
+	 * Starting point at which to create the new commits; must be a
+	 * committish. References pointing at decendants of `onto` will be
+	 * updated to point to the new commits.
+	 */
+	 const char *onto;
+
+	/*
+	 * Update branches that point at commits in the given revision range.
+	 * Requires `onto` to be set.
+	 */
+	int contained;
+};
+
+/* This struct is used as an out-parameter by `replay_revisions()`. */
+struct replay_result {
+	/*
+	 * The set of reference updates that are caused by replaying the
+	 * commits.
+	 */
+	struct replay_ref_update {
+		char *refname;
+		struct object_id old_oid;
+		struct object_id new_oid;
+	} *updates;
+	size_t updates_nr, updates_alloc;
+};
+
+void replay_result_release(struct replay_result *result);
+
+/*
+ * Replay a set of commits onto a new location. Leaves both the working tree,
+ * index and references untouched. Reference updates caused by the replay will
+ * be recorded in the `updates` out pointer.
+ *
+ * Returns 0 on success, 1 on conflict and a negative error code otherwise.
+ */
+int replay_revisions(struct rev_info *revs,
+		     struct replay_revisions_options *opts,
+		     struct replay_result *out);
+
+#endif
-- 
2.52.0.590.g1f87b77810.dirty
Patrick Steinhardt· Jan 13, 2026, 09:54 UTC · re: Patrick Steinhardt · lore

[PATCH v11 3/8] replay: small set of cleanups

Perform a small set of cleanups so that the "replay" logic compiles with "-Wsign-compare" and doesn't use `the_repository` anymore. Note that there are still some implicit dependencies on `the_repository`, e.g. because we use `get_commit_output_encoding()`.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 replay.c | 11 ++++-------
 1 file changed, 4 insertions(+), 7 deletions(-)
Show changes to replay.c +4 −7
diff --git a/replay.c b/replay.c
index c580a9caa2..ae13b59abc 100644
--- a/replay.c
+++ b/replay.c
@@ -1,5 +1,4 @@
 #define USE_THE_REPOSITORY_VARIABLE
-#define DISABLE_SIGN_COMPARE_WARNINGS
 
 #include "git-compat-util.h"
 #include "environment.h"
@@ -78,7 +77,7 @@ static struct commit *create_commit(struct repository *repo,
 	obj = parse_object(repo, &ret);
 
 out:
-	repo_unuse_commit_buffer(the_repository, based_on, message);
+	repo_unuse_commit_buffer(repo, based_on, message);
 	free_commit_extra_headers(extra);
 	free_commit_list(parents);
 	strbuf_release(&msg);
@@ -90,16 +89,14 @@ struct ref_info {
 	struct commit *onto;
 	struct strset positive_refs;
 	struct strset negative_refs;
-	int positive_refexprs;
-	int negative_refexprs;
+	size_t positive_refexprs;
+	size_t negative_refexprs;
 };
 
 static void get_ref_information(struct repository *repo,
 				struct rev_cmdline_info *cmd_info,
 				struct ref_info *ref_info)
 {
-	int i;
-
 	ref_info->onto = NULL;
 	strset_init(&ref_info->positive_refs);
 	strset_init(&ref_info->negative_refs);
@@ -121,7 +118,7 @@ static void get_ref_information(struct repository *repo,
 	 * the second because they'd likely just be replaying commits on top
 	 * of the same commit and not making any difference.
 	 */
-	for (i = 0; i < cmd_info->nr; i++) {
+	for (size_t i = 0; i < cmd_info->nr; i++) {
 		struct rev_cmdline_entry *e = cmd_info->rev + i;
 		struct object_id oid;
 		const char *refexpr = e->name;
-- 
2.52.0.590.g1f87b77810.dirty
Patrick Steinhardt· Jan 13, 2026, 09:54 UTC · re: Patrick Steinhardt · lore

[PATCH v11 4/8] replay: support empty commit ranges

In a subsequent commit we're about to introduce a new user of the replay subsystem. With that new user, the range of commits that we'll want to replay will be identified implicitly via a single commit, and will include all descendants of that commit to any branch. If that commit has no descendants (because it's the tip of some branch), then the range of revisions that we're asked to replay becomes empty. This case does not make sense with git-replay(1), but with the new command it will.

This case is not currently supported by `replay_revisions()` though because we zero-initialize `struct merge_result`. This includes its `.clean` member, which indicates whether the merge ran into a conflict or not. But given that we don't have any revision to replay, we won't ever perform any merge at all, and consequently that member will never be set to `1`. We thus later think that there's been a merge conflict and return an error from `replay_commits()`.

Address this issue by initializing the `.clean` member to `1`.
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 replay.c | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)
Show changes to replay.c +3 −2
diff --git a/replay.c b/replay.c
index ae13b59abc..6680d50bd7 100644
--- a/replay.c
+++ b/replay.c
@@ -266,7 +266,9 @@ int replay_revisions(struct rev_info *revs,
 	struct commit *commit;
 	struct commit *onto = NULL;
 	struct merge_options merge_opt;
-	struct merge_result result;
+	struct merge_result result = {
+		.clean = 1,
+	};
 	char *advance;
 	int ret;
 
@@ -282,7 +284,6 @@ int replay_revisions(struct rev_info *revs,
 	}
 
 	init_basic_merge_options(&merge_opt, revs->repo);
-	memset(&result, 0, sizeof(result));
 	merge_opt.show_rename_progress = 0;
 	last_commit = onto;
 	replayed_commits = kh_init_oid_map();
-- 
2.52.0.590.g1f87b77810.dirty
Patrick Steinhardt· Jan 13, 2026, 09:54 UTC · re: Patrick Steinhardt · lore

[PATCH v11 5/8] replay: support updating detached HEAD

In a subsequent commit we're about to introduce a new git-history(1) command, which will by default work on all local branches and HEAD. This is already well-supported by the replay machinery for most of the part: updating branches is one of its prime use cases, and the HEAD ref is also updated in case it points to any of the branches.

However, what's not supported yet is to update HEAD in case it is not a symbolic ref. We determine the refs that need to be updated by iterating through the decorations of the original commit, but we only update those refs that are `DECORATION_REF_LOCAL`, which covers local branches.

Address this gap by also handling `DECORATION_REF_HEAD`. Note though that this needs to only happen in case we're working on a detached HEAD. If HEAD is pointing to a branch, then we'd already update that branch via `DECORATION_REF_LOCAL`.

Refactor the loop that iterates through the decorations a bit to make the individual conditions easier to understand.

Based-on-patch-by: Elijah Newren <newren@gmail.com>
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 replay.c                 | 47 +++++++++++++++++++++++++++++++++--------------
 t/t3650-replay-basics.sh |  9 +++++++++
 2 files changed, 42 insertions(+), 14 deletions(-)
Show changes to 2 files +42 −14

replay.c, t/t3650-replay-basics.sh

diff --git a/replay.c b/replay.c
index 6680d50bd7..94fb76384b 100644
--- a/replay.c
+++ b/replay.c
@@ -150,11 +150,17 @@ static void get_ref_information(struct repository *repo,
 static void set_up_replay_mode(struct repository *repo,
 			       struct rev_cmdline_info *cmd_info,
 			       const char *onto_name,
+			       bool *detached_head,
 			       char **advance_name,
 			       struct commit **onto,
 			       struct strset **update_refs)
 {
 	struct ref_info rinfo;
+	int head_flags = 0;
+
+	refs_read_ref_full(get_main_ref_store(repo), "HEAD",
+			   RESOLVE_REF_NO_RECURSE, NULL, &head_flags);
+	*detached_head = !(head_flags & REF_ISSYMREF);
 
 	get_ref_information(repo, cmd_info, &rinfo);
 	if (!rinfo.positive_refexprs)
@@ -269,12 +275,13 @@ int replay_revisions(struct rev_info *revs,
 	struct merge_result result = {
 		.clean = 1,
 	};
+	bool detached_head;
 	char *advance;
 	int ret;
 
 	advance = xstrdup_or_null(opts->advance);
-	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto, &advance,
-			   &onto, &update_refs);
+	set_up_replay_mode(revs->repo, &revs->cmdline, opts->onto,
+			   &detached_head, &advance, &onto, &update_refs);
 
 	/* FIXME: Should allow replaying commits with the first as a root commit */
 
@@ -312,18 +319,30 @@ int replay_revisions(struct rev_info *revs,
 		/* Update any necessary branches */
 		if (advance)
 			continue;
-		decoration = get_name_decoration(&commit->object);
-		if (!decoration)
-			continue;
-		while (decoration) {
-			if (decoration->type == DECORATION_REF_LOCAL &&
-			    (opts->contained || strset_contains(update_refs,
-								decoration->name))) {
-				replay_result_queue_update(out, decoration->name,
-							   &commit->object.oid,
-							   &last_commit->object.oid);
-			}
-			decoration = decoration->next;
+
+		for (decoration = get_name_decoration(&commit->object);
+		     decoration;
+		     decoration = decoration->next)
+		{
+			if (decoration->type != DECORATION_REF_LOCAL &&
+			    decoration->type != DECORATION_REF_HEAD)
+				continue;
+
+			/*
+			 * We only need to update HEAD separately in case it's
+			 * detached. If it's not we'd already update the branch
+			 * it is pointing to.
+			 */
+			if (decoration->type == DECORATION_REF_HEAD && !detached_head)
+				continue;
+
+			if (!opts->contained &&
+			    !strset_contains(update_refs, decoration->name))
+				continue;
+
+			replay_result_queue_update(out, decoration->name,
+						   &commit->object.oid,
+						   &last_commit->object.oid);
 		}
 	}
 
diff --git a/t/t3650-replay-basics.sh b/t/t3650-replay-basics.sh
index 307101eeb9..c862aa39f3 100755
--- a/t/t3650-replay-basics.sh
+++ b/t/t3650-replay-basics.sh
@@ -249,6 +249,15 @@ test_expect_success 'using replay on bare repo to rebase multiple divergent bran
 	done
 '
 
+test_expect_success 'using replay to update detached HEAD' '
+	current_head=$(git branch --show-current) &&
+	test_when_finished git switch "$current_head" &&
+	git switch --detach &&
+	test_commit something &&
+	git replay --ref-action=print --onto HEAD~2 --ref-action=print HEAD~..HEAD >updates &&
+	test_grep "update HEAD " updates
+'
+
 test_expect_success 'merge.directoryRenames=false' '
 	# create a test case that stress-tests the rename caching
 	git switch -c rename-onto &&
-- 
2.52.0.590.g1f87b77810.dirty
Patrick Steinhardt· Jan 13, 2026, 09:54 UTC · re: Patrick Steinhardt · lore

[PATCH v11 6/8] wt-status: provide function to expose status for trees

The "wt-status" subsystem is responsible for printing status information around the current state of the working tree. This most importantly includes information around whether the working tree or the index have any changes.

We're about to introduce a new command where the changes in neither of them are actually relevant to us. Instead, what we want is to format the changes between two different trees. While it is a little bit of a stretch to add this as functionality to _working tree_ status, it doesn't make any sense to open-code this functionality, either.

Implement a new function `wt_status_collect_changes_trees()` that diffs two trees and formats the status accordingly. This function is not yet used, but will be in a subsequent commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 wt-status.c | 24 ++++++++++++++++++++++++
 wt-status.h |  9 +++++++++
 2 files changed, 33 insertions(+)
Show changes to 2 files +33 −0

wt-status.c, wt-status.h

diff --git a/wt-status.c b/wt-status.c
index e12adb26b9..95942399f8 100644
--- a/wt-status.c
+++ b/wt-status.c
@@ -612,6 +612,30 @@ static void wt_status_collect_updated_cb(struct diff_queue_struct *q,
 	}
 }
 
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish)
+{
+	struct diff_options opts = { 0 };
+
+	repo_diff_setup(s->repo, &opts);
+	opts.output_format = DIFF_FORMAT_CALLBACK;
+	opts.format_callback = wt_status_collect_updated_cb;
+	opts.format_callback_data = s;
+	opts.detect_rename = s->detect_rename >= 0 ? s->detect_rename : opts.detect_rename;
+	opts.rename_limit = s->rename_limit >= 0 ? s->rename_limit : opts.rename_limit;
+	opts.rename_score = s->rename_score >= 0 ? s->rename_score : opts.rename_score;
+	opts.flags.recursive = 1;
+	diff_setup_done(&opts);
+
+	diff_tree_oid(old_treeish, new_treeish, "", &opts);
+	diffcore_std(&opts);
+	diff_flush(&opts);
+	wt_status_get_state(s->repo, &s->state, 0);
+
+	diff_free(&opts);
+}
+
 static void wt_status_collect_changes_worktree(struct wt_status *s)
 {
 	struct rev_info rev;
diff --git a/wt-status.h b/wt-status.h
index e40a27214a..e9fe32e98c 100644
--- a/wt-status.h
+++ b/wt-status.h
@@ -153,6 +153,15 @@ void wt_status_add_cut_line(struct wt_status *s);
 void wt_status_prepare(struct repository *r, struct wt_status *s);
 void wt_status_print(struct wt_status *s);
 void wt_status_collect(struct wt_status *s);
+
+/*
+ * Collect all changes between the two trees. Changes will be displayed as if
+ * they were staged into the index.
+ */
+void wt_status_collect_changes_trees(struct wt_status *s,
+				     const struct object_id *old_treeish,
+				     const struct object_id *new_treeish);
+
 /*
  * Frees the buffers allocated by wt_status_collect.
  */
-- 
2.52.0.590.g1f87b77810.dirty
Patrick Steinhardt· Jan 13, 2026, 09:54 UTC · re: Patrick Steinhardt · lore

[PATCH v11 7/8] builtin: add new "history" command

When rewriting history via git-rebase(1) there are a few very common use cases:

  - The ordering of two commits should be reversed.
  - A commit should be split up into two commits.
  - A commit should be dropped from the history completely.
  - Multiple commits should be squashed into one.
  - Editing an existing commit that is not the tip of the current
    branch.

While these operations are all doable, it often feels needlessly kludgey to do so by doing an interactive rebase, using the editor to say what one wants, and then perform the actions. Also, some operations like splitting up a commit into two are way more involved than that and require a whole series of commands.

Rebases also do not update dependent branches. The use of stacked branches has grown quite common with competing version control systems like Jujutsu though, so it clearly is a need that users have. While rebases _can_ serve this use case if one always works on the latest stacked branch, it is somewhat awkward and very easy to get wrong.

Add a new "history" command to plug these gaps. This command will have several different subcommands to imperatively rewrite history for common use cases like the above.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 .gitignore                     |  1 +
 Documentation/git-history.adoc | 57 ++++++++++++++++++++++++++++++++++++++++++
 Documentation/meson.build      |  1 +
 Makefile                       |  1 +
 builtin.h                      |  1 +
 builtin/history.c              | 22 ++++++++++++++++
 command-list.txt               |  1 +
 git.c                          |  1 +
 meson.build                    |  1 +
 t/meson.build                  |  1 +
 t/t3450-history.sh             | 17 +++++++++++++
 11 files changed, 104 insertions(+)
Show changes to 11 files +104 −0

.gitignore, Documentation/git-history.adoc, Documentation/meson.build, Makefile, builtin.h, builtin/history.c, command-list.txt, git.c, meson.build, t/meson.build, t/t3450-history.sh

diff --git a/.gitignore b/.gitignore
index 78a45cb5be..24635cf2d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,6 +79,7 @@
 /git-grep
 /git-hash-object
 /git-help
+/git-history
 /git-hook
 /git-http-backend
 /git-http-fetch
diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
new file mode 100644
index 0000000000..68c35f36b9
--- /dev/null
+++ b/Documentation/git-history.adoc
@@ -0,0 +1,57 @@
+git-history(1)
+==============
+
+NAME
+----
+git-history - EXPERIMENTAL: Rewrite history
+
+SYNOPSIS
+--------
+[synopsis]
+git history [<options>]
+
+DESCRIPTION
+-----------
+
+Rewrite history by rearranging or modifying specific commits in the
+history.
+
+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.
+* 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
+  of the original commit to point to the rewritten commit.
+
+Overall, linkgit:git-history[1] aims to provide a more opinionated way to modify
+your commit history that is simpler to use compared to linkgit:git-rebase[1] in
+general.
+
+Use linkgit:git-rebase[1] if you want to reapply a range of commits onto a
+different base, or interactive rebases if you want to edit a range of commits
+at once.
+
+LIMITATIONS
+-----------
+
+This command does not (yet) work with histories that contain merges. You
+should use linkgit:git-rebase[1] with the `--rebase-merges` flag instead.
+
+Furthermore, the command does not support operations that can result in merge
+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.
+
+COMMANDS
+--------
+
+No commands are supported yet.
+
+GIT
+---
+Part of the linkgit:git[1] suite
diff --git a/Documentation/meson.build b/Documentation/meson.build
index f02dbc20cb..fd2e8cc02d 100644
--- a/Documentation/meson.build
+++ b/Documentation/meson.build
@@ -64,6 +64,7 @@ manpages = {
   'git-gui.adoc' : 1,
   'git-hash-object.adoc' : 1,
   'git-help.adoc' : 1,
+  'git-history.adoc' : 1,
   'git-hook.adoc' : 1,
   'git-http-backend.adoc' : 1,
   'git-http-fetch.adoc' : 1,
diff --git a/Makefile b/Makefile
index 1c64a5d2ae..c0569ed8e4 100644
--- a/Makefile
+++ b/Makefile
@@ -1418,6 +1418,7 @@ BUILTIN_OBJS += builtin/get-tar-commit-id.o
 BUILTIN_OBJS += builtin/grep.o
 BUILTIN_OBJS += builtin/hash-object.o
 BUILTIN_OBJS += builtin/help.o
+BUILTIN_OBJS += builtin/history.o
 BUILTIN_OBJS += builtin/hook.o
 BUILTIN_OBJS += builtin/index-pack.o
 BUILTIN_OBJS += builtin/init-db.o
diff --git a/builtin.h b/builtin.h
index 1b35565fbd..93c91d07d4 100644
--- a/builtin.h
+++ b/builtin.h
@@ -172,6 +172,7 @@ int cmd_get_tar_commit_id(int argc, const char **argv, const char *prefix, struc
 int cmd_grep(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hash_object(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_help(int argc, const char **argv, const char *prefix, struct repository *repo);
+int cmd_history(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_hook(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_index_pack(int argc, const char **argv, const char *prefix, struct repository *repo);
 int cmd_init_db(int argc, const char **argv, const char *prefix, struct repository *repo);
diff --git a/builtin/history.c b/builtin/history.c
new file mode 100644
index 0000000000..f6fe32610b
--- /dev/null
+++ b/builtin/history.c
@@ -0,0 +1,22 @@
+#include "builtin.h"
+#include "gettext.h"
+#include "parse-options.h"
+
+int cmd_history(int argc,
+		const char **argv,
+		const char *prefix,
+		struct repository *repo UNUSED)
+{
+	const char * const usage[] = {
+		N_("git history [<options>]"),
+		NULL,
+	};
+	struct option options[] = {
+		OPT_END(),
+	};
+
+	argc = parse_options(argc, argv, prefix, options, usage, 0);
+	if (argc)
+		usagef("unrecognized argument: %s", argv[0]);
+	return 0;
+}
diff --git a/command-list.txt b/command-list.txt
index accd3d0c4b..f9005cf459 100644
--- a/command-list.txt
+++ b/command-list.txt
@@ -115,6 +115,7 @@ git-grep                                mainporcelain           info
 git-gui                                 mainporcelain
 git-hash-object                         plumbingmanipulators
 git-help                                ancillaryinterrogators          complete
+git-history                             mainporcelain           history
 git-hook                                purehelpers
 git-http-backend                        synchingrepositories
 git-http-fetch                          synchelpers
diff --git a/git.c b/git.c
index c5fad56813..744cb6527e 100644
--- a/git.c
+++ b/git.c
@@ -586,6 +586,7 @@ static struct cmd_struct commands[] = {
 	{ "grep", cmd_grep, RUN_SETUP_GENTLY },
 	{ "hash-object", cmd_hash_object },
 	{ "help", cmd_help },
+	{ "history", cmd_history, RUN_SETUP },
 	{ "hook", cmd_hook, RUN_SETUP },
 	{ "index-pack", cmd_index_pack, RUN_SETUP_GENTLY | NO_PARSEOPT },
 	{ "init", cmd_init_db },
diff --git a/meson.build b/meson.build
index a5a4e99b25..3a1d12caa4 100644
--- a/meson.build
+++ b/meson.build
@@ -610,6 +610,7 @@ builtin_sources = [
   'builtin/grep.c',
   'builtin/hash-object.c',
   'builtin/help.c',
+  'builtin/history.c',
   'builtin/hook.c',
   'builtin/index-pack.c',
   'builtin/init-db.c',
diff --git a/t/meson.build b/t/meson.build
index 459c52a489..73006b095a 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -387,6 +387,7 @@ integration_tests = [
   't3436-rebase-more-options.sh',
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
+  't3450-history.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
new file mode 100755
index 0000000000..417c343d43
--- /dev/null
+++ b/t/t3450-history.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+test_description='tests for git-history command'
+
+. ./test-lib.sh
+
+test_expect_success 'does nothing without any arguments' '
+	git history >out 2>&1 &&
+	test_must_be_empty out
+'
+
+test_expect_success 'raises an error with unknown argument' '
+	test_must_fail git history garbage 2>err &&
+	test_grep "unrecognized argument: garbage" err
+'
+
+test_done
-- 
2.52.0.590.g1f87b77810.dirty
Patrick Steinhardt· Jan 13, 2026, 09:54 UTC · re: Patrick Steinhardt · lore

[PATCH v11 8/8] builtin/history: implement "reword" subcommand

Implement a new "reword" subcommand for git-history(1). This subcommand is similar to the user performing an interactive rebase with a single commit changed to use the "reword" instruction.

The "reword" subcommand is built on top of the replay subsystem instead of the sequencer. This leads to some major differences compared to git-rebase(1):

  - We do not check out the commit that is to be reworded and instead
    perform the operation in-memory. This has the obvious benefit of
    being significantly faster compared to git-rebase(1), but even more
    importantly it allows the user to rewrite history even if there are
    local changes in the working tree or in the index.
  - We do not execute any hooks, even though we leave some room for
    changing this in the future.
  - By default, all local branches that contain the commit will be
    rewritten. This especially helps with workflows that use stacked
    branches.
Helped-by: Elijah Newren <newren@gmail.com>
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-history.adoc |  20 +-
 builtin/history.c              | 415 ++++++++++++++++++++++++++++++++++++++++-
 t/meson.build                  |   1 +
 t/t3450-history.sh             |   6 +-
 t/t3451-history-reword.sh      | 391 ++++++++++++++++++++++++++++++++++++++
 5 files changed, 823 insertions(+), 10 deletions(-)
Show changes to 5 files +823 −10

Documentation/git-history.adoc, builtin/history.c, t/meson.build, t/t3450-history.sh, t/t3451-history-reword.sh

diff --git a/Documentation/git-history.adoc b/Documentation/git-history.adoc
index 68c35f36b9..154e262b76 100644
--- a/Documentation/git-history.adoc
+++ b/Documentation/git-history.adoc
@@ -8,7 +8,7 @@ git-history - EXPERIMENTAL: Rewrite history
 SYNOPSIS
 --------
 [synopsis]
-git history [<options>]
+git history reword <commit> [--ref-action=(branches|head|print)]
 
 DESCRIPTION
 -----------
@@ -50,7 +50,23 @@ first-class conflicts.
 COMMANDS
 --------
 
-No commands are supported yet.
+The following commands are available to rewrite history in different ways:
+
+`reword <commit>`::
+	Rewrite the commit message of the specified commit. All the other
+	details of this commit remain unchanged. This command will spawn an
+	editor with the current message of that commit.
+
+OPTIONS
+-------
+
+`--ref-action=(branches|head|print)`::
+	Control which references will be updated by the command, if any. With
+	`branches`, all local branches that point to commits which are
+	descendants of the original commit will be rewritten. With `head`, only
+	the current `HEAD` reference will be rewritten. With `print`, all
+	updates as they would be performed with `branches` are printed in a
+	format that can be consumed by linkgit:git-update-ref[1].
 
 GIT
 ---
diff --git a/builtin/history.c b/builtin/history.c
index f6fe32610b..8dcb9a6046 100644
--- a/builtin/history.c
+++ b/builtin/history.c
@@ -1,22 +1,427 @@
+#define USE_THE_REPOSITORY_VARIABLE
+
 #include "builtin.h"
+#include "commit.h"
+#include "commit-reach.h"
+#include "config.h"
+#include "editor.h"
+#include "environment.h"
 #include "gettext.h"
+#include "hex.h"
 #include "parse-options.h"
+#include "refs.h"
+#include "replay.h"
+#include "revision.h"
+#include "sequencer.h"
+#include "strvec.h"
+#include "tree.h"
+#include "wt-status.h"
+
+#define GIT_HISTORY_REWORD_USAGE \
+	N_("git history reword <commit> [--ref-action=(branches|head|print)]")
+
+static void change_data_free(void *util, const char *str UNUSED)
+{
+	struct wt_status_change_data *d = util;
+	free(d->rename_source);
+	free(d);
+}
+
+static int fill_commit_message(struct repository *repo,
+			       const struct object_id *old_tree,
+			       const struct object_id *new_tree,
+			       const char *default_message,
+			       const char *action,
+			       struct strbuf *out)
+{
+	const char *path = git_path_commit_editmsg();
+	const char *hint =
+		_("Please enter the commit message for the %s changes."
+		  " Lines starting\nwith '%s' will be ignored, and an"
+		  " empty message aborts the commit.\n");
+	struct wt_status s;
+
+	strbuf_addstr(out, default_message);
+	strbuf_addch(out, '\n');
+	strbuf_commented_addf(out, comment_line_str, hint, action, comment_line_str);
+	write_file_buf(path, out->buf, out->len);
+
+	wt_status_prepare(repo, &s);
+	FREE_AND_NULL(s.branch);
+	s.ahead_behind_flags = AHEAD_BEHIND_QUICK;
+	s.commit_template = 1;
+	s.colopts = 0;
+	s.display_comment_prefix = 1;
+	s.hints = 0;
+	s.use_color = 0;
+	s.whence = FROM_COMMIT;
+	s.committable = 1;
+
+	s.fp = fopen(git_path_commit_editmsg(), "a");
+	if (!s.fp)
+		return error_errno(_("could not open '%s'"), git_path_commit_editmsg());
+
+	wt_status_collect_changes_trees(&s, old_tree, new_tree);
+	wt_status_print(&s);
+	wt_status_collect_free_buffers(&s);
+	string_list_clear_func(&s.change, change_data_free);
+
+	strbuf_reset(out);
+	if (launch_editor(path, out, NULL)) {
+		fprintf(stderr, _("Aborting commit as launching the editor failed.\n"));
+		return -1;
+	}
+	strbuf_stripspace(out, comment_line_str);
+
+	cleanup_message(out, COMMIT_MSG_CLEANUP_ALL, 0);
+
+	if (!out->len) {
+		fprintf(stderr, _("Aborting commit due to empty commit message.\n"));
+		return -1;
+	}
+
+	return 0;
+}
+
+static int commit_tree_with_edited_message(struct repository *repo,
+					   const char *action,
+					   struct commit *original,
+					   struct commit **out)
+{
+	const char *exclude_gpgsig[] = {
+		/* We reencode the message, so the encoding needs to be stripped. */
+		"encoding",
+		/* We need to strip signatures as those will become invalid. */
+		"gpgsig",
+		"gpgsig-sha256",
+		NULL,
+	};
+	const char *original_message, *original_body, *ptr;
+	struct commit_extra_header *original_extra_headers = NULL;
+	struct strbuf commit_message = STRBUF_INIT;
+	struct object_id rewritten_commit_oid;
+	struct object_id original_tree_oid;
+	struct object_id parent_tree_oid;
+	char *original_author = NULL;
+	struct commit *parent;
+	size_t len;
+	int ret;
+
+	original_tree_oid = repo_get_commit_tree(repo, original)->object.oid;
+
+	parent = original->parents ? original->parents->item : NULL;
+	if (parent) {
+		if (repo_parse_commit(repo, parent)) {
+			ret = error(_("unable to parse parent commit %s"),
+				    oid_to_hex(&parent->object.oid));
+			goto out;
+		}
+
+		parent_tree_oid = repo_get_commit_tree(repo, parent)->object.oid;
+	} else {
+		oidcpy(&parent_tree_oid, repo->hash_algo->empty_tree);
+	}
+
+	/* We retain authorship of the original commit. */
+	original_message = repo_logmsg_reencode(repo, original, NULL, NULL);
+	ptr = find_commit_header(original_message, "author", &len);
+	if (ptr)
+		original_author = xmemdupz(ptr, len);
+	find_commit_subject(original_message, &original_body);
+
+	ret = fill_commit_message(repo, &parent_tree_oid, &original_tree_oid,
+				  original_body, action, &commit_message);
+	if (ret < 0)
+		goto out;
+
+	original_extra_headers = read_commit_extra_headers(original, exclude_gpgsig);
+
+	ret = commit_tree_extended(commit_message.buf, commit_message.len, &original_tree_oid,
+				   original->parents, &rewritten_commit_oid, original_author,
+				   NULL, NULL, original_extra_headers);
+	if (ret < 0)
+		goto out;
+
+	*out = lookup_commit_or_die(&rewritten_commit_oid, "rewritten commit");
+
+out:
+	free_commit_extra_headers(original_extra_headers);
+	strbuf_release(&commit_message);
+	free(original_author);
+	return ret;
+}
+
+enum ref_action {
+	REF_ACTION_DEFAULT,
+	REF_ACTION_BRANCHES,
+	REF_ACTION_HEAD,
+	REF_ACTION_PRINT,
+};
+
+static int parse_ref_action(const struct option *opt, const char *value, int unset)
+{
+	enum ref_action *action = opt->value;
+
+	BUG_ON_OPT_NEG_NOARG(unset, value);
+	if (!strcmp(value, "branches")) {
+		*action = REF_ACTION_BRANCHES;
+	} else if (!strcmp(value, "head")) {
+		*action = REF_ACTION_HEAD;
+	} else if (!strcmp(value, "print")) {
+		*action = REF_ACTION_PRINT;
+	} else {
+		return error(_("%s expects one of 'branches', 'head' or 'print'"),
+			     opt->long_name);
+	}
+
+	return 0;
+}
+
+static int handle_reference_updates(enum ref_action action,
+				    struct repository *repo,
+				    struct commit *original,
+				    struct commit *rewritten,
+				    const char *reflog_msg)
+{
+	const struct name_decoration *decoration;
+	struct replay_revisions_options opts = { 0 };
+	struct replay_result result = { 0 };
+	struct ref_transaction *transaction = NULL;
+	struct strvec args = STRVEC_INIT;
+	struct strbuf err = STRBUF_INIT;
+	struct commit *head = NULL;
+	struct rev_info revs;
+	char hex[GIT_MAX_HEXSZ + 1];
+	bool detached_head;
+	int head_flags = 0;
+	int ret;
+
+	refs_read_ref_full(get_main_ref_store(repo), "HEAD",
+			   RESOLVE_REF_NO_RECURSE, NULL, &head_flags);
+	detached_head = !(head_flags & REF_ISSYMREF);
+
+	repo_init_revisions(repo, &revs, NULL);
+	strvec_push(&args, "ignored");
+	strvec_push(&args, "--reverse");
+	strvec_push(&args, "--topo-order");
+	strvec_push(&args, "--full-history");
+
+	/* We only want to see commits that are descendants of the old commit. */
+	strvec_pushf(&args, "--ancestry-path=%s",
+		     oid_to_hex(&original->object.oid));
+
+	/*
+	 * Ancestry path may also show ancestors of the old commit, but we
+	 * don't want to see those, either.
+	 */
+	strvec_pushf(&args, "^%s", oid_to_hex(&original->object.oid));
+
+	/*
+	 * When we're asked to update HEAD we need to verify that the commit
+	 * that we want to rewrite is actually an ancestor of it and, if so,
+	 * update it. Otherwise we'll update (or print) all descendant
+	 * branches.
+	 */
+	if (action == REF_ACTION_HEAD) {
+		struct commit_list *from_list = NULL;
+
+		head = lookup_commit_reference_by_name("HEAD");
+		if (!head) {
+			ret = error(_("cannot look up HEAD"));
+			goto out;
+		}
+
+		commit_list_insert(original, &from_list);
+		ret = repo_is_descendant_of(repo, head, from_list);
+		free_commit_list(from_list);
+
+		if (ret < 0) {
+			ret = error(_("cannot determine descendance"));
+			goto out;
+		} else if (!ret) {
+			ret = error(_("rewritten commit must be an ancestor "
+				      "of HEAD when using --ref-action=head"));
+			goto out;
+		}
+
+		strvec_push(&args, "HEAD");
+	} else {
+		strvec_push(&args, "--branches");
+		strvec_push(&args, "HEAD");
+	}
+
+	setup_revisions_from_strvec(&args, &revs, NULL);
+	if (args.nr != 1)
+		BUG("revisions were set up with invalid argument");
+
+	opts.onto = oid_to_hex_r(hex, &rewritten->object.oid);
+
+	ret = replay_revisions(&revs, &opts, &result);
+	if (ret)
+		goto out;
+
+	switch (action) {
+	case REF_ACTION_BRANCHES:
+	case REF_ACTION_HEAD:
+		transaction = ref_store_transaction_begin(get_main_ref_store(repo), 0, &err);
+		if (!transaction) {
+			ret = error(_("failed to begin ref transaction: %s"), err.buf);
+			goto out;
+		}
+
+		for (size_t i = 0; i < result.updates_nr; i++) {
+			ret = ref_transaction_update(transaction,
+						     result.updates[i].refname,
+						     &result.updates[i].new_oid,
+						     &result.updates[i].old_oid,
+						     NULL, NULL, 0, reflog_msg, &err);
+			if (ret) {
+				ret = error(_("failed to update ref '%s': %s"),
+					    result.updates[i].refname, err.buf);
+				goto out;
+			}
+		}
+
+		/*
+		 * `replay_revisions()` only updates references that are
+		 * ancestors of `rewritten`, so we need to manually
+		 * handle updating references that point to `original`.
+		 */
+		for (decoration = get_name_decoration(&original->object);
+		     decoration;
+		     decoration = decoration->next)
+		{
+			if (decoration->type != DECORATION_REF_LOCAL &&
+			    decoration->type != DECORATION_REF_HEAD)
+				continue;
+
+			if (action == REF_ACTION_HEAD &&
+			    decoration->type != DECORATION_REF_HEAD)
+				continue;
+
+			/*
+			 * We only need to update HEAD separately in case it's
+			 * detached. If it's not we'd already update the branch
+			 * it is pointing to.
+			 */
+			if (action == REF_ACTION_BRANCHES &&
+			    decoration->type == DECORATION_REF_HEAD &&
+			    !detached_head)
+				continue;
+
+			ret = ref_transaction_update(transaction,
+						     decoration->name,
+						     &rewritten->object.oid,
+						     &original->object.oid,
+						     NULL, NULL, 0, reflog_msg, &err);
+			if (ret) {
+				ret = error(_("failed to update ref '%s': %s"),
+					    decoration->name, err.buf);
+				goto out;
+			}
+		}
+
+		if (ref_transaction_commit(transaction, &err)) {
+			ret = error(_("failed to commit ref transaction: %s"), err.buf);
+			goto out;
+		}
+
+		break;
+	case REF_ACTION_PRINT:
+		for (size_t i = 0; i < result.updates_nr; i++)
+			printf("update %s %s %s\n",
+			       result.updates[i].refname,
+			       oid_to_hex(&result.updates[i].new_oid),
+			       oid_to_hex(&result.updates[i].old_oid));
+		break;
+	default:
+		BUG("unsupported ref action %d", action);
+	}
+
+	ret = 0;
+
+out:
+	ref_transaction_free(transaction);
+	replay_result_release(&result);
+	release_revisions(&revs);
+	strbuf_release(&err);
+	strvec_clear(&args);
+	return ret;
+}
+
+static int cmd_history_reword(int argc,
+			      const char **argv,
+			      const char *prefix,
+			      struct repository *repo)
+{
+	const char * const usage[] = {
+		GIT_HISTORY_REWORD_USAGE,
+		NULL,
+	};
+	enum ref_action action = REF_ACTION_DEFAULT;
+	struct option options[] = {
+		OPT_CALLBACK_F(0, "ref-action", &action, N_("<action>"),
+			       N_("control ref update behavior (branches|head|print)"),
+			       PARSE_OPT_NONEG, parse_ref_action),
+		OPT_END(),
+	};
+	struct strbuf reflog_msg = STRBUF_INIT;
+	struct commit *original, *rewritten;
+	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;
+
+	original = lookup_commit_reference_by_name(argv[0]);
+	if (!original) {
+		ret = error(_("commit cannot be found: %s"), argv[0]);
+		goto out;
+	}
+
+	ret = commit_tree_with_edited_message(repo, "reworded", original, &rewritten);
+	if (ret < 0) {
+		ret = error(_("failed writing reworded commit"));
+		goto out;
+	}
+
+	strbuf_addf(&reflog_msg, "reword: updating %s", argv[0]);
+
+	ret = handle_reference_updates(action, repo, original, rewritten,
+				       reflog_msg.buf);
+	if (ret < 0) {
+		ret = error(_("failed replaying descendants"));
+		goto out;
+	}
+
+	ret = 0;
+
+out:
+	strbuf_release(&reflog_msg);
+	return ret;
+}
 
 int cmd_history(int argc,
 		const char **argv,
 		const char *prefix,
-		struct repository *repo UNUSED)
+		struct repository *repo)
 {
 	const char * const usage[] = {
-		N_("git history [<options>]"),
+		GIT_HISTORY_REWORD_USAGE,
 		NULL,
 	};
+	parse_opt_subcommand_fn *fn = NULL;
 	struct option options[] = {
+		OPT_SUBCOMMAND("reword", &fn, cmd_history_reword),
 		OPT_END(),
 	};
 
 	argc = parse_options(argc, argv, prefix, options, usage, 0);
-	if (argc)
-		usagef("unrecognized argument: %s", argv[0]);
-	return 0;
+	return fn(argc, argv, prefix, repo);
 }
diff --git a/t/meson.build b/t/meson.build
index 73006b095a..c9f92450dc 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -388,6 +388,7 @@ integration_tests = [
   't3437-rebase-fixup-options.sh',
   't3438-rebase-broken-files.sh',
   't3450-history.sh',
+  't3451-history-reword.sh',
   't3500-cherry.sh',
   't3501-revert-cherry-pick.sh',
   't3502-cherry-pick-merge.sh',
diff --git a/t/t3450-history.sh b/t/t3450-history.sh
index 417c343d43..f513463b92 100755
--- a/t/t3450-history.sh
+++ b/t/t3450-history.sh
@@ -5,13 +5,13 @@ test_description='tests for git-history command'
 . ./test-lib.sh
 
 test_expect_success 'does nothing without any arguments' '
-	git history >out 2>&1 &&
-	test_must_be_empty out
+	test_must_fail git history 2>err &&
+	test_grep "need a subcommand" err
 '
 
 test_expect_success 'raises an error with unknown argument' '
 	test_must_fail git history garbage 2>err &&
-	test_grep "unrecognized argument: garbage" err
+	test_grep "unknown subcommand: .garbage." err
 '
 
 test_done
diff --git a/t/t3451-history-reword.sh b/t/t3451-history-reword.sh
new file mode 100755
index 0000000000..3594421b68
--- /dev/null
+++ b/t/t3451-history-reword.sh
@@ -0,0 +1,391 @@
+#!/bin/sh
+
+test_description='tests for git-history reword subcommand'
+
+. ./test-lib.sh
+. "$TEST_DIRECTORY/lib-log-graph.sh"
+
+reword_with_message () {
+	cat >message &&
+	write_script fake-editor.sh <<-\EOF &&
+	cp message "$1"
+	EOF
+	test_set_editor "$(pwd)"/fake-editor.sh &&
+	git history reword "$@" &&
+	rm fake-editor.sh message
+}
+
+expect_graph () {
+	cat >expect &&
+	lib_test_cmp_graph --graph --format=%s "$@"
+}
+
+expect_log () {
+	git log --format="%s" "$@" >actual &&
+	cat >expect &&
+	test_cmp expect actual
+}
+
+test_expect_success 'can reword tip of a branch' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		reword_with_message HEAD <<-EOF &&
+		third reworded
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		expect_log <<-\EOF &&
+		third reworded
+		second
+		first
+		EOF
+
+		git reflog >reflog &&
+		test_grep "reword: updating HEAD" reflog
+	)
+'
+
+test_expect_success 'can reword commit in the middle' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		git symbolic-ref HEAD >expect &&
+		reword_with_message HEAD~ <<-EOF &&
+		second reworded
+		EOF
+		git symbolic-ref HEAD >actual &&
+		test_cmp expect actual &&
+
+		expect_log <<-\EOF
+		third
+		second reworded
+		first
+		EOF
+	)
+'
+
+test_expect_success 'can reword commit in the middle even on detached head' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third_on_main &&
+		git checkout --detach HEAD^ &&
+		test_commit third_on_head &&
+
+		reword_with_message HEAD~ <<-EOF &&
+		second reworded
+		EOF
+
+		expect_graph HEAD --branches <<-\EOF
+		* third_on_head
+		| * third_on_main
+		|/
+		* second reworded
+		* first
+		EOF
+       )
+'
+
+test_expect_success 'can reword the detached head' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		git checkout --detach HEAD &&
+		test_commit third &&
+
+		reword_with_message HEAD <<-EOF &&
+		third reworded
+		EOF
+
+		expect_log <<-\EOF
+		third reworded
+		second
+		first
+		EOF
+	)
+'
+
+test_expect_success 'can reword root commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+		reword_with_message HEAD~2 <<-EOF &&
+		first reworded
+		EOF
+
+		expect_log <<-\EOF
+		third
+		second
+		first reworded
+		EOF
+	)
+'
+
+test_expect_success 'can reword in a bare repo' '
+	test_when_finished "rm -rf repo repo.git" &&
+	git init repo &&
+	test_commit -C repo first &&
+	git clone --bare repo repo.git &&
+	(
+		cd repo.git &&
+		reword_with_message HEAD <<-EOF &&
+		reworded
+		EOF
+
+		expect_log <<-\EOF
+		reworded
+		EOF
+	)
+'
+
+test_expect_success 'can reword a 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 &&
+
+		git rev-parse ours >ours-before &&
+		reword_with_message theirs <<-EOF &&
+		Reworded theirs
+		EOF
+		git rev-parse ours >ours-after &&
+		test_cmp ours-before ours-after &&
+
+		expect_graph --branches <<-\EOF
+		* Reworded theirs
+		| * ours
+		|/
+		* base
+		EOF
+	)
+'
+
+test_expect_success 'can reword a merge commit' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit base &&
+		git branch branch &&
+		test_commit ours &&
+		git switch branch &&
+		test_commit theirs &&
+		git switch - &&
+		git merge theirs &&
+
+		# It is not possible to replay merge commits embedded in the
+		# history (yet).
+		test_must_fail git history reword HEAD~ 2>err &&
+		test_grep "replaying merge commits is not supported yet" err &&
+
+		# But it is possible to reword a merge commit directly.
+		reword_with_message HEAD <<-EOF &&
+		Reworded merge commit
+		EOF
+		expect_graph <<-\EOF
+		*   Reworded merge commit
+		|\
+		| * theirs
+		* | ours
+		|/
+		* base
+		EOF
+	)
+'
+
+test_expect_success '--ref-action=print 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 ours &&
+		git switch branch &&
+		test_commit theirs &&
+
+		git refs list >refs-expect &&
+		reword_with_message --ref-action=print base >updates <<-\EOF &&
+		reworded commit
+		EOF
+		git refs list >refs-actual &&
+		test_cmp refs-expect refs-actual &&
+
+		test_grep "update refs/heads/branch" updates &&
+		test_grep "update refs/heads/main" updates &&
+		git update-ref --stdin <updates &&
+		expect_log --branches <<-\EOF
+		theirs
+		ours
+		reworded commit
+		EOF
+	)
+'
+
+test_expect_success '--ref-action=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 theirs &&
+		git switch branch &&
+		test_commit ours &&
+
+		# When told to update HEAD, only, the command will refuse to
+		# rewrite commits that are not an ancestor of HEAD.
+		test_must_fail git history reword --ref-action=head theirs 2>err &&
+		test_grep "rewritten commit must be an ancestor of HEAD" err &&
+
+		reword_with_message --ref-action=head base >updates <<-\EOF &&
+		reworded base
+		EOF
+		expect_log HEAD <<-\EOF &&
+		ours
+		reworded base
+		EOF
+		expect_log main <<-\EOF
+		theirs
+		base
+		EOF
+	)
+'
+
+test_expect_success 'editor shows proper status' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		write_script fake-editor.sh <<-\EOF &&
+		cp "$1" . &&
+		printf "\namend a comment\n" >>"$1"
+		EOF
+		test_set_editor "$(pwd)"/fake-editor.sh &&
+		git history reword HEAD &&
+
+		cat >expect <<-EOF &&
+		first
+
+		# Please enter the commit message for the reworded changes. Lines starting
+		# with ${SQ}#${SQ} will be ignored, and an empty message aborts the commit.
+		# Changes to be committed:
+		#	new file:   first.t
+		#
+		EOF
+		test_cmp expect COMMIT_EDITMSG &&
+
+		test_commit_message HEAD <<-\EOF
+		first
+
+		amend a comment
+		EOF
+	)
+'
+
+# For now, git-history(1) does not yet execute any hooks. This is subject to
+# change in the future, and if it does this test here is expected to start
+# failing. In other words, this test is not an endorsement of the current
+# status quo.
+test_expect_success 'hooks are not executed for rewritten commits' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+		test_commit second &&
+		test_commit third &&
+
+		ORIG_PATH="$(pwd)" &&
+		export ORIG_PATH &&
+		for hook in prepare-commit-msg pre-commit post-commit post-rewrite commit-msg
+		do
+			write_script .git/hooks/$hook <<-\EOF || exit 1
+			touch "$ORIG_PATH/hooks.log
+			EOF
+		done &&
+
+		reword_with_message HEAD~ <<-EOF &&
+		second reworded
+		EOF
+
+		cat >expect <<-EOF &&
+		third
+		second reworded
+		first
+		EOF
+		git log --format=%s >actual &&
+		test_cmp expect actual &&
+
+		test_path_is_missing hooks.log
+	)
+'
+
+test_expect_success 'aborts with empty commit message' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		test_commit first &&
+
+		! reword_with_message HEAD 2>err </dev/null &&
+		test_grep "Aborting commit due to empty commit message." err
+	)
+'
+
+test_expect_success 'retains changes in the worktree and index' '
+	test_when_finished "rm -rf repo" &&
+	git init repo &&
+	(
+		cd repo &&
+		touch a b &&
+		git add . &&
+		git commit -m "initial commit" &&
+		echo foo >a &&
+		echo bar >b &&
+		git add b &&
+		reword_with_message HEAD <<-EOF &&
+		message
+		EOF
+		cat >expect <<-\EOF &&
+		 M a
+		M  b
+		?? actual
+		?? expect
+		EOF
+		git status --porcelain >actual &&
+		test_cmp expect actual
+	)
+'
+
+test_done
-- 
2.52.0.590.g1f87b77810.dirty
SZEDER Gábor· Jan 16, 2026, 16:28 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v11 8/8] builtin/history: implement "reword" subcommand

On Tue, Jan 13, 2026 at 10:54:39AM +0100, Patrick Steinhardt wrote:
Show 13 quoted lines
> Implement a new "reword" subcommand for git-history(1). This subcommand
> is similar to the user performing an interactive rebase with a single
> commit changed to use the "reword" instruction.
> 
> The "reword" subcommand is built on top of the replay subsystem
> instead of the sequencer. This leads to some major differences compared
> to git-rebase(1):
> 
>   - We do not check out the commit that is to be reworded and instead
>     perform the operation in-memory. This has the obvious benefit of
>     being significantly faster compared to git-rebase(1), but even more
>     importantly it allows the user to rewrite history even if there are
>     local changes in the working tree or in the index.

In an earlier round I pointed out some of the differences between the 'reword' instruction of 'git rebase' and 'git history rebase', including some drawbacks of the latter. It's disheartening to see that you only picked those differences that are in favor of your 'git history' command, but neglected its drawbacks.

Please strive for less biased and more objective commit messages.
Show 6 quoted lines
>   - We do not execute any hooks, even though we leave some room for
>     changing this in the future.
> 
>   - By default, all local branches that contain the commit will be
>     rewritten. This especially helps with workflows that use stacked
>     branches.

Please don't just state that all local branches containing the modified commit are rewritten, but justify why it behaves that way.

Git's porcelain commands operate on the current branch, unless the user specifies a different branch or an option like '--all' or '--branches'. The default chosen here is inconsistent with the rest of Git.

This is a bad default for any future subcommands implementing common history rewriting operations that can cause conflicts.

Users must remember to specify a non-default '--ref-action' if they don't want this behavior. If they forget to do so and don't notice it, the old commits will be gc-ed away. Therefore, I consider this to be a dangerous default that can lead to data loss.

I firmly believe that operating on all local branches must always be the result of an explicit user action.

Elijah Newren· Jan 17, 2026, 22:56 UTC · re: SZEDER Gábor · lore

Re: [PATCH v11 8/8] builtin/history: implement "reword" subcommand

On Fri, Jan 16, 2026 at 8:28 AM SZEDER Gábor <szeder.dev@gmail.com> wrote:
Show 21 quoted lines
>
> On Tue, Jan 13, 2026 at 10:54:39AM +0100, Patrick Steinhardt wrote:
> > Implement a new "reword" subcommand for git-history(1). This subcommand
> > is similar to the user performing an interactive rebase with a single
> > commit changed to use the "reword" instruction.
> >
> > The "reword" subcommand is built on top of the replay subsystem
> > instead of the sequencer. This leads to some major differences compared
> > to git-rebase(1):
> >
> >   - We do not check out the commit that is to be reworded and instead
> >     perform the operation in-memory. This has the obvious benefit of
> >     being significantly faster compared to git-rebase(1), but even more
> >     importantly it allows the user to rewrite history even if there are
> >     local changes in the working tree or in the index.
>
> In an earlier round I pointed out some of the differences between the
> 'reword' instruction of 'git rebase' and 'git history rebase',
> including some drawbacks of the latter.  It's disheartening to see
> that you only picked those differences that are in favor of your 'git
> history' command, but neglected its drawbacks.

That'd be https://lore.kernel.org/git/aSVpXPtrqa0cBsEm@szeder.dev/ , right? I see two things you commented on there. Although I'm not Patrick, let me respond to each, in reverse order:

Personally, I'm having difficulty understanding your second stated advantage of picking the commit from the rebase instruction sheet. That instruction sheet is obtained by rebasing on top of something, and it seems as easy to me to see the commits since that point in a git log command and pick your desired commit from log output as it is to invoke an interactive rebase on top of that base commit to get the rebase instruction sheet and then pick out your desired commit from there. Same number of operations and work either way, so I don't see how either is more or less work than the other.

You did make a good point that one of the differences is that you don't have the commit checked out. That's a useful distinction to be aware of. To me, that seems to be somewhat implied already both by "in-memory" and "even if there are local changes in the working tree or index", though it wouldn't hurt to explicitly call it out.

> Please strive for less biased and more objective commit messages.

He rewrote the commit message from v6 to v7 (https://lore.kernel.org/git/20251027-b4-pks-history-builtin-v6-5-407dd3f57ad3@pks.im/ -> https://lore.kernel.org/git/20251203-b4-pks-history-builtin-v7-5-9e9f849bfd0e@pks.im/), as far as I can tell precisely to respond to your feedback. Perhaps he didn't achieve what you wanted, but why jump to the conclusion of "bias" rather than that he missed conveying an important distinction as clearly as you wanted?

Show 9 quoted lines
> >   - We do not execute any hooks, even though we leave some room for
> >     changing this in the future.
> >
> >   - By default, all local branches that contain the commit will be
> >     rewritten. This especially helps with workflows that use stacked
> >     branches.
>
> Please don't just state that all local branches containing the
> modified commit are rewritten, but justify why it behaves that way.

It feels like each of your complaints with the new proposed commands (given commit not checked out, rebase instruction sheet vs listing commit, and HEAD-only) can be boiled down to the fact that they don't behave like `git rebase`. Is that accurate? If you like rebase, is there a reason you are worried you can't just keep using it? I don't see why others should be required to implement another exact copy of rebase, though. Further, if we only wanted minor modifications, we could have just done those to git rebase.

If you aren't just arguing to match git rebase's behavior exactly, let me try to explain the all-descendant-branches thing from my angle.

One of the issues that I've long hated about git rebase is that if you have multiple inter-dependent branches, it's a royal pain to rebase them all. You cannot rebase them independently, because that disconnects the history by duplicating the shared portions so that you have N copies of each of those. And I couldn't see a way to fix that inside git rebase; its design basically ties you to a single branch.

Further, maybe it'd be useful to explore the different proposed defaults a bit with the 'history reword' example. Let's assume we used your preferred default (rewrite only a single branch) for a user who didn't like that default. If a user does a history reword, and later realizes that their other branches didn't get updated, how do they fix the others? They might be inclined to loop over the other branches and do a 'git history reword CommitZ' on each of them, but then they'd get the nasty surprise of having N different reworded CommitZ's (even if they reworded identically), one per branch. Alternatively, if they are aware that they can't simply reuse the same command to replay the other branches, they'll then start asking questions about how exactly to fix up all the other branches...and the command(s) they need to run is going to be a lot more complicated, especially if they have since added additional commits on top of the active branch that they don't want to undo and lose. It feels like a hard recovery story. In contrast, we can consider the case of the default being to update all branches for a user who doesn't want it. If that user finds that other local branches were also rebased and they didn't want them to be, they just go reset that branch or branches from the reflog, which is pretty easy.

> Git's porcelain commands operate on the current branch, unless the
> user specifies a different branch or an option like '--all' or
> '--branches'.  The default chosen here is inconsistent with the rest
> of Git.
That's a good point that there are existing commands that default to
the current branch.  By my accounting, commands that operate on a
range of commits are:
   "view a range of commits": log, and derivatives like rev-list
   "edit a range of commits": fast-import, filter-branch, filter-repo,
rebase, replay
   "copy a range of commits (or copy their inverse)": cherry-pick, revert

Hopefully I didn't miss any from skimming over 'git help --all'; my apologies if so. Anyway, let's consider these commands, in reverse order:

I'm not sure the "copy a range of commits" provide much of a precedent for editing a range, since the replay equivalent was always envisioned to only update a single branch for those despite otherwise being envisioned as a rewrite-all-branches-by-default thing. But there's actually a unifying piece of logic that can lead you to the different branch handling: we want to avoid having duplicates of commits simultaneously in use and thus default to as few copies as possible. That logic means that for "copy a range", we apply them to just one branch. The same logic for "edit a range", means to rebase all descendants on any local branch.

For the "edit a range of commits", filter-branch did default to a single branch but is deeply deprecated and more or less warns you that the whole tool was a design mistake. The replacement (filter-repo) in its name makes it clear that one of the mistakes was single branch vs. all branches. fast-import is also more of an all-branch thing, though it doesn't so much have a default. git-rebase does have a single-branch precedent, but that was called out rather strongly in my design goals for its newer alternative, git-replay ("Decapitate HEAD-centric assumptions", https://lore.kernel.org/git/20230407072415.1360068-1-christian.couder@gmail.com/). If I hadn't viewed the single-branch handling in rebase as a mistake, I probably would have never created git-replay and instead just incrementally improved git-rebase. Since it was that mistake that lead to new tools in the first place, I'm not sure how much of a precedent git-rebase should be considered to be setting here.

For "viewing a range of commits", git log does lend towards your argument; it could be seen as precedent setting in favor of just-the-current-branch.

So, the precedent seems to not be uniform among git commands, though I think git log is strongly in your favor. As a really rough measure of how much that precedent matters, we can look at how folks have commented on what they think the default should be so far in this whole thread:

Rebase-all-descendant branches:
  * Phillip (https://lore.kernel.org/git/91bd9241-96c1-4b34-98a9-af3bad345c4d@gmail.com/)
  * Junio (https://lore.kernel.org/git/xmqqms3qh13e.fsf@gitster.g/)
  * Kristoffer (https://lore.kernel.org/git/b3ddfaa4-526b-41e3-b12a-0fec846ac7bc@app.fastmail.com/)
  * Ben (https://lore.kernel.org/git/3600D877-4999-4EE3-8C1C-893E12D35B6A@gmail.com/)
  * me (this email, among others)
  * Martin (https://lore.kernel.org/git/CANiSa6hxjghKQMhURx8qC2t=+1gEE7p8YaHbWkg3rYOYa=poVg@mail.gmail.com/)
  * Patrick ("Yup" from
https://lore.kernel.org/git/aKs3tqjE510MF0T-@pks.im/, plus this v11
we're responding to, though he did seem to vacillate over the course
of the series)
Rebase-current-branch-only:
  * You (your email that I'm responding to)
Do not allow editing a commit shared by multiple branches:
  * Matthias (https://lore.kernel.org/git/4m6rmefbv4hftclimitz5rp6yapswjtnjsxymrsdkuan4jbg3u@dm5jzdiq5cxz/)

(Sorry if I missed any, I tried to find them all in the threads on this topic.)

> This is a bad default for any future subcommands implementing common
> history rewriting operations that can cause conflicts.

Totally disagree; we'd want rebase-all-descendant-local-branches for those commands too. In fact, that's precisely what I did with "git replay edit". (True, "git replay edit" was just a proof-of-concept because I didn't have conflict handling implemented, but it was very much an intentional default for a command known to need to deal with conflicts before being productionized).

Further, we have two semi-independent implementations of replay-all-descendant-branches-by-default in the form of JJ and GitButler, with real world use (not just demos) and apparent consensus that it not only works but was a good decision.

So, I'm a little unsure at how you arrived at this conclusion; do you care to elucidate?

> Users must remember to specify a non-default '--ref-action' if they
> don't want this behavior.  If they forget to do so and don't notice
> it, the old commits will be gc-ed away.  Therefore, I consider this
> to be a dangerous default that can lead to data loss.

You elided over "for a really long time so that the reflogs expire", but you bring up a good point. I think the key here is "don't notice it". With "git replay edit", I'd print notices about what was updated after each operation (particularly important since the operation could be a `git commit --amend` or `git reset HEAD~1` or whatever, which causes commits which are the descendants of the one you are operating on to be replayed). To avoid the "don't notice it" issue, we could do the same with Patrick's history command. We could also prevent a forgotten --ref-action by allowing a config variable, which I'll cover more below.

> I firmly believe that operating on all local branches must always be
> the result of an explicit user action.

Over at https://lore.kernel.org/git/aUVaEPGoOkATQGl3@szeder.dev/ , you alternatively suggested the idea of an "escape hatch". I think that may be a good idea. What if we had a "history.scope" config variable, with values like "descendant-branches", "current-branch", and "error-if-multiple-branches", corresponding to each of the requested defaults we've seen in response to this series? I can't imagine using anything other than descendant-branches, and the preponderance of those who have commented on the default so far seem to be in agreement, but it would allow you and Matthias and others like you two to pick an alternative. Thoughts?

Junio C Hamano· Jan 18, 2026, 17:50 UTC · re: Elijah Newren · lore

Re: [PATCH v11 8/8] builtin/history: implement "reword" subcommand

Elijah Newren <newren@gmail.com> writes:
Show 6 quoted lines
> ... that they don't want to undo and lose.  It feels like a
> hard recovery story.  In contrast, we can consider the case of the
> default being to update all branches for a user who doesn't want it.
> If that user finds that other local branches were also rebased and
> they didn't want them to be, they just go reset that branch or
> branches from the reflog, which is pretty easy.

Very nicely put. I originally thought that the "reflog is your friend" argument would apply in both direction, but leaving the tips of some branches behind while rewriting the tips of some other branches, that can reach the tips of these left-bhind branches, would mean we have no other commit to go back to.

Thanks.
Junio C Hamano· Feb 3, 2026, 00:01 UTC · re: Elijah Newren · lore

Re: [PATCH v11 8/8] builtin/history: implement "reword" subcommand

Elijah Newren <newren@gmail.com> writes:
Show 41 quoted lines
> On Fri, Jan 16, 2026 at 8:28 AM SZEDER Gábor <szeder.dev@gmail.com> wrote:
>>
> ...
> Personally, I'm having difficulty understanding your second stated
> advantage of picking the commit from the rebase instruction sheet.
> That instruction sheet is obtained by rebasing on top of something,
> and it seems as easy to me to see the commits since that point in a
> git log command and pick your desired commit from log output as it is
> to invoke an interactive rebase on top of that base commit to get the
> rebase instruction sheet and then pick out your desired commit from
> there.  Same number of operations and work either way, so I don't see
> how either is more or less work than the other.
>
> You did make a good point that one of the differences is that you
> don't have the commit checked out.  That's a useful distinction to be
> aware of.  To me, that seems to be somewhat implied already both by
> "in-memory" and "even if there are local changes in the working tree
> or index", though it wouldn't hurt to explicitly call it out.
>
> It feels like each of your complaints with the new proposed commands
> (given commit not checked out, rebase instruction sheet vs listing
> commit, and HEAD-only) can be boiled down to the fact that they don't
> behave like `git rebase`.  Is that accurate?  If you like rebase, is
> there a reason you are worried you can't just keep using it?  I don't
> see why others should be required to implement another exact copy of
> rebase, though.  Further, if we only wanted minor modifications, we
> could have just done those to git rebase.
>
>> I firmly believe that operating on all local branches must always be
>> the result of an explicit user action.
>
> Over at https://lore.kernel.org/git/aUVaEPGoOkATQGl3@szeder.dev/ , you
> alternatively suggested the idea of an "escape hatch".  I think that
> may be a good idea.  What if we had a "history.scope" config variable,
> with values like "descendant-branches", "current-branch", and
> "error-if-multiple-branches", corresponding to each of the requested
> defaults we've seen in response to this series?  I can't imagine using
> anything other than descendant-branches, and the preponderance of
> those who have commented on the default so far seem to be in
> agreement, but it would allow you and Matthias and others like you two
> to pick an alternative.  Thoughts?

The discussion stalled after this exchange. What is the next move? I think the it is SZEDER's turn to respond?

The topic is depended on Phillip's "git replay" tweak to drop commits that are originally not-empty and become empty in the replayed history, blocking its advance, which is doubly unfortunate.

Thanks.
Patrick Steinhardt· Feb 5, 2026, 08:19 UTC · re: Junio C Hamano · lore

Re: [PATCH v11 8/8] builtin/history: implement "reword" subcommand

On Mon, Feb 02, 2026 at 04:01:10PM -0800, Junio C Hamano wrote:
Show 29 quoted lines
> Elijah Newren <newren@gmail.com> writes:
> 
> > On Fri, Jan 16, 2026 at 8:28 AM SZEDER Gábor <szeder.dev@gmail.com> wrote:
> >>
> > ...
> > Personally, I'm having difficulty understanding your second stated
> > advantage of picking the commit from the rebase instruction sheet.
> > That instruction sheet is obtained by rebasing on top of something,
> > and it seems as easy to me to see the commits since that point in a
> > git log command and pick your desired commit from log output as it is
> > to invoke an interactive rebase on top of that base commit to get the
> > rebase instruction sheet and then pick out your desired commit from
> > there.  Same number of operations and work either way, so I don't see
> > how either is more or less work than the other.
> >
> > You did make a good point that one of the differences is that you
> > don't have the commit checked out.  That's a useful distinction to be
> > aware of.  To me, that seems to be somewhat implied already both by
> > "in-memory" and "even if there are local changes in the working tree
> > or index", though it wouldn't hurt to explicitly call it out.
> >
> > It feels like each of your complaints with the new proposed commands
> > (given commit not checked out, rebase instruction sheet vs listing
> > commit, and HEAD-only) can be boiled down to the fact that they don't
> > behave like `git rebase`.  Is that accurate?  If you like rebase, is
> > there a reason you are worried you can't just keep using it?  I don't
> > see why others should be required to implement another exact copy of
> > rebase, though.  Further, if we only wanted minor modifications, we
> > could have just done those to git rebase.

git-history(1) is definitely quite different compared to git-rebase(1), and that's by design. Not everyone will like it, and many folks will probably continue to just use git-rebase(1) themselves. But as you say, it does address some use cases that git-rebase(1) doesn't handle itself, and it tries to provide an opinionated interface to make common tasks easier to handle for folks who aren't advanced users.

I of course wouldn't mind highlighting more of the differences in the docs and/or the commit message, of course. It's tough sometimes to find the right level of details.

Show 13 quoted lines
> >> I firmly believe that operating on all local branches must always be
> >> the result of an explicit user action.
> >
> > Over at https://lore.kernel.org/git/aUVaEPGoOkATQGl3@szeder.dev/ , you
> > alternatively suggested the idea of an "escape hatch".  I think that
> > may be a good idea.  What if we had a "history.scope" config variable,
> > with values like "descendant-branches", "current-branch", and
> > "error-if-multiple-branches", corresponding to each of the requested
> > defaults we've seen in response to this series?  I can't imagine using
> > anything other than descendant-branches, and the preponderance of
> > those who have commented on the default so far seem to be in
> > agreement, but it would allow you and Matthias and others like you two
> > to pick an alternative.  Thoughts?

I would certainly be happy to add such an escape hatch to the command so that it only rewrites the current branch. We already have the infrastructure for that in place anyway, so it's trivial to add a configuration key that changes the default.

> The discussion stalled after this exchange.  What is the next move?
> I think the it is SZEDER's turn to respond?

I dunno. From my point of view I still believe this is ready to be merged. Sure, there will be follow-up topics:

  - The above configuration to make it work on a single branch, only.
  - First-class conflicts. This is something that I also discussed with
    Elijah already, and we will coordinate on this topic going forward.
  - Additional subcommands, of course.

But I don't think that missing support for first-class conflicts needs to hold back this topic. The proposed command cannot result in conflicts anyway, and there's more commands that won't. Furthermore, the command is marked as experimental, so the initial versions are expected to be somewhat limited in their functionality so that we can get feedback and iterate on the design if we see that it needs some tweaking.

Thanks!
Patrick
Elijah Newren· Jan 16, 2026, 06:43 UTC · re: Patrick Steinhardt · lore

Re: [PATCH v11 0/8] Introduce git-history(1) command for easy history editing

On Tue, Jan 13, 2026 at 1:54 AM Patrick Steinhardt <ps@pks.im> wrote: [...]

Show 9 quoted lines
> Changes in v11:
>   - Fix overly clever `BUG()` condition.
>   - Drop the `struct replay_result::merge_conflict` field.
>   - Return a positive value from `replay_revisions()` in case there was
>     a conflict.
>   - Improve a commit message.
>   - Fix check for whether `setup_revisions_from_strvec()` was
>     successful.
>   - Link to v10: https://lore.kernel.org/r/20260112-b4-pks-history-builtin-v10-0-e3c6aa5b4cec@pks.im
This version looks good to merge down.
Junio C Hamano· Jan 16, 2026, 15:21 UTC · re: Elijah Newren · lore

Re: [PATCH v11 0/8] Introduce git-history(1) command for easy history editing

Elijah Newren <newren@gmail.com> writes:
Show 13 quoted lines
> On Tue, Jan 13, 2026 at 1:54 AM Patrick Steinhardt <ps@pks.im> wrote:
> [...]
>> Changes in v11:
>>   - Fix overly clever `BUG()` condition.
>>   - Drop the `struct replay_result::merge_conflict` field.
>>   - Return a positive value from `replay_revisions()` in case there was
>>     a conflict.
>>   - Improve a commit message.
>>   - Fix check for whether `setup_revisions_from_strvec()` was
>>     successful.
>>   - Link to v10: https://lore.kernel.org/r/20260112-b4-pks-history-builtin-v10-0-e3c6aa5b4cec@pks.im
>
> This version looks good to merge down.
Thanks.
SZEDER Gábor· Jan 16, 2026, 16:32 UTC · re: Junio C Hamano · lore

Re: [PATCH v11 0/8] Introduce git-history(1) command for easy history editing

On Fri, Jan 16, 2026 at 07:21:21AM -0800, Junio C Hamano wrote:
Show 17 quoted lines
> Elijah Newren <newren@gmail.com> writes:
> 
> > On Tue, Jan 13, 2026 at 1:54 AM Patrick Steinhardt <ps@pks.im> wrote:
> > [...]
> >> Changes in v11:
> >>   - Fix overly clever `BUG()` condition.
> >>   - Drop the `struct replay_result::merge_conflict` field.
> >>   - Return a positive value from `replay_revisions()` in case there was
> >>     a conflict.
> >>   - Improve a commit message.
> >>   - Fix check for whether `setup_revisions_from_strvec()` was
> >>     successful.
> >>   - Link to v10: https://lore.kernel.org/r/20260112-b4-pks-history-builtin-v10-0-e3c6aa5b4cec@pks.im
> >
> > This version looks good to merge down.
> 
> Thanks.
FWIW, I think it's far from ready to be merged.
Junio C Hamano· Jan 21, 2026, 19:02 UTC · re: SZEDER Gábor · lore

Re: [PATCH v11 0/8] Introduce git-history(1) command for easy history editing

SZEDER Gábor <szeder.dev@gmail.com> writes:
Show 20 quoted lines
> On Fri, Jan 16, 2026 at 07:21:21AM -0800, Junio C Hamano wrote:
>> Elijah Newren <newren@gmail.com> writes:
>> 
>> > On Tue, Jan 13, 2026 at 1:54 AM Patrick Steinhardt <ps@pks.im> wrote:
>> > [...]
>> >> Changes in v11:
>> >>   - Fix overly clever `BUG()` condition.
>> >>   - Drop the `struct replay_result::merge_conflict` field.
>> >>   - Return a positive value from `replay_revisions()` in case there was
>> >>     a conflict.
>> >>   - Improve a commit message.
>> >>   - Fix check for whether `setup_revisions_from_strvec()` was
>> >>     successful.
>> >>   - Link to v10: https://lore.kernel.org/r/20260112-b4-pks-history-builtin-v10-0-e3c6aa5b4cec@pks.im
>> >
>> > This version looks good to merge down.
>> 
>> Thanks.
>
> FWIW, I think it's far from ready to be merged.
Will the discussion continue in the other subthread, i.e.,
https://lore.kernel.org/git/CABPp-BHkNLdH4C7U4sFoVhrsSPH8KAaDtOdLEQGyajmXZz9hVg@mail.gmail.com/

← back to recent threads