Git development
 help / color / mirror / Atom feed
From: Scott Chacon <scott@gitbutler.net>
To: git@vger.kernel.org
Subject: [RFC PATCH 3/4] commit: add --hash=sha256 to sign a tree-sha256 header
Date: Fri,  2 Oct 2026 10:18:45 +0200	[thread overview]
Message-ID: <20261002081846.25144-4-scott@gitbutler.net> (raw)
In-Reply-To: <20261002081846.25144-1-scott@gitbutler.net>

Teach "git commit -S" the same "--hash=sha256" option as "git tag",
which adds the tree-sha256 of the tree being committed as an extra
header after "committer":

  tree <tree>
  parent <parent>
  author <ident>
  committer <ident>
  tree-sha256 <hex>
  gpgsig <signature>

Extra headers are written before the commit is signed, so the
signature covers it, and "git verify-commit" works as before.

When amending, we normally carry over the extra headers of the commit
being amended. Don't do that for tree-sha256, which would be wrong as
soon as the tree changes, and add a new one only if the amended commit
is signed with "--hash=sha256".

---
 Documentation/git-commit.adoc | 11 +++++++-
 builtin/commit.c              | 38 ++++++++++++++++++++++++---
 t/t7032-tree-sha256-signed.sh | 49 +++++++++++++++++++++++++++++++++++
 3 files changed, 93 insertions(+), 5 deletions(-)

diff --git a/Documentation/git-commit.adoc b/Documentation/git-commit.adoc
index 8329c1034b..c027de2adb 100644
--- a/Documentation/git-commit.adoc
+++ b/Documentation/git-commit.adoc
@@ -15,7 +15,7 @@ git commit [-a | --interactive | --patch] [-s] [-v] [-u[<mode>]] [--amend]
 	   [--date=<date>] [--cleanup=<mode>] [--[no-]status]
 	   [-i | -o] [--pathspec-from-file=<file> [--pathspec-file-nul]]
 	   [(--trailer <token>[(=|:)<value>])...] [-S[<keyid>]]
-	   [--] [<pathspec>...]
+	   [--hash=<algorithm>] [--] [<pathspec>...]
 
 DESCRIPTION
 -----------
@@ -400,6 +400,15 @@ changes to tracked files.
 	countermand both `commit.gpgSign` configuration variable, and
 	earlier `--gpg-sign`.
 
+`--hash=<algorithm>`::
+	When signing, add a `tree-sha256` header holding a SHA-256
+	digest of every file in the commit's tree, including the
+	contents of checked-out submodules, so that the signature covers
+	the content directly rather than only its SHA-1 object names.
+	_<algorithm>_ is `sha256`, or `none` (the default).
+	Giving `--hash=sha256` without signing is an error. All
+	submodules must be checked out.
+
 `--`::
 	Do not interpret any more arguments as options.
 
diff --git a/builtin/commit.c b/builtin/commit.c
index 840b6b4083..871a2bdcd7 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -43,6 +43,7 @@
 #include "commit-graph.h"
 #include "pretty.h"
 #include "trailer.h"
+#include "tree-sha256.h"
 
 static const char * const builtin_commit_usage[] = {
 	N_("git commit [-a | --interactive | --patch] [-s] [-v] [-u[<mode>]] [--amend]\n"
@@ -52,7 +53,7 @@ static const char * const builtin_commit_usage[] = {
 	   "           [--date=<date>] [--cleanup=<mode>] [--[no-]status]\n"
 	   "           [-i | -o] [--pathspec-from-file=<file> [--pathspec-file-nul]]\n"
 	   "           [(--trailer <token>[(=|:)<value>])...] [-S[<keyid>]]\n"
-	   "           [--] [<pathspec>...]"),
+	   "           [--hash=<algorithm>] [--] [<pathspec>...]"),
 	NULL
 };
 
@@ -129,7 +130,8 @@ static int quiet, verbose, no_verify, allow_empty, dry_run, renew_authorship;
 static int config_commit_verbose = -1; /* unspecified */
 static int no_post_rewrite, allow_empty_message, pathspec_file_nul;
 static const char *untracked_files_arg, *force_date, *ignore_submodule_arg, *ignored_arg;
-static const char *sign_commit, *pathspec_from_file;
+static const char *sign_commit, *pathspec_from_file, *hash_arg;
+static int tree_hash;
 static struct strvec trailer_args = STRVEC_INIT;
 
 /*
@@ -1737,6 +1739,8 @@ int cmd_commit(int argc,
 			.flags = PARSE_OPT_OPTARG,
 			.defval = (intptr_t) "",
 		},
+		OPT_STRING(0, "hash", &hash_arg, N_("algorithm"),
+			   N_("sign a tree-sha256 header of the committed tree (sha256 or none)")),
 		/* end commit message options */
 
 		OPT_GROUP(N_("Commit contents options")),
