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