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