1package main
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "os"
8 "os/exec"
9 "regexp"
10 "strings"
11 "time"
12)
13
14// Fetcher is the network/git boundary of wlbsync.
15type Fetcher interface {
16 // Resolve returns the ref and commit for src. An empty pin selects the
17 // default ref of the source (latest tag or branch HEAD); otherwise pin is
18 // a tag, a branch, or a 40-hex commit.
19 Resolve(src upstream, pin string) (ref, commit string, err error)
20 // Checkout materialises commit of src (reached through ref) in the empty
21 // directory dir.
22 Checkout(src upstream, ref, commit, dir string) error
23}
24
25var fullSHA = regexp.MustCompile(`^[0-9a-f]{40}$`)
26
27// gitFetcher implements Fetcher with the git command line.
28type gitFetcher struct{}
29
30const gitTimeout = 5 * time.Minute
31
32func git(dir string, args ...string) (string, error) {
33 ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
34 defer cancel()
35 cmd := exec.CommandContext(ctx, "git", args...)
36 cmd.Dir = dir
37 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
38 var stdout, stderr bytes.Buffer
39 cmd.Stdout, cmd.Stderr = &stdout, &stderr
40 if err := cmd.Run(); err != nil {
41 return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
42 }
43 return strings.TrimSpace(stdout.String()), nil
44}
45
46// lsRemote lists the tags (peeled to commits) and branches of url.
47func lsRemote(url string) (tags, heads map[string]string, err error) {
48 out, err := git("", "ls-remote", "--tags", "--heads", url)
49 if err != nil {
50 return nil, nil, err
51 }
52 tags, heads = parseLsRemote(out)
53 return tags, heads, nil
54}
55
56// parseLsRemote parses `git ls-remote --tags --heads` output. Annotated tags
57// report the tag object and a peeled "^{}" line; the peeled commit wins.
58func parseLsRemote(out string) (tags, heads map[string]string) {
59 tags, heads = map[string]string{}, map[string]string{}
60 for _, line := range strings.Split(out, "\n") {
61 sha, name, ok := strings.Cut(strings.TrimSpace(line), "\t")
62 if !ok {
63 continue
64 }
65 switch {
66 case strings.HasPrefix(name, "refs/tags/"):
67 tag := strings.TrimPrefix(name, "refs/tags/")
68 if t, peeled := strings.CutSuffix(tag, "^{}"); peeled {
69 tags[t] = sha
70 } else if _, seen := tags[t]; !seen {
71 tags[t] = sha
72 }
73 case strings.HasPrefix(name, "refs/heads/"):
74 heads[strings.TrimPrefix(name, "refs/heads/")] = sha
75 }
76 }
77 return tags, heads
78}
79
80// latestTag returns the highest tag of src accepted by its tag filters.
81func latestTag(src upstream, tags map[string]string) (string, error) {
82 best := ""
83 for t := range tags {
84 if src.TagRe != nil && !src.TagRe.MatchString(t) {
85 continue
86 }
87 if src.TagOK != nil && !src.TagOK(t) {
88 continue
89 }
90 if best == "" || compareVersions(t, best) > 0 {
91 best = t
92 }
93 }
94 if best == "" {
95 return "", fmt.Errorf("%s: no tag matches the release pattern", src.ID)
96 }
97 return best, nil
98}
99
100// Resolve implements Fetcher.
101func (gitFetcher) Resolve(src upstream, pin string) (string, string, error) {
102 if fullSHA.MatchString(pin) {
103 if src.Kind == PinBranch {
104 return src.Branch, pin, nil // recorded as the branch, at the pinned commit
105 }
106 return pin, pin, nil
107 }
108 tags, heads, err := lsRemote(src.URL)
109 if err != nil {
110 return "", "", fmt.Errorf("%s: %w", src.ID, err)
111 }
112 if src.Kind == PinBranch {
113 branch := src.Branch
114 if pin != "" {
115 branch = pin
116 }
117 sha, ok := heads[branch]
118 if !ok {
119 return "", "", fmt.Errorf("%s: branch %q not found", src.ID, branch)
120 }
121 return branch, sha, nil
122 }
123 ref := pin
124 if ref == "" {
125 if ref, err = latestTag(src, tags); err != nil {
126 return "", "", err
127 }
128 }
129 sha, ok := tags[ref]
130 if !ok {
131 return "", "", fmt.Errorf("%s: tag %q not found", src.ID, ref)
132 }
133 return ref, sha, nil
134}
135
136// Checkout implements Fetcher: a shallow clone of the tag or branch, falling
137// back to a shallow fetch of the commit when the branch moved past a pinned
138// commit or the ref is itself a commit. HEAD is verified against commit.
139func (gitFetcher) Checkout(src upstream, ref, commit, dir string) error {
140 if !fullSHA.MatchString(ref) {
141 _, err := git("", "clone", "-q", "--depth", "1", "--branch", ref, src.URL, dir)
142 if err != nil {
143 return fmt.Errorf("%s: %w", src.ID, err)
144 }
145 if verifyHead(src, dir, commit) == nil {
146 return nil
147 }
148 if src.Kind != PinBranch {
149 return verifyHead(src, dir, commit)
150 }
151 // The branch moved since the commit was resolved or pinned.
152 if err := os.RemoveAll(dir); err != nil {
153 return err
154 }
155 if err := os.MkdirAll(dir, 0o755); err != nil {
156 return err
157 }
158 }
159 if err := checkoutCommit(src.URL, commit, dir); err != nil {
160 return fmt.Errorf("%s: %w", src.ID, err)
161 }
162 return nil
163}
164
165func checkoutCommit(url, commit, dir string) error {
166 steps := [][]string{
167 {"init", "-q"},
168 {"remote", "add", "origin", url},
169 {"fetch", "-q", "--depth", "1", "origin", commit},
170 {"-c", "advice.detachedHead=false", "checkout", "-q", "FETCH_HEAD"},
171 }
172 for _, s := range steps {
173 if _, err := git(dir, s...); err != nil {
174 return err
175 }
176 }
177 return verifyHead(upstream{ID: url}, dir, commit)
178}
179
180func verifyHead(src upstream, dir, commit string) error {
181 head, err := git(dir, "rev-parse", "HEAD")
182 if err != nil {
183 return err
184 }
185 if head != commit {
186 return fmt.Errorf("%s: checked out %s, expected %s (ref moved during sync?)", src.ID, head, commit)
187 }
188 return nil
189}