From: Souma <git@5ouma.me>
To: git@vger.kernel.org
Cc: gitster@pobox.com, ps@pks.im, Souma <git@5ouma.me>
Subject: [PATCH v2 0/2] history: support signing rewritten commits
Date: Fri, 17 Jul 2026 14:51:40 +0000 [thread overview]
Message-ID: <20260717145142.39478-1-git@5ouma.me> (raw)
In-Reply-To: <20260703145037.69832-1-git@5ouma.me>
The history commands create commits directly and via the replay
machinery, but currently have no way to honor `commit.gpgSign` or an
explicit signing request. This means users who require signed commits
lose that property when rewriting history.
Teach the replay API to accept a signing key, then expose the standard
`-S`/`--gpg-sign[=<key-id>]` and `--no-gpg-sign` interface across the
`history drop`, `history fixup`, `history reword`, and `history split`
subcommands. The selected policy applies to every new commit, including
both halves of a split and replayed descendants. A drop of the tip
creates no replacement commit and therefore has nothing to sign.
The implementation follows the precedence used by rebase, cherry-pick,
and revert: `commit.gpgSign` supplies the default, command-line options
override it, and the last command-line option wins.
The signature records the attestation of the current committer to the
rewritten commit while retaining the original author identity; it does
not claim authorship of commits written by somebody else.
Changes since v1:
- Split the replay signing plumbing into a preparatory patch
- Fold the documentation and tests into the feature patch so each
commit builds and passes t0450
- Move `sign_commit` before the output parameter of `commit_tree_ext()` and
update its callers accordingly
- Pass `NULL` to `git_default_config()`
- Document why configuration is read before command-line options
- Clarify that every rewritten commit is signed, including commits with
a different author
- Add signing support and tests for the new `history drop` subcommand
- Add coverage for selecting an explicit signing key
Souma (2):
replay: allow callers to sign commits
builtin/history: sign rewritten commits
Documentation/git-history.adoc | 16 +++++--
builtin/history.c | 84 ++++++++++++++++++++++++++--------
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 ++++++++++++++++++++
8 files changed, 286 insertions(+), 29 deletions(-)
Range-diff against v1:
1: 60f7c13514 ! 1: 3f4dc0b982 builtin/history: sign rewritten commits
@@ Metadata
Author: Souma <git@5ouma.me>
## Commit message ##
- builtin/history: sign rewritten commits
+ replay: allow callers to sign commits
- The history commands create replacement commits directly instead of
- using the sequencer or the commit porcelain. As a result, rewritten
- commits ignore commit.gpgsign and cannot be signed on demand.
+ The replay machinery creates commits directly through
+ `commit_tree_extended()`, but callers cannot currently request
+ signatures. Commands that replay rewritten history consequently cannot
+ carry their signing policy through to descendant commits.
- Read the usual signing configuration before parsing history options.
- Add the commit-style -S/--gpg-sign knob, and pass the selected
- signing key through direct rewrites and replayed descendants.
+ Add `sign_commit` to `replay_revisions_options` and thread it through
+ commit creation. `NULL` preserves the existing unsigned behavior, an
+ empty string selects the default signing key, and a non-empty string
+ selects an explicit key. Existing callers zero-initialize the options
+ structure, so their behavior is unchanged.
Signed-off-by: Souma <git@5ouma.me>
- ## builtin/history.c ##
-@@
- #include "wt-status.h"
-
- #define GIT_HISTORY_FIXUP_USAGE \
-- N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]")
-+ N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)] [--[no-]gpg-sign[=<key-id>]]")
- #define GIT_HISTORY_REWORD_USAGE \
-- N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
-+ N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)] [--[no-]gpg-sign[=<key-id>]]")
- #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>...]")
-
- static void change_data_free(void *util, const char *str UNUSED)
- {
-@@ builtin/history.c: enum commit_tree_flags {
- COMMIT_TREE_EDIT_MESSAGE = (1 << 0),
- };
-
-+static int history_config(const char *var, const char *value,
-+ const struct config_context *ctx, void *data)
-+{
-+ const char **sign_commit = data;
-+
-+ if (!strcmp(var, "commit.gpgsign")) {
-+ *sign_commit = git_config_bool(var, value) ? "" : NULL;
-+ return 0;
-+ }
-+
-+ return git_default_config(var, value, ctx, data);
-+}
-+
-+#define OPT_HISTORY_GPG_SIGN(v) { \
-+ .type = OPTION_STRING, \
-+ .short_name = 'S', \
-+ .long_name = "gpg-sign", \
-+ .value = (v), \
-+ .argh = N_("key-id"), \
-+ .help = N_("GPG-sign rewritten commits"), \
-+ .flags = PARSE_OPT_OPTARG, \
-+ .defval = (intptr_t) "", \
-+}
-+
- 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 object_id *old_tree,
- const struct object_id *new_tree,
- struct commit **out,
-+ const char *sign_commit,
- enum commit_tree_flags flags)
- {
- const char *exclude_gpgsig[] = {
-@@ builtin/history.c: static int commit_tree_ext(struct repository *repo,
-
- ret = commit_tree_extended(commit_message.buf, commit_message.len, new_tree,
- parents, &rewritten_commit_oid, original_author,
-- NULL, NULL, original_extra_headers);
-+ NULL, sign_commit, original_extra_headers);
- if (ret < 0)
- goto out;
-
-@@ builtin/history.c: static int commit_tree_ext(struct repository *repo,
- static int commit_tree_with_edited_message(struct repository *repo,
- const char *action,
- struct commit *original,
-- struct commit **out)
-+ struct commit **out,
-+ const char *sign_commit)
- {
- struct object_id parent_tree_oid;
- const struct object_id *tree_oid;
-@@ builtin/history.c: static int commit_tree_with_edited_message(struct repository *repo,
- }
-
- return commit_tree_ext(repo, action, original, original->parents,
-- &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
-+ &parent_tree_oid, tree_oid, out, sign_commit,
-+ COMMIT_TREE_EDIT_MESSAGE);
- }
-
- enum ref_action {
-@@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,
- struct commit *rewritten,
- const char *reflog_msg,
- int dry_run,
-+ const char *sign_commit,
- enum replay_empty_commit_action empty)
- {
- const struct name_decoration *decoration;
- struct replay_revisions_options opts = {
- .empty = empty,
-+ .sign_commit = sign_commit,
- };
- struct replay_result result = { 0 };
- struct ref_transaction *transaction = NULL;
-@@ builtin/history.c: static int cmd_history_fixup(int argc,
- enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP;
- enum ref_action action = REF_ACTION_DEFAULT;
- enum commit_tree_flags flags = 0;
-+ const char *sign_commit = NULL;
- int dry_run = 0;
- struct option options[] = {
- OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
-@@ builtin/history.c: static int cmd_history_fixup(int argc,
- OPT_CALLBACK_F(0, "empty", &empty, "(drop|keep|abort)",
- N_("how to handle commits that become empty"),
- PARSE_OPT_NONEG, parse_opt_empty),
-+ OPT_HISTORY_GPG_SIGN(&sign_commit),
- OPT_END(),
- };
- struct merge_result merge_result = { 0 };
-@@ builtin/history.c: static int cmd_history_fixup(int argc,
- bool skip_commit = false;
- int ret;
-
-+ repo_config(repo, history_config, &sign_commit);
-+
- 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;
-@@ builtin/history.c: static int cmd_history_fixup(int argc,
- if (!skip_commit) {
- ret = commit_tree_ext(repo, "fixup", original, original->parents,
- &original_tree->object.oid, &merge_result.tree->object.oid,
-- &rewritten, flags);
-+ &rewritten, sign_commit, flags);
- if (ret < 0) {
- ret = error(_("failed writing fixed-up commit"));
- goto out;
-@@ builtin/history.c: static int cmd_history_fixup(int argc,
- strbuf_addf(&reflog_msg, "fixup: updating %s", argv[0]);
-
- ret = handle_reference_updates(&revs, action, original, rewritten,
-- reflog_msg.buf, dry_run, empty);
-+ reflog_msg.buf, dry_run, sign_commit, empty);
- if (ret < 0) {
- ret = error(_("failed replaying descendants"));
- goto out;
-@@ builtin/history.c: static int cmd_history_reword(int argc,
- NULL,
- };
- enum ref_action action = REF_ACTION_DEFAULT;
-+ const char *sign_commit = NULL;
- int dry_run = 0;
- struct option options[] = {
- OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
-@@ builtin/history.c: static int cmd_history_reword(int argc,
- PARSE_OPT_NONEG, parse_ref_action),
- OPT_BOOL('n', "dry-run", &dry_run,
- N_("perform a dry-run without updating any refs")),
-+ OPT_HISTORY_GPG_SIGN(&sign_commit),
- OPT_END(),
- };
- struct strbuf reflog_msg = STRBUF_INIT;
-@@ builtin/history.c: static int cmd_history_reword(int argc,
- struct rev_info revs = { 0 };
- int ret;
-
-+ repo_config(repo, history_config, &sign_commit);
-+
- 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;
-@@ builtin/history.c: static int cmd_history_reword(int argc,
- if (ret)
- goto out;
-
-- ret = commit_tree_with_edited_message(repo, "reworded", original, &rewritten);
-+ ret = commit_tree_with_edited_message(repo, "reworded", original,
-+ &rewritten, sign_commit);
- if (ret < 0) {
- ret = error(_("failed writing reworded commit"));
- goto out;
-@@ builtin/history.c: static int cmd_history_reword(int argc,
- strbuf_addf(&reflog_msg, "reword: updating %s", argv[0]);
-
- ret = handle_reference_updates(&revs, action, original, rewritten,
-- reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);
-+ reflog_msg.buf, dry_run, sign_commit,
-+ REPLAY_EMPTY_COMMIT_ABORT);
- if (ret < 0) {
- ret = error(_("failed replaying descendants"));
- goto out;
-@@ builtin/history.c: static int write_ondisk_index(struct repository *repo,
- static int split_commit(struct repository *repo,
- struct commit *original,
- struct pathspec *pathspec,
-- struct commit **out)
-+ struct commit **out,
-+ const char *sign_commit)
- {
- struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
- struct strbuf index_file = STRBUF_INIT;
-@@ builtin/history.c: static int split_commit(struct repository *repo,
- * 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,
-- &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
-+ &split_tree->object.oid, &first_commit, sign_commit,
-+ COMMIT_TREE_EDIT_MESSAGE);
- if (ret < 0) {
- ret = error(_("failed writing first commit"));
- goto out;
-@@ 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,
-- new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
-+ new_tree_oid, &second_commit, sign_commit,
-+ COMMIT_TREE_EDIT_MESSAGE);
- if (ret < 0) {
- ret = error(_("failed writing second commit"));
- goto out;
-@@ builtin/history.c: static int cmd_history_split(int argc,
- NULL,
- };
- enum ref_action action = REF_ACTION_DEFAULT;
-+ const char *sign_commit = NULL;
- int dry_run = 0;
- struct option options[] = {
- OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
-@@ builtin/history.c: static int cmd_history_split(int argc,
- PARSE_OPT_NONEG, parse_ref_action),
- OPT_BOOL('n', "dry-run", &dry_run,
- N_("perform a dry-run without updating any refs")),
-+ OPT_HISTORY_GPG_SIGN(&sign_commit),
- OPT_END(),
- };
- struct commit *original, *rewritten = NULL;
-@@ builtin/history.c: static int cmd_history_split(int argc,
- struct rev_info revs = { 0 };
- int ret;
-
-+ repo_config(repo, history_config, &sign_commit);
-+
- argc = parse_options(argc, argv, prefix, options, usage, 0);
- if (argc < 1) {
- ret = error(_("command expects a committish"));
- 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_split(int argc,
- goto out;
- }
-
-- ret = split_commit(repo, original, &pathspec, &rewritten);
-+ ret = split_commit(repo, original, &pathspec, &rewritten, sign_commit);
- if (ret < 0)
- goto out;
-
- strbuf_addf(&reflog_msg, "split: updating %s", argv[0]);
-
- ret = handle_reference_updates(&revs, action, original, rewritten,
-- reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);
-+ reflog_msg.buf, dry_run, sign_commit,
-+ REPLAY_EMPTY_COMMIT_ABORT);
- if (ret < 0) {
- ret = error(_("failed replaying descendants"));
- goto out;
-
## replay.c ##
@@ replay.c: static struct commit *create_commit(struct repository *repo,
struct tree *tree,
2: 9935928b01 < -: ---------- doc: document history signing options
3: c017e90034 ! 2: 0e63c0b66a t345x: cover signed history rewrites
@@ Metadata
Author: Souma <git@5ouma.me>
## Commit message ##
- t345x: cover signed history rewrites
+ builtin/history: sign rewritten commits
- History signing needs regression coverage because these commands bypass the
- usual commit machinery and create replacement commits through lower-level
- APIs.
+ The history commands create replacement commits directly instead of
+ using the sequencer or the commit porcelain. As a result, rewritten
+ commits ignore `commit.gpgSign` and cannot be signed on demand.
- Add GPG-gated tests for config-driven signing, command-line signing,
- --no-gpg-sign precedence, and signing of replayed descendants after fixup,
- reword, and split.
+ Read the signing configuration before parsing options so that it
+ establishes the default and later `-S`/`--gpg-sign` or `--no-gpg-sign`
+ options override it. Pass the selected key through direct rewrites and
+ the replay machinery.
+
+ Sign every newly created commit, including both halves of a split and
+ replayed descendants. Dropping the tip creates no replacement commit,
+ so there is nothing to sign. As with `rebase --gpg-sign`, the signature
+ records the attestation of the current committer to the rewritten
+ commit while retaining the original author identity; it does not claim
+ authorship of commits written by somebody else.
+
+ Document the behavior and add GPG-gated coverage for configuration,
+ command-line overrides, last-option-wins precedence, replayed
+ descendants, split commits, an explicit signing key, and the
+ no-new-commit drop case.
Signed-off-by: Souma <git@5ouma.me>
+ ## Documentation/git-history.adoc ##
+@@ Documentation/git-history.adoc: git-history - EXPERIMENTAL: Rewrite history
+ SYNOPSIS
+ --------
+ [synopsis]
+-git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)]
+-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 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>...]
+
+ DESCRIPTION
+ -----------
+@@ Documentation/git-history.adoc: OPTIONS
+ `--reedit-message`::
+ Open an editor to modify the target commit's message.
+
++`-S[<key-id>]`::
++`--gpg-sign[=<key-id>]`::
++`--no-gpg-sign`::
++ GPG-sign rewritten commits. The _<key-id>_ argument is optional and
++ defaults to the committer identity; if specified, it must be stuck to
++ the option without a space. `--no-gpg-sign` is useful to countermand
++ both `commit.gpgSign` configuration and earlier `--gpg-sign`.
++
+ `--empty=(drop|keep|abort)`::
+ Control what happens when a commit becomes empty as a result of the
+ fixup. This can happen in two situations:
+
+ ## builtin/history.c ##
+@@
+ #include "wt-status.h"
+
+ #define GIT_HISTORY_DROP_USAGE \
+- N_("git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)]")
++ N_("git history drop <commit> [--dry-run] [--update-refs=(branches|head)] [--empty=(drop|keep|abort)] [--[no-]gpg-sign[=<key-id>]]")
+ #define GIT_HISTORY_FIXUP_USAGE \
+- N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)]")
++ N_("git history fixup <commit> [--dry-run] [--update-refs=(branches|head)] [--reedit-message] [--empty=(drop|keep|abort)] [--[no-]gpg-sign[=<key-id>]]")
+ #define GIT_HISTORY_REWORD_USAGE \
+- N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)]")
++ N_("git history reword <commit> [--dry-run] [--update-refs=(branches|head)] [--[no-]gpg-sign[=<key-id>]]")
+ #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>...]")
+
+ static void change_data_free(void *util, const char *str UNUSED)
+ {
+@@ builtin/history.c: enum commit_tree_flags {
+ COMMIT_TREE_EDIT_MESSAGE = (1 << 0),
+ };
+
++static int history_config(const char *var, const char *value,
++ const struct config_context *ctx, void *data)
++{
++ const char **sign_commit = data;
++
++ if (!strcmp(var, "commit.gpgsign")) {
++ *sign_commit = git_config_bool(var, value) ? "" : NULL;
++ return 0;
++ }
++
++ return git_default_config(var, value, ctx, NULL);
++}
++
++#define OPT_HISTORY_GPG_SIGN(v) { \
++ .type = OPTION_STRING, \
++ .short_name = 'S', \
++ .long_name = "gpg-sign", \
++ .value = (v), \
++ .argh = N_("key-id"), \
++ .help = N_("GPG-sign rewritten commits"), \
++ .flags = PARSE_OPT_OPTARG, \
++ .defval = (intptr_t)"", \
++}
++
+ static int commit_tree_ext(struct repository *repo,
+ const char *action,
+ struct commit *commit_with_message,
+ const struct commit_list *parents,
+ const struct object_id *old_tree,
+ const struct object_id *new_tree,
++ const char *sign_commit,
+ struct commit **out,
+ enum commit_tree_flags flags)
+ {
+@@ builtin/history.c: static int commit_tree_ext(struct repository *repo,
+
+ ret = commit_tree_extended(commit_message.buf, commit_message.len, new_tree,
+ parents, &rewritten_commit_oid, original_author,
+- NULL, NULL, original_extra_headers);
++ NULL, sign_commit, original_extra_headers);
+ if (ret < 0)
+ goto out;
+
+@@ builtin/history.c: static int commit_tree_ext(struct repository *repo,
+ static int commit_tree_with_edited_message(struct repository *repo,
+ const char *action,
+ struct commit *original,
++ const char *sign_commit,
+ struct commit **out)
+ {
+ struct object_id parent_tree_oid;
+@@ builtin/history.c: static int commit_tree_with_edited_message(struct repository *repo,
+ }
+
+ return commit_tree_ext(repo, action, original, original->parents,
+- &parent_tree_oid, tree_oid, out, COMMIT_TREE_EDIT_MESSAGE);
++ &parent_tree_oid, tree_oid, sign_commit, out,
++ COMMIT_TREE_EDIT_MESSAGE);
+ }
+
+ enum ref_action {
+@@ builtin/history.c: static int compute_pending_ref_updates(struct rev_info *revs,
+ enum ref_action action,
+ struct commit *original,
+ struct commit *rewritten,
++ const char *sign_commit,
+ enum replay_empty_commit_action empty,
+ struct replay_result *result)
+ {
+ const struct name_decoration *decoration;
+ struct replay_revisions_options opts = {
+ .empty = empty,
++ .sign_commit = sign_commit,
+ };
+ char hex[GIT_MAX_HEXSZ + 1];
+ bool detached_head;
+@@ builtin/history.c: static int handle_reference_updates(struct rev_info *revs,
+ struct commit *rewritten,
+ const char *reflog_msg,
+ int dry_run,
++ const char *sign_commit,
+ enum replay_empty_commit_action empty)
+ {
+ struct replay_result result = { 0 };
+ int ret;
+
+ ret = compute_pending_ref_updates(revs, action, original, rewritten,
+- empty, &result);
++ sign_commit, empty, &result);
+ if (ret)
+ goto out;
+
+@@ builtin/history.c: static int cmd_history_fixup(int argc,
+ enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP;
+ enum ref_action action = REF_ACTION_DEFAULT;
+ enum commit_tree_flags flags = 0;
++ const char *sign_commit = NULL;
+ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+@@ builtin/history.c: static int cmd_history_fixup(int argc,
+ OPT_CALLBACK_F(0, "empty", &empty, "(drop|keep|abort)",
+ N_("how to handle commits that become empty"),
+ PARSE_OPT_NONEG, parse_opt_empty),
++ OPT_HISTORY_GPG_SIGN(&sign_commit),
+ OPT_END(),
+ };
+ struct merge_result merge_result = { 0 };
+@@ builtin/history.c: static int cmd_history_fixup(int argc,
+ bool skip_commit = false;
+ int ret;
+
++ repo_config(repo, history_config, &sign_commit);
+ 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;
+@@ builtin/history.c: static int cmd_history_fixup(int argc,
+ if (!skip_commit) {
+ ret = commit_tree_ext(repo, "fixup", original, original->parents,
+ &original_tree->object.oid, &merge_result.tree->object.oid,
+- &rewritten, flags);
++ sign_commit, &rewritten, flags);
+ if (ret < 0) {
+ ret = error(_("failed writing fixed-up commit"));
+ goto out;
+@@ builtin/history.c: static int cmd_history_fixup(int argc,
+ strbuf_addf(&reflog_msg, "fixup: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, original, rewritten,
+- reflog_msg.buf, dry_run, empty);
++ reflog_msg.buf, dry_run, sign_commit, empty);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+@@ builtin/history.c: static int cmd_history_reword(int argc,
+ NULL,
+ };
+ enum ref_action action = REF_ACTION_DEFAULT;
++ const char *sign_commit = NULL;
+ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+@@ builtin/history.c: static int cmd_history_reword(int argc,
+ PARSE_OPT_NONEG, parse_ref_action),
+ OPT_BOOL('n', "dry-run", &dry_run,
+ N_("perform a dry-run without updating any refs")),
++ OPT_HISTORY_GPG_SIGN(&sign_commit),
+ OPT_END(),
+ };
+ struct strbuf reflog_msg = STRBUF_INIT;
+@@ builtin/history.c: static int cmd_history_reword(int argc,
+ struct rev_info revs = { 0 };
+ int ret;
+
++ repo_config(repo, history_config, &sign_commit);
+ 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;
+@@ builtin/history.c: static int cmd_history_reword(int argc,
+ if (ret)
+ goto out;
+
+- ret = commit_tree_with_edited_message(repo, "reworded", original, &rewritten);
++ ret = commit_tree_with_edited_message(repo, "reworded", original,
++ sign_commit, &rewritten);
+ if (ret < 0) {
+ ret = error(_("failed writing reworded commit"));
+ goto out;
+@@ builtin/history.c: static int cmd_history_reword(int argc,
+ strbuf_addf(&reflog_msg, "reword: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, original, rewritten,
+- reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);
++ reflog_msg.buf, dry_run, sign_commit,
++ REPLAY_EMPTY_COMMIT_ABORT);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+@@ builtin/history.c: static int write_ondisk_index(struct repository *repo,
+ static int split_commit(struct repository *repo,
+ struct commit *original,
+ struct pathspec *pathspec,
++ const char *sign_commit,
+ struct commit **out)
+ {
+ struct interactive_options interactive_opts = INTERACTIVE_OPTIONS_INIT;
+@@ builtin/history.c: static int split_commit(struct repository *repo,
+ * 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,
+- &split_tree->object.oid, &first_commit, COMMIT_TREE_EDIT_MESSAGE);
++ &split_tree->object.oid, sign_commit, &first_commit,
++ COMMIT_TREE_EDIT_MESSAGE);
+ if (ret < 0) {
+ ret = error(_("failed writing first commit"));
+ goto out;
+@@ 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,
+- new_tree_oid, &second_commit, COMMIT_TREE_EDIT_MESSAGE);
++ new_tree_oid, sign_commit, &second_commit,
++ COMMIT_TREE_EDIT_MESSAGE);
+ if (ret < 0) {
+ ret = error(_("failed writing second commit"));
+ goto out;
+@@ builtin/history.c: static int cmd_history_split(int argc,
+ NULL,
+ };
+ enum ref_action action = REF_ACTION_DEFAULT;
++ const char *sign_commit = NULL;
+ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+@@ builtin/history.c: static int cmd_history_split(int argc,
+ PARSE_OPT_NONEG, parse_ref_action),
+ OPT_BOOL('n', "dry-run", &dry_run,
+ N_("perform a dry-run without updating any refs")),
++ OPT_HISTORY_GPG_SIGN(&sign_commit),
+ OPT_END(),
+ };
+ struct commit *original, *rewritten = NULL;
+@@ builtin/history.c: static int cmd_history_split(int argc,
+ struct rev_info revs = { 0 };
+ int ret;
+
++ repo_config(repo, history_config, &sign_commit);
+ argc = parse_options(argc, argv, prefix, options, usage, 0);
+ if (argc < 1) {
+ ret = error(_("command expects a committish"));
+ 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_split(int argc,
+ goto out;
+ }
+
+- ret = split_commit(repo, original, &pathspec, &rewritten);
++ ret = split_commit(repo, original, &pathspec, sign_commit, &rewritten);
+ if (ret < 0)
+ goto out;
+
+ strbuf_addf(&reflog_msg, "split: updating %s", argv[0]);
+
+ ret = handle_reference_updates(&revs, action, original, rewritten,
+- reflog_msg.buf, dry_run, REPLAY_EMPTY_COMMIT_ABORT);
++ reflog_msg.buf, dry_run, sign_commit,
++ REPLAY_EMPTY_COMMIT_ABORT);
+ if (ret < 0) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+@@ builtin/history.c: static int cmd_history_drop(int argc,
+ };
+ enum replay_empty_commit_action empty = REPLAY_EMPTY_COMMIT_DROP;
+ enum ref_action action = REF_ACTION_DEFAULT;
++ const char *sign_commit = NULL;
+ int dry_run = 0;
+ struct option options[] = {
+ OPT_CALLBACK_F(0, "update-refs", &action, "(branches|head)",
+@@ builtin/history.c: static int cmd_history_drop(int argc,
+ OPT_CALLBACK_F(0, "empty", &empty, "(drop|keep|abort)",
+ N_("how to handle descendants that become empty"),
+ PARSE_OPT_NONEG, parse_opt_empty),
++ OPT_HISTORY_GPG_SIGN(&sign_commit),
+ OPT_END(),
+ };
+ struct strbuf reflog_msg = STRBUF_INIT;
+@@ builtin/history.c: static int cmd_history_drop(int argc,
+ bool head_moves = false;
+ int ret;
+
++ repo_config(repo, history_config, &sign_commit);
+ 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;
+@@ builtin/history.c: static int cmd_history_drop(int argc,
+ rewritten = original->parents->item;
+
+ ret = compute_pending_ref_updates(&revs, action, original, rewritten,
+- empty, &result);
++ sign_commit, empty, &result);
+ if (ret) {
+ ret = error(_("failed replaying descendants"));
+ goto out;
+
## t/t3451-history-reword.sh ##
@@ t/t3451-history-reword.sh: test_description='tests for git-history reword subcommand'
@@ t/t3451-history-reword.sh: test_expect_success 'can reword commit in the middle'
+test_reword_gpg_sign ! true --no-gpg-sign
+test_reword_gpg_sign ! true --gpg-sign --no-gpg-sign
+test_reword_gpg_sign false --no-gpg-sign --gpg-sign
++
++test_expect_success GPG 'reword 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 &&
++
++ reword_with_message -SB7227189 HEAD~ <<-EOF &&
++ second reworded
++ EOF
++
++ 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 'can reword commit in the middle even on detached head' '
test_when_finished "rm -rf repo" &&
@@ t/t3453-history-fixup.sh: test_expect_success 'preserves commit message and auth
test_expect_success 'updates all descendant branches by default' '
test_when_finished "rm -rf repo" &&
git init repo --initial-branch=main &&
+
+ ## t/t3454-history-drop.sh ##
+@@ t/t3454-history-drop.sh: test_description='tests for git-history drop subcommand'
+
+ . ./test-lib.sh
+ . "$TEST_DIRECTORY/lib-log-graph.sh"
++. "$TEST_DIRECTORY/lib-gpg.sh"
+
+ expect_graph () {
+ cat >expect &&
+@@ t/t3454-history-drop.sh: expect_log () {
+ test_cmp expect actual
+ }
+
++test_drop_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 "drop $* with commit.gpgsign=$conf $will sign replayed descendants" "
++ test_when_finished 'rm -rf repo' &&
++ git init repo &&
++ (
++ cd repo &&
++ test_commit first &&
++ test_commit second &&
++ test_commit third &&
++
++ git config commit.gpgsign $conf &&
++ git history drop $* HEAD~ &&
++
++ $must_fail git verify-commit HEAD
++ )
++ "
++}
++
+ test_expect_success 'errors on missing commit argument' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
+@@ t/t3454-history-drop.sh: test_expect_success 'drops a commit in the middle and replays descendants' '
+ )
+ '
+
++test_drop_gpg_sign ! false
++test_drop_gpg_sign true
++test_drop_gpg_sign false --gpg-sign
++test_drop_gpg_sign ! true --no-gpg-sign
++test_drop_gpg_sign ! true --gpg-sign --no-gpg-sign
++test_drop_gpg_sign false --no-gpg-sign --gpg-sign
++
++test_expect_success GPG 'drop has no commit to sign when dropping the tip' '
++ test_when_finished "rm -rf repo" &&
++ git init repo &&
++ (
++ cd repo &&
++ test_commit first &&
++ test_commit second &&
++
++ git history drop --gpg-sign HEAD &&
++
++ test_must_fail git verify-commit HEAD
++ )
++'
++
+ test_expect_success 'drops the HEAD commit' '
+ test_when_finished "rm -rf repo" &&
+ git init repo &&
--
2.55.0
next prev parent reply other threads:[~2026-07-17 14:52 UTC|newest]
Thread overview: 9+ 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 ` Souma [this message]
2026-07-17 14:51 ` [PATCH v2 1/2] replay: allow callers to sign commits Souma
2026-07-17 14:51 ` [PATCH v2 2/2] builtin/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=20260717145142.39478-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