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