Skip to content
Merged
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@ fmt.Println(clone.Head(ctx, dst))

Pass `true` as the final argument for a full clone, including when an existing shallow checkout needs to be unshallowed. Clone and fetch errors are returned as `*clone.UnreachableError`, except when the context was canceled or reached its deadline. `errors.As` retrieves the URL and underlying Git error. `ValidateURL` accepts `https://` URLs, while `ValidateRef` rejects leading hyphens, `..`, and characters outside letters, digits, `.`, `_`, `/`, and `-`.

## Temporarily check out an exact tag

`CheckoutTag` resolves only a local tag with the given exact name, peels an
annotated tag to its commit, and checks it out with detached HEAD. The returned
function restores the previous commit and reattaches its branch if that branch
has not moved. Both operations discard tracked changes.

```go
restore, err := clone.CheckoutTag(ctx, dst, "v1.2.3")
if err != nil {
log.Fatal(err)
}
defer func() {
if err := restore(context.Background()); err != nil {
log.Printf("restore checkout: %v", err)
}
}()
```

Use `errors.Is(err, clone.ErrTagNotFound)` to distinguish an absent tag from
an invalid tag or another local repository error. `CheckoutTag` does not fetch.

## Persistent cache

`Cache` stores one checkout per URL under `Root`. `Prepare` holds a per-URL lock while updating the shallow checkout, then replaces `dst` with a copy and returns its commit. The destination must be outside `Root`.
Expand Down
153 changes: 153 additions & 0 deletions checkout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package clone

import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
)

// ErrTagNotFound reports that an exact local tag does not exist.
var ErrTagNotFound = errors.New("tag not found")

// CheckoutTag force-checks out the commit named by an exact local tag in
// detached-HEAD state. It returns a function that force-restores the previous
// HEAD commit and reattaches its branch when that branch has not moved.
//
// CheckoutTag does not fetch. It discards tracked working-tree and index
// changes both when checking out the tag and when restoring the previous HEAD.
// The caller supplies the restoration context so cleanup can continue with a
// fresh context after the original operation is cancelled.
func CheckoutTag(ctx context.Context, dir, tag string) (restore func(context.Context) error, err error) {
tagRef := "refs/tags/" + tag
if _, err := Run(ctx, dir, nil, "check-ref-format", tagRef); err != nil {
return nil, fmt.Errorf("invalid tag %q: %w", tag, err)
}

previous, err := headCommit(ctx, dir)
if err != nil {
return nil, err
}
branch, err := headBranch(ctx, dir)
if err != nil {
return nil, err
}

commit, err := tagCommit(ctx, dir, tag, tagRef)
if err != nil {
return nil, err
}
if err := checkoutCommit(ctx, dir, commit); err != nil {
return nil, fmt.Errorf("checkout tag %q: %w", tag, err)
}

return func(restoreCtx context.Context) error {
if err := checkoutCommit(restoreCtx, dir, previous); err != nil {
return fmt.Errorf("restore HEAD %s: %w", previous, err)
}
if branch == "" {
return nil
}
current, err := exactRefCommit(restoreCtx, dir, branch)
if err != nil {
return fmt.Errorf("restore branch %q: %w", branch, err)
}
if current != previous {
return fmt.Errorf("restore branch %q: moved from %s to %s", branch, previous, current)
}
if _, err := Run(restoreCtx, dir, nil, "symbolic-ref", "HEAD", branch); err != nil {
return fmt.Errorf("restore branch %q: %w", branch, err)
}
return nil
}, nil
}

func headCommit(ctx context.Context, dir string) (string, error) {
out, err := Run(ctx, dir, nil, "rev-parse", "--verify", "HEAD")
if err != nil {
return "", fmt.Errorf("resolve HEAD: %w", err)
}
commit := strings.TrimSpace(out)
if !ValidCommit(commit) {
return "", fmt.Errorf("resolve HEAD: invalid commit %q", commit)
}
return commit, nil
}

func headBranch(ctx context.Context, dir string) (string, error) {
out, err := Run(ctx, dir, nil, "symbolic-ref", "--quiet", "HEAD")
if err == nil {
return strings.TrimSpace(out), nil
}
if gitExitCode(err) == 1 {
return "", nil
}
return "", fmt.Errorf("resolve HEAD branch: %w", err)
}

func tagCommit(ctx context.Context, dir, tag, tagRef string) (string, error) {
out, err := Run(ctx, dir, nil, "show-ref", "--tags", "--", tagRef)
if err != nil {
if gitExitCode(err) == 1 {
return "", fmt.Errorf("%w: %q", ErrTagNotFound, tag)
}
return "", fmt.Errorf("resolve tag %q: %w", tag, err)
}
object, ok := exactShowRefObject(out, tagRef)
if !ok {
return "", fmt.Errorf("%w: %q", ErrTagNotFound, tag)
}
if !ValidCommit(object) {
return "", fmt.Errorf("resolve tag %q: invalid object %q", tag, object)
}

peeled, err := Run(ctx, dir, nil, "rev-parse", "--verify", object+"^{commit}")
if err != nil {
return "", fmt.Errorf("resolve tag %q commit: %w", tag, err)
}
commit := strings.TrimSpace(peeled)
if !ValidCommit(commit) {
return "", fmt.Errorf("resolve tag %q commit: invalid commit %q", tag, commit)
}
return commit, nil
}

