Volume XXII, number 279Tuesday, October 6, 2026Latest message 1 hour ago

The Git List

News and archive of git@vger.kernel.org, since April 2005

RFC patch, 4 partssign a SHA-256 digest of the tree in commits and tags

13 messages between Oct 2, 2026 and Oct 5, 2026, from Scott Chacon, Junio C Hamano, brian m. carlson, Patrick Steinhardt.

Plain Markdown or JSON for tools and agents. Diffs are folded; open one to read it.

Scott ChaconOct 2, 2026, 08:18 UTC on lore

I'm concerned about the ecosystem impact of moving the `git init` default hashing function to SHA-256 in 3.0. I have suggested that it may be more feasible with similar benefits to add the ability to inject an independently calculated and verifiable tree content sha into signed objects instead.

This RFC series is meant to demonstrate how this might work.

It adds the ability to directly rehash the full tree contents when signing a commit or tag with SHA-256 without the repository needing to be in the sha256 object format.

In this series "git tag -s --hash=sha256" and "git commit -S --hash=sha256" compute a SHA-256 digest over every file in the tree (and submodules) and put that additional hash in a header before signing:

  object 78bd45828aa36fbde3161f49da15272dff3d06f5
  type commit
  tag v1.0
  tagger A U Thor <author@example.com> 1790749714 +0200
  tree-sha256 775aff90d07c9a73f19ef83ab89bd8e95d1d835325d53cc57a2232b015783d89
  Release 1.0
  -----BEGIN SSH SIGNATURE-----

For a commit it goes after "committer", before "gpgsig". Setting gpg.treeHash=sha256 makes it the default for everything you sign.

The digest is SHA-256 over one record per file, sorted by path:
  <hex sha256 of content> SP <path> NUL

The file mode isn't included. Submodules are followed into their own repositories and contribute "<hex digest of their tree> SP <path>/ NUL", so the signature covers their contents too; if a submodule isn't available, we fail rather than sign something we can't vouch for.

Old versions of Git are fine with the new header: fsck ignores extra headers after "tagger" by default (and always for commits), and "git tag -v" and "git verify-commit" check the signature as before.

  - Patch 1 adds the digest, with a test-tool helper so it can be
    tested on its own.
  - Patches 2 and 3 add --hash to "git tag" and "git commit".
  - Patch 4 adds gpg.treeHash.

From a speed perspective, it's not fast but it's not slow. The default build on my M5 is 245ms for a git.git signed tag call, ~5s for the Linux tree. An accelerated OpenSSL build is 135ms for git.git, 1.8s for Linux.

However, this is single threaded. We could easily do parallel hashing which should make it many times faster - my previous tests in Rust on 18 threads on my M5 did git.git in 36ms and Linux tree in 0.5s (verified the same hash).

Not in this series, and what I'd like opinions on:
  - Any interest? Would the list find this approach a viable alternative
    to not switching the default hash function to sha-256 in 3.0? Not that
    it wouldn't be an available object format, but that it wouldn't need to
    be the default one.
  - Verification. "git tag -v" and "git verify-commit" don't recompute
    the digest yet. I'd like to agree on the format before adding that.
  - Excluding submodules. Large projects can have submodules that most
    people never check out, and they can't sign with --hash today. One
    option is an "excluded:<commit>" header that still covers the
    pinned commit but not its contents, with a header listing the
    excluded paths so that verification can report them.
  - Naming. The header is "tree-sha256", the option "--hash", and the
    config "gpg.treeHash". I'm not attached to any of them.
Scott Chacon (4):
  tree-sha256: hash the contents of a tree with SHA-256
  tag: add --hash=sha256 to sign a tree-sha256 header
  commit: add --hash=sha256 to sign a tree-sha256 header
  gpg: add gpg.treeHash to sign a tree-sha256 header by default
 Documentation/config/gpg.adoc |   6 +
 Documentation/git-commit.adoc |  11 +-
 Documentation/git-tag.adoc    |  11 +-
 Makefile                      |   2 +
 builtin/commit.c              |  46 ++++++-
 builtin/tag.c                 |  41 +++++-
 meson.build                   |   1 +
 t/helper/meson.build          |   1 +
 t/helper/test-tool.c          |   1 +
 t/helper/test-tool.h          |   1 +
 t/helper/test-tree-sha256.c   |  31 +++++
 t/meson.build                 |   2 +
 t/t1018-tree-sha256.sh        | 123 +++++++++++++++++
 t/t7032-tree-sha256-signed.sh | 169 +++++++++++++++++++++++
 tree-sha256.c                 | 247 ++++++++++++++++++++++++++++++++++
 tree-sha256.h                 |  36 +++++
 16 files changed, 720 insertions(+), 9 deletions(-)
 create mode 100644 t/helper/test-tree-sha256.c
 create mode 100755 t/t1018-tree-sha256.sh
 create mode 100755 t/t7032-tree-sha256-signed.sh
 create mode 100644 tree-sha256.c
 create mode 100644 tree-sha256.h
base-commit: a018953688f1b10bddf91bff8747068f5f4746a4
-- 
2.50.1 (Apple Git-155)
Scott ChaconOct 2, 2026, 08:18 UTC in reply to Scott Chacon on lore

[RFC PATCH 1/4] tree-sha256: hash the contents of a tree with SHA-256

Add a way to compute a SHA-256 digest of the contents of a tree that doesn't depend on the object format, so that it can be put in the signed payload. Each blob in the tree, recursively, becomes one record, and the digest is SHA-256 over the records sorted by path:

  <hex sha256 of content> SP <path> NUL

A gitlink is followed into the submodule's own repository, and the tree of the commit it pins is hashed in the same way, giving one record with a trailing slash on the path:

  <hex digest of submodule tree> SP <path>/ NUL

A path never ends in a slash in a tree, so these can't be confused with files. If a submodule isn't available, or doesn't have the pinned commit, we can't say anything about its contents and so fail, listing every one of them. A submodule that pins a commit already being hashed above it would be recorded as "cycle:<commit>" instead, which can't happen without a hash collision but keeps the walk from going around forever if it does.

The digest can be reproduced with "git ls-tree -r", "git cat-file" and sha256sum, which is how the new test checks it, through a new "test-tool tree-sha256". The following patches use it in "git tag" and "git commit".

---
 Makefile                    |   2 +
 meson.build                 |   1 +
 t/helper/meson.build        |   1 +
 t/helper/test-tool.c        |   1 +
 t/helper/test-tool.h        |   1 +
 t/helper/test-tree-sha256.c |  31 +++++
 t/meson.build               |   1 +
 t/t1018-tree-sha256.sh      | 123 +++++++++++++++++++
 tree-sha256.c               | 238 ++++++++++++++++++++++++++++++++++++
 tree-sha256.h               |  30 +++++
 10 files changed, 429 insertions(+)
 create mode 100644 t/helper/test-tree-sha256.c
 create mode 100755 t/t1018-tree-sha256.sh
 create mode 100644 tree-sha256.c
 create mode 100644 tree-sha256.h
Show changes to 10 files +429 −0

Makefile, meson.build, t/helper/meson.build, t/helper/test-tool.c, t/helper/test-tool.h, t/helper/test-tree-sha256.c, t/meson.build, t/t1018-tree-sha256.sh, tree-sha256.c, tree-sha256.h

diff --git a/Makefile b/Makefile
index c649c93c51..e042163635 100644
--- a/Makefile
+++ b/Makefile
@@ -878,6 +878,7 @@ TEST_BUILTINS_OBJS += test-submodule.o
 TEST_BUILTINS_OBJS += test-subprocess.o
 TEST_BUILTINS_OBJS += test-synthesize.o
 TEST_BUILTINS_OBJS += test-trace2.o
+TEST_BUILTINS_OBJS += test-tree-sha256.o
 TEST_BUILTINS_OBJS += test-truncate.o
 TEST_BUILTINS_OBJS += test-userdiff.o
 TEST_BUILTINS_OBJS += test-wildmatch.o
@@ -1357,6 +1358,7 @@ LIB_OBJS += trailer.o
 LIB_OBJS += transport-helper.o
 LIB_OBJS += transport.o
 LIB_OBJS += tree-diff.o
+LIB_OBJS += tree-sha256.o
 LIB_OBJS += tree-walk.o
 LIB_OBJS += tree.o
 LIB_OBJS += unpack-trees.o
