1
2
3
4
5 package codehost
6
7 import (
8 "context"
9 "errors"
10 "fmt"
11 "internal/lazyregexp"
12 "io"
13 "io/fs"
14 "os"
15 "path/filepath"
16 "sort"
17 "strconv"
18 "strings"
19 "sync"
20 "sync/atomic"
21 "time"
22
23 "cmd/go/internal/base"
24 "cmd/go/internal/cfg"
25 "cmd/go/internal/lockedfile"
26 "cmd/go/internal/str"
27 "cmd/internal/par"
28
29 "golang.org/x/mod/semver"
30 )
31
32
33
34
35
36
37
38
39
40
41 type VCSError struct {
42 Err error
43 }
44
45 func (e *VCSError) Error() string { return e.Err.Error() }
46
47 func (e *VCSError) Unwrap() error { return e.Err }
48
49 func vcsErrorf(format string, a ...any) error {
50 return &VCSError{Err: fmt.Errorf(format, a...)}
51 }
52
53 type vcsCacheKey struct {
54 vcs string
55 remote string
56 local bool
57 }
58
59 func NewRepo(ctx context.Context, vcs, remote string, local bool) (Repo, error) {
60 return vcsRepoCache.Do(vcsCacheKey{vcs, remote, local}, func() (Repo, error) {
61 repo, err := newVCSRepo(ctx, vcs, remote, local)
62 if err != nil {
63 return nil, &VCSError{err}
64 }
65 return repo, nil
66 })
67 }
68
69 var vcsRepoCache par.ErrCache[vcsCacheKey, Repo]
70
71 type vcsRepo struct {
72 mu lockedfile.Mutex
73
74 remote string
75 cmd *vcsCmd
76 dir string
77 local bool
78
79 tagsOnce sync.Once
80 tags map[string]bool
81
82 branchesOnce sync.Once
83 branches map[string]bool
84
85 fetchOnce sync.Once
86 fetchErr error
87 fetched atomic.Bool
88
89 repoSumOnce sync.Once
90 repoSum string
91
92 statCache par.ErrCache[string, *RevInfo]
93 readFileCache par.ErrCache[[2]string, []byte]
94 }
95
96 func newVCSRepo(ctx context.Context, vcs, remote string, local bool) (Repo, error) {
97 if vcs == "git" {
98 return newGitRepo(ctx, remote, local)
99 }
100 r := &vcsRepo{remote: remote, local: local}
101 cmd := vcsCmds[vcs]
102 if cmd == nil {
103 return nil, fmt.Errorf("unknown vcs: %s %s", vcs, remote)
104 }
105 r.cmd = cmd
106 if local {
107 info, err := os.Stat(remote)
108 if err != nil {
109 return nil, err
110 }
111 if !info.IsDir() {
112 return nil, fmt.Errorf("%s exists but is not a directory", remote)
113 }
114 r.dir = remote
115 r.mu.Path = r.dir + ".lock"
116 return r, nil
117 }
118 if !strings.Contains(remote, "://") {
119 return nil, fmt.Errorf("invalid vcs remote: %s %s", vcs, remote)
120 }
121 var err error
122 r.dir, r.mu.Path, err = WorkDir(ctx, vcsWorkDirType+vcs, r.remote)
123 if err != nil {
124 return nil, err
125 }
126
127 if cmd.init == nil {
128 return r, nil
129 }
130
131 unlock, err := r.mu.Lock()
132 if err != nil {
133 return nil, err
134 }
135 defer unlock()
136
137 if _, err := os.Stat(filepath.Join(r.dir, "."+vcs)); err != nil {
138 release, err := base.AcquireNet()
139 if err != nil {
140 return nil, err
141 }
142 _, err = Run(ctx, r.dir, cmd.init(r.remote))
143 if err == nil && cmd.postInit != nil {
144 err = cmd.postInit(ctx, r)
145 }
146 release()
147
148 if err != nil {
149 os.RemoveAll(r.dir)
150 return nil, err
151 }
152 }
153 return r, nil
154 }
155
156 const vcsWorkDirType = "vcs1."
157
158 type vcsCmd struct {
159 vcs string
160 init func(remote string) []string
161 postInit func(context.Context, *vcsRepo) error
162 repoSum func(remote string) []string
163 lookupRef func(remote, ref string) []string
164 tags func(remote string) []string
165 tagsNeedsFetch bool
166 tagRE *lazyregexp.Regexp
167 branches func(remote string) []string
168 branchesNeedsFetch bool
169 branchRE *lazyregexp.Regexp
170 badLocalRevRE *lazyregexp.Regexp
171 statLocal func(rev, remote string) []string
172 parseStat func(rev, out string) (*RevInfo, error)
173 fetch []string
174 latest string
175 descendsFrom func(rev, tag string) []string
176 recentTags func(rev string) []string
177 readFile func(rev, file, remote string) []string
178 readZip func(rev, subdir, remote, target string) []string
179
180
181 doReadZip func(ctx context.Context, dst io.Writer, workDir, rev, subdir, remote string) error
182 }
183
184 var re = lazyregexp.New
185
186 var vcsCmds = map[string]*vcsCmd{
187 "hg": {
188 vcs: "hg",
189 repoSum: func(remote string) []string {
190 return []string{
191 "hg",
192 "--config=extensions.goreposum=" + filepath.Join(cfg.GOROOT, "lib/hg/goreposum.py"),
193 "goreposum",
194 "--",
195 remote,
196 }
197 },
198 lookupRef: func(remote, ref string) []string {
199 return []string{
200 "hg",
201 "--config=extensions.goreposum=" + filepath.Join(cfg.GOROOT, "lib/hg/goreposum.py"),
202 "golookup",
203 "--",
204 remote,
205 ref,
206 }
207 },
208 init: func(remote string) []string {
209 return []string{"hg", "init", "."}
210 },
211 postInit: hgAddRemote,
212 tags: func(remote string) []string {
213 return []string{"hg", "tags", "-q"}
214 },
215 tagsNeedsFetch: true,
216 tagRE: re(`(?m)^[^\n]+$`),
217 branches: func(remote string) []string {
218 return []string{"hg", "branches", "-c", "-q"}
219 },
220 branchesNeedsFetch: true,
221 branchRE: re(`(?m)^[^\n]+$`),
222 badLocalRevRE: re(`(?m)^(tip)$`),
223 statLocal: func(rev, remote string) []string {
224 return []string{"hg", "log", "-l1", fmt.Sprintf("--rev=%s", rev), "--template", "{node} {date|hgdate} {tags}"}
225 },
226 parseStat: hgParseStat,
227 fetch: []string{"hg", "pull", "-f"},
228 latest: "tip",
229 descendsFrom: func(rev, tag string) []string {
230 return []string{"hg", "log", "--rev=ancestors(" + rev + ") and " + tag}
231 },
232 recentTags: func(rev string) []string {
233 return []string{"hg", "log", "--rev=ancestors(" + rev + ") and tag()", "--template", "{tags}\n"}
234 },
235 readFile: func(rev, file, remote string) []string {
236 return []string{"hg", "cat", fmt.Sprintf("--rev=%s", rev), "--", file}
237 },
238 readZip: func(rev, subdir, remote, target string) []string {
239 pattern := []string{}
240 if subdir != "" {
241 pattern = []string{fmt.Sprintf("--include=%s", subdir+"/**")}
242 }
243 return str.StringList("hg", "archive", "-t", "zip", "--no-decode", fmt.Sprintf("--rev=%s", rev), "--prefix=prefix/", pattern, "--", target)
244 },
245 },
246
247 "svn": {
248 vcs: "svn",
249 init: nil,
250 tags: func(remote string) []string {
251 return []string{"svn", "list", "--", strings.TrimSuffix(remote, "/trunk") + "/tags"}
252 },
253 tagRE: re(`(?m)^(.*?)/?$`),
254 statLocal: func(rev, remote string) []string {
255 suffix := "@" + rev
256 if rev == "latest" {
257 suffix = ""
258 }
259 return []string{"svn", "log", "-l1", "--xml", "--", remote + suffix}
260 },
261 parseStat: svnParseStat,
262 latest: "latest",
263 readFile: func(rev, file, remote string) []string {
264 return []string{"svn", "cat", "--", remote + "/" + file + "@" + rev}
265 },
266 doReadZip: svnReadZip,
267 },
268
269 "fossil": {
270 vcs: "fossil",
271 init: func(remote string) []string {
272 return []string{"fossil", "clone", "--", remote, ".fossil"}
273 },
274 fetch: []string{"fossil", "pull", "-R", ".fossil"},
275 tags: func(remote string) []string {
276 return []string{"fossil", "tag", "-R", ".fossil", "list"}
277 },
278 tagRE: re(`XXXTODO`),
279 statLocal: func(rev, remote string) []string {
280 return []string{"fossil", "info", "-R", ".fossil", "--", rev}
281 },
282 parseStat: fossilParseStat,
283 latest: "trunk",
284 readFile: func(rev, file, remote string) []string {
285 return []string{"fossil", "cat", "-R", ".fossil", fmt.Sprintf("-r=%s", rev), "--", file}
286 },
287 readZip: func(rev, subdir, remote, target string) []string {
288 extra := []string{}
289 if subdir != "" && !strings.ContainsAny(subdir, "*?[],") {
290 extra = []string{fmt.Sprintf("--include=%s", subdir)}
291 }
292
293
294 return str.StringList("fossil", "zip", "-R", ".fossil", "--name", "prefix", extra, "--", rev, target)
295 },
296 },
297 }
298
299 func (r *vcsRepo) loadTags(ctx context.Context) {
300 if r.cmd.tagsNeedsFetch {
301 r.fetchOnce.Do(func() { r.fetch(ctx) })
302 }
303
304 out, err := Run(ctx, r.dir, r.cmd.tags(r.remote))
305 if err != nil {
306 return
307 }
308
309
310 r.tags = make(map[string]bool)
311 for _, tag := range r.cmd.tagRE.FindAllString(string(out), -1) {
312 if r.cmd.badLocalRevRE != nil && r.cmd.badLocalRevRE.MatchString(tag) {
313 continue
314 }
315 r.tags[tag] = true
316 }
317 }
318
319 func (r *vcsRepo) loadBranches(ctx context.Context) {
320 if r.cmd.branches == nil {
321 return
322 }
323
324 if r.cmd.branchesNeedsFetch {
325 r.fetchOnce.Do(func() { r.fetch(ctx) })
326 }
327
328 out, err := Run(ctx, r.dir, r.cmd.branches(r.remote))
329 if err != nil {
330 return
331 }
332
333 r.branches = make(map[string]bool)
334 for _, branch := range r.cmd.branchRE.FindAllString(string(out), -1) {
335 if r.cmd.badLocalRevRE != nil && r.cmd.badLocalRevRE.MatchString(branch) {
336 continue
337 }
338 r.branches[branch] = true
339 }
340 }
341
342 func (r *vcsRepo) loadRepoSum(ctx context.Context) {
343 if r.cmd.repoSum == nil {
344 return
345 }
346 where := r.remote
347 if r.fetched.Load() {
348 where = "."
349 }
350 out, err := Run(ctx, r.dir, r.cmd.repoSum(where))
351 if err != nil {
352 return
353 }
354 r.repoSum = strings.TrimSpace(string(out))
355 }
356
357 func (r *vcsRepo) lookupRef(ctx context.Context, ref string) (string, error) {
358 if r.cmd.lookupRef == nil {
359 return "", fmt.Errorf("no lookupRef")
360 }
361 out, err := Run(ctx, r.dir, r.cmd.lookupRef(r.remote, ref))
362 if err != nil {
363 return "", err
364 }
365 return strings.TrimSpace(string(out)), nil
366 }
367
368
369 func (r *vcsRepo) repoSumOrigin(ctx context.Context) *Origin {
370 origin := &Origin{
371 VCS: r.cmd.vcs,
372 URL: r.remote,
373 RepoSum: r.repoSum,
374 }
375 r.repoSumOnce.Do(func() { r.loadRepoSum(ctx) })
376 origin.RepoSum = r.repoSum
377 return origin
378 }
379
380 func (r *vcsRepo) CheckReuse(ctx context.Context, old *Origin, subdir string) error {
381 if old == nil {
382 return fmt.Errorf("missing origin")
383 }
384 if old.VCS != r.cmd.vcs || old.URL != r.remote {
385 return fmt.Errorf("origin moved from %v %q to %v %q", old.VCS, old.URL, r.cmd.vcs, r.remote)
386 }
387 if old.Subdir != subdir {
388 return fmt.Errorf("origin moved from %v %q %q to %v %q %q", old.VCS, old.URL, old.Subdir, r.cmd.vcs, r.remote, subdir)
389 }
390
391 if old.Ref == "" && old.RepoSum == "" && old.Hash != "" {
392
393 hash, err := r.lookupRef(ctx, old.Hash)
394 if err == nil && hash == old.Hash {
395 return nil
396 }
397 if err != nil {
398 return fmt.Errorf("looking up hash: %v", err)
399 }
400 return fmt.Errorf("hash changed")
401 }
402
403 if old.Ref != "" && old.RepoSum == "" {
404 hash, err := r.lookupRef(ctx, old.Ref)
405 if err == nil && hash != "" && hash == old.Hash {
406 return nil
407 }
408 }
409
410 r.repoSumOnce.Do(func() { r.loadRepoSum(ctx) })
411 if r.repoSum != "" {
412 if old.RepoSum == "" {
413 return fmt.Errorf("non-specific origin")
414 }
415 if old.RepoSum != r.repoSum {
416 return fmt.Errorf("repo changed")
417 }
418 return nil
419 }
420 return fmt.Errorf("vcs %s: CheckReuse: %w", r.cmd.vcs, errors.ErrUnsupported)
421 }
422
423 func (r *vcsRepo) Tags(ctx context.Context, prefix string) (*Tags, error) {
424 unlock, err := r.mu.Lock()
425 if err != nil {
426 return nil, err
427 }
428 defer unlock()
429
430 r.tagsOnce.Do(func() { r.loadTags(ctx) })
431 tags := &Tags{
432 Origin: r.repoSumOrigin(ctx),
433 List: []Tag{},
434 }
435 for tag := range r.tags {
436 if strings.HasPrefix(tag, prefix) {
437 tags.List = append(tags.List, Tag{tag, ""})
438 }
439 }
440 sort.Slice(tags.List, func(i, j int) bool {
441 return tags.List[i].Name < tags.List[j].Name
442 })
443 return tags, nil
444 }
445
446 func (r *vcsRepo) Stat(ctx context.Context, rev string) (*RevInfo, error) {
447 return r.statCache.Do(rev, func() (*RevInfo, error) {
448 unlock, err := r.mu.Lock()
449 if err != nil {
450 return nil, err
451 }
452 defer unlock()
453
454 if rev == "latest" {
455 rev = r.cmd.latest
456 }
457 r.branchesOnce.Do(func() { r.loadBranches(ctx) })
458 if r.local {
459
460
461 return r.statLocal(ctx, rev)
462 }
463 revOK := (r.cmd.badLocalRevRE == nil || !r.cmd.badLocalRevRE.MatchString(rev)) && !r.branches[rev]
464 if revOK {
465 if info, err := r.statLocal(ctx, rev); err == nil {
466 return info, nil
467 }
468 }
469
470 r.fetchOnce.Do(func() { r.fetch(ctx) })
471 if r.fetchErr != nil {
472 return nil, r.fetchErr
473 }
474 info, err := r.statLocal(ctx, rev)
475 if err != nil {
476 return info, err
477 }
478 if !revOK {
479 info.Version = info.Name
480 }
481 return info, nil
482 })
483 }
484
485 func (r *vcsRepo) fetch(ctx context.Context) {
486 if len(r.cmd.fetch) > 0 {
487 release, err := base.AcquireNet()
488 if err != nil {
489 r.fetchErr = err
490 return
491 }
492 _, r.fetchErr = Run(ctx, r.dir, r.cmd.fetch)
493 release()
494 r.fetched.Store(true)
495 }
496 }
497
498 func (r *vcsRepo) statLocal(ctx context.Context, rev string) (*RevInfo, error) {
499 out, err := Run(ctx, r.dir, r.cmd.statLocal(rev, r.remote))
500 if err != nil {
501 info := &RevInfo{Origin: r.repoSumOrigin(ctx)}
502 return info, &UnknownRevisionError{Rev: rev}
503 }
504 info, err := r.cmd.parseStat(rev, string(out))
505 if err != nil {
506 return nil, err
507 }
508 if info.Origin == nil {
509 info.Origin = new(Origin)
510 }
511 info.Origin.VCS = r.cmd.vcs
512 info.Origin.URL = r.remote
513 info.Origin.Ref = rev
514 if strings.HasPrefix(info.Name, rev) && len(rev) >= 12 {
515 info.Origin.Ref = ""
516 }
517 return info, nil
518 }
519
520 func (r *vcsRepo) Latest(ctx context.Context) (*RevInfo, error) {
521 return r.Stat(ctx, "latest")
522 }
523
524 func (r *vcsRepo) ReadFile(ctx context.Context, rev, file string, maxSize int64) ([]byte, error) {
525 return r.readFileCache.Do([2]string{rev, file}, func() ([]byte, error) {
526 if rev == "latest" {
527 rev = r.cmd.latest
528 }
529 _, err := r.Stat(ctx, rev)
530 if err != nil {
531 return nil, err
532 }
533
534
535 unlock, err := r.mu.Lock()
536 if err != nil {
537 return nil, err
538 }
539 defer unlock()
540
541 out, err := Run(ctx, r.dir, r.cmd.readFile(rev, file, r.remote))
542 if err != nil {
543 return nil, fs.ErrNotExist
544 }
545 return out, nil
546 })
547 }
548
549 func (r *vcsRepo) RecentTag(ctx context.Context, rev, prefix string, allowed func(string) bool) (tag string, err error) {
550
551
552 unlock, err := r.mu.Lock()
553 if err != nil {
554 return "", err
555 }
556
557 if r.cmd.recentTags == nil {
558 unlock()
559 return "", vcsErrorf("vcs %s: RecentTag: %w", r.cmd.vcs, errors.ErrUnsupported)
560 }
561 out, err := Run(ctx, r.dir, r.cmd.recentTags(rev))
562 unlock()
563 if err != nil {
564 return "", err
565 }
566
567 highest := ""
568 for _, tag := range strings.Fields(string(out)) {
569 if !strings.HasPrefix(tag, prefix) || !allowed(tag) {
570 continue
571 }
572 semtag := tag[len(prefix):]
573 if semver.Compare(semtag, highest) > 0 {
574 highest = semtag
575 }
576 }
577 if highest != "" {
578 return prefix + highest, nil
579 }
580 return "", nil
581 }
582
583 func (r *vcsRepo) DescendsFrom(ctx context.Context, rev, tag string) (bool, error) {
584 unlock, err := r.mu.Lock()
585 if err != nil {
586 return false, err
587 }
588 defer unlock()
589
590 if r.cmd.descendsFrom == nil {
591 return false, vcsErrorf("vcs %s: DescendsFrom: %w", r.cmd.vcs, errors.ErrUnsupported)
592 }
593
594 out, err := Run(ctx, r.dir, r.cmd.descendsFrom(rev, tag))
595 if err != nil {
596 return false, err
597 }
598 return strings.TrimSpace(string(out)) != "", nil
599 }
600
601 func (r *vcsRepo) ReadZip(ctx context.Context, rev, subdir string, maxSize int64) (zip io.ReadCloser, err error) {
602 if r.cmd.readZip == nil && r.cmd.doReadZip == nil {
603 return nil, vcsErrorf("vcs %s: ReadZip: %w", r.cmd.vcs, errors.ErrUnsupported)
604 }
605
606 if rev == "latest" {
607 rev = r.cmd.latest
608 }
609 _, err = r.Stat(ctx, rev)
610 if err != nil {
611 return nil, err
612 }
613
614 unlock, err := r.mu.Lock()
615 if err != nil {
616 return nil, err
617 }
618 defer unlock()
619
620 f, err := os.CreateTemp("", "go-readzip-*.zip")
621 if err != nil {
622 return nil, err
623 }
624 if r.cmd.doReadZip != nil {
625 lw := &limitedWriter{
626 W: f,
627 N: maxSize,
628 ErrLimitReached: errors.New("ReadZip: encoded file exceeds allowed size"),
629 }
630 err = r.cmd.doReadZip(ctx, lw, r.dir, rev, subdir, r.remote)
631 if err == nil {
632 _, err = f.Seek(0, io.SeekStart)
633 }
634 } else if r.cmd.vcs == "fossil" {
635
636
637
638
639
640 args := r.cmd.readZip(rev, subdir, r.remote, filepath.Base(f.Name()))
641 for i := range args {
642 if args[i] == ".fossil" {
643 args[i] = filepath.Join(r.dir, ".fossil")
644 }
645 }
646 _, err = Run(ctx, filepath.Dir(f.Name()), args)
647 } else {
648 _, err = Run(ctx, r.dir, r.cmd.readZip(rev, subdir, r.remote, f.Name()))
649 }
650 if err != nil {
651 f.Close()
652 os.Remove(f.Name())
653 return nil, err
654 }
655 return &deleteCloser{f}, nil
656 }
657
658
659 type deleteCloser struct {
660 *os.File
661 }
662
663 func (d *deleteCloser) Close() error {
664 defer os.Remove(d.File.Name())
665 return d.File.Close()
666 }
667
668 func hgAddRemote(ctx context.Context, r *vcsRepo) error {
669
670 return os.WriteFile(filepath.Join(r.dir, ".hg/hgrc"), []byte(fmt.Sprintf("[paths]\ndefault = %s\n", r.remote)), 0666)
671 }
672
673 func hgParseStat(rev, out string) (*RevInfo, error) {
674 f := strings.Fields(out)
675 if len(f) < 3 {
676 return nil, vcsErrorf("unexpected response from hg log: %q", out)
677 }
678 hash := f[0]
679 version := rev
680 if strings.HasPrefix(hash, version) {
681 version = hash
682 }
683 t, err := strconv.ParseInt(f[1], 10, 64)
684 if err != nil {
685 return nil, vcsErrorf("invalid time from hg log: %q", out)
686 }
687
688 var tags []string
689 for _, tag := range f[3:] {
690 if tag != "tip" {
691 tags = append(tags, tag)
692 }
693 }
694 sort.Strings(tags)
695
696 info := &RevInfo{
697 Origin: &Origin{Hash: hash},
698 Name: hash,
699 Short: ShortenSHA1(hash),
700 Time: time.Unix(t, 0).UTC(),
701 Version: version,
702 Tags: tags,
703 }
704 return info, nil
705 }
706
707 func fossilParseStat(rev, out string) (*RevInfo, error) {
708 for line := range strings.SplitSeq(out, "\n") {
709 if strings.HasPrefix(line, "uuid:") || strings.HasPrefix(line, "hash:") {
710 f := strings.Fields(line)
711 if len(f) != 5 || len(f[1]) != 40 || f[4] != "UTC" {
712 return nil, vcsErrorf("unexpected response from fossil info: %q", line)
713 }
714 t, err := time.Parse(time.DateTime, f[2]+" "+f[3])
715 if err != nil {
716 return nil, vcsErrorf("unexpected response from fossil info: %q", line)
717 }
718 hash := f[1]
719 version := rev
720 if strings.HasPrefix(hash, version) {
721 version = hash
722 }
723 info := &RevInfo{
724 Origin: &Origin{Hash: hash},
725 Name: hash,
726 Short: ShortenSHA1(hash),
727 Time: t,
728 Version: version,
729 }
730 return info, nil
731 }
732 }
733 return nil, vcsErrorf("unexpected response from fossil info: %q", out)
734 }
735
736 type limitedWriter struct {
737 W io.Writer
738 N int64
739 ErrLimitReached error
740 }
741
742 func (l *limitedWriter) Write(p []byte) (n int, err error) {
743 if l.N > 0 {
744 max := len(p)
745 if l.N < int64(max) {
746 max = int(l.N)
747 }
748 n, err = l.W.Write(p[:max])
749 l.N -= int64(n)
750 if err != nil || n >= len(p) {
751 return n, err
752 }
753 }
754
755 return n, l.ErrLimitReached
756 }
757
View as plain text