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
next prev 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