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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.