git/list[1] front-page[2] threads[3] people[4] search[5] about
 

[PATCH v4 0/2] history: sign rewritten commits

From
SSouma <git@5ouma.me>
Date
Oct 2, 2026, 13:27 UTC
Message-ID
<20261002132718.3830-1-git@5ouma.me>
In-Reply-To
<20260703145037.69832-1-git@5ouma.me>

History rewriting creates commits through two paths: the history commands write replacement commits directly, while the replay machinery recreates descendants above the rewritten range. Neither path currently honors `commit.gpgSign` or an explicit signing request, so rewriting signed history can leave the resulting commits unsigned.

Add a signing-key option to the replay API, then have the history commands pass the selected signer through both paths. Expose the standard `-S`/`--gpg-sign[=<key-id>]` and `--no-gpg-sign` options for `drop`, `fixup`, `reword`, `split`, and `squash`. This applies one signing policy to every commit created by the rewrite, including both commits from `split`, the commit from `squash`, and replayed descendants.

The behavior follows rebase, cherry-pick, and revert: `commit.gpgSign` supplies the default, command-line options override it, and the last command-line option wins. The signature attests the current committer's rewrite while preserving the original author identity.

Changes since v3:
 - Add signing support to `git history squash`, including its synopsis,
   configuration and command-line behavior, and replayed descendants
 - Add GPG-gated squash tests for configuration, option precedence,
   explicit keys, the squashed commit, and replayed descendants
 - Document the signing options for the squash subcommand
 - Clarify the commit messages based on review feedback, including why
   configuration is loaded before option parsing and that the replay
   infrastructure is consumed by the follow-up history change
Souma (2):
  replay: allow callers to sign commits
  history: sign rewritten commits
 Documentation/git-history.adoc | 18 +++++--
 builtin/history.c              | 96 +++++++++++++++++++++++++---------
 replay.c                       | 13 +++--
 replay.h                       |  6 +++
 t/t3451-history-reword.sh      | 63 ++++++++++++++++++++++
 t/t3452-history-split.sh       | 44 ++++++++++++++++
 t/t3453-history-fixup.sh       | 39 ++++++++++++++
 t/t3454-history-drop.sh        | 50 ++++++++++++++++++
 t/t3455-history-squash.sh      | 61 +++++++++++++++++++++
 9 files changed, 356 insertions(+), 34 deletions(-)
Range-diff against v3:
1:  ca35b0acaa ! 1:  d45cce8e25 replay: allow callers to sign commits
    @@ Commit message
         Add a signing-key option to replay_revisions_options and pass it to
         commit_tree_extended() when creating replayed commits.
     
    +    This provides the replay infrastructure for history commands to sign
    +    replayed descendants.
    +
         Signed-off-by: Souma <git@5ouma.me>
     
      ## replay.c ##
