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