checkout.go

  1// Package checkout prepares the repositories of a run: the main checkout
  2// and the manifest's extra repos, laid out as <run>/<repo> and siblings so
  3// relative paths such as ../servers resolve the same locally and on the
  4// runner.
  5//
  6// Remote repos are fetched into a bare mirror per repo (one network fetch per
  7// run, objects shared between runs) and checked out with `git clone --shared`
  8// at a detached commit: each checkout is independent, so a run directory can
  9// be deleted with rm -rf, and nothing is written into the mirror's or the
 10// developer's own repository metadata.
 11package checkout
 12
 13import (
 14	"context"
 15	"errors"
 16	"fmt"
 17	"os"
 18	"os/exec"
 19	"path/filepath"
 20	"strings"
 21)
 22
 23// Source resolves repo names to clone URLs and keeps mirrors.
 24type Source struct {
 25	// MirrorsDir holds <repo>.git bare mirrors.
 26	MirrorsDir string
 27	// URL returns the clone URL of a repo.
 28	URL func(repo string) (string, error)
 29}
 30
 31// Mirror makes sure the mirror of repo exists and is up to date, and returns
 32// its path. Mirrors never auto-gc, so objects a live checkout borrows through
 33// alternates are not pruned under it; `ci gc` collects them.
 34func (s *Source) Mirror(ctx context.Context, repo string) (string, error) {
 35	url, err := s.URL(repo)
 36	if err != nil {
 37		return "", err
 38	}
 39	dir := filepath.Join(s.MirrorsDir, repo+".git")
 40	if _, err := os.Stat(filepath.Join(dir, "HEAD")); errors.Is(err, os.ErrNotExist) {
 41		if err := os.MkdirAll(s.MirrorsDir, 0o755); err != nil {
 42			return "", err
 43		}
 44		tmp := dir + ".tmp"
 45		_ = os.RemoveAll(tmp)
 46		if _, err := git(ctx, "", "clone", "--mirror", "--quiet", url, tmp); err != nil {
 47			return "", fmt.Errorf("mirror %s: %w", repo, err)
 48		}
 49		if _, err := git(ctx, tmp, "config", "gc.auto", "0"); err != nil {
 50			return "", err
 51		}
 52		if err := os.Rename(tmp, dir); err != nil {
 53			return "", err
 54		}
 55		return dir, nil
 56	} else if err != nil {
 57		return "", err
 58	}
 59	if _, err := git(ctx, dir, "remote", "set-url", "origin", url); err != nil {
 60		return "", err
 61	}
 62	if _, err := git(ctx, dir, "fetch", "--quiet", "--prune", "origin"); err != nil {
 63		return "", fmt.Errorf("fetch %s: %w", repo, err)
 64	}
 65	return dir, nil
 66}
 67
 68// Resolve returns the commit rev names in the repository at gitDir. An empty
 69// rev is HEAD (for a mirror: the forge's default branch).
 70func Resolve(ctx context.Context, gitDir, rev string) (string, error) {
 71	if rev == "" {
 72		rev = "HEAD"
 73	}
 74	out, err := git(ctx, gitDir, "rev-parse", "--verify", "--quiet", rev+"^{commit}")
 75	if err != nil {
 76		return "", fmt.Errorf("no commit %q in %s", rev, gitDir)
 77	}
 78	return strings.TrimSpace(out), nil
 79}
 80
 81// Clone checks out sha from the repository at from into dest (which must not
 82// exist), sharing its objects. If origin is set it becomes the checkout's
 83// origin remote, so recipes can push to the forge.
 84func Clone(ctx context.Context, from, sha, dest, origin string) error {
 85	if _, err := os.Lstat(dest); err == nil {
 86		return fmt.Errorf("%s already exists", dest)
 87	}
 88	if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
 89		return err
 90	}
 91	if _, err := git(ctx, "", "clone", "--shared", "--no-checkout", "--quiet", from, dest); err != nil {
 92		return err
 93	}
 94	if _, err := git(ctx, dest, "checkout", "--quiet", "--detach", sha); err != nil {
 95		return err
 96	}
 97	if origin != "" {
 98		if _, err := git(ctx, dest, "remote", "set-url", "origin", origin); err != nil {
 99			return err
100		}
101	}
102	return nil
103}
104
105// GitDir returns the git directory behind a local repository: `jj git root`
106// for jj repos (which also covers secondary workspaces and non-colocated
107// repos), else .git.
108func GitDir(root string) (string, error) {
109	if _, err := os.Stat(filepath.Join(root, ".jj")); err == nil {
110		cmd := exec.Command("jj", "git", "root")
111		cmd.Dir = root
112		out, err := cmd.Output()
113		if err != nil {
114			return "", fmt.Errorf("%s: jj git root: %w", root, err)
115		}
116		return strings.TrimSpace(string(out)), nil
117	}
118	p := filepath.Join(root, ".git")
119	if _, err := os.Stat(p); err != nil {
120		return "", fmt.Errorf("%s: no .git or .jj", root)
121	}
122	return p, nil
123}
124
125// Inside reports whether p is dir or below it (lexically).
126func Inside(dir, p string) bool {
127	rel, err := filepath.Rel(dir, p)
128	return err == nil && rel != ".." && !strings.HasPrefix(rel, "../")
129}
130
131func git(ctx context.Context, dir string, args ...string) (string, error) {
132	if dir != "" {
133		args = append([]string{"-C", dir}, args...)
134	}
135	cmd := exec.CommandContext(ctx, "git", args...)
136	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
137	var stderr strings.Builder
138	cmd.Stderr = &stderr
139	out, err := cmd.Output()
140	if err != nil {
141		return "", fmt.Errorf("git %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
142	}
143	return string(out), nil
144}