1
2
3
4
5 package codehost
6
7 import (
8 "bytes"
9 "context"
10 "crypto/sha256"
11 "encoding/base64"
12 "errors"
13 "fmt"
14 "io"
15 "io/fs"
16 "net/url"
17 "os"
18 "os/exec"
19 "path/filepath"
20 "runtime"
21 "slices"
22 "sort"
23 "strconv"
24 "strings"
25 "sync"
26 "time"
27
28 "cmd/go/internal/base"
29 "cmd/go/internal/lockedfile"
30 "cmd/go/internal/par"
31 "cmd/go/internal/web"
32
33 "golang.org/x/mod/semver"
34 )
35
36
37
38 func LocalGitRepo(ctx context.Context, remote string) (Repo, error) {
39 return newGitRepoCached(ctx, remote, true)
40 }
41
42
43
44 type notExistError struct {
45 err error
46 }
47
48 func (e notExistError) Error() string { return e.err.Error() }
49 func (notExistError) Is(err error) bool { return err == fs.ErrNotExist }
50
51 const gitWorkDirType = "git3"
52
53 var gitRepoCache par.ErrCache[gitCacheKey, Repo]
54
55 type gitCacheKey struct {
56 remote string
57 localOK bool
58 }
59
60 func newGitRepoCached(ctx context.Context, remote string, localOK bool) (Repo, error) {
61 return gitRepoCache.Do(gitCacheKey{remote, localOK}, func() (Repo, error) {
62 return newGitRepo(ctx, remote, localOK)
63 })
64 }
65
66 func newGitRepo(ctx context.Context, remote string, localOK bool) (Repo, error) {
67 r := &gitRepo{remote: remote}
68 if strings.Contains(remote, "://") {
69
70 var err error
71 r.dir, r.mu.Path, err = WorkDir(ctx, gitWorkDirType, r.remote)
72 if err != nil {
73 return nil, err
74 }
75
76 unlock, err := r.mu.Lock()
77 if err != nil {
78 return nil, err
79 }
80 defer unlock()
81
82 if _, err := os.Stat(filepath.Join(r.dir, "objects")); err != nil {
83 if _, err := Run(ctx, r.dir, "git", "init", "--bare"); err != nil {
84 os.RemoveAll(r.dir)
85 return nil, err
86 }
87
88
89
90
91 if _, err := Run(ctx, r.dir, "git", "remote", "add", "origin", "--", r.remote); err != nil {
92 os.RemoveAll(r.dir)
93 return nil, err
94 }
95 if runtime.GOOS == "windows" {
96
97
98
99
100
101
102
103
104
105 if _, err := Run(ctx, r.dir, "git", "config", "core.longpaths", "true"); err != nil {
106 os.RemoveAll(r.dir)
107 return nil, err
108 }
109 }
110 }
111 r.remoteURL = r.remote
112 r.remote = "origin"
113 } else {
114
115
116
117
118 if strings.Contains(remote, ":") {
119 return nil, fmt.Errorf("git remote cannot use host:path syntax")
120 }
121 if !localOK {
122 return nil, fmt.Errorf("git remote must not be local directory")
123 }
124 r.local = true
125 info, err := os.Stat(remote)
126 if err != nil {
127 return nil, err
128 }
129 if !info.IsDir() {
130 return nil, fmt.Errorf("%s exists but is not a directory", remote)
131 }
132 r.dir = remote
133 r.mu.Path = r.dir + ".lock"
134 }
135 return r, nil
136 }
137
138 type gitRepo struct {
139 ctx context.Context
140
141 remote, remoteURL string
142 local bool
143 dir string
144
145 mu lockedfile.Mutex
146
147 fetchLevel int
148
149 statCache par.ErrCache[string, *RevInfo]
150
151 refsOnce sync.Once
152
153
154 refs map[string]string
155 refsErr error
156
157 localTagsOnce sync.Once
158 localTags sync.Map
159 }
160
161 const (
162
163 fetchNone = iota
164 fetchSome
165 fetchAll
166 )
167
168
169
170 func (r *gitRepo) loadLocalTags(ctx context.Context) {
171
172
173
174 out, err := Run(ctx, r.dir, "git", "tag", "-l")
175 if err != nil {
176 return
177 }
178
179 for _, line := range strings.Split(string(out), "\n") {
180 if line != "" {
181 r.localTags.Store(line, true)
182 }
183 }
184 }
185
186 func (r *gitRepo) CheckReuse(ctx context.Context, old *Origin, subdir string) error {
187 if old == nil {
188 return fmt.Errorf("missing origin")
189 }
190 if old.VCS != "git" || old.URL != r.remoteURL {
191 return fmt.Errorf("origin moved from %v %q to %v %q", old.VCS, old.URL, "git", r.remoteURL)
192 }
193 if old.Subdir != subdir {
194 return fmt.Errorf("origin moved from %v %q %q to %v %q %q", old.VCS, old.URL, old.Subdir, "git", r.remoteURL, subdir)
195 }
196
197
198
199
200
201
202 if old.Hash == "" && old.TagSum == "" && old.RepoSum == "" {
203 return fmt.Errorf("non-specific origin")
204 }
205
206 r.loadRefs(ctx)
207 if r.refsErr != nil {
208 return r.refsErr
209 }
210
211 if old.Ref != "" {
212 hash, ok := r.refs[old.Ref]
213 if !ok {
214 return fmt.Errorf("ref %q deleted", old.Ref)
215 }
216 if hash != old.Hash {
217 return fmt.Errorf("ref %q moved from %s to %s", old.Ref, old.Hash, hash)
218 }
219 }
220 if old.TagSum != "" {
221 tags, err := r.Tags(ctx, old.TagPrefix)
222 if err != nil {
223 return err
224 }
225 if tags.Origin.TagSum != old.TagSum {
226 return fmt.Errorf("tags changed")
227 }
228 }
229 if old.RepoSum != "" {
230 if r.repoSum(r.refs) != old.RepoSum {
231 return fmt.Errorf("refs changed")
232 }
233 }
234 return nil
235 }
236
237
238
239 func (r *gitRepo) loadRefs(ctx context.Context) (map[string]string, error) {
240 r.refsOnce.Do(func() {
241
242
243
244 release, err := base.AcquireNet()
245 if err != nil {
246 r.refsErr = err
247 return
248 }
249 out, gitErr := Run(ctx, r.dir, "git", "ls-remote", "-q", r.remote)
250 release()
251
252 if gitErr != nil {
253 if rerr, ok := gitErr.(*RunError); ok {
254 if bytes.Contains(rerr.Stderr, []byte("fatal: could not read Username")) {
255 rerr.HelpText = "Confirm the import path was entered correctly.\nIf this is a private repository, see https://golang.org/doc/faq#git_https for additional information."
256 }
257 }
258
259
260
261
262
263 if u, err := url.Parse(r.remoteURL); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
264 if _, err := web.GetBytes(u); errors.Is(err, fs.ErrNotExist) {
265 gitErr = notExistError{gitErr}
266 }
267 }
268
269 r.refsErr = gitErr
270 return
271 }
272
273 refs := make(map[string]string)
274 for _, line := range strings.Split(string(out), "\n") {
275 f := strings.Fields(line)
276 if len(f) != 2 {
277 continue
278 }
279 if f[1] == "HEAD" || strings.HasPrefix(f[1], "refs/heads/") || strings.HasPrefix(f[1], "refs/tags/") {
280 refs[f[1]] = f[0]
281 }
282 }
283 for ref, hash := range refs {
284 if k, found := strings.CutSuffix(ref, "^{}"); found {
285 refs[k] = hash
286 delete(refs, ref)
287 }
288 }
289 r.refs = refs
290 })
291 return r.refs, r.refsErr
292 }
293
294 func (r *gitRepo) Tags(ctx context.Context, prefix string) (*Tags, error) {
295 refs, err := r.loadRefs(ctx)
296 if err != nil {
297 return nil, err
298 }
299
300 tags := &Tags{
301 Origin: &Origin{
302 VCS: "git",
303 URL: r.remoteURL,
304 TagPrefix: prefix,
305 },
306 List: []Tag{},
307 }
308 for ref, hash := range refs {
309 if !strings.HasPrefix(ref, "refs/tags/") {
310 continue
311 }
312 tag := ref[len("refs/tags/"):]
313 if !strings.HasPrefix(tag, prefix) {
314 continue
315 }
316 tags.List = append(tags.List, Tag{tag, hash})
317 }
318 sort.Slice(tags.List, func(i, j int) bool {
319 return tags.List[i].Name < tags.List[j].Name
320 })
321
322 dir := prefix[:strings.LastIndex(prefix, "/")+1]
323 h := sha256.New()
324 for _, tag := range tags.List {
325 if isOriginTag(strings.TrimPrefix(tag.Name, dir)) {
326 fmt.Fprintf(h, "%q %s\n", tag.Name, tag.Hash)
327 }
328 }
329 tags.Origin.TagSum = "t1:" + base64.StdEncoding.EncodeToString(h.Sum(nil))
330 return tags, nil
331 }
332
333
334
335
336
337 func (r *gitRepo) repoSum(refs map[string]string) string {
338 var list []string
339 for ref := range refs {
340 list = append(list, ref)
341 }
342 sort.Strings(list)
343 h := sha256.New()
344 for _, ref := range list {
345 fmt.Fprintf(h, "%q %s\n", ref, refs[ref])
346 }
347 return "r1:" + base64.StdEncoding.EncodeToString(h.Sum(nil))
348 }
349
350
351
352 func (r *gitRepo) unknownRevisionInfo(refs map[string]string) *RevInfo {
353 return &RevInfo{
354 Origin: &Origin{
355 VCS: "git",
356 URL: r.remoteURL,
357 RepoSum: r.repoSum(refs),
358 },
359 }
360 }
361
362 func (r *gitRepo) Latest(ctx context.Context) (*RevInfo, error) {
363 refs, err := r.loadRefs(ctx)
364 if err != nil {
365 return nil, err
366 }
367 if refs["HEAD"] == "" {
368 return nil, ErrNoCommits
369 }
370 statInfo, err := r.Stat(ctx, refs["HEAD"])
371 if err != nil {
372 return nil, err
373 }
374
375
376 info := new(RevInfo)
377 *info = *statInfo
378 info.Origin = new(Origin)
379 if statInfo.Origin != nil {
380 *info.Origin = *statInfo.Origin
381 }
382 info.Origin.Ref = "HEAD"
383 info.Origin.Hash = refs["HEAD"]
384
385 return info, nil
386 }
387
388
389
390
391
392 func (r *gitRepo) findRef(ctx context.Context, hash string) (ref string, ok bool) {
393 refs, err := r.loadRefs(ctx)
394 if err != nil {
395 return "", false
396 }
397 for ref, h := range refs {
398 if h == hash {
399 return ref, true
400 }
401 }
402 return "", false
403 }
404
405
406
407
408
409
410
411 const minHashDigits = 7
412
413
414
415 func (r *gitRepo) stat(ctx context.Context, rev string) (info *RevInfo, err error) {
416 if r.local {
417 return r.statLocal(ctx, rev, rev)
418 }
419
420
421 didStatLocal := false
422 if len(rev) >= minHashDigits && len(rev) <= 40 && AllHex(rev) {
423 if info, err := r.statLocal(ctx, rev, rev); err == nil {
424 return info, nil
425 }
426 didStatLocal = true
427 }
428
429
430
431 r.localTagsOnce.Do(func() { r.loadLocalTags(ctx) })
432 if _, ok := r.localTags.Load(rev); ok {
433 return r.statLocal(ctx, rev, "refs/tags/"+rev)
434 }
435
436
437
438
439 refs, err := r.loadRefs(ctx)
440 if err != nil {
441 return nil, err
442 }
443
444
445
446 var ref, hash string
447 if refs["refs/tags/"+rev] != "" {
448 ref = "refs/tags/" + rev
449 hash = refs[ref]
450
451 } else if refs["refs/heads/"+rev] != "" {
452 ref = "refs/heads/" + rev
453 hash = refs[ref]
454 rev = hash
455 } else if rev == "HEAD" && refs["HEAD"] != "" {
456 ref = "HEAD"
457 hash = refs[ref]
458 rev = hash
459 } else if len(rev) >= minHashDigits && len(rev) <= 40 && AllHex(rev) {
460
461
462 prefix := rev
463
464 for k, h := range refs {
465 if strings.HasPrefix(h, prefix) {
466 if hash != "" && hash != h {
467
468
469 return nil, fmt.Errorf("ambiguous revision %s", rev)
470 }
471 if ref == "" || ref > k {
472 ref = k
473 }
474 rev = h
475 hash = h
476 }
477 }
478 if hash == "" && len(rev) == 40 {
479 hash = rev
480 }
481 } else {
482 return r.unknownRevisionInfo(refs), &UnknownRevisionError{Rev: rev}
483 }
484
485 defer func() {
486 if info != nil {
487 info.Origin.Hash = info.Name
488
489 if ref != info.Origin.Hash {
490 info.Origin.Ref = ref
491 }
492 }
493 }()
494
495
496 unlock, err := r.mu.Lock()
497 if err != nil {
498 return nil, err
499 }
500 defer unlock()
501
502
503
504
505
506 if !didStatLocal {
507 if info, err := r.statLocal(ctx, rev, hash); err == nil {
508 tag, fromTag := strings.CutPrefix(ref, "refs/tags/")
509 if fromTag && !slices.Contains(info.Tags, tag) {
510
511
512 _, err := Run(ctx, r.dir, "git", "tag", tag, hash)
513 if err != nil {
514 return nil, err
515 }
516 r.localTags.Store(tag, true)
517 return r.statLocal(ctx, rev, ref)
518 }
519 return info, err
520 }
521 }
522
523
524
525
526
527
528
529
530 if r.fetchLevel <= fetchSome && ref != "" && hash != "" && !r.local {
531 r.fetchLevel = fetchSome
532 var refspec string
533 if ref == "HEAD" {
534
535
536
537
538
539 ref = hash
540 refspec = hash + ":refs/dummy"
541 } else {
542
543
544
545
546 refspec = ref + ":" + ref
547 }
548
549 release, err := base.AcquireNet()
550 if err != nil {
551 return nil, err
552 }
553
554
555
556
557 _, err = Run(ctx, r.dir, "git", "-c", "protocol.version=2", "fetch", "-f", "--depth=1", r.remote, refspec)
558 release()
559
560 if err == nil {
561 return r.statLocal(ctx, rev, ref)
562 }
563
564
565
566 }
567
568
569
570 if err := r.fetchRefsLocked(ctx); err != nil {
571 return nil, err
572 }
573
574 return r.statLocal(ctx, rev, rev)
575 }
576
577
578
579
580
581
582
583
584
585
586 func (r *gitRepo) fetchRefsLocked(ctx context.Context) error {
587 if r.fetchLevel < fetchAll {
588
589
590
591
592
593
594 release, err := base.AcquireNet()
595 if err != nil {
596 return err
597 }
598 defer release()
599
600 if _, err := Run(ctx, r.dir, "git", "fetch", "-f", r.remote, "refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"); err != nil {
601 return err
602 }
603
604 if _, err := os.Stat(filepath.Join(r.dir, "shallow")); err == nil {
605 if _, err := Run(ctx, r.dir, "git", "fetch", "--unshallow", "-f", r.remote); err != nil {
606 return err
607 }
608 }
609
610 r.fetchLevel = fetchAll
611 }
612 return nil
613 }
614
615
616
617 func (r *gitRepo) statLocal(ctx context.Context, version, rev string) (*RevInfo, error) {
618 out, err := Run(ctx, r.dir, "git", "-c", "log.showsignature=false", "log", "--no-decorate", "-n1", "--format=format:%H %ct %D", rev, "--")
619 if err != nil {
620
621 var info *RevInfo
622 if refs, err := r.loadRefs(ctx); err == nil {
623 info = r.unknownRevisionInfo(refs)
624 }
625 return info, &UnknownRevisionError{Rev: rev}
626 }
627 f := strings.Fields(string(out))
628 if len(f) < 2 {
629 return nil, fmt.Errorf("unexpected response from git log: %q", out)
630 }
631 hash := f[0]
632 if strings.HasPrefix(hash, version) {
633 version = hash
634 }
635 t, err := strconv.ParseInt(f[1], 10, 64)
636 if err != nil {
637 return nil, fmt.Errorf("invalid time from git log: %q", out)
638 }
639
640 info := &RevInfo{
641 Origin: &Origin{
642 VCS: "git",
643 URL: r.remoteURL,
644 Hash: hash,
645 },
646 Name: hash,
647 Short: ShortenSHA1(hash),
648 Time: time.Unix(t, 0).UTC(),
649 Version: hash,
650 }
651 if !strings.HasPrefix(hash, rev) {
652 info.Origin.Ref = rev
653 }
654
655
656
657 for i := 2; i < len(f); i++ {
658 if f[i] == "tag:" {
659 i++
660 if i < len(f) {
661 info.Tags = append(info.Tags, strings.TrimSuffix(f[i], ","))
662 }
663 }
664 }
665 sort.Strings(info.Tags)
666
667
668
669
670 for _, tag := range info.Tags {
671 if version == tag {
672 info.Version = version
673 }
674 }
675
676 return info, nil
677 }
678
679 func (r *gitRepo) Stat(ctx context.Context, rev string) (*RevInfo, error) {
680 if rev == "latest" {
681 return r.Latest(ctx)
682 }
683 return r.statCache.Do(rev, func() (*RevInfo, error) {
684 return r.stat(ctx, rev)
685 })
686 }
687
688 func (r *gitRepo) ReadFile(ctx context.Context, rev, file string, maxSize int64) ([]byte, error) {
689
690 info, err := r.Stat(ctx, rev)
691 if err != nil {
692 return nil, err
693 }
694 out, err := Run(ctx, r.dir, "git", "cat-file", "blob", info.Name+":"+file)
695 if err != nil {
696 return nil, fs.ErrNotExist
697 }
698 return out, nil
699 }
700
701 func (r *gitRepo) RecentTag(ctx context.Context, rev, prefix string, allowed func(tag string) bool) (tag string, err error) {
702 info, err := r.Stat(ctx, rev)
703 if err != nil {
704 return "", err
705 }
706 rev = info.Name
707
708
709
710 describe := func() (definitive bool) {
711 var out []byte
712 out, err = Run(ctx, r.dir, "git", "for-each-ref", "--format", "%(refname)", "refs/tags", "--merged", rev)
713 if err != nil {
714 return true
715 }
716
717
718 var highest string
719 for _, line := range strings.Split(string(out), "\n") {
720 line = strings.TrimSpace(line)
721
722
723 if !strings.HasPrefix(line, "refs/tags/") {
724 continue
725 }
726 line = line[len("refs/tags/"):]
727
728 if !strings.HasPrefix(line, prefix) {
729 continue
730 }
731 if !allowed(line) {
732 continue
733 }
734
735 semtag := line[len(prefix):]
736 if semver.Compare(semtag, highest) > 0 {
737 highest = semtag
738 }
739 }
740
741 if highest != "" {
742 tag = prefix + highest
743 }
744
745 return tag != "" && !AllHex(tag)
746 }
747
748 if describe() {
749 return tag, err
750 }
751
752
753
754 tags, err := r.Tags(ctx, prefix+"v")
755 if err != nil {
756 return "", err
757 }
758 if len(tags.List) == 0 {
759 return "", nil
760 }
761
762
763
764
765 unlock, err := r.mu.Lock()
766 if err != nil {
767 return "", err
768 }
769 defer unlock()
770
771 if err := r.fetchRefsLocked(ctx); err != nil {
772 return "", err
773 }
774
775
776
777
778
779
780
781
782
783
784
785 describe()
786 return tag, err
787 }
788
789 func (r *gitRepo) DescendsFrom(ctx context.Context, rev, tag string) (bool, error) {
790
791
792
793
794
795
796 _, err := Run(ctx, r.dir, "git", "merge-base", "--is-ancestor", "--", tag, rev)
797
798
799
800
801
802
803 if err == nil {
804 return true, nil
805 }
806
807
808 tags, err := r.Tags(ctx, tag)
809 if err != nil {
810 return false, err
811 }
812 if len(tags.List) == 0 {
813 return false, nil
814 }
815
816
817
818
819 if _, err = r.stat(ctx, rev); err != nil {
820 return false, err
821 }
822
823
824 unlock, err := r.mu.Lock()
825 if err != nil {
826 return false, err
827 }
828 defer unlock()
829
830 if r.fetchLevel < fetchAll {
831
832
833
834
835 if err := r.fetchRefsLocked(ctx); err != nil {
836 return false, err
837 }
838 }
839
840 _, err = Run(ctx, r.dir, "git", "merge-base", "--is-ancestor", "--", tag, rev)
841 if err == nil {
842 return true, nil
843 }
844 if ee, ok := err.(*RunError).Err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
845 return false, nil
846 }
847 return false, err
848 }
849
850 func (r *gitRepo) ReadZip(ctx context.Context, rev, subdir string, maxSize int64) (zip io.ReadCloser, err error) {
851
852 args := []string{}
853 if subdir != "" {
854 args = append(args, "--", subdir)
855 }
856 info, err := r.Stat(ctx, rev)
857 if err != nil {
858 return nil, err
859 }
860
861 unlock, err := r.mu.Lock()
862 if err != nil {
863 return nil, err
864 }
865 defer unlock()
866
867 if err := ensureGitAttributes(r.dir); err != nil {
868 return nil, err
869 }
870
871
872
873
874
875
876 archive, err := Run(ctx, r.dir, "git", "-c", "core.autocrlf=input", "-c", "core.eol=lf", "archive", "--format=zip", "--prefix=prefix/", info.Name, args)
877 if err != nil {
878 if bytes.Contains(err.(*RunError).Stderr, []byte("did not match any files")) {
879 return nil, fs.ErrNotExist
880 }
881 return nil, err
882 }
883
884 return io.NopCloser(bytes.NewReader(archive)), nil
885 }
886
887
888
889
890
891
892
893
894 func ensureGitAttributes(repoDir string) (err error) {
895 const attr = "\n* -export-subst -export-ignore\n"
896
897 d := repoDir + "/info"
898 p := d + "/attributes"
899
900 if err := os.MkdirAll(d, 0755); err != nil {
901 return err
902 }
903
904 f, err := os.OpenFile(p, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0666)
905 if err != nil {
906 return err
907 }
908 defer func() {
909 closeErr := f.Close()
910 if closeErr != nil {
911 err = closeErr
912 }
913 }()
914
915 b, err := io.ReadAll(f)
916 if err != nil {
917 return err
918 }
919 if !bytes.HasSuffix(b, []byte(attr)) {
920 _, err := f.WriteString(attr)
921 return err
922 }
923
924 return nil
925 }
926
View as plain text