From 3225d8306a82b0b8e0ceedf33ddba5188d97ef15 Mon Sep 17 00:00:00 2001 From: Joah Gerstenberg Date: Tue, 4 Aug 2026 22:04:41 -0500 Subject: [PATCH 1/2] Support pinning git package sources to a full commit SHA Git sources currently pass the #ref fragment to 'git clone --branch', which only accepts branch and tag names, so packages cannot be pinned to an exact commit. Treat a #ref that is a full lowercase hex commit hash (40 or 64 characters) as a commit SHA: initialise an empty repository and fetch just that commit rather than cloning a branch. The ETag of a pinned commit is the commit itself, so no remote check is needed, and validation checks repository reachability since ls-remote cannot list an unadvertised commit. Fetching by SHA requires the server to allow it (GitHub and GitLab do). Abbreviated hashes are not treated as commits since they are indistinguishable from branch names. Buzz-Message: buzz://message?channel=573ff355-6c19-422d-b425-112853d0ec7e&id=16f5b666c0976829a1985868753407c7b20b393d89f99a8b39106be9fe6a1514 Amp-Thread-ID: https://ampcode.com/threads/T-019fce27-e59e-727c-b6d3-cdeeba894a4e Co-authored-by: Amp --- cache/git.go | 51 ++++++++++++++++++++++++---- cache/source_test.go | 79 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/cache/git.go b/cache/git.go index bdf505cf..503e90a5 100644 --- a/cache/git.go +++ b/cache/git.go @@ -27,12 +27,16 @@ func (s *gitSource) Download(b *ui.Task, cache *Cache, checksum string) (string, if err != nil { return "", "", "", err } - args := []string{"git", "clone", "--depth=1"} - if tag != "" { - args = append(args, "--branch="+tag) + if isFullGitSHA(tag) { + err = checkoutGitCommit(b, cache.root, repo, tag, checkoutDir) + } else { + args := []string{"git", "clone", "--depth=1"} + if tag != "" { + args = append(args, "--branch="+tag) + } + args = append(args, "--", repo, checkoutDir) + err = util.RunInDir(b, cache.root, args...) } - args = append(args, "--", repo, checkoutDir) - err = util.RunInDir(b, cache.root, args...) if err != nil { return "", "", "", errors.WithStack(err) } @@ -51,6 +55,10 @@ func (s *gitSource) ETag(b *ui.Task) (etag string, err error) { if err != nil { return "", err } + if isFullGitSHA(tag) { + // A pinned commit is immutable, so no remote check is needed. + return tag, nil + } if tag == "" { tag = "HEAD" } @@ -72,7 +80,9 @@ func (s *gitSource) Validate() error { if err != nil { return err } - if tag == "" { + if tag == "" || isFullGitSHA(tag) { + // A commit SHA cannot be listed with ls-remote, so just verify that + // the repository is reachable. tag = "HEAD" } cmd := exec.Command("git", "ls-remote", "--", repo, tag) //nolint @@ -83,6 +93,35 @@ func (s *gitSource) Validate() error { return nil } +// checkoutGitCommit fetches a single commit by SHA and checks it out. +// +// A commit SHA cannot be passed to "git clone --branch", so initialise an +// empty repository and fetch just the commit instead. This requires the +// server to allow fetching by commit SHA (GitHub and GitLab both do). +func checkoutGitCommit(b *ui.Task, root, repo, sha, checkoutDir string) error { + if err := util.RunInDir(b, root, "git", "init", "--", checkoutDir); err != nil { + return errors.WithStack(err) + } + if err := util.RunInDir(b, checkoutDir, "git", "fetch", "--depth=1", "--", repo, sha); err != nil { + return errors.WithStack(err) + } + return errors.WithStack(util.RunInDir(b, checkoutDir, "git", "checkout", "--detach", "FETCH_HEAD")) +} + +// isFullGitSHA reports whether ref is a full lowercase hex commit hash +// (40 characters for SHA-1, 64 for SHA-256). +func isFullGitSHA(ref string) bool { + if len(ref) != 40 && len(ref) != 64 { + return false + } + for _, r := range ref { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + return false + } + } + return true +} + func parseGitURL(source string) (repo, tag string, err error) { parts := strings.SplitN(source, "#", 2) repo = parts[0] diff --git a/cache/source_test.go b/cache/source_test.go index 568a4577..d267526a 100644 --- a/cache/source_test.go +++ b/cache/source_test.go @@ -4,9 +4,12 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/alecthomas/assert/v2" + + "github.com/cashapp/hermit/ui" ) func TestGitParseRepo(t *testing.T) { @@ -90,6 +93,82 @@ func TestGitSourceRCEAttempt(t *testing.T) { } } +func TestIsFullGitSHA(t *testing.T) { + tests := []struct { + ref string + want bool + }{ + {"6bccbcae2934bdd10ede93d493ee1eeeef5f24e2", true}, + {strings.Repeat("a", 64), true}, + {"", false}, + {"main", false}, + {"v1.2.3", false}, + // Abbreviated SHAs are indistinguishable from branch names. + {"6bccbca", false}, + {strings.Repeat("a", 39), false}, + {strings.Repeat("a", 41), false}, + // Git prints SHAs in lowercase. + {strings.Repeat("A", 40), false}, + {strings.Repeat("g", 40), false}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, isFullGitSHA(tt.ref), tt.ref) + } +} + +// TestGitSourceCommitSHAPinning verifies that a git source can be pinned to a +// full commit SHA rather than a branch or tag. +func TestGitSourceCommitSHAPinning(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git command not found") + } + + tmpDir := t.TempDir() + repoDir := filepath.Join(tmpDir, "repo") + assert.NoError(t, os.MkdirAll(repoDir, 0750)) + mustGit := func(args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repoDir + out, err := cmd.CombinedOutput() + assert.NoError(t, err, string(out)) + return strings.TrimSpace(string(out)) + } + mustGit("init") + mustGit("config", "user.email", "test@example.com") + mustGit("config", "user.name", "Test") + // Fetching an unadvertised commit from a local repository requires this; + // hosting services such as GitHub and GitLab allow it by default. + mustGit("config", "uploadpack.allowReachableSHA1InWant", "true") + assert.NoError(t, os.WriteFile(filepath.Join(repoDir, "file.txt"), []byte("first"), 0600)) + mustGit("add", "file.txt") + mustGit("commit", "-m", "first") + pinned := mustGit("rev-parse", "HEAD") + assert.NoError(t, os.WriteFile(filepath.Join(repoDir, "file.txt"), []byte("second"), 0600)) + mustGit("add", "file.txt") + mustGit("commit", "-m", "second") + + src := &gitSource{URL: "file://" + repoDir + "#" + pinned} + + // The ETag of a pinned commit is the commit itself, with no remote call. + etag, err := src.ETag(nil) + assert.NoError(t, err) + assert.Equal(t, pinned, etag) + + assert.NoError(t, src.Validate()) + + cacheRoot := filepath.Join(tmpDir, "cache") + assert.NoError(t, os.MkdirAll(cacheRoot, 0750)) + cache := &Cache{root: cacheRoot} + log, _ := ui.NewForTesting() + dir, etag, _, err := src.Download(log.Task("test"), cache, "checksum") + assert.NoError(t, err) + assert.Equal(t, pinned, etag) + content, err := os.ReadFile(filepath.Join(dir, "file.txt")) + assert.NoError(t, err) + assert.Equal(t, "first", string(content)) +} + func TestGitURLParsing(t *testing.T) { tests := []struct { url string From ae3fae12a54ffe5b953b6df88c88a17fb5670072 Mon Sep 17 00:00:00 2001 From: Joah Gerstenberg Date: Tue, 4 Aug 2026 22:18:23 -0500 Subject: [PATCH 2/2] Preserve advertised branch/tag refs that look like commit SHAs Git permits branch and tag names that are exactly 40 or 64 lowercase hex characters. Unconditionally reinterpreting such refs as commit object IDs would change existing 'git clone --branch' behaviour for those repositories. Resolve an advertised exact branch or tag first (branches take precedence over tags, matching 'git clone --branch'), and only fall back to commit-SHA fetching when the remote advertises no ref with that name. The ETag of a full-hex ref follows the same resolution. Buzz-Message: buzz://message?channel=573ff355-6c19-422d-b425-112853d0ec7e&id=37a4a83af83ddcf40f55a799a2d7e52e69f2908db5098993c82d4a96727015a6 Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019fce44-560f-770d-bacb-fba12dd9ae31 --- cache/git.go | 42 +++++++++++++++++++++++++++++++++++++++++- cache/source_test.go | 24 +++++++++++++++++++++--- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/cache/git.go b/cache/git.go index 503e90a5..6dadadf0 100644 --- a/cache/git.go +++ b/cache/git.go @@ -27,7 +27,18 @@ func (s *gitSource) Download(b *ui.Task, cache *Cache, checksum string) (string, if err != nil { return "", "", "", err } + pinned := false if isFullGitSHA(tag) { + // A full-hex name may still be a valid branch or tag name. Only treat + // it as a commit pin when the remote does not advertise a ref with + // that exact name, preserving prior "git clone --branch" behaviour. + advertised, aerr := resolveAdvertisedRef(b, repo, tag) + if aerr != nil { + return "", "", "", aerr + } + pinned = advertised == "" + } + if pinned { err = checkoutGitCommit(b, cache.root, repo, tag, checkoutDir) } else { args := []string{"git", "clone", "--depth=1"} @@ -56,7 +67,15 @@ func (s *gitSource) ETag(b *ui.Task) (etag string, err error) { return "", err } if isFullGitSHA(tag) { - // A pinned commit is immutable, so no remote check is needed. + advertised, err := resolveAdvertisedRef(b, repo, tag) + if err != nil { + return "", errors.Wrap(err, s.URL) + } + if advertised != "" { + return advertised, nil + } + // Not an advertised branch or tag, so it is a pinned commit, which is + // immutable and its own ETag. return tag, nil } if tag == "" { @@ -93,6 +112,27 @@ func (s *gitSource) Validate() error { return nil } +// resolveAdvertisedRef returns the commit the remote advertises for the +// branch or tag with the exact name ref, or "" when the remote advertises no +// such ref. Branches take precedence over tags, matching "git clone --branch". +func resolveAdvertisedRef(b *ui.Task, repo, ref string) (string, error) { + bts, err := util.Capture(b, "git", "ls-remote", "--", repo, "refs/heads/"+ref, "refs/tags/"+ref) + if err != nil { + return "", errors.WithStack(err) + } + out := strings.TrimSpace(string(bts)) + if out == "" { + return "", nil + } + // ls-remote output is sorted by ref name, so refs/heads sorts first. + line, _, _ := strings.Cut(out, "\n") + sha, _, ok := strings.Cut(line, "\t") + if !ok { + return "", errors.Errorf("invalid ls-remote output: %s", line) + } + return sha, nil +} + // checkoutGitCommit fetches a single commit by SHA and checks it out. // // A commit SHA cannot be passed to "git clone --branch", so initialise an diff --git a/cache/source_test.go b/cache/source_test.go index d267526a..b21f6bb6 100644 --- a/cache/source_test.go +++ b/cache/source_test.go @@ -149,9 +149,11 @@ func TestGitSourceCommitSHAPinning(t *testing.T) { mustGit("commit", "-m", "second") src := &gitSource{URL: "file://" + repoDir + "#" + pinned} + log, _ := ui.NewForTesting() - // The ETag of a pinned commit is the commit itself, with no remote call. - etag, err := src.ETag(nil) + // The remote advertises no branch or tag by this name, so it is treated + // as a pinned commit and is its own ETag. + etag, err := src.ETag(log.Task("test")) assert.NoError(t, err) assert.Equal(t, pinned, etag) @@ -160,13 +162,29 @@ func TestGitSourceCommitSHAPinning(t *testing.T) { cacheRoot := filepath.Join(tmpDir, "cache") assert.NoError(t, os.MkdirAll(cacheRoot, 0750)) cache := &Cache{root: cacheRoot} - log, _ := ui.NewForTesting() dir, etag, _, err := src.Download(log.Task("test"), cache, "checksum") assert.NoError(t, err) assert.Equal(t, pinned, etag) content, err := os.ReadFile(filepath.Join(dir, "file.txt")) assert.NoError(t, err) assert.Equal(t, "first", string(content)) + + // A branch whose name is exactly a full-hex string must still resolve as + // a branch, not be reinterpreted as a commit object ID. + hexBranch := strings.Repeat("a", 40) + mustGit("branch", hexBranch, "HEAD") + branchSrc := &gitSource{URL: "file://" + repoDir + "#" + hexBranch} + + etag, err = branchSrc.ETag(log.Task("test")) + assert.NoError(t, err) + assert.Equal(t, mustGit("rev-parse", "HEAD"), etag) + + dir, etag, _, err = branchSrc.Download(log.Task("test"), cache, "checksum2") + assert.NoError(t, err) + assert.Equal(t, mustGit("rev-parse", "HEAD"), etag) + content, err = os.ReadFile(filepath.Join(dir, "file.txt")) + assert.NoError(t, err) + assert.Equal(t, "second", string(content)) } func TestGitURLParsing(t *testing.T) {