diff --git a/README.md b/README.md index 98f1c0d..11849ab 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/checkout.go b/checkout.go new file mode 100644 index 0000000..323b89e --- /dev/null +++ b/checkout.go @@ -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 +} diff --git a/checkout_test.go b/checkout_test.go new file mode 100644 index 0000000..fd71c24 --- /dev/null +++ b/checkout_test.go @@ -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) + } +} diff --git a/doc.go b/doc.go index d449156..c1f7086 100644 --- a/doc.go +++ b/doc.go @@ -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