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}