From: Souma Date: Fri, 02 Oct 2026 13:27:16 GMT Subject: [PATCH v4 0/2] history: sign rewritten commits 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[=]` 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 ## 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 @@ Documentation/git-history.adoc: git-history - EXPERIMENTAL: Rewrite history -git history fixup [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)] -git history reword [--dry-run] [--update-refs=(branches|head)] -git history split [--dry-run] [--update-refs=(branches|head)] [--] [...] +-git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] +git history drop [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)] [--[no-]gpg-sign[=]] +git history fixup [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)] [--[no-]gpg-sign[=]] +git history reword [--dry-run] [--update-refs=(branches|head)] [--[no-]gpg-sign[=]] +git history split [--dry-run] [--update-refs=(branches|head)] [--[no-]gpg-sign[=]] [--] [...] ++git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] [--[no-]gpg-sign[=]] DESCRIPTION ----------- @@ builtin/history.c #define GIT_HISTORY_SPLIT_USAGE \ - N_("git history split [--dry-run] [--update-refs=(branches|head)] [--] [...]") + N_("git history split [--dry-run] [--update-refs=(branches|head)] [--[no-]gpg-sign[=]] [--] [...]") + #define GIT_HISTORY_SQUASH_USAGE \ +- N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] ") ++ N_("git history squash [--dry-run] [--update-refs=(branches|head)] [--[no-]edit] [--[no-]gpg-sign[=]] ") 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