@@ -1821,6 +1825,14 @@ int cmd_commit(int argc,
 	argc = parse_and_validate_options(argc, argv, builtin_commit_options,
 					  builtin_commit_usage,
 					  prefix, current_head, &s);
+	if (hash_arg) {
+		tree_hash = parse_signing_hash(hash_arg);
+		if (tree_hash < 0)
+			die(_("unsupported --hash value '%s' (use 'sha256' or 'none')"),
+			    hash_arg);
+		if (tree_hash && !sign_commit)
+			die(_("--hash=%s requires a signed commit (-S)"), hash_arg);
+	}
 	if (trailer_args.nr)
 		trailer_config_init();
 
@@ -1928,13 +1940,31 @@ int cmd_commit(int argc,
 	}
 
 	if (amend) {
-		const char *exclude_gpgsig[3] = { "gpgsig", "gpgsig-sha256", NULL };
-		extra = read_commit_extra_headers(current_head, exclude_gpgsig);
+		const char *exclude[4] = {
+			"gpgsig", "gpgsig-sha256", TREE_SHA256_HEADER, NULL
+		};
+		extra = read_commit_extra_headers(current_head, exclude);
 	} else {
 		struct commit_extra_header **tail = &extra;
 		append_merge_tag_headers(parents, &tail);
 	}
 
+	if (sign_commit && tree_hash) {
+		struct commit_extra_header **tail = &extra;
+		struct strbuf hex = STRBUF_INIT;
+
+		if (tree_sha256_hex(the_repository,
+				    &the_repository->index->cache_tree->oid, &hex)) {
+			rollback_index_files();
+			die(_("unable to compute %s"), TREE_SHA256_HEADER);
+		}
+		while (*tail)
+			tail = &(*tail)->next;
+		CALLOC_ARRAY(*tail, 1);
+		(*tail)->key = xstrdup(TREE_SHA256_HEADER);
+		(*tail)->value = strbuf_detach(&hex, &(*tail)->len);
+	}
+
 	if (commit_tree_extended(sb.buf, sb.len, &the_repository->index->cache_tree->oid,
 				 parents, &oid, author_ident.buf, NULL,
 				 sign_commit, extra)) {
diff --git a/t/t7032-tree-sha256-signed.sh b/t/t7032-tree-sha256-signed.sh
index 083f25e665..44c363b5d2 100755
--- a/t/t7032-tree-sha256-signed.sh
+++ b/t/t7032-tree-sha256-signed.sh
@@ -73,4 +73,53 @@ test_expect_success GPGSSH 'tag --hash=sha256 needs an object with a tree' '
 	test_must_fail git rev-parse --verify v5
 '
 
+test_expect_success GPGSSH 'commit -S --hash=sha256 signs a tree-sha256 header' '
+	test_tick &&
+	git commit --allow-empty -S --hash=sha256 -m signed &&
+	header_of commit HEAD >actual &&
+	test_cmp expect actual &&
+	git verify-commit HEAD
+'
+
+test_expect_success GPGSSH 'commit -S without --hash has no header' '
+	test_tick &&
+	git commit --allow-empty -S -m signed &&
+	header_of commit HEAD >actual &&
+	test_must_be_empty actual &&
+	git commit --allow-empty -S --hash=none -m signed &&
+	header_of commit HEAD >actual &&
+	test_must_be_empty actual
+'
+
+test_expect_success GPGSSH 'commit --hash=sha256 requires signing' '
+	git rev-parse HEAD >before &&
+	test_must_fail git commit --allow-empty --hash=sha256 -m unsigned 2>err &&
+	test_grep "requires a signed commit" err &&
+	test_must_fail git commit --allow-empty -S --no-gpg-sign --hash=sha256 \
+		-m unsigned 2>err &&
+	test_grep "requires a signed commit" err &&
+	test_must_fail git commit --allow-empty -S --hash=md5 -m signed 2>err &&
+	test_grep "unsupported --hash value" err &&
+	git rev-parse HEAD >after &&
+	test_cmp before after
+'
+
+test_expect_success GPGSSH 'amending recomputes or drops the header' '
+	git commit --allow-empty -S --hash=sha256 -m signed &&
+	echo changed >dir/file &&
+	git add dir/file &&
+	test_tick &&
+	git commit --amend -S --hash=sha256 -m amended &&
+	test-tool tree-sha256 HEAD >expect-amended &&
+	! test_cmp expect expect-amended &&
+	header_of commit HEAD >actual &&
+	test_cmp expect-amended actual &&
+	git verify-commit HEAD &&
+
+	test_tick &&
+	git commit --amend -m "amended unsigned" &&
+	header_of commit HEAD >actual &&
+	test_must_be_empty actual
+'
+
 test_done
-- 
2.50.1 (Apple Git-155)


  parent reply	other threads:[~2026-10-02  8:18 UTC|newest]

Thread overview: 22+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-02  8:18 [RFC PATCH 0/4] sign a SHA-256 digest of the tree in commits and tags Scott Chacon
2026-10-02  8:18 ` [RFC PATCH 1/4] tree-sha256: hash the contents of a tree with SHA-256 Scott Chacon
2026-10-02 15:45   ` Junio C Hamano
2026-10-02  8:18 ` [RFC PATCH 2/4] tag: add --hash=sha256 to sign a tree-sha256 header Scott Chacon
2026-10-02 15:49   ` Junio C Hamano
2026-10-02  8:18 ` Scott Chacon [this message]
2026-10-02  8:18 ` [RFC PATCH 4/4] gpg: add gpg.treeHash to sign a tree-sha256 header by default Scott Chacon
2026-10-02 15:52 ` [RFC PATCH 0/4] sign a SHA-256 digest of the tree in commits and tags Junio C Hamano
2026-10-02 19:06 ` brian m. carlson
2026-10-05  9:32   ` Scott Chacon
2026-10-05 12:41     ` Patrick Steinhardt
2026-10-05 14:16       ` Scott Chacon
2026-10-05 22:57         ` brian m. carlson
2026-10-06 13:36         ` Johannes Schindelin
2026-10-06 16:16       ` Kristoffer Haugsbakk
2026-10-06 21:55         ` brian m. carlson
2026-10-06 22:38           ` Junio C Hamano
2026-10-06 23:40             ` brian m. carlson
2026-10-06  9:00   ` Christian Couder
2026-10-06 22:26     ` brian m. carlson
2026-10-07 12:26       ` Christian Couder
2026-10-07 21:07         ` brian m. carlson

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=20261002081846.25144-4-scott@gitbutler.net \
    --to=scott@gitbutler.net \
    --cc=git@vger.kernel.org \
    /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