From eb5aa5fd2cd506da5401040d99b56d695eb3058a Mon Sep 17 00:00:00 2001 From: Juho Makinen Date: Fri, 23 Oct 2026 15:34:44 +1100 Subject: [PATCH 1/2] feat: fetch a source again at the revision it was fetched at Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 7 ++++++ api.go | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++ api_test.go | 31 +++++++++++++++++++++++++ default.go | 8 +++++++ git.go | 30 ++++++++++++++++++++++++- git_test.go | 36 +++++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 98bd1f4..85b7b92 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,11 @@ fetcher := getit.New( // Fetch an archive err := fetcher.Fetch(ctx, "user/repo?ref=main&depth=1", "./destination") + +// Fetch an archive and the revision fetched, then fetch that revision again later +revision, err := fetcher.FetchRevision(ctx, "user/repo?ref=main", "./destination") +pinned, err := fetcher.AtRevision("user/repo?ref=main", revision) +err = fetcher.Fetch(ctx, pinned, "./again") ``` + +Only git sources support revisions, which are commit hashes. Other sources return `ErrNoRevisions`. diff --git a/api.go b/api.go index 6065f33..8cbf1b1 100644 --- a/api.go +++ b/api.go @@ -4,6 +4,7 @@ package getit import ( "bytes" "context" + "errors" "fmt" "net/http" "net/url" @@ -26,12 +27,35 @@ type Resolver interface { Fetch(ctx context.Context, source Source, dest string) error } +// ErrNoRevisions is returned for a source whose resolver is not a +// [RevisionResolver]. +var ErrNoRevisions = errors.New("source does not support revisions") + +// RevisionResolver is a [Resolver] that can fetch a source again exactly as +// it was. +type RevisionResolver interface { + Resolver + // FetchRevision fetches like Fetch and returns the revision fetched. + FetchRevision(ctx context.Context, source Source, dest string) (string, error) + // AtRevision returns source pinned to a revision returned by FetchRevision. + AtRevision(source Source, revision string) Source +} + // Source is a resolved source with optional sub-directory. type Source struct { URL *url.URL SubDir string } +// String returns the source in the form Resolve accepts. +func (s Source) String() string { + u := *s.URL + if s.SubDir != "" { + u.Path += "//" + s.SubDir + } + return u.String() +} + // Fetcher retrieves archives from a pluggable source. // // All sources support an optional subdirectory, specified via appending a // to the URL path: @@ -96,6 +120,47 @@ func (f *Fetcher) Fetch(ctx context.Context, source, dest string) error { return nil } +// FetchRevision fetches like Fetch and returns the revision fetched, to pass +// to AtRevision later. +// +// It returns [ErrNoRevisions] if the source's resolver is not a +// [RevisionResolver]. +func (f *Fetcher) FetchRevision(ctx context.Context, source, dest string) (string, error) { + resolver, src, err := f.resolveRevisions(source) + if err != nil { + return "", err + } + revision, err := resolver.FetchRevision(ctx, src, dest) + if err != nil { + return "", fmt.Errorf("fetching %s: %w", source, err) + } + return revision, nil +} + +// AtRevision returns source pinned to a revision returned by FetchRevision. +// +// It returns [ErrNoRevisions] if the source's resolver is not a +// [RevisionResolver]. +func (f *Fetcher) AtRevision(source, revision string) (string, error) { + resolver, src, err := f.resolveRevisions(source) + if err != nil { + return "", err + } + return resolver.AtRevision(src, revision).String(), nil +} + +func (f *Fetcher) resolveRevisions(source string) (RevisionResolver, Source, error) { + resolver, src, err := f.Resolve(source) + if err != nil { + return nil, Source{}, err + } + revisions, ok := resolver.(RevisionResolver) + if !ok { + return nil, Source{}, fmt.Errorf("%s: %w", source, ErrNoRevisions) + } + return revisions, src, nil +} + // FetchIntoPipe retrieves the given URL using Go's HTTP library then pipes it into the input of the given command. func FetchIntoPipe(ctx context.Context, u *url.URL, cmd string, args ...string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) diff --git a/api_test.go b/api_test.go index eeb991a..5a589d6 100644 --- a/api_test.go +++ b/api_test.go @@ -119,3 +119,34 @@ func TestFetchIntoPipeInvalidURL(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "fetching") } + +func TestAtRevision(t *testing.T) { + const commit = "9f3c1e7f6b3a5c0d2e4f6a8b0c2d4e6f8a0b2c4d" + tests := []struct { + name string + source string + expected string + }{ + {name: "Git", source: "git+ssh://github.com/org/repo", expected: "git+ssh://github.com/org/repo?commit=" + commit + "&depth=1"}, + {name: "SubDirAndRef", source: "git+https://github.com/org/repo//app?ref=main&depth=5", expected: "git+https://github.com/org/repo//app?commit=" + commit + "&depth=1"}, + {name: "Shorthand", source: "org/repo?ref=main", expected: "git+https://github.com/org/repo?commit=" + commit + "&depth=1"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pinned, err := getit.AtRevision(tt.source, commit) + assert.NoError(t, err) + assert.Equal(t, tt.expected, pinned) + + _, source, err := getit.Resolve(pinned) + assert.NoError(t, err) + assert.Equal(t, commit, source.URL.Query().Get("commit")) + }) + } +} + +func TestRevisionsUnsupported(t *testing.T) { + _, err := getit.AtRevision("https://example.com/archive.tar.gz", "abc") + assert.IsError(t, err, getit.ErrNoRevisions) + _, err = getit.FetchRevision(context.Background(), t.TempDir(), t.TempDir()) + assert.IsError(t, err, getit.ErrNoRevisions) +} diff --git a/default.go b/default.go index cddf963..58dd229 100644 --- a/default.go +++ b/default.go @@ -22,3 +22,11 @@ func Resolve(source string) (Resolver, Source, error) { return Default.Resolve(s // Fetch fetches an archive from a source and unpacks it to a destination. func Fetch(ctx context.Context, source, dest string) error { return Default.Fetch(ctx, source, dest) } + +// FetchRevision fetches like Fetch and returns the revision fetched. +func FetchRevision(ctx context.Context, source, dest string) (string, error) { + return Default.FetchRevision(ctx, source, dest) +} + +// AtRevision returns source pinned to a revision returned by FetchRevision. +func AtRevision(source, revision string) (string, error) { return Default.AtRevision(source, revision) } diff --git a/git.go b/git.go index e4107e4..ccb0c1e 100644 --- a/git.go +++ b/git.go @@ -27,9 +27,11 @@ import ( // A commit is checked out detached and cannot be combined with ref. It needs // git 2.49 or later, and a server that allows fetching a commit by hash, such // as any server speaking git protocol v2. +// +// Git is a [RevisionResolver] whose revisions are commit hashes. type Git struct{} -var _ Resolver = (*Git)(nil) +var _ RevisionResolver = (*Git)(nil) func NewGit() *Git { return &Git{} } @@ -61,6 +63,32 @@ func (g *Git) Fetch(ctx context.Context, source Source, dest string) error { return nil } +// FetchRevision clones like Fetch and returns the commit hash cloned. +func (g *Git) FetchRevision(ctx context.Context, source Source, dest string) (string, error) { + if err := g.Fetch(ctx, source, dest); err != nil { + return "", err + } + cmd := exec.CommandContext(ctx, "git", "-C", dest, "rev-parse", "HEAD") + killProcessGroup(cmd) + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git rev-parse failed: %w: %s", err, output) + } + return strings.TrimSpace(string(output)), nil +} + +// AtRevision replaces the source's ref with the commit revision, fetching +// only that commit. +func (g *Git) AtRevision(source Source, revision string) Source { + u := *source.URL + query := u.Query() + query.Del("ref") + query.Set("commit", revision) + query.Set("depth", "1") + u.RawQuery = query.Encode() + return Source{URL: &u, SubDir: source.SubDir} +} + // convertGitURL converts a getit git URL to a standard git URL. // git+https://host/path -> https://host/path // git+ssh://host/path -> git@host:path (SCP-style) diff --git a/git_test.go b/git_test.go index 425d372..cafd4ce 100644 --- a/git_test.go +++ b/git_test.go @@ -245,6 +245,42 @@ func TestGitFetchWithCommitAndRef(t *testing.T) { assert.Contains(t, err.Error(), "cannot be used together") } +func TestGitRevisions(t *testing.T) { + repoDir, runGit := createTestRepo(t) + gitOutput := func(dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.Output() + assert.NoError(t, err) + return strings.TrimSpace(string(output)) + } + first := gitOutput(repoDir, "rev-parse", "HEAD") + assert.NoError(t, os.WriteFile(filepath.Join(repoDir, "file.txt"), []byte("second\n"), 0o644)) + runGit("add", ".") + runGit("commit", "-m", "second") + + // A shallow clone of the ref does not reach the first commit, and git + // rejects a ref with a commit. + u, err := url.Parse("git+file://" + repoDir + "?ref=master&depth=1") + assert.NoError(t, err) + source := Source{URL: u} + git := NewGit() + + latest := t.TempDir() + revision, err := git.FetchRevision(context.Background(), source, latest) + assert.NoError(t, err) + assert.Equal(t, gitOutput(repoDir, "rev-parse", "HEAD"), revision) + + earlier := t.TempDir() + revision, err = git.FetchRevision(context.Background(), git.AtRevision(source, first), earlier) + assert.NoError(t, err) + assert.Equal(t, first, revision) + content, err := os.ReadFile(filepath.Join(earlier, "file.txt")) + assert.NoError(t, err) + assert.Equal(t, "hello from test\n", string(content)) +} + func TestGitFetchCancelledContext(t *testing.T) { repoDir, _ := createTestRepo(t) From 6382136e029b23c5d0280f84975d6484761b8198 Mon Sep 17 00:00:00 2001 From: Juho Makinen Date: Fri, 23 Oct 2026 15:46:39 +1100 Subject: [PATCH 2/2] refactor: keep the source string helper unexported Co-Authored-By: Claude Opus 5.5 (1M context) --- api.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/api.go b/api.go index 8cbf1b1..536d9e5 100644 --- a/api.go +++ b/api.go @@ -47,15 +47,6 @@ type Source struct { SubDir string } -// String returns the source in the form Resolve accepts. -func (s Source) String() string { - u := *s.URL - if s.SubDir != "" { - u.Path += "//" + s.SubDir - } - return u.String() -} - // Fetcher retrieves archives from a pluggable source. // // All sources support an optional subdirectory, specified via appending a // to the URL path: @@ -146,7 +137,16 @@ func (f *Fetcher) AtRevision(source, revision string) (string, error) { if err != nil { return "", err } - return resolver.AtRevision(src, revision).String(), nil + return sourceString(resolver.AtRevision(src, revision)), nil +} + +// sourceString returns source in the form Resolve accepts. +func sourceString(source Source) string { + u := *source.URL + if source.SubDir != "" { + u.Path += "//" + source.SubDir + } + return u.String() } func (f *Fetcher) resolveRevisions(source string) (RevisionResolver, Source, error) {