2:  f0a1a88411 ! 2:  8b4766fc0e history: sign rewritten commits
    @@ Commit message
         signing key through direct rewrites and replayed descendants while
         preserving the original author identity.
     
    -    Cover configuration, command-line precedence, explicit keys, split commits,
    -    and replayed descendants with GPG-gated tests.
    +    Load history configuration before parsing command-line options so
    +    command-line signing options override commit.gpgSign.
    +
    +    Cover configuration, command-line precedence, explicit keys, split
    +    commits, and replayed descendants with GPG-gated tests.
     
         Signed-off-by: Souma <git@5ouma.me>
     
    @@ Documentation/git-history.adoc: git-history - EXPERIMENTAL: Rewrite history
     -git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]
     -git history reword <commit> [--dry-run] [--update-refs=(branches|head)]
     -git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]
    +-git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>
     +git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)] [--[no-]gpg-sign[=<key-id>]]
     +git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)] [--[no-]gpg-sign[=<key-id>]]
     +git history reword <commit> [--dry-run] [--update-refs=(branches|head)] [--[no-]gpg-sign[=<key-id>]]
     +git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--[no-]gpg-sign[=<key-id>]] [--] [<pathspec>...]
    ++git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] [--[no-]gpg-sign[=<key-id>]] <revision-range>
      
      DESCRIPTION
      -----------
    @@ builtin/history.c
      #define GIT_HISTORY_SPLIT_USAGE \
     -	N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--] [<pathspec>...]")
     +	N_("git history split <commit> [--dry-run] [--update-refs=(branches|head)] [--[no-]gpg-sign[=<key-id>]] [--] [<pathspec>...]")
    + #define GIT_HISTORY_SQUASH_USAGE \
    +-	N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] <revision-range>")
    ++	N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] [--[no-]gpg-sign[=<key-id>]] <revision-range>")
      
      static void change_data_free(void *util, const char *str UNUSED)
      {
    @@ builtin/history.c: enum commit_tree_flags {
      static int commit_tree_ext(struct repository *repo,
      			   const char *action,
      			   struct commit *commit_with_message,
    +@@ builtin/history.c: static int commit_tree_ext(struct repository *repo,
      			   const struct commit_list *parents,
      			   const struct object_id *old_tree,
      			   const struct object_id *new_tree,
    @@ builtin/history.c: static int commit_tree_ext(struct repository *repo,
      	if (ret < 0)
      		goto out;
      
    -@@ builtin/history.c: static int commit_tree_ext(struct repository *repo,
    +@@ builtin/history.c: static int first_parent_tree_oid(struct repository *repo,
      static int commit_tree_with_edited_message(struct repository *repo,
      					   const char *action,
      					   struct commit *original,
    @@ builtin/history.c: static int commit_tree_ext(struct repository *repo,
      {
      	struct object_id parent_tree_oid;
     @@ builtin/history.c: static int commit_tree_with_edited_message(struct repository *repo,
    - 	}
    + 		return -1;
      
    - 	return commit_tree_ext(repo, action, original, original->parents,
    + 	return commit_tree_ext(repo, action, original, NULL, original->parents,
     -			       &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
     +			       &parent_tree_oid, tree_oid, sign_commit, out,
     +			       COMMIT_TREE_EDIT_MESSAGE);
    @@ builtin/history.c: static int cmd_history_fixup(int argc,
      		action = REF_ACTION_BRANCHES;
     @@ builtin/history.c: static int cmd_history_fixup(int argc,
      	if (!skip_commit) {
    - 		ret = commit_tree_ext(repo, "fixup", original, original->parents,
    + 		ret = commit_tree_ext(repo, "fixup", original, NULL, original->parents,
      				      &original_tree->object.oid, &merge_result.tree->object.oid,
     -				      &rewritten, flags);
     +				      sign_commit, &rewritten, flags);
    @@ builtin/history.c: static int write_ondisk_index(struct repository *repo,
      {
      	struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
     @@ builtin/history.c: static int split_commit(struct repository *repo,
    + 	 * The first commit is constructed from the split-out tree. The base
      	 * that shall be diffed against is the parent of the original commit.
      	 */
    - 	ret = commit_tree_ext(repo, "split-out", original, original->parents, &parent_tree_oid,
    +-	ret = commit_tree_ext(repo, "split-out", original, NULL, original->parents, &parent_tree_oid,
     -			      &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
    -+			      &split_tree->object.oid, sign_commit, &first_commit,
    ++	ret = commit_tree_ext(repo, "split-out", original, NULL, original->parents,
    ++			      &parent_tree_oid, &split_tree->object.oid, sign_commit,
    ++			      &first_commit,
     +			      COMMIT_TREE_EDIT_MESSAGE);
      	if (ret < 0) {
      		ret = error(_("failed writing first commit"));
    @@ builtin/history.c: static int split_commit(struct repository *repo,
     @@ builtin/history.c: static int split_commit(struct repository *repo,
      	new_tree_oid = &repo_get_commit_tree(repo, original)->object.oid;
      
    - 	ret = commit_tree_ext(repo, "split-out", original, parents, old_tree_oid,
    + 	ret = commit_tree_ext(repo, "split-out", original, NULL, parents, old_tree_oid,
     -			      new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
     +			      new_tree_oid, sign_commit, &second_commit,
     +			      COMMIT_TREE_EDIT_MESSAGE);
    @@ builtin/history.c: static int cmd_history_split(int argc,
      	if (ret < 0) {
      		ret = error(_("failed replaying descendants"));
      		goto out;
    +@@ builtin/history.c: static int cmd_history_squash(int argc,
    + 		NULL,
    + 	};
    + 	enum ref_action action = REF_ACTION_DEFAULT;
    ++	const char *sign_commit = NULL;
    + 	int dry_run = 0;
    + 	int edit = 1;
    + 	struct option options[] = {
    +@@ builtin/history.c: static int cmd_history_squash(int argc,
    + 			 N_("perform a dry-run without updating any refs")),
    + 		OPT_BOOL('e', "edit", &edit,
    + 			 N_("edit the commit message")),
    ++		OPT_HISTORY_GPG_SIGN(&sign_commit),
    + 		OPT_END(),
    + 	};
    + 	struct strbuf reflog_msg = STRBUF_INIT;
    +@@ builtin/history.c: static int cmd_history_squash(int argc,
    + 	struct rev_info revs = { 0 };
    + 	int ret;
    + 
    ++	repo_config(repo, history_config, &sign_commit);
    + 	argc = parse_options(argc, argv, prefix, options, usage,
    + 			     PARSE_OPT_KEEP_UNKNOWN_OPT | PARSE_OPT_KEEP_ARGV0);
    + 	if (argc < 2) {
    + 		ret = error(_("command expects a revision range"));
    + 		goto out;
    + 	}
    +-	repo_config(repo, git_default_config, NULL);
    + 
    + 	if (action == REF_ACTION_DEFAULT)
    + 		action = REF_ACTION_BRANCHES;
    +@@ builtin/history.c: static int cmd_history_squash(int argc,
    + 
    + 	ret = commit_tree_ext(repo, "squash", oldest, message_template,
    + 			      oldest->parents, base_tree_oid, tip_tree_oid,
    +-			      &rewritten,
    ++			      sign_commit, &rewritten,
    + 			      edit ? COMMIT_TREE_EDIT_MESSAGE : 0);
    + 	if (ret < 0) {
    + 		ret = error(_("failed writing squashed commit"));
    +@@ builtin/history.c: static int cmd_history_squash(int argc,
    + 
    + 	ret = handle_reference_updates(&revs, action, tip, rewritten,
    + 				       reflog_msg.buf, dry_run,
    ++				       sign_commit,
    + 				       REPLAY_EMPTY_COMMIT_ABORT);
    + 	if (ret < 0) {
    + 		ret = error(_("failed replaying descendants"));
     @@ builtin/history.c: static int cmd_history_drop(int argc,
      	};
      	enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP;
    @@ t/t3454-history-drop.sh: test_expect_success 'drops a commit in the middle and r
      test_expect_success 'drops the HEAD commit' '
      	test_when_finished "rm -rf repo" &&
      	git init repo &&
    +
    + ## t/t3455-history-squash.sh ##
    +@@
    + test_description='tests for git-history squash subcommand'
    + 
    + . ./test-lib.sh
    ++. "$TEST_DIRECTORY/lib-gpg.sh"
    + 
    + stage_file () {
    + 	printf "%s\n" "$1" >file &&
    +@@ t/t3455-history-squash.sh: check_commit_author () {
    + 	test_cmp expect actual
    + }
    + 
    ++test_squash_gpg_sign () {
    ++	must_fail= will=will
    ++	if test "x$1" = "x!"
    ++	then
    ++		must_fail=test_must_fail
    ++		will="will not"
    ++		shift
    ++	fi
    ++	conf=$1
    ++	shift
    ++
    ++	test_expect_success GPG "squash $* with commit.gpgsign=$conf $will sign rewritten history" "
    ++		test_when_finished 'rm -rf repo' &&
    ++		git init repo &&
    ++		(
    ++			cd repo &&
    ++			test_commit first &&
    ++			test_commit second &&
    ++			test_commit third &&
    ++			test_commit fourth &&
    ++
    ++			git config commit.gpgsign $conf &&
    ++			git history squash --no-edit $* HEAD~3..HEAD~1 &&
    ++
    ++			$must_fail git verify-commit HEAD~ &&
    ++			$must_fail git verify-commit HEAD
    ++		)
    ++	"
    ++}
    ++
    ++test_squash_gpg_sign ! false
    ++test_squash_gpg_sign   true
    ++test_squash_gpg_sign   false --gpg-sign
    ++test_squash_gpg_sign ! true  --no-gpg-sign
    ++test_squash_gpg_sign ! true  --gpg-sign --no-gpg-sign
    ++test_squash_gpg_sign   false --no-gpg-sign --gpg-sign
    ++
    ++test_expect_success GPG 'squash uses an explicit signing key for rewritten history' '
    ++	test_when_finished "rm -rf repo" &&
    ++	git init repo &&
    ++	(
    ++		cd repo &&
    ++		test_commit first &&
    ++		test_commit second &&
    ++		test_commit third &&
    ++		test_commit fourth &&
    ++
    ++		git history squash --no-edit -SB7227189 HEAD~3..HEAD~1 &&
    ++
    ++		git verify-commit HEAD~ &&
    ++		git verify-commit HEAD &&
    ++		git log -2 --format=%GK >actual &&
    ++		cat >expect <<-\EOF &&
    ++		65A0EEA02E30CAD7
    ++		65A0EEA02E30CAD7
    ++		EOF
    ++		test_cmp expect actual
    ++	)
    ++'
    ++
    + test_expect_success 'setup linear history touching two files' '
    + 	test_commit base file a start &&
    + 	GIT_AUTHOR_NAME=One GIT_AUTHOR_EMAIL=one@example.com \
-- 
2.56.0
Previous: SoumaNext: Junio C Hamano
Message 20 of 30 in “history: sign rewritten commits”
  1. 0/3 history: sign rewritten commitsSouma, Jul 3, 2026
  2. 1/3 builtin/history: sign rewritten commitsSouma, Jul 3, 2026
  3. Patrick SteinhardtJul 16, 2026
  4. 2/3 doc: document history signing optionsSouma, Jul 3, 2026
  5. Patrick SteinhardtJul 16, 2026
  6. 3/3 t345x: cover signed history rewritesSouma, Jul 3, 2026
  7. 0/2 history: support signing rewritten commitsSouma, Jul 17, 2026
  8. 1/2 replay: allow callers to sign commitsSouma, Jul 17, 2026
  9. Patrick SteinhardtSep 11, 2026
  10. 2/2 builtin/history: sign rewritten commitsSouma, Jul 17, 2026
  11. Patrick SteinhardtSep 11, 2026
  12. 0/2 history: support signing rewritten commitsSouma, Sep 12, 2026
  13. 1/2 replay: allow callers to sign commitsSouma, Sep 12, 2026
  14. Patrick SteinhardtSep 28, 2026
  15. 2/2 history: sign rewritten commitsSouma, Sep 12, 2026
  16. Patrick SteinhardtSep 28, 2026
  17. Junio C HamanoSep 28, 2026
  18. Junio C HamanoSep 28, 2026
  19. SoumaSep 28, 2026
  20. 0/2 history: sign rewritten commitsSouma, Oct 2, 2026
  21. Junio C HamanoOct 2, 2026
  22. SoumaOct 3, 2026
  23. 1/2 replay: allow callers to sign commitsSouma, Oct 2, 2026
  24. 2/2 history: sign rewritten commitsSouma, Oct 2, 2026
  25. 0/2 history: sign rewritten commitsSouma, Oct 3, 2026
  26. Junio C HamanoOct 4, 2026
  27. SoumaOct 5, 2026
  28. Junio C HamanoOct 6, 2026
  29. 1/2 replay: allow callers to sign commitsSouma, Oct 3, 2026
  30. 2/2 history: sign rewritten commitsSouma, Oct 3, 2026

Read the whole thread, see it on lore, or plain text.

$ cat FOOTERMessages come from the public archive at lore.kernel.org/git, fetched every hour. The front page is chosen and written each morning by an AI editor and can be wrong; the threads themselves are the record. About and API. For agents: an MCP server at https://gitlist.dev/mcp, and any thread, story or person page as Markdown by adding .md to its URL (or sending Accept: text/markdown). Details in /llms.txt.