Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
65 changes: 65 additions & 0 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package getit
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"net/url"
Expand All @@ -26,6 +27,20 @@ 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine to break compatibility - we're still < 1.0

// 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
Expand Down Expand Up @@ -96,6 +111,56 @@ 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 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) {
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)
Expand Down
31 changes: 31 additions & 0 deletions api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
8 changes: 8 additions & 0 deletions default.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
30 changes: 29 additions & 1 deletion git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{} }

Expand Down Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading