Git development
 help / color / mirror / Atom feed
From: Souma <git@5ouma.me>
To: git@vger.kernel.org
Cc: gitster@pobox.com, ps@pks.im, Souma <git@5ouma.me>
Subject: [PATCH v4 0/2] history: sign rewritten commits
Date: Fri,  2 Oct 2026 22:27:16 +0900	[thread overview]
Message-ID: <20261002132718.3830-1-git@5ouma.me> (raw)
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

  parent reply	other threads:[~2026-10-02 13:27 UTC|newest]

Thread overview: 30+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-03 14:50 [PATCH 0/3] history: sign rewritten commits Souma
2026-07-03 14:50 ` [PATCH 1/3] builtin/history: " Souma
2026-07-16 10:18   ` Patrick Steinhardt
2026-07-03 14:50 ` [PATCH 2/3] doc: document history signing options Souma
2026-07-16 10:18   ` Patrick Steinhardt
2026-07-03 14:50 ` [PATCH 3/3] t345x: cover signed history rewrites Souma
2026-07-17 14:51 ` [PATCH v2 0/2] history: support signing rewritten commits Souma
2026-07-17 14:51 ` [PATCH v2 1/2] replay: allow callers to sign commits Souma
2026-09-11  7:57   ` Patrick Steinhardt
2026-07-17 14:51 ` [PATCH v2 2/2] builtin/history: sign rewritten commits Souma
2026-09-11  7:57   ` Patrick Steinhardt
2026-09-12 16:00 ` [PATCH v3 0/2] history: support signing " Souma
2026-09-12 16:00 ` [PATCH v3 1/2] replay: allow callers to sign commits Souma
2026-09-28  7:40   ` Patrick Steinhardt
2026-09-12 16:00 ` [PATCH v3 2/2] history: sign rewritten commits Souma
2026-09-28  7:32   ` Patrick Steinhardt
2026-09-28 15:00     ` Junio C Hamano
2026-09-28 18:52       ` Junio C Hamano
2026-09-28 23:39         ` Souma
2026-10-02 13:27 ` Souma [this message]
2026-10-02 22:47   ` [PATCH v4 0/2] " Junio C Hamano
2026-10-03 13:38     ` Souma
2026-10-02 13:27 ` [PATCH v4 1/2] replay: allow callers to sign commits Souma
2026-10-02 13:27 ` [PATCH v4 2/2] history: sign rewritten commits Souma
2026-10-03 13:40 ` [PATCH v5 0/2] " Souma
2026-10-04 14:17   ` Junio C Hamano
2026-10-05  6:59     ` Souma
2026-10-06 15:58       ` Junio C Hamano
2026-10-03 13:40 ` [PATCH v5 1/2] replay: allow callers to sign commits Souma
2026-10-03 13:40 ` [PATCH v5 2/2] history: sign rewritten commits Souma

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261002132718.3830-1-git@5ouma.me \
    --to=git@5ouma.me \
    --cc=git@vger.kernel.org \
    --cc=gitster@pobox.com \
    --cc=ps@pks.im \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox