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