diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 82f60ae..fbb6ae7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,12 @@ updates: interval: weekly open-pull-requests-limit: 10 + - package-ecosystem: gomod + directory: /gogit + schedule: + interval: weekly + open-pull-requests-limit: 10 + - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33d2a4a..6adb8bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,9 +28,22 @@ jobs: - name: Build run: go build -v ./... + - name: Build gogit module + working-directory: gogit + run: go build -v ./... + - name: Test run: go test -v -race ./... + - name: Test gogit module against minimum clone version + working-directory: gogit + run: go test -v -race ./... + + - name: Test gogit module with sibling clone module + run: | + go work init . ./gogit + go test -v -race ./gogit/... + lint: runs-on: ubuntu-latest steps: @@ -43,7 +56,13 @@ jobs: with: go-version: '1.25' - - name: golangci-lint + - name: Lint root module + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 + with: + version: latest + + - name: Lint gogit module uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 with: version: latest + working-directory: gogit diff --git a/README.md b/README.md index 11849ab..175c788 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # clone -Go library for programs that keep local checkouts of HTTPS Git repositories. Clone, fetch, remote queries, and command retries shell out to the `git` binary, which must be on `PATH`. Blob reads use [go-git](https://github.com/go-git/go-git) in process. The package supports Go 1.25 or later. +Go library for programs that keep local checkouts of HTTPS Git repositories. Clone, fetch, remote queries, command retries, and blob reads shell out to the `git` binary, which must be on `PATH`. Programs that read many blobs can use the optional `gogit` module for in-process object access. Both modules support Go 1.25 or later. ## Install @@ -79,9 +79,13 @@ if err := cache.EnsureCommit(ctx, url, commit); err != nil { ## Read a file from a commit -`InspectBlob` reads the object in process and returns at most `maxBytes`. The object's size distinguishes content exactly at the limit from truncated content. Complete reads use `magic.Detect`; truncated reads use `magic.DetectPrefix` so the result can report that later bytes may change the classification. The returned content is retained for text, binary, and unknown results. Repositories using object formats unsupported by go-git fall back to `git show`. +`clone.InspectBlob` runs `git show` and returns at most `maxBytes`. The optional `github.com/git-pkgs/clone/gogit` module reads the object in process and falls back to `git show` for unsupported object formats and repository layouts. Install it separately when repeated process startup is costly: -Both blob functions validate commits and paths before reading the repository. `ValidCommit` and `SanitizePath` are also available when callers need to validate input earlier: +``` +go get github.com/git-pkgs/clone/gogit +``` + +Both implementations use the object's size to distinguish content exactly at the limit from truncated content. Complete reads use `magic.Detect`; truncated reads use `magic.DetectPrefix` so the result can report that later bytes may change the classification. They retain returned content for text, binary, and unknown results, and validate commits and paths before reading the repository. `ValidCommit` and `SanitizePath` are also available when callers need to validate input earlier: ```go path, ok := clone.SanitizePath("cmd/tool/main.go") @@ -89,7 +93,7 @@ if !ok || !clone.ValidCommit(commit) { log.Fatal("invalid commit or path") } -result, err := clone.InspectBlob( +result, err := gogit.InspectBlob( ctx, filepath.Join(cache.Dir(url), "src"), commit, @@ -106,9 +110,7 @@ if result.Detection.Kind == magic.KindText && fmt.Println("truncated:", result.Truncated) ``` -`Blob` remains available for callers that only need its original NUL-based -binary flag. It returns nil content when a NUL occurs within the returned range; -a NUL beyond `maxBytes` is not observed. +`clone.Blob` and `gogit.Blob` are available for callers that only need the original NUL-based binary flag. They return nil content when a NUL occurs within the returned range; a NUL beyond `maxBytes` is not observed. ## Remote queries diff --git a/blob.go b/blob.go index eaef105..0681e08 100644 --- a/blob.go +++ b/blob.go @@ -20,10 +20,10 @@ type BlobResult struct { Truncated bool } -// InspectBlob reads path from commit in dir and classifies the returned bytes. -// It uses prefix detection when maxBytes truncates the blob. commit and path -// are validated with ValidCommit and SanitizePath before reading the -// repository. +// InspectBlob reads path from commit in dir through the git binary and +// classifies the returned bytes. It uses prefix detection when maxBytes +// truncates the blob. commit and path are validated with ValidCommit and +// SanitizePath before invoking Git. func InspectBlob(ctx context.Context, dir, commit, blobPath string, maxBytes int64) (BlobResult, error) { content, truncated, err := readBlob(ctx, dir, commit, blobPath, maxBytes) if err != nil { @@ -44,9 +44,9 @@ func InspectBlob(ctx context.Context, dir, commit, blobPath string, maxBytes int }, nil } -// Blob reads path from commit in dir. It caps content at maxBytes and reports -// whether the blob is binary or was truncated. commit and path are validated -// with ValidCommit and SanitizePath before reading the repository. +// Blob reads path from commit in dir through the git binary. It caps content +// at maxBytes and reports whether the blob is binary or was truncated. commit +// and path are validated with ValidCommit and SanitizePath before invoking Git. func Blob(ctx context.Context, dir, commit, blobPath string, maxBytes int64) (content []byte, binary, truncated bool, err error) { content, truncated, err = readBlob(ctx, dir, commit, blobPath, maxBytes) if err != nil { @@ -75,42 +75,7 @@ func readBlob(ctx context.Context, dir, commit, blobPath string, maxBytes int64) if err := ctx.Err(); err != nil { return nil, false, err } - - raw, truncated, goGitErr := readBlobWithGoGit(ctx, dir, commit, clean, maxBytes) - if goGitErr == nil { - return raw, truncated, nil - } - if ctxErr := ctx.Err(); ctxErr != nil { - return nil, false, ctxErr - } - // Native Git remains the compatibility path for object formats and - // repository layouts that go-git v5 cannot read. This also covers - // abbreviated SHA-256 object IDs, whose length alone does not identify the - // repository's object format. - raw, truncated, gitErr := readBlobWithGit(ctx, dir, commit, clean, maxBytes) - if gitErr != nil { - return nil, false, errors.Join( - fmt.Errorf("go-git blob read: %w", goGitErr), - fmt.Errorf("git blob read: %w", gitErr), - ) - } - return raw, truncated, nil -} - -type contextReader struct { - ctx context.Context - reader io.Reader -} - -func (r contextReader) Read(p []byte) (int, error) { - if err := r.ctx.Err(); err != nil { - return 0, err - } - n, err := r.reader.Read(p) - if err == nil { - err = r.ctx.Err() - } - return n, err + return readBlobWithGit(ctx, dir, commit, clean, maxBytes) } func readBlobWithGit(ctx context.Context, dir, commit, clean string, maxBytes int64) (content []byte, truncated bool, err error) { diff --git a/blob_test.go b/blob_test.go index 41fff04..c0c9e5b 100644 --- a/blob_test.go +++ b/blob_test.go @@ -232,101 +232,13 @@ func TestBlobReadsTextAtLimit(t *testing.T) { } } -func TestBlobReadsWithoutGitOnPath(t *testing.T) { +func TestBlobRequiresGitOnPath(t *testing.T) { dir, commit := seedBlobRepository(t) t.Setenv("PATH", t.TempDir()) - content, binary, truncated, err := Blob(context.Background(), dir, commit, "nested/file.txt", 6) - if err != nil { - t.Fatalf("Blob: %v", err) - } - if string(content) != "nested" || binary || truncated { - t.Errorf("Blob = (%q, %v, %v), want exact text", content, binary, truncated) - } -} - -func TestBlobReadsPackedObject(t *testing.T) { - dir, commit := seedBlobRepository(t) - runGitTest(t, dir, "gc", "--quiet", "--prune=now") - t.Setenv("PATH", t.TempDir()) - - content, binary, truncated, err := Blob(context.Background(), dir, commit, "big.txt", 32) - if err != nil { - t.Fatalf("Blob: %v", err) - } - if !bytes.Equal(content, bytes.Repeat([]byte("a"), 32)) || binary || !truncated { - t.Errorf("Blob = (%q, %v, %v), want truncated text", content, binary, truncated) - } -} - -func TestBlobReadsLinkedWorktree(t *testing.T) { - dir, commit := seedBlobRepository(t) - worktree := filepath.Join(t.TempDir(), "checkout") - runGitTest(t, dir, "worktree", "add", "--quiet", "--detach", worktree, commit) - gitFile := filepath.Join(worktree, ".git") - resolved, err := resolveGitFile(gitFile) - if err != nil { - t.Fatal(err) - } - relative, err := filepath.Rel(worktree, resolved) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(gitFile, []byte("gitdir: "+relative+"\n"), 0o644); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", t.TempDir()) - - content, binary, truncated, err := Blob(context.Background(), worktree, commit, "exact.txt", 5) - if err != nil { - t.Fatalf("Blob: %v", err) - } - if string(content) != "12345" || binary || truncated { - t.Errorf("Blob = (%q, %v, %v), want exact text", content, binary, truncated) - } -} - -func TestBlobResolvesAbbreviatedCommitFromSubdirectory(t *testing.T) { - dir, commit := seedBlobRepository(t) - nested := filepath.Join(dir, "nested") - t.Setenv("PATH", t.TempDir()) - - content, binary, truncated, err := Blob(context.Background(), nested, commit[:7], "exact.txt", 5) - if err != nil { - t.Fatalf("Blob: %v", err) - } - if string(content) != "12345" || binary || truncated { - t.Errorf("Blob = (%q, %v, %v), want exact text", content, binary, truncated) - } -} - -func TestBlobReadsBareRepositoryWithoutGitOnPath(t *testing.T) { - dir, commit := seedBlobRepository(t) - bare := filepath.Join(t.TempDir(), "repo.git") - runGitTest(t, dir, "clone", "--quiet", "--bare", dir, bare) - t.Setenv("PATH", t.TempDir()) - - content, binary, truncated, err := Blob(context.Background(), bare, commit, "exact.txt", 5) - if err != nil { - t.Fatalf("Blob: %v", err) - } - if string(content) != "12345" || binary || truncated { - t.Errorf("Blob = (%q, %v, %v), want exact text", content, binary, truncated) - } -} - -func TestBlobPeelsAnnotatedTagWithoutGitOnPath(t *testing.T) { - dir, _ := seedBlobRepository(t) - runGitTest(t, dir, "tag", "-a", "blob-test", "-m", "blob test") - tag := runGitTest(t, dir, "rev-parse", "blob-test^{tag}") - t.Setenv("PATH", t.TempDir()) - - content, binary, truncated, err := Blob(context.Background(), dir, tag, "exact.txt", 5) - if err != nil { - t.Fatalf("Blob: %v", err) - } - if string(content) != "12345" || binary || truncated { - t.Errorf("Blob = (%q, %v, %v), want exact text", content, binary, truncated) + _, _, _, err := Blob(context.Background(), dir, commit, "exact.txt", 5) + if !errors.Is(err, exec.ErrNotFound) { + t.Fatalf("error = %v, want exec.ErrNotFound", err) } } @@ -341,20 +253,7 @@ func TestBlobHonorsCanceledContext(t *testing.T) { } } -func TestBlobPreservesGoGitAndGitErrors(t *testing.T) { - dir := t.TempDir() - t.Setenv("PATH", t.TempDir()) - - _, _, _, err := Blob(context.Background(), dir, strings.Repeat("a", 40), "file.txt", 5) - if !errors.Is(err, exec.ErrNotFound) { - t.Errorf("error = %v, want exec.ErrNotFound", err) - } - if err == nil || !strings.Contains(err.Error(), dir) { - t.Errorf("error = %v, want starting path %q", err, dir) - } -} - -func TestBlobReadsSHA256RepositoryWithGitFallback(t *testing.T) { +func TestBlobReadsSHA256Repository(t *testing.T) { requireGit(t) dir := t.TempDir() cmd := exec.Command("git", "init", "--quiet", "--object-format=sha256", "-b", "main") diff --git a/doc.go b/doc.go index c1f7086..52a560e 100644 --- a/doc.go +++ b/doc.go @@ -1,7 +1,7 @@ // Package clone keeps local checkouts of HTTPS Git repositories. It provides // shallow clone-or-fetch, exact temporary tag checkouts, bounded retries for // network failures, a persistent cache, and capped reads and content -// classification for files from commits. Blob reads use go-git in process. -// Clone, fetch, checkout, remote queries, and command retries shell out to the -// git binary, which must be on PATH. +// classification for files from commits. Operations shell out to the git +// binary, which must be on PATH. The github.com/git-pkgs/clone/gogit module +// provides in-process blob reads. package clone diff --git a/go.mod b/go.mod index 34f4b3c..80ee1eb 100644 --- a/go.mod +++ b/go.mod @@ -2,19 +2,4 @@ module github.com/git-pkgs/clone go 1.25.6 -require ( - github.com/git-pkgs/magic v0.2.0 - github.com/go-git/go-billy/v5 v5.9.1 - github.com/go-git/go-git/v5 v5.19.2 -) - -require ( - github.com/cyphar/filepath-securejoin v0.6.1 // indirect - github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/pjbgf/sha1cd v0.6.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect -) +require github.com/git-pkgs/magic v0.2.0 diff --git a/go.sum b/go.sum index 8f7d4b5..41f45ab 100644 --- a/go.sum +++ b/go.sum @@ -1,72 +1,2 @@ -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= -github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/git-pkgs/magic v0.2.0 h1:c7HqVxnP8c88EaVMH0/KraDFVTcmiXckRiSvNZEnvMQ= github.com/git-pkgs/magic v0.2.0/go.mod h1:3ndidt+yvFaI1M0aEkkzkOlFnLPkeVQASIUojazcxCI= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.9.1 h1:8U73XiOTfINdItHVa6z4Gv7ToObcZ6grkqQbLryLCdA= -github.com/go-git/go-billy/v5 v5.9.1/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= -github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= -github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= -github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= -github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gogit/blob.go b/gogit/blob.go new file mode 100644 index 0000000..a09e1c9 --- /dev/null +++ b/gogit/blob.go @@ -0,0 +1,115 @@ +package gogit + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "math" + + "github.com/git-pkgs/clone" + "github.com/git-pkgs/magic" +) + +// InspectBlob reads path from commit through go-git and classifies the +// returned bytes. It falls back to the git binary when go-git cannot read the +// repository. +func InspectBlob(ctx context.Context, dir, commit, blobPath string, maxBytes int64) (clone.BlobResult, error) { + clean, err := validateBlobRequest(ctx, commit, blobPath, maxBytes) + if err != nil { + return clone.BlobResult{}, err + } + content, truncated, goGitErr := readBlobWithGoGit(ctx, dir, commit, clean, maxBytes) + if goGitErr != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return clone.BlobResult{}, ctxErr + } + result, gitErr := clone.InspectBlob(ctx, dir, commit, clean, maxBytes) + if gitErr != nil { + return clone.BlobResult{}, combineBlobReadErrors(goGitErr, gitErr) + } + return result, nil + } + + var detection magic.Result + if truncated { + detection = magic.DetectPrefix(content) + } else { + detection = magic.Detect(content) + } + + return clone.BlobResult{ + Content: content, + Detection: detection, + Truncated: truncated, + }, nil +} + +// Blob reads path from commit through go-git. It caps content at maxBytes and +// reports whether the blob is binary or was truncated. It falls back to the +// git binary when go-git cannot read the repository. +func Blob(ctx context.Context, dir, commit, blobPath string, maxBytes int64) (content []byte, binary, truncated bool, err error) { + clean, err := validateBlobRequest(ctx, commit, blobPath, maxBytes) + if err != nil { + return nil, false, false, err + } + content, truncated, goGitErr := readBlobWithGoGit(ctx, dir, commit, clean, maxBytes) + if goGitErr != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, false, false, ctxErr + } + content, binary, truncated, gitErr := clone.Blob(ctx, dir, commit, clean, maxBytes) + if gitErr != nil { + return nil, false, false, combineBlobReadErrors(goGitErr, gitErr) + } + return content, binary, truncated, nil + } + if bytes.IndexByte(content, 0) != -1 { + return nil, true, truncated, nil + } + return content, false, truncated, nil +} + +func validateBlobRequest(ctx context.Context, commit, blobPath string, maxBytes int64) (string, error) { + if maxBytes < 0 { + return "", fmt.Errorf("maxBytes must be non-negative") + } + if maxBytes == math.MaxInt64 { + return "", fmt.Errorf("maxBytes is too large") + } + if !clone.ValidCommit(commit) { + return "", fmt.Errorf("invalid commit %q", commit) + } + clean, ok := clone.SanitizePath(blobPath) + if !ok { + return "", fmt.Errorf("invalid path %q", blobPath) + } + if err := ctx.Err(); err != nil { + return "", err + } + return clean, nil +} + +func combineBlobReadErrors(goGitErr, gitErr error) error { + return errors.Join( + fmt.Errorf("go-git blob read: %w", goGitErr), + fmt.Errorf("git blob read: %w", gitErr), + ) +} + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r contextReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + n, err := r.reader.Read(p) + if err == nil { + err = r.ctx.Err() + } + return n, err +} diff --git a/gogit/blob_test.go b/gogit/blob_test.go new file mode 100644 index 0000000..a099da9 --- /dev/null +++ b/gogit/blob_test.go @@ -0,0 +1,345 @@ +package gogit + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/git-pkgs/magic" +) + +func seedBlobRepository(t testing.TB) (string, string) { + t.Helper() + requireGit(t) + + dir := t.TempDir() + runGitTest(t, dir, "init", "--quiet", "-b", "main") + files := map[string][]byte{ + "exact.txt": []byte("12345"), + "big.txt": bytes.Repeat([]byte("a"), 128<<10), + "binary": {'a', 0, 'b'}, + "empty": {}, + "png": []byte("\x89PNG\r\n\x1a\n"), + "nested/file.txt": []byte("nested"), + } + for name, content := range files { + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatal(err) + } + } + runGitTest(t, dir, "add", ".") + runGitTest(t, dir, "commit", "--quiet", "-m", "files") + return dir, runGitTest(t, dir, "rev-parse", "HEAD") +} + +func BenchmarkBlob(b *testing.B) { + dir, commit := seedBlobRepository(b) + b.ReportAllocs() + + for b.Loop() { + content, binary, truncated, err := Blob(context.Background(), dir, commit, "exact.txt", 5) + if err != nil { + b.Fatal(err) + } + if len(content) != 5 || binary || truncated { + b.Fatal("unexpected Blob result") + } + } +} + +func BenchmarkInspectBlob(b *testing.B) { + dir, commit := seedBlobRepository(b) + b.ReportAllocs() + + for b.Loop() { + result, err := InspectBlob(context.Background(), dir, commit, "exact.txt", 5) + if err != nil { + b.Fatal(err) + } + if len(result.Content) != 5 || result.Detection.Kind != magic.KindText || result.Truncated { + b.Fatal("unexpected InspectBlob result") + } + } +} + +func TestInspectBlobClassifiesContentWithoutGitOnPath(t *testing.T) { + dir, commit := seedBlobRepository(t) + t.Setenv("PATH", t.TempDir()) + + tests := []struct { + name string + path string + maxBytes int64 + wantContent []byte + wantDetection magic.Result + wantTruncated bool + }{ + { + name: "complete text", + path: "exact.txt", + maxBytes: 5, + wantContent: []byte("12345"), + wantDetection: magic.Result{ + Kind: magic.KindText, + MIME: "text/plain", + Format: "text", + Encoding: "utf-8", + }, + }, + { + name: "truncated text", + path: "big.txt", + maxBytes: 32, + wantContent: bytes.Repeat([]byte("a"), 32), + wantTruncated: true, + wantDetection: magic.Result{ + Kind: magic.KindText, + MIME: "text/plain", + Format: "text", + Encoding: "utf-8", + Reason: magic.ReasonNeedMore, + }, + }, + { + name: "binary signature", + path: "png", + maxBytes: 8, + wantContent: []byte("\x89PNG\r\n\x1a\n"), + wantDetection: magic.Result{ + Kind: magic.KindBinary, + MIME: "image/png", + Format: "png", + }, + }, + { + name: "empty", + path: "empty", + maxBytes: 0, + wantContent: []byte{}, + wantDetection: magic.Result{ + Kind: magic.KindText, + MIME: "text/plain", + Format: "text", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := InspectBlob(context.Background(), dir, commit, tt.path, tt.maxBytes) + if err != nil { + t.Fatalf("InspectBlob: %v", err) + } + if !bytes.Equal(result.Content, tt.wantContent) { + t.Errorf("Content = %q, want %q", result.Content, tt.wantContent) + } + if result.Detection != tt.wantDetection { + t.Errorf("Detection = %#v, want %#v", result.Detection, tt.wantDetection) + } + if result.Truncated != tt.wantTruncated { + t.Errorf("Truncated = %v, want %v", result.Truncated, tt.wantTruncated) + } + }) + } +} + +func TestInspectBlobRejectsInvalidInput(t *testing.T) { + dir, commit := seedBlobRepository(t) + tests := []struct { + name string + commit string + path string + maxBytes int64 + contains string + }{ + {"invalid limit", commit, "exact.txt", -1, "non-negative"}, + {"invalid commit", "HEAD", "exact.txt", 100, "invalid commit"}, + {"invalid path", commit, "../exact.txt", 100, "invalid path"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := InspectBlob(context.Background(), dir, tt.commit, tt.path, tt.maxBytes) + if err == nil || !strings.Contains(err.Error(), tt.contains) { + t.Errorf("error = %v, want error containing %q", err, tt.contains) + } + }) + } +} + +func TestBlobReadsPackedObjectWithoutGitOnPath(t *testing.T) { + dir, commit := seedBlobRepository(t) + runGitTest(t, dir, "gc", "--quiet", "--prune=now") + t.Setenv("PATH", t.TempDir()) + + content, binary, truncated, err := Blob(context.Background(), dir, commit, "big.txt", 32) + if err != nil { + t.Fatalf("Blob: %v", err) + } + if !bytes.Equal(content, bytes.Repeat([]byte("a"), 32)) || binary || !truncated { + t.Errorf("Blob = (%q, %v, %v), want truncated text", content, binary, truncated) + } +} + +func TestBlobReadsLinkedWorktreeWithoutGitOnPath(t *testing.T) { + dir, commit := seedBlobRepository(t) + worktree := filepath.Join(t.TempDir(), "checkout") + runGitTest(t, dir, "worktree", "add", "--quiet", "--detach", worktree, commit) + gitFile := filepath.Join(worktree, ".git") + resolved, err := resolveGitFile(gitFile) + if err != nil { + t.Fatal(err) + } + relative, err := filepath.Rel(worktree, resolved) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(gitFile, []byte("gitdir: "+relative+"\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", t.TempDir()) + + content, binary, truncated, err := Blob(context.Background(), worktree, commit, "exact.txt", 5) + if err != nil { + t.Fatalf("Blob: %v", err) + } + if string(content) != "12345" || binary || truncated { + t.Errorf("Blob = (%q, %v, %v), want exact text", content, binary, truncated) + } +} + +func TestBlobResolvesAbbreviatedCommitFromSubdirectory(t *testing.T) { + dir, commit := seedBlobRepository(t) + nested := filepath.Join(dir, "nested") + t.Setenv("PATH", t.TempDir()) + + content, binary, truncated, err := Blob(context.Background(), nested, commit[:7], "exact.txt", 5) + if err != nil { + t.Fatalf("Blob: %v", err) + } + if string(content) != "12345" || binary || truncated { + t.Errorf("Blob = (%q, %v, %v), want exact text", content, binary, truncated) + } +} + +func TestBlobReadsBareRepositoryWithoutGitOnPath(t *testing.T) { + dir, commit := seedBlobRepository(t) + bare := filepath.Join(t.TempDir(), "repo.git") + runGitTest(t, dir, "clone", "--quiet", "--bare", dir, bare) + t.Setenv("PATH", t.TempDir()) + + content, binary, truncated, err := Blob(context.Background(), bare, commit, "exact.txt", 5) + if err != nil { + t.Fatalf("Blob: %v", err) + } + if string(content) != "12345" || binary || truncated { + t.Errorf("Blob = (%q, %v, %v), want exact text", content, binary, truncated) + } +} + +func TestBlobPeelsAnnotatedTagWithoutGitOnPath(t *testing.T) { + dir, _ := seedBlobRepository(t) + runGitTest(t, dir, "tag", "-a", "blob-test", "-m", "blob test") + tag := runGitTest(t, dir, "rev-parse", "blob-test^{tag}") + t.Setenv("PATH", t.TempDir()) + + content, binary, truncated, err := Blob(context.Background(), dir, tag, "exact.txt", 5) + if err != nil { + t.Fatalf("Blob: %v", err) + } + if string(content) != "12345" || binary || truncated { + t.Errorf("Blob = (%q, %v, %v), want exact text", content, binary, truncated) + } +} + +func TestBlobHonorsCanceledContext(t *testing.T) { + dir, commit := seedBlobRepository(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, _, err := Blob(ctx, dir, commit, "exact.txt", 5) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } +} + +func TestBlobPreservesGoGitAndGitErrors(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", t.TempDir()) + + _, _, _, err := Blob(context.Background(), dir, strings.Repeat("a", 40), "file.txt", 5) + if !errors.Is(err, exec.ErrNotFound) { + t.Errorf("error = %v, want exec.ErrNotFound", err) + } + if err == nil || !strings.Contains(err.Error(), dir) { + t.Errorf("error = %v, want starting path %q", err, dir) + } +} + +func TestBlobAPIsFallBackForSHA256Repository(t *testing.T) { + requireGit(t) + dir := t.TempDir() + cmd := exec.Command("git", "init", "--quiet", "--object-format=sha256", "-b", "main") + cmd.Dir = dir + cmd.Env = gitTestEnv() + if out, err := cmd.CombinedOutput(); err != nil { + t.Skipf("git does not support SHA-256 repositories: %s", out) + } + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "binary"), []byte{'a', 0, 'b'}, 0o644); err != nil { + t.Fatal(err) + } + runGitTest(t, dir, "add", "file.txt", "binary") + runGitTest(t, dir, "commit", "--quiet", "-m", "file") + commit := runGitTest(t, dir, "rev-parse", "HEAD") + + for _, revision := range []string{commit, commit[:12]} { + content, binary, truncated, err := Blob(context.Background(), dir, revision, "file.txt", 7) + if err != nil { + t.Fatalf("Blob(%q): %v", revision, err) + } + if string(content) != "content" || binary || truncated { + t.Errorf("Blob(%q) = (%q, %v, %v), want exact text", revision, content, binary, truncated) + } + + content, binary, truncated, err = Blob(context.Background(), dir, revision, "binary", 3) + if err != nil { + t.Fatalf("Blob(%q, binary): %v", revision, err) + } + if content != nil || !binary || truncated { + t.Errorf("Blob(%q, binary) = (%q, %v, %v), want complete binary", revision, content, binary, truncated) + } + + result, inspectErr := InspectBlob(context.Background(), dir, revision, "binary", 3) + if inspectErr != nil { + t.Fatalf("InspectBlob(%q): %v", revision, inspectErr) + } + if !bytes.Equal(result.Content, []byte{'a', 0, 'b'}) || result.Detection.Kind != magic.KindBinary || result.Truncated { + t.Errorf("InspectBlob(%q) = %#v, want complete classified binary", revision, result) + } + } +} + +func TestBlobDetectsNULWithinReturnedRange(t *testing.T) { + dir, commit := seedBlobRepository(t) + t.Setenv("PATH", t.TempDir()) + + content, binary, truncated, err := Blob(context.Background(), dir, commit, "binary", 3) + if err != nil { + t.Fatalf("Blob: %v", err) + } + if content != nil || !binary || truncated { + t.Errorf("Blob = (%v, %v, %v), want nil, binary, complete", content, binary, truncated) + } +} diff --git a/gogit/doc.go b/gogit/doc.go new file mode 100644 index 0000000..acfde75 --- /dev/null +++ b/gogit/doc.go @@ -0,0 +1,4 @@ +// Package gogit reads bounded blobs from local Git repositories through +// go-git. It falls back to the git binary for object formats and repository +// layouts that go-git v5 cannot read. +package gogit diff --git a/gogit/go.mod b/gogit/go.mod new file mode 100644 index 0000000..3b32656 --- /dev/null +++ b/gogit/go.mod @@ -0,0 +1,21 @@ +module github.com/git-pkgs/clone/gogit + +go 1.25.6 + +require ( + github.com/git-pkgs/clone v0.2.1 // minimum compatible version + github.com/git-pkgs/magic v0.2.0 + github.com/go-git/go-billy/v5 v5.9.1 + github.com/go-git/go-git/v5 v5.19.2 +) + +require ( + github.com/cyphar/filepath-securejoin v0.6.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/gogit/go.sum b/gogit/go.sum new file mode 100644 index 0000000..cdbb349 --- /dev/null +++ b/gogit/go.sum @@ -0,0 +1,74 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/git-pkgs/clone v0.2.1 h1:9Hl3UgMpGwYGlsUYR2KbMexISMTCDV5/1L7r1FFA/lw= +github.com/git-pkgs/clone v0.2.1/go.mod h1:lgbobKgJ6XbPZPsbn4iK0fVskYpFr4stjKKCeG9RsRc= +github.com/git-pkgs/magic v0.2.0 h1:c7HqVxnP8c88EaVMH0/KraDFVTcmiXckRiSvNZEnvMQ= +github.com/git-pkgs/magic v0.2.0/go.mod h1:3ndidt+yvFaI1M0aEkkzkOlFnLPkeVQASIUojazcxCI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.1 h1:8U73XiOTfINdItHVa6z4Gv7ToObcZ6grkqQbLryLCdA= +github.com/go-git/go-billy/v5 v5.9.1/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= +github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/object_store.go b/gogit/object_store.go similarity index 99% rename from object_store.go rename to gogit/object_store.go index 8ee63d2..23f3bc1 100644 --- a/object_store.go +++ b/gogit/object_store.go @@ -1,4 +1,4 @@ -package clone +package gogit import ( "bufio" diff --git a/gogit/test_helpers_test.go b/gogit/test_helpers_test.go new file mode 100644 index 0000000..2176eae --- /dev/null +++ b/gogit/test_helpers_test.go @@ -0,0 +1,39 @@ +package gogit + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func requireGit(t testing.TB) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not installed") + } +} + +func gitTestEnv() []string { + env := append([]string{}, os.Environ()...) + return append(env, + "GIT_CONFIG_GLOBAL="+os.DevNull, + "GIT_CONFIG_NOSYSTEM=1", + "GIT_AUTHOR_NAME=clone tests", + "GIT_AUTHOR_EMAIL=clone-tests@example.invalid", + "GIT_COMMITTER_NAME=clone tests", + "GIT_COMMITTER_EMAIL=clone-tests@example.invalid", + ) +} + +func runGitTest(t testing.TB, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = gitTestEnv() + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %s: %v", args, out, err) + } + return strings.TrimSpace(string(out)) +}