From: Scott Chacon <scott@gitbutler.net>
To: git@vger.kernel.org
Subject: [RFC PATCH 2/4] tag: add --hash=sha256 to sign a tree-sha256 header
Date: Fri, 2 Oct 2026 10:18:44 +0200 [thread overview]
Message-ID: <20261002081846.25144-3-scott@gitbutler.net> (raw)
In-Reply-To: <20261002081846.25144-1-scott@gitbutler.net>
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
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)
next prev 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 ` Scott Chacon [this message]
2026-10-02 15:49 ` [RFC PATCH 2/4] tag: add --hash=sha256 to sign a tree-sha256 header Junio C Hamano
2026-10-02 8:18 ` [RFC PATCH 3/4] commit: " Scott Chacon
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-3-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