diff --git a/meson.build b/meson.build
index 0a95d90d21..771e1247f7 100644
--- a/meson.build
+++ b/meson.build
@@ -562,6 +562,7 @@ libgit_sources = [
   'transport-helper.c',
   'transport.c',
   'tree-diff.c',
+  'tree-sha256.c',
   'tree-walk.c',
   'tree.c',
   'unpack-trees.c',
diff --git a/t/helper/meson.build b/t/helper/meson.build
index 3235f10ab8..6f6a4e0a42 100644
--- a/t/helper/meson.build
+++ b/t/helper/meson.build
@@ -72,6 +72,7 @@ test_tool_sources = [
   'test-synthesize.c',
   'test-tool.c',
   'test-trace2.c',
+  'test-tree-sha256.c',
   'test-truncate.c',
   'test-userdiff.c',
   'test-wildmatch.c',
diff --git a/t/helper/test-tool.c b/t/helper/test-tool.c
index b71a22b43b..d3452f7297 100644
--- a/t/helper/test-tool.c
+++ b/t/helper/test-tool.c
@@ -84,6 +84,7 @@ static struct test_cmd cmds[] = {
 	{ "subprocess", cmd__subprocess },
 	{ "synthesize", cmd__synthesize },
 	{ "trace2", cmd__trace2 },
+	{ "tree-sha256", cmd__tree_sha256 },
 	{ "truncate", cmd__truncate },
 	{ "userdiff", cmd__userdiff },
 	{ "xml-encode", cmd__xml_encode },
diff --git a/t/helper/test-tool.h b/t/helper/test-tool.h
index f2885b33d5..410d71f6a9 100644
--- a/t/helper/test-tool.h
+++ b/t/helper/test-tool.h
@@ -77,6 +77,7 @@ int cmd__submodule_nested_repo_config(int argc, const char **argv);
 int cmd__subprocess(int argc, const char **argv);
 int cmd__synthesize(int argc, const char **argv);
 int cmd__trace2(int argc, const char **argv);
+int cmd__tree_sha256(int argc, const char **argv);
 int cmd__truncate(int argc, const char **argv);
 int cmd__userdiff(int argc, const char **argv);
 int cmd__xml_encode(int argc, const char **argv);
diff --git a/t/helper/test-tree-sha256.c b/t/helper/test-tree-sha256.c
new file mode 100644
index 0000000000..d3fca5c0b7
--- /dev/null
+++ b/t/helper/test-tree-sha256.c
@@ -0,0 +1,31 @@
+#define USE_THE_REPOSITORY_VARIABLE
+
+#include "test-tool.h"
+#include "git-compat-util.h"
+#include "hash.h"
+#include "object-name.h"
+#include "repository.h"
+#include "setup.h"
+#include "strbuf.h"
+#include "tree-sha256.h"
+
+int cmd__tree_sha256(int argc, const char **argv)
+{
+	struct object_id oid;
+	struct strbuf hex = STRBUF_INIT;
+	int ret = 0;
+
+	setup_git_directory(the_repository);
+	if (argc != 2)
+		die("usage: test-tool tree-sha256 <tree-ish>");
+	if (repo_get_oid(the_repository, argv[1], &oid))
+		die("not a valid object name: %s", argv[1]);
+
+	if (tree_sha256_hex(the_repository, &oid, &hex))
+		ret = 1;
+	else
+		puts(hex.buf);
+
+	strbuf_release(&hex);
+	return ret;
+}
diff --git a/t/meson.build b/t/meson.build
index 3ca7b27104..8ff3dbe69d 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -172,6 +172,7 @@ integration_tests = [
   't1015-read-index-unmerged.sh',
   't1016-compatObjectFormat.sh',
   't1017-cat-file-remote-object-info.sh',
+  't1018-tree-sha256.sh',
   't1020-subdirectory.sh',
   't1022-read-tree-partial-clone.sh',
   't1050-large.sh',
diff --git a/t/t1018-tree-sha256.sh b/t/t1018-tree-sha256.sh
new file mode 100755
index 0000000000..ff2edd159e
--- /dev/null
+++ b/t/t1018-tree-sha256.sh
@@ -0,0 +1,123 @@
+#!/bin/sh
+
+test_description='SHA-256 digest of the contents of a tree'
+
+. ./test-lib.sh
+
+# Recompute the tree-sha256 of <rev> in repository <dir> by hand: one
+# "<sha256 of content> <path>" record per file and "<digest> <path>/"
+# per submodule, sorted by path, NUL-terminated and hashed together.
+expect_tree_sha256 () {
+	git -C "$1" ls-tree -r --format="%(objectmode) %(objectname) %(path)" "$2" |
+	while read mode oid path
+	do
+		case "$mode" in
+		160000)
+			printf "%s %s/\n" "$(expect_tree_sha256 "$1/$path" "$oid")" "$path" ;;
+		*)
+			printf "%s %s\n" "$(git -C "$1" cat-file blob "$oid" |
+					    test-tool sha256)" "$path" ;;
+		esac
+	done |
+	LC_ALL=C sort -t " " -k2 |
+	tr "\n" "\000" |
+	test-tool sha256
+}
+
+test_expect_success 'setup' '
+	git config --global protocol.file.allow always &&
+
+	git init inner &&
+	test_commit -C inner inner-file &&
+	git init sub &&
+	test_commit -C sub sub-file &&
+	git -C sub submodule add ../inner inner &&
+	git -C sub commit -m "add inner" &&
+
+	mkdir -p a/deeper &&
+	echo one >a/deeper/file &&
+	echo two >a.b &&
+	echo exe >exe &&
+	git add a a.b exe &&
+	test_ln_s_add a.b link &&
+	git commit -m initial
+'
+
+test_expect_success 'digest of files, directories and symlinks' '
+	expect_tree_sha256 . HEAD >expect &&
+	test-tool tree-sha256 HEAD >actual &&
+	test_cmp expect actual
+'
+
+test_expect_success 'commits, tags and trees give the same digest' '
+	git tag -a -m tag v1 &&
+	test-tool tree-sha256 v1 >tag &&
+	test-tool tree-sha256 HEAD^{tree} >tree &&
+	test_cmp expect tag &&
+	test_cmp expect tree
+'
+
+test_expect_success 'file mode is not part of the digest' '
+	test_chmod +x exe &&
+	git commit -m executable &&
+	test-tool tree-sha256 HEAD >actual &&
+	test_cmp expect actual
+'
+
+test_expect_success 'content and paths are' '
+	echo changed >a/deeper/file &&
+	git commit -a -m changed &&
+	test-tool tree-sha256 HEAD >changed &&
+	! test_cmp expect changed &&
+
+	git mv a.b a.c &&
+	git commit -m renamed &&
+	test-tool tree-sha256 HEAD >renamed &&
+	! test_cmp changed renamed
+'
+
+test_expect_success 'submodules are hashed recursively' '
+	git submodule add ./sub sub &&
+	git submodule update --init --recursive &&
+	git commit -m "add sub" &&
+	expect_tree_sha256 . HEAD >expect &&
+	test-tool tree-sha256 HEAD >actual &&
+	test_cmp expect actual
+'
+
+test_expect_success 'submodule contents are part of the digest' '
+	test_commit -C sub/inner more &&
+	git -C sub commit -a -m "update inner" &&
+	git commit -a -m "update sub" &&
+	test-tool tree-sha256 HEAD >updated &&
+	! test_cmp expect updated &&
+	expect_tree_sha256 . HEAD >expect &&
+	test_cmp expect updated
+'
+
+test_expect_success 'submodules are read from their gitdir without a worktree' '
+	mv sub/inner inner.away &&
+	test_when_finished "mv inner.away sub/inner" &&
+	test-tool tree-sha256 HEAD >actual &&
+	test_cmp expect actual
+'
+
+test_expect_success 'unavailable submodules are an error' '
+	mv sub/inner inner.away &&
+	mv sub/.git/modules/inner inner.git.away &&
+	test_when_finished "mv inner.away sub/inner && mv inner.git.away sub/.git/modules/inner" &&
+	test_must_fail test-tool tree-sha256 HEAD 2>err &&
+	test_grep "sub/inner (not checked out)" err
+'
+
+test_expect_success 'submodule missing the pinned commit is an error' '
+	tree=$(printf "160000 commit %s\tinner\n" $(test_oid deadbeef) |
+	       git -C sub mktree) &&
+	(
+		cd sub &&
+		test_must_fail test-tool tree-sha256 $tree 2>err &&
+		test_grep "inner (checked out, but missing commit $(test_oid deadbeef))" err
+	)
+'
+
+test_done
diff --git a/tree-sha256.c b/tree-sha256.c
new file mode 100644
index 0000000000..fb58962232
--- /dev/null
+++ b/tree-sha256.c
@@ -0,0 +1,238 @@
+#include "git-compat-util.h"
+#include "tree-sha256.h"
+#include "commit.h"
+#include "gettext.h"
+#include "hash.h"
+#include "hex.h"
+#include "object.h"
+#include "odb.h"
+#include "oid-array.h"
+#include "pathspec.h"
+#include "repository.h"
+#include "string-list.h"
+#include "strbuf.h"
+#include "tree.h"
+
+struct record {
+	char *path; /* submodules carry a trailing '/' */
+	struct object_id oid;
+	unsigned submodule:1;
+};
+
+struct collect {
+	struct record *items;
+	size_t nr, alloc;
+};
+
+struct walk {
+	/* "<path> (<reason>)" for each submodule that can't be hashed */
+	struct string_list missing;
+};
+
+static int collect_entry(const struct object_id *oid, struct strbuf *base,
+			 const char *pathname, unsigned mode, void *context)
+{
+	struct collect *c = context;
+	struct record *rec;
+
+	if (S_ISDIR(mode))
+		return READ_TREE_RECURSIVE;
+
+	ALLOC_GROW(c->items, c->nr + 1, c->alloc);
+	rec = &c->items[c->nr++];
+	oidcpy(&rec->oid, oid);
+	rec->submodule = S_ISGITLINK(mode);
+	rec->path = xstrfmt("%.*s%s%s", (int)base->len, base->buf, pathname,
+			    rec->submodule ? "/" : "");
+	return 0;
+}
+
+static int record_cmp(const void *a_, const void *b_)
+{
+	const struct record *a = a_, *b = b_;
+	return strcmp(a->path, b->path);
+}
+
+static int hash_tree(struct repository *r, const struct object_id *oid,
+		     const char *prefix, struct oid_array *chain,
+		     struct walk *walk, unsigned char *digest);
+
+static int in_chain(const struct oid_array *chain, const struct object_id *oid)
+{
+	for (size_t i = 0; i < chain->nr; i++)
+		if (oideq(&chain->oid[i], oid))
+			return 1;
+	return 0;
+}
+
+/*
+ * Hash the submodule of "r" at "path", pinned at "commit", and append
+ * its digest in hex to "out". "treeish" is the tree "path" was found
+ * in, which is where .gitmodules is read from if the submodule's
+ * gitdir isn't at "path". "full" is the path from the top repository,
+ * for messages.
+ *
+ * Returns 1 if the submodule is unavailable (recording why in
+ * walk->missing), -1 on other errors and 0 on success.
+ */
+static int hash_submodule(struct repository *r, const struct object_id *treeish,
+			  const char *path, const char *full,
+			  const struct object_id *commit,
+			  struct oid_array *chain, struct walk *walk,
+			  struct strbuf *out)
+{
+	const struct git_hash_algo *sha256 = &hash_algos[GIT_HASH_SHA256];
+	unsigned char digest[GIT_MAX_RAWSZ];
+	struct repository sub;
+	struct strbuf sub_prefix = STRBUF_INIT;
+	int ret;
+
+	if (repo_submodule_init(&sub, r, path, treeish)) {
+		string_list_append_nodup(&walk->missing, xstrfmt("%s (%s)", full,
+				    _("not checked out")));
+		return 1;
+	}
+	if (!odb_has_object(sub.objects, commit, 0)) {
+		string_list_append_nodup(&walk->missing, xstrfmt("%s (%s %s)", full,
+				    _("checked out, but missing commit"),
+				    oid_to_hex(commit)));
+		repo_clear(&sub);
+		return 1;
+	}
+
+	strbuf_addf(&sub_prefix, "%s/", full);
+	oid_array_append(chain, commit);
+	ret = hash_tree(&sub, commit, sub_prefix.buf, chain, walk, digest);
+	chain->nr--;
+	if (!ret)
+		strbuf_addstr(out, hash_to_hex_algop(digest, sha256));
+
+	strbuf_release(&sub_prefix);
+	repo_clear(&sub);
+	return ret;
+}
+
+/*
+ * Hash one tree of repository "r". "prefix" is the path of "r" from the
+ * top repository (empty, or ending in '/'), and "chain" holds the
+ * commits of the submodules being hashed above this one.
+ */
+static int hash_tree(struct repository *r, const struct object_id *oid,
+		     const char *prefix, struct oid_array *chain,
+		     struct walk *walk, unsigned char *digest)
+{
+	const struct git_hash_algo *sha256 = &hash_algos[GIT_HASH_SHA256];
+	struct git_hash_ctx outer;
+	struct collect c = { 0 };
+	struct pathspec pathspec = { 0 };
+	struct strbuf value = STRBUF_INIT;
+	struct tree *tree;
+	int ret = 0;
+
+	tree = repo_parse_tree_indirect(r, oid);
+	if (!tree)
+		return error(_("unable to read tree for %s in %s"),
+			     oid_to_hex(oid), *prefix ? prefix : ".");
+	if (read_tree(r, tree, &pathspec, collect_entry, &c))
+		return error(_("unable to read tree %s"),
+			     oid_to_hex(&tree->object.oid));
+	QSORT(c.items, c.nr, record_cmp);
+
+	git_hash_init(&outer, sha256);
+	for (size_t i = 0; i < c.nr; i++) {
+		struct record *rec = &c.items[i];
+
+		strbuf_reset(&value);
+		if (!rec->submodule) {
+			struct git_hash_ctx ctx;
+			unsigned char blob_digest[GIT_MAX_RAWSZ];
+			enum object_type type;
+			size_t size;
+			void *data;
+
+			data = odb_read_object(r->objects, &rec->oid, &type, &size);
+			if (!data || type != OBJ_BLOB) {
+				free(data);
+				ret = error(_("unable to read blob %s for %s%s"),
+					    oid_to_hex(&rec->oid), prefix, rec->path);
+				break;
+			}
+			git_hash_init(&ctx, sha256);
+			git_hash_update(&ctx, data, size);
+			git_hash_final(blob_digest, &ctx);
+			free(data);
+			strbuf_addstr(&value, hash_to_hex_algop(blob_digest, sha256));
+		} else if (in_chain(chain, &rec->oid)) {
+			/*
+			 * A commit can't contain itself without a hash
+			 * collision, but don't rely on that to stop.
+			 */
+			strbuf_addf(&value, "cycle:%s", oid_to_hex(&rec->oid));
+		} else {
+			char *path = xstrndup(rec->path, strlen(rec->path) - 1);
+			char *full = xstrfmt("%s%s", prefix, path);
+			int res = hash_submodule(r, &tree->object.oid, path, full, &rec->oid,
+						 chain, walk, &value);
+			free(path);
+			free(full);
+			if (res < 0) {
+				ret = -1;
+				break;
+			}
+		}
+
+		git_hash_update(&outer, value.buf, value.len);
+		git_hash_update(&outer, " ", 1);
+		git_hash_update(&outer, rec->path, strlen(rec->path));
+		git_hash_update(&outer, "", 1);
+	}
+	git_hash_final(digest, &outer);
+
+	for (size_t i = 0; i < c.nr; i++)
+		free(c.items[i].path);
+	free(c.items);
+	strbuf_release(&value);
+	return ret;
+}
+
+int tree_sha256_hex(struct repository *r, const struct object_id *oid,
+		    struct strbuf *hex)
+{
+	const struct git_hash_algo *sha256 = &hash_algos[GIT_HASH_SHA256];
+	unsigned char digest[GIT_MAX_RAWSZ];
+	struct walk walk = { .missing = STRING_LIST_INIT_DUP };
+	struct oid_array chain = OID_ARRAY_INIT;
+	struct commit *top;
+	struct tree *tree;
+	int ret;
+
+	tree = repo_parse_tree_indirect(r, oid);
+	if (!tree)
+		return error(_("cannot compute %s: %s does not point to a tree"),
+			     TREE_SHA256_HEADER, oid_to_hex(oid));
+
+	top = lookup_commit_reference_gently(r, oid, 1);
+	if (top)
+		oid_array_append(&chain, &top->object.oid);
+
+	ret = hash_tree(r, &tree->object.oid, "", &chain, &walk, digest);
+
+	if (!ret && walk.missing.nr) {
+		struct strbuf list = STRBUF_INIT;
+
+		string_list_sort(&walk.missing);
+		for (size_t i = 0; i < walk.missing.nr; i++)
+			strbuf_addf(&list, "\n  %s", walk.missing.items[i].string);
+		ret = error(_("%"PRIuMAX" submodule(s) are not available, and "
+			      "a %s can't be computed without their tree hashes:%s\n"
+			      "Check them out with `git submodule update --init --recursive`."),
+			    (uintmax_t)walk.missing.nr, TREE_SHA256_HEADER, list.buf);
+		strbuf_release(&list);
+	}
+	if (!ret)
+		strbuf_addstr(hex, hash_to_hex_algop(digest, sha256));
+
+	string_list_clear(&walk.missing, 0);
+	oid_array_clear(&chain);
+	return ret;
+}
diff --git a/tree-sha256.h b/tree-sha256.h
new file mode 100644
index 0000000000..6d3e5018aa
--- /dev/null
+++ b/tree-sha256.h
@@ -0,0 +1,30 @@
+#ifndef TREE_SHA256_H
+#define TREE_SHA256_H
+
+struct repository;
+struct object_id;
+struct strbuf;
+
+/* The header that carries the digest in signed commits and tags. */
+#define TREE_SHA256_HEADER "tree-sha256"
+
+/*
+ * Compute the tree-sha256 of the tree reachable from "oid" (a tree,
+ * commit or tag) and append it to "hex" as 64 lowercase hex digits.
+ *
+ * Every blob and symlink in the tree, recursively, contributes one
+ * record "<hex sha256 of content> SP <path> NUL". Every submodule
+ * contributes "<hex tree-sha256 of submodule> SP <path>/ NUL", hashed
+ * from the checked-out submodule's own repository at the commit the
+ * superproject pins; a submodule whose commit is already being hashed
+ * further up the chain is recorded as "cycle:<commit> SP <path>/ NUL".
+ * Records are sorted by path (byte order) and the digest is SHA-256
+ * over their concatenation.
+ *
+ * Returns 0 on success. On failure (for example a submodule that is
+ * not checked out) reports every problem with error() and returns -1.
+ */
+int tree_sha256_hex(struct repository *r, const struct object_id *oid,
+		    struct strbuf *hex);
+
+#endif /* TREE_SHA256_H */
-- 
2.50.1 (Apple Git-155)
Scott ChaconOct 2, 2026, 08:18 UTC in reply to Scott Chacon on lore

[RFC PATCH 2/4] tag: add --hash=sha256 to sign a tree-sha256 header

Teach "git tag -s" and "git tag -u" a "--hash=sha256" option that puts the tree-sha256 of the tagged tree in a header after "tagger":

  object <commit>
  type commit
  tag <name>
  tagger <ident>
  tree-sha256 <hex>

Being in the header, it is part of the signed payload, and so the signature now covers the contents of the tagged tree directly.

"--hash=sha256" without signing is an error, as there is nothing to gain from an unsigned digest. It also makes "git tag" create a tag object, so that it isn't silently dropped when making a lightweight tag. "--hash=none" is accepted so that a later patch can let it override a configured default.

---
 Documentation/git-tag.adoc    | 11 ++++-
 builtin/tag.c                 | 29 +++++++++++--
 t/meson.build                 |  1 +
 t/t7032-tree-sha256-signed.sh | 76 +++++++++++++++++++++++++++++++++++
 tree-sha256.c                 |  9 +++++
 tree-sha256.h                 |  6 +++
 6 files changed, 128 insertions(+), 4 deletions(-)
 create mode 100755 t/t7032-tree-sha256-signed.sh
Show changes to 6 files +128 −4

Documentation/git-tag.adoc, builtin/tag.c, t/meson.build, t/t7032-tree-sha256-signed.sh, tree-sha256.c, tree-sha256.h

diff --git a/Documentation/git-tag.adoc b/Documentation/git-tag.adoc
index cea3202fdb..8901090a6d 100644
--- a/Documentation/git-tag.adoc
+++ b/Documentation/git-tag.adoc
@@ -9,7 +9,7 @@ git-tag - Create, list, delete or verify tags
 SYNOPSIS
 --------
 [synopsis]
-git tag [-a | -s | -u <key-id>] [-f] [-m <msg> | -F <file>] [-e]
+git tag [-a | -s | -u <key-id>] [--hash=<algorithm>] [-f] [-m <msg> | -F <file>] [-e]
 	[(--trailer <token>[(=|:)<value>])...]
 	<tagname> [<commit> | <object>]
 git tag -d <tagname>...
@@ -84,6 +84,15 @@ OPTIONS
 	`gpg.format` configuration variable. See
 	linkgit:git-config[1].
 
+`--hash=<algorithm>`::
+	When signing, add a `tree-sha256` header holding a SHA-256
+	digest of every file in the tagged object'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.
+
 `-f`::
 `--force`::
 	Replace an existing tag with the given name (instead of failing)
diff --git a/builtin/tag.c b/builtin/tag.c
index 06c125b53c..9bc4c946d1 100644
--- a/builtin/tag.c
+++ b/builtin/tag.c
@@ -33,9 +33,10 @@
 #include "write-or-die.h"
 #include "object-file-convert.h"
 #include "trailer.h"
+#include "tree-sha256.h"
 
 static const char * const git_tag_usage[] = {
-	N_("git tag [-a | -s | -u <key-id>] [-f] [-m <msg> | -F <file>] [-e]\n"
+	N_("git tag [-a | -s | -u <key-id>] [--hash=<algorithm>] [-f] [-m <msg> | -F <file>] [-e]\n"
 	   "        [(--trailer <token>[(=|:)<value>])...]\n"
 	   "        <tagname> [<commit> | <object>]"),
 	N_("git tag -d <tagname>..."),
@@ -281,6 +282,7 @@ struct create_tag_options {
 	unsigned int message_given:1;
 	unsigned int use_editor:1;
 	unsigned int sign;
+	unsigned int tree_hash;
 	enum {
 		CLEANUP_NONE,
 		CLEANUP_SPACE,
@@ -316,11 +318,19 @@ static void create_tag(const struct object_id *object, const char *object_ref,
 		    "object %s\n"
 		    "type %s\n"
 		    "tag %s\n"
-		    "tagger %s\n\n",
+		    "tagger %s\n",
 		    oid_to_hex(object),
 		    type_name(type),
 		    tag,
 		    git_committer_info(IDENT_STRICT));
+	if (opt->sign && opt->tree_hash) {
+		strbuf_addstr(&header, TREE_SHA256_HEADER " ");
+		if (tree_sha256_hex(the_repository, object, &header))
+			die(_("unable to compute %s for %s"),
+			    TREE_SHA256_HEADER, object_ref);
+		strbuf_addch(&header, '\n');
+	}
+	strbuf_addch(&header, '\n');
 
 	should_edit = opt->use_editor || !opt->message_given;
 	if (should_edit || trailer_args->nr) {
@@ -468,6 +478,7 @@ int cmd_tag(int argc,
 	int cmdmode = 0, create_tag_object = 0;
 	char *msgfile = NULL;
 	const char *keyid = NULL;
+	const char *hash_arg = NULL;
 	struct msg_arg msg = { .buf = STRBUF_INIT };
 	struct ref_transaction *transaction;
 	struct strbuf err = STRBUF_INIT;
@@ -503,6 +514,8 @@ int cmd_tag(int argc,
 			   N_("add custom trailer(s)")),
 		OPT_BOOL('e', "edit", &edit_flag, N_("force edit of tag message")),
 		OPT_BOOL('s', "sign", &opt.sign, N_("annotated and GPG-signed tag")),
+		OPT_STRING(0, "hash", &hash_arg, N_("algorithm"),
+			   N_("sign a tree-sha256 header of the tagged tree (sha256 or none)")),
 		OPT_CLEANUP(&cleanup_arg),
 		OPT_STRING('u', "local-user", &keyid, N_("key-id"),
 					N_("use another key to sign the tag")),
@@ -556,6 +569,14 @@ int cmd_tag(int argc,
 
 	argc = parse_options(argc, argv, prefix, options, git_tag_usage, 0);
 
+	if (hash_arg) {
+		int tree_hash = parse_signing_hash(hash_arg);
+		if (tree_hash < 0)
+			die(_("unsupported --hash value '%s' (use 'sha256' or 'none')"),
+			    hash_arg);
+		opt.tree_hash = tree_hash;
+	}
+
 	if (!cmdmode) {
 		if (argc == 0)
 			cmdmode = 'l';
@@ -579,7 +600,7 @@ int cmd_tag(int argc,
 		set_signing_key(keyid);
 	}
 	create_tag_object = (opt.sign || annotate || msg.given || msgfile ||
-			     edit_flag || trailer_args.nr);
+			     edit_flag || trailer_args.nr || opt.tree_hash);
 
 	if ((create_tag_object || force) && (cmdmode != 0))
 		usage_with_options(git_tag_usage, options);
@@ -683,6 +704,8 @@ int cmd_tag(int argc,
 	if (create_tag_object) {
 		if (force_sign_annotate && !annotate)
 			opt.sign = 1;
+		if (opt.tree_hash && !opt.sign)
+			die(_("--hash=%s requires a signed tag (-s or -u)"), hash_arg);
 		path = repo_git_path(the_repository, "TAG_EDITMSG");
 		create_tag(&object, object_ref, tag, &buf, &opt, &prev, &object,
 			   &trailer_args, path);
diff --git a/t/meson.build b/t/meson.build
index 8ff3dbe69d..ff83368847 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -877,6 +877,7 @@ integration_tests = [
   't7012-skip-worktree-writing.sh',
   't7030-verify-tag.sh',
   't7031-verify-tag-signed-ssh.sh',
+  't7032-tree-sha256-signed.sh',
   't7060-wtstatus.sh',
   't7061-wtstatus-ignore.sh',
   't7062-wtstatus-ignorecase.sh',
diff --git a/t/t7032-tree-sha256-signed.sh b/t/t7032-tree-sha256-signed.sh
new file mode 100755
index 0000000000..083f25e665
--- /dev/null
+++ b/t/t7032-tree-sha256-signed.sh
@@ -0,0 +1,76 @@
+#!/bin/sh
+
+test_description='signed tags and commits with a tree-sha256 header'
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+GNUPGHOME_NOT_USED=$GNUPGHOME
+. "$TEST_DIRECTORY/lib-gpg.sh"
+
+# Print the value of the tree-sha256 header of <type> <object>, if any.
+header_of () {
+	git cat-file "$1" "$2" >object &&
+	sed -n "/^$/q; s/^tree-sha256 //p" object
+}
+
+test_expect_success GPGSSH 'setup' '
+	git config --global gpg.format ssh &&
+	git config --global gpg.ssh.allowedSignersFile "${GPGSSH_ALLOWED_SIGNERS}" &&
+	git config --global user.signingkey "${GPGSSH_KEY_PRIMARY}" &&
+	mkdir dir &&
+	echo one >dir/file &&
+	echo two >file &&
+	git add dir file &&
+	test_tick &&
+	git commit -m initial &&
+	test-tool tree-sha256 HEAD >expect
+'
+
+test_expect_success GPGSSH 'tag -s --hash=sha256 signs a tree-sha256 header' '
+	git tag -s --hash=sha256 -m release v1 &&
+	header_of tag v1 >actual &&
+	test_cmp expect actual &&
+	sed -n "4,5p" object >lines &&
+	test_grep "^tagger " lines &&
+	test_grep "^tree-sha256 " lines &&
+	git tag -v v1 &&
+	git fsck --strict
+'
+
+test_expect_success GPGSSH 'tag -u --hash=sha256 signs a tree-sha256 header' '
+	git tag -u "${GPGSSH_KEY_PRIMARY}" --hash=sha256 -m release v2 &&
+	header_of tag v2 >actual &&
+	test_cmp expect actual &&
+	git tag -v v2
+'
+
+test_expect_success GPGSSH 'tag -s without --hash has no header' '
+	git tag -s -m release v3 &&
+	header_of tag v3 >actual &&
+	test_must_be_empty actual &&
+	git tag -s --hash=none -m release v4 &&
+	header_of tag v4 >actual &&
+	test_must_be_empty actual
+'
+
+test_expect_success GPGSSH 'tag --hash=sha256 requires signing' '
+	test_must_fail git tag --hash=sha256 -m release v5 2>err &&
+	test_grep "requires a signed tag" err &&
+	test_must_fail git tag --hash=sha256 v5 2>err &&
+	test_grep "requires a signed tag" err &&
+	test_must_fail git rev-parse --verify v5
+'
+
+test_expect_success GPGSSH 'tag --hash rejects unknown algorithms' '
+	test_must_fail git tag -s --hash=md5 -m release v5 2>err &&
+	test_grep "unsupported --hash value" err &&
+	test_must_fail git rev-parse --verify v5
+'
+
+test_expect_success GPGSSH 'tag --hash=sha256 needs an object with a tree' '
+	test_must_fail git tag -s --hash=sha256 -m blob v5 HEAD:file &&
+	test_must_fail git rev-parse --verify v5
+'
+
+test_done
diff --git a/tree-sha256.c b/tree-sha256.c
index fb58962232..90f0306521 100644
--- a/tree-sha256.c
+++ b/tree-sha256.c
@@ -236,3 +236,12 @@ int tree_sha256_hex(struct repository *r, const struct object_id *oid,
 	oid_array_clear(&chain);
 	return ret;
 }
+
+int parse_signing_hash(const char *value)
+{
+	if (!strcasecmp(value, "sha256"))
+		return 1;
+	if (!strcasecmp(value, "none"))
+		return 0;
+	return -1;
+}
diff --git a/tree-sha256.h b/tree-sha256.h
index 6d3e5018aa..dc070129ea 100644
--- a/tree-sha256.h
+++ b/tree-sha256.h
@@ -27,4 +27,10 @@ struct strbuf;
 int tree_sha256_hex(struct repository *r, const struct object_id *oid,
 		    struct strbuf *hex);
 
+/*
+ * Parse the value of a --hash=<algorithm> option. Returns 1 for
+ * "sha256", 0 for "none", and -1 for anything else.
+ */
+int parse_signing_hash(const char *value);
+
 #endif /* TREE_SHA256_H */
-- 
2.50.1 (Apple Git-155)
Scott ChaconOct 2, 2026, 08:18 UTC in reply to Scott Chacon on lore

[RFC PATCH 3/4] commit: add --hash=sha256 to sign a tree-sha256 header

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(-)
Show changes to 3 files +93 −5

Documentation/git-commit.adoc, builtin/commit.c, t/t7032-tree-sha256-signed.sh

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)
Scott ChaconOct 2, 2026, 08:18 UTC in reply to Scott Chacon on lore

[RFC PATCH 4/4] gpg: add gpg.treeHash to sign a tree-sha256 header by default

Someone who wants their signatures to cover the contents of their trees wants it for every tag and commit they sign, and shouldn't have to remember "--hash=sha256" each time, much as "tag.gpgSign" and "commit.gpgSign" save them from remembering "-s" and "-S".

Add "gpg.treeHash", which "git tag" and "git commit" use as the default for "--hash". It is a single variable rather than one for each command, since the reason for wanting it is the same for both.

It only applies to objects that are signed: with it set, unsigned commits and annotated or lightweight tags are made as before, rather than failing as an explicit "--hash=sha256" without signing does. "--hash=none" overrides it.

---
 Documentation/config/gpg.adoc |  6 +++++
 Documentation/git-commit.adoc |  2 +-
 Documentation/git-tag.adoc    |  2 +-
 builtin/commit.c              |  8 +++++++
 builtin/tag.c                 | 14 ++++++++++-
 t/t7032-tree-sha256-signed.sh | 44 +++++++++++++++++++++++++++++++++++
 tree-sha256.h                 |  4 ++--
 7 files changed, 75 insertions(+), 5 deletions(-)
Show changes to 7 files +75 −5

Documentation/config/gpg.adoc, Documentation/git-commit.adoc, Documentation/git-tag.adoc, builtin/commit.c, builtin/tag.c, t/t7032-tree-sha256-signed.sh, tree-sha256.h

diff --git a/Documentation/config/gpg.adoc b/Documentation/config/gpg.adoc
index 240e46c050..6728c13a62 100644
--- a/Documentation/config/gpg.adoc
+++ b/Documentation/config/gpg.adoc
@@ -16,6 +16,12 @@ gpg.format::
 See linkgit:gitformat-signature[5] for the signature format, which differs
 based on the selected `gpg.format`.
 
+gpg.treeHash::
+	When set to `sha256`, `git commit` and `git tag` add a
+	`tree-sha256` header to every commit and tag they sign, as if
+	`--hash=sha256` were given. Defaults to `none`. See the
+	`--hash` option in linkgit:git-commit[1] and linkgit:git-tag[1].
+
 gpg.<format>.program::
 	Use this to customize the program used for the signing format you
 	chose. (see `gpg.program` and `gpg.format`) `gpg.program` can still
diff --git a/Documentation/git-commit.adoc b/Documentation/git-commit.adoc
index c027de2adb..1ed6635eee 100644
--- a/Documentation/git-commit.adoc
+++ b/Documentation/git-commit.adoc
@@ -405,7 +405,7 @@ changes to tracked files.
 	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).
+	_<algorithm>_ is `sha256`, or `none` to override `gpg.treeHash`.
 	Giving `--hash=sha256` without signing is an error. All
 	submodules must be checked out.
 
diff --git a/Documentation/git-tag.adoc b/Documentation/git-tag.adoc
index 8901090a6d..445be41db1 100644
--- a/Documentation/git-tag.adoc
+++ b/Documentation/git-tag.adoc
@@ -89,7 +89,7 @@ OPTIONS
 	digest of every file in the tagged object'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).
+	_<algorithm>_ is `sha256`, or `none` to override `gpg.treeHash`.
 	Giving `--hash=sha256` without signing is an error. All
 	submodules must be checked out.
 
diff --git a/builtin/commit.c b/builtin/commit.c
index 871a2bdcd7..7e56c434db 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -1687,6 +1687,14 @@ static int git_commit_config(const char *k, const char *v,
 		sign_commit = git_config_bool(k, v) ? "" : NULL;
 		return 0;
 	}
+	if (!strcmp(k, "gpg.treehash")) {
+		if (!v)
+			return config_error_nonbool(k);
+		tree_hash = parse_signing_hash(v);
+		if (tree_hash < 0)
+			return error(_("invalid value for '%s': '%s'"), k, v);
+		return 0;
+	}
 	if (!strcmp(k, "commit.verbose")) {
 		int is_bool;
 		config_commit_verbose = git_config_bool_or_int(k, v, ctx->kvi,
diff --git a/builtin/tag.c b/builtin/tag.c
index 9bc4c946d1..86871317ed 100644
--- a/builtin/tag.c
+++ b/builtin/tag.c
@@ -51,6 +51,7 @@ static const char * const git_tag_usage[] = {
 static unsigned int colopts;
 static int force_sign_annotate;
 static int config_sign_tag = -1; /* unspecified */
+static int config_tree_hash;
 
 static int list_tags(struct ref_filter *filter, struct ref_sorting *sorting,
 		     struct ref_format *format)
@@ -223,6 +224,15 @@ static int git_tag_config(const char *var, const char *value,
 		return 0;
 	}
 
+	if (!strcmp(var, "gpg.treehash")) {
+		if (!value)
+			return config_error_nonbool(var);
+		config_tree_hash = parse_signing_hash(value);
+		if (config_tree_hash < 0)
+			return error(_("invalid value for '%s': '%s'"), var, value);
+		return 0;
+	}
+
 	if (!strcmp(var, "tag.forcesignannotated")) {
 		force_sign_annotate = git_config_bool(var, value);
 		return 0;
@@ -601,6 +611,8 @@ int cmd_tag(int argc,
 	}
 	create_tag_object = (opt.sign || annotate || msg.given || msgfile ||
 			     edit_flag || trailer_args.nr || opt.tree_hash);
+	if (!hash_arg)
+		opt.tree_hash = config_tree_hash;
 
 	if ((create_tag_object || force) && (cmdmode != 0))
 		usage_with_options(git_tag_usage, options);
@@ -704,7 +716,7 @@ int cmd_tag(int argc,
 	if (create_tag_object) {
 		if (force_sign_annotate && !annotate)
 			opt.sign = 1;
-		if (opt.tree_hash && !opt.sign)
+		if (opt.tree_hash && !opt.sign && hash_arg)
 			die(_("--hash=%s requires a signed tag (-s or -u)"), hash_arg);
 		path = repo_git_path(the_repository, "TAG_EDITMSG");
 		create_tag(&object, object_ref, tag, &buf, &opt, &prev, &object,
diff --git a/t/t7032-tree-sha256-signed.sh b/t/t7032-tree-sha256-signed.sh
index 44c363b5d2..5a656a7816 100755
--- a/t/t7032-tree-sha256-signed.sh
+++ b/t/t7032-tree-sha256-signed.sh
@@ -122,4 +122,48 @@ test_expect_success GPGSSH 'amending recomputes or drops the header' '
 	test_must_be_empty actual
 '
 
+test_expect_success GPGSSH 'gpg.treeHash signs the header by default' '
+	test-tool tree-sha256 HEAD >expect &&
+	test_config gpg.treeHash sha256 &&
+	git tag -s -m release v6 &&
+	header_of tag v6 >actual &&
+	test_cmp expect actual &&
+	test_tick &&
+	git commit --allow-empty -S -m signed &&
+	header_of commit HEAD >actual &&
+	test_cmp expect actual
+'
+
+test_expect_success GPGSSH 'gpg.treeHash leaves unsigned objects alone' '
+	test_config gpg.treeHash sha256 &&
+	git tag -a -m annotated v7 &&
+	header_of tag v7 >actual &&
+	test_must_be_empty actual &&
+	git tag v8 &&
+	test "$(git cat-file -t v8)" = commit &&
+	test_tick &&
+	git commit --allow-empty -m unsigned &&
+	header_of commit HEAD >actual &&
+	test_must_be_empty actual
+'
+
+test_expect_success GPGSSH '--hash=none overrides gpg.treeHash' '
+	test_config gpg.treeHash sha256 &&
+	git tag -s --hash=none -m release v9 &&
+	header_of tag v9 >actual &&
+	test_must_be_empty actual &&
+	test_tick &&
+	git commit --allow-empty -S --hash=none -m signed &&
+	header_of commit HEAD >actual &&
+	test_must_be_empty actual
+'
+
+test_expect_success GPGSSH 'invalid gpg.treeHash is an error' '
+	test_config gpg.treeHash md5 &&
+	test_must_fail git tag -s -m release v10 2>err &&
+	test_grep "invalid value for .gpg.treehash." err &&
+	test_must_fail git commit --allow-empty -S -m signed 2>err &&
+	test_grep "invalid value for .gpg.treehash." err
+'
+
 test_done
diff --git a/tree-sha256.h b/tree-sha256.h
index dc070129ea..6d54c2c9f9 100644
--- a/tree-sha256.h
+++ b/tree-sha256.h
@@ -28,8 +28,8 @@ int tree_sha256_hex(struct repository *r, const struct object_id *oid,
 		    struct strbuf *hex);
 
 /*
- * Parse the value of a --hash=<algorithm> option. Returns 1 for
- * "sha256", 0 for "none", and -1 for anything else.
+ * Parse the value of a --hash=<algorithm> option or of gpg.treeHash.
+ * Returns 1 for "sha256", 0 for "none", and -1 for anything else.
  */
 int parse_signing_hash(const char *value);
 
-- 
2.50.1 (Apple Git-155)
Junio C HamanoOct 2, 2026, 15:45 UTC in reply to Scott Chacon on lore

Re: [RFC PATCH 1/4] tree-sha256: hash the contents of a tree with SHA-256

Scott Chacon <scott@gitbutler.net> writes:
Show 6 quoted lines
> Add a way to compute a SHA-256 digest of the contents of a tree that
> doesn't depend on the object format, so that it can be put in the
> signed payload. Each blob in the tree, recursively, becomes one record, 
> and the digest is SHA-256 over the records sorted by path:
>
>   <hex sha256 of content> SP <path> NUL

Would three trees, one records a blob with a single word "hello" at a path as an executable regular file, another records the same blob at the same path but as a non-executable regular file, and the third records a symbolic link whose target is "hello", hash to the same result? Should they?

Show 20 quoted lines
> +static int hash_tree(struct repository *r, const struct object_id *oid,
> +		     const char *prefix, struct oid_array *chain,
> +		     struct walk *walk, unsigned char *digest)
> +{
> +	const struct git_hash_algo *sha256 = &hash_algos[GIT_HASH_SHA256];
> +	struct git_hash_ctx outer;
> +	struct collect c = { 0 };
> +	struct pathspec pathspec = { 0 };
> +	struct strbuf value = STRBUF_INIT;
> +	struct tree *tree;
> +	int ret = 0;
> +
> +	tree = repo_parse_tree_indirect(r, oid);
> +	if (!tree)
> +		return error(_("unable to read tree for %s in %s"),
> +			     oid_to_hex(oid), *prefix ? prefix : ".");
> +	if (read_tree(r, tree, &pathspec, collect_entry, &c))
> +		return error(_("unable to read tree %s"),
> +			     oid_to_hex(&tree->object.oid));
> +	QSORT(c.items, c.nr, record_cmp);

I am somewhat torn but moderately against this sorting there. If we have two tree objects that would result in the same checkout, but one is corrupt in such a way that whose entries are not sorted correctly, we want them to hash to a different value to signal that, don't we?

Show 24 quoted lines
> +	git_hash_init(&outer, sha256);
> +	for (size_t i = 0; i < c.nr; i++) {
> +		struct record *rec = &c.items[i];
> +
> +		strbuf_reset(&value);
> +		if (!rec->submodule) {
> +			struct git_hash_ctx ctx;
> +			unsigned char blob_digest[GIT_MAX_RAWSZ];
> +			enum object_type type;
> +			size_t size;
> +			void *data;
> +
> +			data = odb_read_object(r->objects, &rec->oid, &type, &size);
> +			if (!data || type != OBJ_BLOB) {
> +				free(data);
> +				ret = error(_("unable to read blob %s for %s%s"),
> +					    oid_to_hex(&rec->oid), prefix, rec->path);
> +				break;
> +			}
> +			git_hash_init(&ctx, sha256);
> +			git_hash_update(&ctx, data, size);
> +			git_hash_final(blob_digest, &ctx);
> +			free(data);
> +			strbuf_addstr(&value, hash_to_hex_algop(blob_digest, sha256));

This forces us to read the inflated blob contents as a whole in-core before we hash. I wonder if we can use the streaming interface like how archive-{tar,zip}.c uses odb_stream_from_object() to read the contents in smaller chunks? Instead of writing the contents out like they do, we would instead hash the bytes here.

Junio C HamanoOct 2, 2026, 15:49 UTC in reply to Scott Chacon on lore

Re: [RFC PATCH 2/4] tag: add --hash=sha256 to sign a tree-sha256 header

Scott Chacon <scott@gitbutler.net> writes:
Show 18 quoted lines
> @@ -316,11 +318,19 @@ static void create_tag(const struct object_id *object, const char *object_ref,
>  		    "object %s\n"
>  		    "type %s\n"
>  		    "tag %s\n"
> -		    "tagger %s\n\n",
> +		    "tagger %s\n",
>  		    oid_to_hex(object),
>  		    type_name(type),
>  		    tag,
>  		    git_committer_info(IDENT_STRICT));
> +	if (opt->sign && opt->tree_hash) {
> +		strbuf_addstr(&header, TREE_SHA256_HEADER " ");
> +		if (tree_sha256_hex(the_repository, object, &header))
> +			die(_("unable to compute %s for %s"),
> +			    TREE_SHA256_HEADER, object_ref);
> +		strbuf_addch(&header, '\n');
> +	}
> +	strbuf_addch(&header, '\n');

This is a very nice reorganization. The hardcoded double LF at the end was a declaration that we wanted to make it hard to add new fields, but it becomes a hindrance when we want to add an optional field.

Junio C HamanoOct 2, 2026, 15:52 UTC in reply to Scott Chacon on lore

Re: [RFC PATCH 0/4] sign a SHA-256 digest of the tree in commits and tags

Scott Chacon <scott@gitbutler.net> writes:
Show 6 quoted lines
> I'm concerned about the ecosystem impact of moving the `git init` default
> hashing function to SHA-256 in 3.0. I have suggested that it may be more 
> feasible with similar benefits to add the ability to inject an independently
> calculated and verifiable tree content sha into signed objects instead.
>
> This RFC series is meant to demonstrate how this might work.

I have offered a few minor comments on the implementation, but those are conditional on the assumption that if this is a good idea, we would want these improvements. I have not yet formed an opinion on the overall direction.

Thanks.
brian m. carlsonOct 2, 2026, 19:06 UTC in reply to Scott Chacon on lore

Re: [RFC PATCH 0/4] sign a SHA-256 digest of the tree in commits and tags

On 2026-10-02 at 08:18:42, Scott Chacon wrote:
> I'm concerned about the ecosystem impact of moving the `git init` default
> hashing function to SHA-256 in 3.0. I have suggested that it may be more
> feasible with similar benefits to add the ability to inject an independently
> calculated and verifiable tree content sha into signed objects instead.

I don't think this is a good idea. There are lots of reasons it's not, but the simplest one is that Git requires collision resistance because it is impossible to store two different colliding blobs. We don't have any such blobs yet, but I fully expect SHA-1 to become as weak as MD5, in which case there will be a large number of items that cannot be stored in a Git repository. Even if you don't want to store those blobs, there are many people, such as security researchers, who _do_ want to store those blobs and that requires a SHA-256 repository. Your approach does nothing to address that problem.

Consequently, we need to make the problem better as soon as possible and that means moving away from SHA-1. TLS, OpenPGP, and other major ecosystems have already made this transition and we're very far behind the times. The Canadian government already recommends users to have moved away from SHA-1 and the U.S. government will no longer allow SHA-1 for any purpose as of 2030. I want to be clear that 4 years in the large business and government sector is nothing.

I'll also add that the design we have is the design we've had for many years and there has been ample opportunity to propose alternative designs. The plan for Git 3.0 is around the March timeframe and making substantial changes now is far too late. Every major forge has support for SHA-256, whether publicly or in preview, and no forge has support for this design, nor do I anticipate it seeing a lot of traction, especially since we explicitly rejected the kind of half-transition you're proposing for security and other reasons. Git 3.0 and the requirement for SHA-256 were discussed at Git Merge 2024 in Berlin and discussion has happened on the list quite a bit since then, so it shouldn't be a surprise to anyone.

The thing you really want is the interoperability work, which can automatically rewrite repositories from one hash algorithm to another during a clone or fetch operation. Yes, it isn't quite that simple for submodules, but if you recursively clone the repository and all its submodules, it should be possible to rewrite it in place, although that hasn't been written yet. That work has not yet been sent upstream because some of it was written at $DAYJOB, which requires that we use Outlook and we all know that Outlook corrupts patches. However, there is some intention for another company to handle the polishing and sending, so it should be available sooner or later.

-- 
brian m. carlson (they/them)
Toronto, Ontario, CA
Scott ChaconOct 5, 2026, 09:32 UTC in reply to brian m. carlson on lore

Re: [RFC PATCH 0/4] sign a SHA-256 digest of the tree in commits and tags

Hey all,

There are basically two things to respond to here and that I feel the list should consider before 3.0.

One is this specific proposal of an independent content hash in a signed header, which I find interesting and potentially helpful regarding SHA-1 issues, but not necessarily fundamental.

The other is if the default object store for 3.0 should be sha256 or sha1.

On Fri, Oct 2, 2026 at 9:06 PM brian m. carlson <sandals@crustytoothpaste.net> wrote:

Show 16 quoted lines
>
> On 2026-10-02 at 08:18:42, Scott Chacon wrote:
> > I'm concerned about the ecosystem impact of moving the `git init` default
> > hashing function to SHA-256 in 3.0. I have suggested that it may be more
> > feasible with similar benefits to add the ability to inject an independently
> > calculated and verifiable tree content sha into signed objects instead.
>
> I don't think this is a good idea.  There are lots of reasons it's not,
> but the simplest one is that Git requires collision resistance because
> it is impossible to store two different colliding blobs.  We don't have
> any such blobs yet, but I fully expect SHA-1 to become as weak as MD5,
> in which case there will be a large number of items that cannot be
> stored in a Git repository.  Even if you don't want to store those
> blobs, there are many people, such as security researchers, who _do_
> want to store those blobs and that requires a SHA-256 repository.  Your
> approach does nothing to address that problem.

My approach was not meant to address that problem, partially because I believe it to be an incredibly niche problem. For security researchers or people over-interpreting NIST guidelines to mean "use at all" rather than "use for signatures", then SHA256 is clearly already a way to initiate a Git repository and can be used that way. They can do so today - making sha256 the default in 3.0 does not help or hinder them.

I don't mean to say we should remove different hash algorithms from Git, but that the _default_ should not bifurcate the entire community so that security researchers are slightly happier in still mostly theoretical situations.

Even if Git were based on MD5 content hashing, nearly everyone using it in nearly every normal scenario would probably be just fine. We can sign something that is not based on that hashing function (the basis of this series), but overall, the social trust mechanisms of pull sources are the predominant security layer, independent of hashing function.

It's important to differentiate this, since it's being conflated. Using SHA-1 for signatures is clearly problematic. Using SHA-1 as the content-addressing hash for it's Merkle DAG is not. The way Git uses SHA-1 primarily does not rely on a hash function being collision-free; it relies on a hash function being one-way, which SHA-1 is perfectly good for and always will be.

The only real issue is that it _also_ uses that hash for signature integrity, which means we can solve the main issue simply by not _also_ using it for signature integrity.

Show 7 quoted lines
> Consequently, we need to make the problem better as soon as possible and
> that means moving away from SHA-1.  TLS, OpenPGP, and other major
> ecosystems have already made this transition and we're very far behind
> the times.  The Canadian government already recommends users to have
> moved away from SHA-1 and the U.S. government will no longer allow SHA-1
> for any purpose as of 2030.  I want to be clear that 4 years in the
> large business and government sector is nothing.

I feel like this is arguably overstated. This is conflating "any purpose" with "applying cryptographic protection" / digital signatures. Part of the point of this series was to ensure that signing would be based on SHA-256 and could in theory make signatures on Git objects compliant with these NIST-style mandates while still using SHA-1 for the more basic odb content-addressing work.

In other words, I don't believe that these governments and businesses ban SHA-1 _for any purpose_. They ban it for the use of cryptographic protection and I'm saying that a simpler approach to solving that problem is to re-seperate content addressing hashes from protective signature hashes.

TLS, OpenPGP, etc all mostly stopped using SHA-1 for signatures, sure, but that's because providing security is essentially all those projects do. Governments still let you use modern browsers and websockets, even though SHA-1 is used in that protocol, specifically because it doesn't depend on any security properties of SHA-1.

This series is likewise proposing an alternative, non-SHA-1 based signing and content verification method along with an argument that maybe seperating those concerns is simpler and less backwards-incompatible.

> I'll also add that the design we have is the design we've had for many
> years and there has been ample opportunity to propose alternative
> designs.  The plan for Git 3.0 is around the March timeframe and making
> substantial changes now is far too late.

First of all, if you include the compat work, which imho is incredibly important to this transition being feasible, "the design that we have" is not even completed yet and is slightly different every time I hear it. As recently as 8 months ago, you yourself stated "We don't believe anyone is getting useful use out of the interoperability code in its current state" [1] and I can verify that this is still the case - interop is currently completely unusable.

The point of my tree-sha256 series is to actually massively simplify the work remaining and user experience impact. I'm saying "don't make it the default", which means that the entire ecosystem doesn't need to Y2K everything for the next 5 months. Even in Git core, there are still _substantial_ changes to make for the compat stuff, if I'm not mistaken. If pack index v3 isn't in core now, do you think it's going to be in libgit2 and gix and JGit and whatever by March? Not even the stuff that landed here 6 years ago is in JGit today.

As for the late hour comment, I've felt that this "flag day" hard cut has been a rather impractical approach to this problem for a while now and I have mentioned it to several of you in person in the past. However, I thought maybe some clever solution would come up over the last two years, but seeing Emily's talk at Git Merge, this close to the proposed cutover, convinced me that it's going to be a usability nightmare for everyone. And as above stated, I'm not convinced that this is anywhere near valuable enough of an outcome for the cost and difficulty associated.

I also think that a lot of other people would agree, if they had an idea that this was coming. I believe that many, many users will be surprised and confused by this when it hits. It turns out that not very many people read the mailing list.

> Every major forge has support
> for SHA-256, whether publicly or in preview,

Nobody has access to this for GitHub, which is where almost all usage is and where the kinks could theoretically have been ironed out. If 3.0 comes out in March, there will have been no time for anyone to give feedback or make substantial changes before everyone is forced into real usage of this highly incompatible change.

So Bitbucket doesn't, Gerrit doesn't, GitHub doesn't in any practical sense (I'm curious if anyone on even this mailing list has access to it's "preview"). GitLab has it under "experimental". I'm hesitant to agree that Codeberg or whatever constitutes "every major forge".

If anything, this is one of my biggest problems with this breaking change proposal - it has not been tested in a real way by nearly _anyone_, nor are major parts of the transistion plan (compatObjectFormat, pack index v3, fetch/push compatibility, compat sig verification, etc) fully implemented even a few months out from the cutover.

As one small but interesting example, I'm honestly fascinated that there is only now a thread here about the GitHub specific usability issues [2] with mixed odb repos (between several GitHub-y people, nonetheless) that hasn't been previously considered (the "limbo" idea). This is the kind of thing (among many others, I'm sure) that would come up if people had time to use this at all before a default switch.

> and no forge has support
> for this design, nor do I anticipate it seeing a lot of traction,
> especially since we explicitly rejected the kind of half-transition
> you're proposing for security and other reasons.

One of the nice things about the design of this particular series is that no forge support is needed. It would work today.

The new tag/commit header fscks fine and is transferred fine. You can't rebase signatures anyhow, so dropped headers aren't an issue (like commit-ids sometimes are). Verification is trusted locally and if `verify-commit` and `verify-tag` learn this header too, I'm unclear what "forge support" you think would be needed. New clients add the new, more secure header, new clients verify it properly, old clients fall back gracefully.

> Git 3.0 and the
> requirement for SHA-256 were discussed at Git Merge 2024 in Berlin and
> discussion has happened on the list quite a bit since then, so it
> shouldn't be a surprise to anyone.

Yes, my objection is late-ish, but again, it's because I'm not satisfied with the transition plan or implementation and I assumed it would have had more time to have some real world usage before the cutover.

Furthermore, that's just from someone who has actually been there for many of these discussions. There are a lot of discussions on the list that will be a surprise to _users_.

Do you have any idea how many custom scripts (various kinds of hooks, CI scripts, etc) around the world are going to explode on all new repositories because they have `/^[0-9a-f]{40}$/` hard coded somewhere? It will be the first time most users have any idea that Git 3.0 creates repos with a different structure - errors like that or "fatal: the receiving end does not support this repository's hash algorithm", or Eclipse simply not working, or a hundred other little issues, will flood unsuspecting Git users. Furthermore, in many cases it will be _very_ difficult to figure out why exactly this is happening on some repos and not others.

So yes, it will surprise many, many people.
Show 10 quoted lines
> The thing you really want is the interoperability work, which can
> automatically rewrite repositories from one hash algorithm to another
> during a clone or fetch operation.  Yes, it isn't quite that simple for
> submodules, but if you recursively clone the repository and all its
> submodules, it should be possible to rewrite it in place, although that
> hasn't been written yet.  That work has not yet been sent upstream
> because some of it was written at $DAYJOB, which requires that we use
> Outlook and we all know that Outlook corrupts patches.  However, there
> is some intention for another company to handle the polishing and
> sending, so it should be available sooner or later.

I think that well thought through, fully implemented and thoroughly tested interop work is fundamental and neccesary to a change in the default object format, yes. I find it confusing, especially for a project so backwards compatibility focused, that this is not a more widely held viewpoint.

You don't have to accept a version of this series or it's approach (though I do believe that some simpler signing strategy change fundamentally solves the main cryptographic security issues, including NIST-y gov issues), but if nothing else, I would encourage the group to ship 3.0 without the SHA-256 default and let it be used more widely on an opt-in basis, let tools and forges work out the compat issues and have time to get fixes and modifications upstream, and if it's still a pressing issue, change the default in 4.0 or whatever.

Thanks, Scott

[1] https://lore.kernel.org/git/20260207200446.2837699-2-sandals@crustytoothpaste.net/ [2] https://lore.kernel.org/git/20261002224400.GA834158@coredump.intra.peff.net/

Patrick SteinhardtOct 5, 2026, 12:41 UTC in reply to Scott Chacon on lore

Re: [RFC PATCH 0/4] sign a SHA-256 digest of the tree in commits and tags

On Mon, Oct 05, 2026 at 11:32:54AM +0200, Scott Chacon wrote:
> On Fri, Oct 2, 2026 at 9:06 PM brian m. carlson
> <sandals@crustytoothpaste.net> wrote:
> > On 2026-10-02 at 08:18:42, Scott Chacon wrote:
[snip]
Show 28 quoted lines
> > Every major forge has support
> > for SHA-256, whether publicly or in preview,
> 
> Nobody has access to this for GitHub, which is where almost all usage
> is and where the kinks could theoretically have been ironed out. If
> 3.0 comes out in March, there will have been no time for anyone to
> give feedback or make substantial changes before everyone is forced
> into real usage of this highly incompatible change.
> 
> So Bitbucket doesn't, Gerrit doesn't, GitHub doesn't in any practical
> sense (I'm curious if anyone on even this mailing list has access to
> it's "preview"). GitLab has it under "experimental". I'm hesitant to
> agree that Codeberg or whatever constitutes "every major forge".
> 
> If anything, this is one of my biggest problems with this breaking
> change proposal - it has not been tested in a real way by nearly
> _anyone_, nor are major parts of the transistion plan
> (compatObjectFormat, pack index v3, fetch/push compatibility, compat
> sig verification, etc) fully implemented even a few months out from
> the cutover.
> 
> As one small but interesting example, I'm honestly fascinated that
> there is only now a thread here about the GitHub specific usability
> issues [2] with mixed odb repos (between several GitHub-y people,
> nonetheless) that hasn't been previously considered (the "limbo"
> idea). This is the kind of thing (among many others, I'm sure) that
> would come up if people had time to use this at all before a default
> switch.

The biggest problem I have is that the ecosystem has been entirely unwilling to do anything about the SHA-256 move before we announced that this is going to become mandatory. Only then were developers even able to convince anybody (especially those paying the wages) to get the time to implement support for it.

So there is some kind of ossification happening in the space. But things are finally moving now that the due-date is drawing closer. I would be extremely hesitant to change course again and drop this breaking change now that there finally is some movement. Because the only consequence of that would be that the ecosystem will stop working on it again. And even more so, I would even expect that this will make the next time we want to do a breaking change exponentially harder as the lesson learned is that nobody needs to do anything.

Maybe I'm too pessimistic about this, but I don't think so. We've been working on this whole transition for almost a decade by now, and only now where we're forcing the ecosystem to adapt are large players like GitHub even moving.

Patrick
Scott ChaconOct 5, 2026, 14:16 UTC in reply to Patrick Steinhardt on lore

Re: [RFC PATCH 0/4] sign a SHA-256 digest of the tree in commits and tags

Thanks Steiny,
A quick response,
On Mon, Oct 5, 2026 at 2:41 PM Patrick Steinhardt <ps@pks.im> wrote:
Show 5 quoted lines
> The biggest problem I have is that the ecosystem has been entirely
> unwilling to do anything about the SHA-256 move before we announced that
> this is going to become mandatory. Only then were developers even able
> to convince anybody (especially those paying the wages) to get the time
> to implement support for it.

Bit of a simple question, but is it possible that this is because nobody really finds it a concerning problem?

Show 13 quoted lines
> So there is some kind of ossification happening in the space. But things
> are finally moving now that the due-date is drawing closer. I would be
> extremely hesitant to change course again and drop this breaking change
> now that there finally is some movement. Because the only consequence of
> that would be that the ecosystem will stop working on it again. And even
> more so, I would even expect that this will make the next time we want
> to do a breaking change exponentially harder as the lesson learned is
> that nobody needs to do anything.
>
> Maybe I'm too pessimistic about this, but I don't think so. We've been
> working on this whole transition for almost a decade by now, and only
> now where we're forcing the ecosystem to adapt are large players like
> GitHub even moving.

I want to remind everyone here quickly what "working on this whole transition for a decade" has looked like, because this seems to be phrased like everyone wanted this but GitHub was hesitant and pulled into this important work only by the heroic 3.0 breaking change decision.

GitHub has been essentially the _only one_ pushing this endeavour from the beginning of this problem set.

If we assume Brian, Haggerty, Peff, Taylor and Derrick have been acting on behalf of GitHub, then you Steiny, are essentially the only major contributor to this project in the last decade that is not GitHub/MS (Eric maybe?). Very honestly, nobody else seems to care. GH has single handedly created this issue and then somehow simultaneously been the blocking factor to it's rollout because it also, simultaneously, does not really find it to be an actually important issue. Google maybe helped design the transition plan in 2017, but hasn't seemed to care too much since then. Nobody else has really weighed in, at least with patches.

Scott
brian m. carlsonOct 5, 2026, 22:57 UTC in reply to Scott Chacon on lore

Re: [RFC PATCH 0/4] sign a SHA-256 digest of the tree in commits and tags

On 2026-10-05 at 14:16:48, Scott Chacon wrote:
> Thanks Steiny,
> 
> A quick response,
Hey,
Show 9 quoted lines
> On Mon, Oct 5, 2026 at 2:41 PM Patrick Steinhardt <ps@pks.im> wrote:
> > The biggest problem I have is that the ecosystem has been entirely
> > unwilling to do anything about the SHA-256 move before we announced that
> > this is going to become mandatory. Only then were developers even able
> > to convince anybody (especially those paying the wages) to get the time
> > to implement support for it.
> 
> Bit of a simple question, but is it possible that this is because
> nobody really finds it a concerning problem?

I think that's an oversimplification. I think people don't realize that Git is using SHA-1 and once SHA-256 is the default they will be very much in favour of using it. As I've said elsewhere in the thread, the need to move away from SHA-1 is going to become gradually urgent for a large segment of major institutions.

I can say that I've also had inquiries from large government agencies and corporations and they very much know about SHA-256 and want it. It's also very much desired by many in the open source community based on feedback that I've received there.

To respond to what Patrick said, I think in general there is a huge reluctance to invest in Git as an open source project and much open source investment is driven by internal corporate needs. As such, there's been a huge investment in scaling Git and a lot less investment in anything else, even if sometimes that ends up with less desirable outcomes. Customers get developers paged if their repositories don't scale, but they don't page about SHA-256. That doesn't mean it's not important or valuable.

Show 22 quoted lines
> > So there is some kind of ossification happening in the space. But things
> > are finally moving now that the due-date is drawing closer. I would be
> > extremely hesitant to change course again and drop this breaking change
> > now that there finally is some movement. Because the only consequence of
> > that would be that the ecosystem will stop working on it again. And even
> > more so, I would even expect that this will make the next time we want
> > to do a breaking change exponentially harder as the lesson learned is
> > that nobody needs to do anything.
> >
> > Maybe I'm too pessimistic about this, but I don't think so. We've been
> > working on this whole transition for almost a decade by now, and only
> > now where we're forcing the ecosystem to adapt are large players like
> > GitHub even moving.
> 
> I want to remind everyone here quickly what "working on this whole
> transition for a decade" has looked like, because this seems to be
> phrased like everyone wanted this but GitHub was hesitant and pulled
> into this important work only by the heroic 3.0 breaking change
> decision.
> 
> GitHub has been essentially the _only one_ pushing this endeavour from
> the beginning of this problem set.

I will merely say in this regard that I don't speak in my corporate capacity from this email address, so I don't think I'd like to respond to this statement. Patrick and I and the other contributors have discussed SHA-256 and Git 3.0 at the Contributor's Summits in 2024, 2025, and 2026 and so I think there's a good understanding of where different people and companies have been contributing to that and other efforts.

What I will say is that my experience on SHA-256 is that it challenges a lot of assumptions that people have built into their code over the years and therefore any sort of migration to support SHA-256 involves a lot of work, including substantial code changes and database migrations. That means that sometimes people have been doing substantial work behind the scenes and it's just not visible until it's done. You can see how this works by looking at open source projects like libgit2 and gitoxide, where extensive changes have landed over time. My experience is that reftable is another project where this is the case as well.

Show 10 quoted lines
> If we assume Brian, Haggerty, Peff, Taylor and Derrick have been
> acting on behalf of GitHub, then you Steiny, are essentially the only
> major contributor to this project in the last decade that is not
> GitHub/MS (Eric maybe?). Very honestly, nobody else seems to care. GH
> has single handedly created this issue and then somehow simultaneously
> been the blocking factor to it's rollout because it also,
> simultaneously, does not really find it to be an actually important
> issue. Google maybe helped design the transition plan in 2017, but
> hasn't seemed to care too much since then. Nobody else has really
> weighed in, at least with patches.

I do want to clarify this, since I think there's a lot of confusion. When I send contributions or patches from my personal email address, they're personal contributions. Only if the patches contain my work address (which is extremely rarely) are they in my corporate capacity or done on corporate time.

The SHA-256 work that I've been doing has almost exclusively been in my personal capacity[0]. There is some of the interoperability work that I was able to do on work time and those patches reflect the appropriate email address and sign-off, but before that I have done almost no SHA-256 work on company time. This work has been done mostly on nights and weekends, as with almost all of my other contributions, including on the security list. I contribute because I like the project and want it succeed, not because I'm paid to do so.

I also want to state that I've received a great amount of assistance and contributions, including reviews, patches, thoughtful ideas, and miscellaneous assistance, from a wide variety of contributors to the list and I could not have done it without them. Someone who has only provided reviews or design ideas has still aided the SHA-256 project and Git as a whole immensely. Patrick is just one of many people who have aided in such a way.

As mentioned earlier, I am of course not going to comment on anything related to my employer on any of this. If you want their opinion, you should ask them.

[0] The interested reader may wish to run the following command:
    git log --format='%ae' | grep -E '^(sandals|bk2204)@' | sort | uniq -c
-- 
brian m. carlson (they/them)
Toronto, Ontario, CA

Back to recent threads