func exactShowRefObject(out, ref string) (string, bool) {
for line := range strings.SplitSeq(out, "\n") {
object, foundRef, ok := strings.Cut(line, " ")
if ok && foundRef == ref {
return object, true
}
}
return "", false
}

func exactRefCommit(ctx context.Context, dir, ref string) (string, error) {
out, err := Run(ctx, dir, nil, "show-ref", "--verify", "--hash", ref)
if err != nil {
return "", err
}
commit := strings.TrimSpace(out)
if !ValidCommit(commit) {
return "", fmt.Errorf("invalid commit %q", commit)
}
return commit, nil
}

func checkoutCommit(ctx context.Context, dir, commit string) error {
if !ValidCommit(commit) {
return fmt.Errorf("invalid commit %q", commit)
}
_, err := Run(ctx, dir, nil,
"-c", "advice.detachedHead=false", "checkout", "--force", "--detach", commit)
return err
}

func gitExitCode(err error) int {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode()
}
return -1
}
200 changes: 200 additions & 0 deletions checkout_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package clone

import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)

type checkoutFixture struct {
dir string
firstSHA string
headSHA string
}

func newCheckoutFixture(t *testing.T) checkoutFixture {
t.Helper()
requireGit(t)

dir := t.TempDir()
runGitTest(t, dir, "init", "--quiet", "-b", "main")
marker := filepath.Join(dir, "version.txt")
if err := os.WriteFile(marker, []byte("first\n"), 0o644); err != nil {
t.Fatal(err)
}
runGitTest(t, dir, "add", "version.txt")
runGitTest(t, dir, "commit", "--quiet", "-m", "first")
firstSHA := runGitTest(t, dir, "rev-parse", "HEAD")
runGitTest(t, dir, "tag", "v1", firstSHA)
runGitTest(t, dir, "tag", "-a", "v1-annotated", "-m", "v1-annotated", firstSHA)
runGitTest(t, dir, "tag", "v1.0.0+build", firstSHA)

if err := os.WriteFile(marker, []byte("head\n"), 0o644); err != nil {
t.Fatal(err)
}
runGitTest(t, dir, "commit", "--quiet", "-am", "head")
headSHA := runGitTest(t, dir, "rev-parse", "HEAD")
runGitTest(t, dir, "branch", "v1", headSHA)

return checkoutFixture{dir: dir, firstSHA: firstSHA, headSHA: headSHA}
}

func TestCheckoutTagChecksOutExactTagAndRestoresBranch(t *testing.T) {
fixture := newCheckoutFixture(t)
restore, err := CheckoutTag(context.Background(), fixture.dir, "v1")
if err != nil {
t.Fatal(err)
}
if got := Head(context.Background(), fixture.dir); got != fixture.firstSHA {
t.Fatalf("tag HEAD = %q, want %q", got, fixture.firstSHA)
}
if _, err := Run(context.Background(), fixture.dir, nil, "symbolic-ref", "--quiet", "HEAD"); err == nil {
t.Fatal("tag checkout left HEAD attached")
}
if err := os.WriteFile(filepath.Join(fixture.dir, "version.txt"), []byte("dirty\n"), 0o644); err != nil {
t.Fatal(err)
}

if err := restore(context.Background()); err != nil {
t.Fatal(err)
}
if got := Head(context.Background(), fixture.dir); got != fixture.headSHA {
t.Errorf("restored HEAD = %q, want %q", got, fixture.headSHA)
}
branch, err := Run(context.Background(), fixture.dir, nil, "symbolic-ref", "--quiet", "HEAD")
if err != nil {
t.Fatal(err)
}
if got := strings.TrimSpace(branch); got != "refs/heads/main" {
t.Errorf("restored branch = %q, want refs/heads/main", got)
}
content, err := os.ReadFile(filepath.Join(fixture.dir, "version.txt"))
if err != nil {
t.Fatal(err)
}
if got := string(content); got != "head\n" {
t.Errorf("restored content = %q, want %q", got, "head\n")
}
}

func TestCheckoutTagPeelsAnnotatedTag(t *testing.T) {
fixture := newCheckoutFixture(t)
restore, err := CheckoutTag(context.Background(), fixture.dir, "v1-annotated")
if err != nil {
t.Fatal(err)
}
defer func() {
if err := restore(context.Background()); err != nil {
t.Errorf("restore: %v", err)
}
}()
if got := Head(context.Background(), fixture.dir); got != fixture.firstSHA {
t.Errorf("tag HEAD = %q, want %q", got, fixture.firstSHA)
}
}

