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


  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