From 3de1f7c50f3a39e85bac365c90eca156f2781cdd Mon Sep 17 00:00:00 2001 From: Juho Makinen Date: Fri, 23 Oct 2026 14:57:44 +1100 Subject: [PATCH 1/2] feat: support checking out a git commit Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- git.go | 57 ++++++++++++++++++++++++++++++++++++++++++----- git_test.go | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 47ccafc..98bd1f4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is a bit like hashicorp/go-getter, but much simpler, and with less supporte ## Features -- **Git repositories**: Clone from git://, git+ssh://, or git+https:// URLs with optional ref and depth parameters +- **Git repositories**: Clone from git://, git+ssh://, or git+https:// URLs with optional ref, commit and depth parameters - **TAR archives**: Fetch and extract .tar, .tar.gz, .tar.bz2, .tar.xz, and other compressed tarballs - **ZIP archives**: Download and unzip .zip files - **GitHub shortcuts**: Map shorthand URLs like `user/repo` or `github.com/user/repo` to full git URLs diff --git a/git.go b/git.go index 694e690..0d7a118 100644 --- a/git.go +++ b/git.go @@ -2,14 +2,19 @@ package getit import ( "context" + "errors" "fmt" "net/url" "os/exec" + "regexp" "strings" "github.com/kballard/go-shellquote" ) +// commitHash matches a full SHA-1 or SHA-256 commit hash. +var commitHash = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`) + // The Git [Resolver] uses Git repositories as archive sources, cloning directly. // // The URL format supported is: @@ -21,7 +26,12 @@ import ( // All forms support the following query parameters that control cloning behaviour: // // ref= +// commit= // depth= +// +// A commit is a full commit hash, checked out detached. It cannot be combined +// with ref, and needs a server that allows fetching a commit by hash, such as +// any server speaking git protocol v2. type Git struct{} var _ Resolver = (*Git)(nil) @@ -33,22 +43,59 @@ func (g *Git) Match(source *url.URL) bool { } func (g *Git) Fetch(ctx context.Context, source Source, dest string) error { + query := source.URL.Query() + repoURL := convertGitURL(source.URL) + if commit := query.Get("commit"); commit != "" { + return fetchCommit(ctx, repoURL, commit, query, dest) + } + args := []string{"clone"} - if depth := source.URL.Query().Get("depth"); depth != "" { + if depth := query.Get("depth"); depth != "" { args = append(args, "--depth", depth) } - if ref := source.URL.Query().Get("ref"); ref != "" { + if ref := query.Get("ref"); ref != "" { args = append(args, "--branch", ref) } - - repoURL := convertGitURL(source.URL) args = append(args, repoURL, dest) + return runGit(ctx, "", args...) +} + +// fetchCommit fetches commit into a new repository at dest and checks it out. +func fetchCommit(ctx context.Context, repoURL, commit string, query url.Values, dest string) error { + if query.Get("ref") != "" { + return errors.New("git source cannot set both ref and commit") + } + if !commitHash.MatchString(commit) { + return fmt.Errorf("git commit %q is not a full commit hash", commit) + } + fetch := []string{"fetch", "--quiet"} + if depth := query.Get("depth"); depth != "" { + fetch = append(fetch, "--depth", depth) + } + fetch = append(fetch, "origin", commit) + if err := runGit(ctx, "", "init", "--quiet", dest); err != nil { + return err + } + for _, args := range [][]string{ + {"remote", "add", "origin", repoURL}, + fetch, + {"checkout", "--quiet", "--detach", "FETCH_HEAD"}, + } { + if err := runGit(ctx, dest, args...); err != nil { + return err + } + } + return nil +} +// runGit runs a git command in dir, or the current directory if dir is empty. +func runGit(ctx context.Context, dir string, args ...string) error { cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir killProcessGroup(cmd) if output, err := cmd.CombinedOutput(); err != nil { argsStr := shellquote.Join(args...) - return fmt.Errorf("git clone failed: git %s: %w: %s", argsStr, err, output) + return fmt.Errorf("git %s failed: git %s: %w: %s", args[0], argsStr, err, output) } return nil } diff --git a/git_test.go b/git_test.go index 865a317..ffd115c 100644 --- a/git_test.go +++ b/git_test.go @@ -7,6 +7,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "testing" "time" @@ -189,6 +190,69 @@ func TestGitFetchWithDepth(t *testing.T) { assert.Equal(t, "1\n", string(output)) } +func TestGitFetchWithCommit(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)) + } + + for _, content := range []string{"second", "third"} { + assert.NoError(t, os.WriteFile(filepath.Join(repoDir, "file.txt"), []byte(content+"\n"), 0o644)) + runGit("add", ".") + runGit("commit", "-m", content) + } + commit := gitOutput(repoDir, "rev-parse", "HEAD~1") + + tests := []struct { + name string + query string + commits string + }{ + {name: "FullHistory", query: "?commit=" + commit, commits: "2"}, + {name: "Shallow", query: "?commit=" + commit + "&depth=1", commits: "1"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := url.Parse("git+file://" + repoDir + tt.query) + assert.NoError(t, err) + + dest := filepath.Join(t.TempDir(), "dest") + err = NewGit().Fetch(context.Background(), Source{URL: u}, dest) + assert.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(dest, "file.txt")) + assert.NoError(t, err) + assert.Equal(t, "second\n", string(content)) + assert.Equal(t, commit, gitOutput(dest, "rev-parse", "HEAD")) + assert.Equal(t, tt.commits, gitOutput(dest, "rev-list", "--count", "HEAD")) + }) + } +} + +func TestGitFetchWithInvalidCommit(t *testing.T) { + tests := []struct { + name string + query string + error string + }{ + {name: "NotAHash", query: "?commit=main", error: `git commit "main" is not a full commit hash`}, + {name: "WithRef", query: "?commit=" + strings.Repeat("a", 40) + "&ref=main", error: "git source cannot set both ref and commit"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := url.Parse("git+file:///nonexistent/repo/path" + tt.query) + assert.NoError(t, err) + err = NewGit().Fetch(context.Background(), Source{URL: u}, t.TempDir()) + assert.EqualError(t, err, tt.error) + }) + } +} + func TestGitFetchCancelledContext(t *testing.T) { repoDir, _ := createTestRepo(t) From 0987df4bff338b152379d9cfced9aaf995852a65 Mon Sep 17 00:00:00 2001 From: Juho Makinen Date: Fri, 23 Oct 2026 15:07:43 +1100 Subject: [PATCH 2/2] refactor: check out a commit with git clone --revision Co-Authored-By: Claude Opus 5.5 (1M context) --- git.go | 61 ++++++++++------------------------------------------- git_test.go | 26 ++++++++--------------- 2 files changed, 20 insertions(+), 67 deletions(-) diff --git a/git.go b/git.go index 0d7a118..e4107e4 100644 --- a/git.go +++ b/git.go @@ -2,19 +2,14 @@ package getit import ( "context" - "errors" "fmt" "net/url" "os/exec" - "regexp" "strings" "github.com/kballard/go-shellquote" ) -// commitHash matches a full SHA-1 or SHA-256 commit hash. -var commitHash = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`) - // The Git [Resolver] uses Git repositories as archive sources, cloning directly. // // The URL format supported is: @@ -29,9 +24,9 @@ var commitHash = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`) // commit= // depth= // -// A commit is a full commit hash, checked out detached. It cannot be combined -// with ref, and needs a server that allows fetching a commit by hash, such as -// any server speaking git protocol v2. +// 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. type Git struct{} var _ Resolver = (*Git)(nil) @@ -43,59 +38,25 @@ func (g *Git) Match(source *url.URL) bool { } func (g *Git) Fetch(ctx context.Context, source Source, dest string) error { - query := source.URL.Query() - repoURL := convertGitURL(source.URL) - if commit := query.Get("commit"); commit != "" { - return fetchCommit(ctx, repoURL, commit, query, dest) - } - args := []string{"clone"} - if depth := query.Get("depth"); depth != "" { + if depth := source.URL.Query().Get("depth"); depth != "" { args = append(args, "--depth", depth) } - if ref := query.Get("ref"); ref != "" { + if ref := source.URL.Query().Get("ref"); ref != "" { args = append(args, "--branch", ref) } - args = append(args, repoURL, dest) - return runGit(ctx, "", args...) -} - -// fetchCommit fetches commit into a new repository at dest and checks it out. -func fetchCommit(ctx context.Context, repoURL, commit string, query url.Values, dest string) error { - if query.Get("ref") != "" { - return errors.New("git source cannot set both ref and commit") - } - if !commitHash.MatchString(commit) { - return fmt.Errorf("git commit %q is not a full commit hash", commit) + if commit := source.URL.Query().Get("commit"); commit != "" { + args = append(args, "--revision", commit) } - fetch := []string{"fetch", "--quiet"} - if depth := query.Get("depth"); depth != "" { - fetch = append(fetch, "--depth", depth) - } - fetch = append(fetch, "origin", commit) - if err := runGit(ctx, "", "init", "--quiet", dest); err != nil { - return err - } - for _, args := range [][]string{ - {"remote", "add", "origin", repoURL}, - fetch, - {"checkout", "--quiet", "--detach", "FETCH_HEAD"}, - } { - if err := runGit(ctx, dest, args...); err != nil { - return err - } - } - return nil -} -// runGit runs a git command in dir, or the current directory if dir is empty. -func runGit(ctx context.Context, dir string, args ...string) error { + repoURL := convertGitURL(source.URL) + args = append(args, repoURL, dest) + cmd := exec.CommandContext(ctx, "git", args...) - cmd.Dir = dir killProcessGroup(cmd) if output, err := cmd.CombinedOutput(); err != nil { argsStr := shellquote.Join(args...) - return fmt.Errorf("git %s failed: git %s: %w: %s", args[0], argsStr, err, output) + return fmt.Errorf("git clone failed: git %s: %w: %s", argsStr, err, output) } return nil } diff --git a/git_test.go b/git_test.go index ffd115c..425d372 100644 --- a/git_test.go +++ b/git_test.go @@ -234,23 +234,15 @@ func TestGitFetchWithCommit(t *testing.T) { } } -func TestGitFetchWithInvalidCommit(t *testing.T) { - tests := []struct { - name string - query string - error string - }{ - {name: "NotAHash", query: "?commit=main", error: `git commit "main" is not a full commit hash`}, - {name: "WithRef", query: "?commit=" + strings.Repeat("a", 40) + "&ref=main", error: "git source cannot set both ref and commit"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - u, err := url.Parse("git+file:///nonexistent/repo/path" + tt.query) - assert.NoError(t, err) - err = NewGit().Fetch(context.Background(), Source{URL: u}, t.TempDir()) - assert.EqualError(t, err, tt.error) - }) - } +func TestGitFetchWithCommitAndRef(t *testing.T) { + repoDir, _ := createTestRepo(t) + + u, err := url.Parse("git+file://" + repoDir + "?commit=" + strings.Repeat("a", 40) + "&ref=master") + assert.NoError(t, err) + + err = NewGit().Fetch(context.Background(), Source{URL: u}, t.TempDir()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot be used together") } func TestGitFetchCancelledContext(t *testing.T) {