func TestCheckoutTagAcceptsGitValidTagOutsideValidateRefPolicy(t *testing.T) {
fixture := newCheckoutFixture(t)
restore, err := CheckoutTag(context.Background(), fixture.dir, "v1.0.0+build")
if err != nil {
t.Fatal(err)
}
if err := restore(context.Background()); err != nil {
t.Fatal(err)
}
}

func TestCheckoutTagRestoresDetachedHead(t *testing.T) {
fixture := newCheckoutFixture(t)
runGitTest(t, fixture.dir, "checkout", "--quiet", "--detach", fixture.headSHA)
restore, err := CheckoutTag(context.Background(), fixture.dir, "v1")
if err != nil {
t.Fatal(err)
}
if err := restore(context.Background()); err != nil {
t.Fatal(err)
}
if got := Head(context.Background(), fixture.dir); got != fixture.headSHA {
t.Errorf("restored HEAD = %q, want %q", got, fixture.headSHA)
}
if _, err := Run(context.Background(), fixture.dir, nil, "symbolic-ref", "--quiet", "HEAD"); err == nil {
t.Fatal("restore attached a previously detached HEAD")
}
}

func TestCheckoutTagRestoreRefusesMovedBranch(t *testing.T) {
fixture := newCheckoutFixture(t)
restore, err := CheckoutTag(context.Background(), fixture.dir, "v1")
if err != nil {
t.Fatal(err)
}
runGitTest(t, fixture.dir, "update-ref", "refs/heads/main", fixture.firstSHA)
if err := restore(context.Background()); err == nil || !strings.Contains(err.Error(), "moved") {
t.Fatalf("restore error = %v, want moved-branch error", err)
}
if got := Head(context.Background(), fixture.dir); got != fixture.headSHA {
t.Errorf("HEAD after refused branch attachment = %q, want %q", got, fixture.headSHA)
}
if _, err := Run(context.Background(), fixture.dir, nil, "symbolic-ref", "--quiet", "HEAD"); err == nil {
t.Fatal("restore attached HEAD to a moved branch")
}
}

func TestCheckoutTagRejectsMissingAndRevisionLikeTagsWithoutMovingHead(t *testing.T) {
fixture := newCheckoutFixture(t)
runGitTest(t, fixture.dir, "tag", "nested/refs/tags/suffix-only", fixture.firstSHA)
for _, test := range []struct {
tag string
notFound bool
}{
{tag: "missing", notFound: true},
{tag: "suffix-only", notFound: true},
{tag: "--ignore-skip-worktree-bits", notFound: true},
{tag: "v1~1"},
} {
t.Run(test.tag, func(t *testing.T) {
restore, err := CheckoutTag(context.Background(), fixture.dir, test.tag)
if err == nil || restore != nil {
t.Fatalf("CheckoutTag(%q) restore set = %t, error = %v; want nil restore and error", test.tag, restore != nil, err)
}
if errors.Is(err, ErrTagNotFound) != test.notFound {
t.Errorf("error = %v, ErrTagNotFound = %t", err, test.notFound)
}
if got := Head(context.Background(), fixture.dir); got != fixture.headSHA {
t.Errorf("HEAD after rejected tag = %q, want %q", got, fixture.headSHA)
}
})
}
}

func TestCheckoutTagHonorsContexts(t *testing.T) {
fixture := newCheckoutFixture(t)
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := CheckoutTag(cancelled, fixture.dir, "v1"); !errors.Is(err, context.Canceled) {
t.Fatalf("checkout error = %v, want context.Canceled", err)
}

restore, err := CheckoutTag(context.Background(), fixture.dir, "v1")
if err != nil {
t.Fatal(err)
}
if err := restore(cancelled); !errors.Is(err, context.Canceled) {
t.Errorf("restore error = %v, want context.Canceled", err)
}
if err := restore(context.Background()); err != nil {
t.Fatalf("cleanup restore: %v", err)
}
}

func TestCheckoutTagRejectsNonCommitTag(t *testing.T) {
fixture := newCheckoutFixture(t)
blob := runGitTest(t, fixture.dir, "hash-object", "-w", "version.txt")
runGitTest(t, fixture.dir, "tag", "blob", blob)
if _, err := CheckoutTag(context.Background(), fixture.dir, "blob"); err == nil || errors.Is(err, ErrTagNotFound) {
t.Fatalf("error = %v, want non-commit tag error", err)
}
}
9 changes: 5 additions & 4 deletions doc.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Package clone keeps local checkouts of HTTPS Git repositories. It provides
// shallow clone-or-fetch, 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, remote queries, and command
// retries shell out to the git binary, which must be on PATH.
// 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.
package clone