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
391
392
393
394 func (r *gitRepo) findRef(ctx context.Context, hash string) (ref string, ok bool) {
395 refs, err := r.loadRefs(ctx)
396 if err != nil {
397 return "", false
398 }
399 for ref, h := range refs {
400 if h == hash {
401 return ref, true
402 }
403 }
404 return "", false
405 }
406
407 func (r *gitRepo) checkConfigSHA256(ctx context.Context) bool {
408 if hashType, sha256CfgErr := r.runGit(ctx, "git", "config", "extensions.objectformat"); sha256CfgErr == nil {
409 return "sha256" == strings.TrimSpace(string(hashType))
410 }
411 return false
412 }
413
414 func (r *gitRepo) hexHashLen() int {
415 if !r.sha256Hashes {
416 return 160 / 4
417 }
418 return 256 / 4
419 }
420
421
422
423 func (r *gitRepo) shortenObjectHash(rev string) string {
424 if !r.sha256Hashes {
425 return ShortenSHA1(rev)
426 }
427 if AllHex(rev) && len(rev) == 256/4 {
428 return rev[:12]
429 }
430 return rev
431 }
432
433
434
435
436
437
438
439 const minHashDigits = 7
440
441
442
443 func (r *gitRepo) stat(ctx context.Context, rev string) (info *RevInfo, err error) {
444
445 didStatLocal := false
446 if len(rev) >= minHashDigits && len(rev) <= r.hexHashLen() && AllHex(rev) {
447 if info, err := r.statLocal(ctx, rev, rev); err == nil {
448 return info, nil
449 }
450 didStatLocal = true
451 }
452
453
454
455 r.localTagsOnce.Do(func() { r.loadLocalTags(ctx) })
456 if _, ok := r.localTags.Load(rev); ok {
457 return r.statLocal(ctx, rev, "refs/tags/"+rev)
458 }
459
460
461
462
463
464 refs, err := r.loadRefs(ctx)
465 if err != nil {
466 return nil, err
467 }
468
469
470
471 var ref, hash string
472 if refs["refs/tags/"+rev] != "" {
473 ref = "refs/tags/" + rev
474 hash = refs[ref]
475
476 } else if refs["refs/heads/"+rev] != "" {
477 ref = "refs/heads/" + rev
478 hash = refs[ref]
479 rev = hash
480 } else if rev == "HEAD" && refs["HEAD"] != "" {
481 ref = "HEAD"
482 hash = refs[ref]
483 rev = hash
484 } else if len(rev) >= minHashDigits && len(rev) <= r.hexHashLen() && AllHex(rev) {
485
486
487 prefix := rev
488
489 for k, h := range refs {
490 if strings.HasPrefix(h, prefix) {
491 if hash != "" && hash != h {
492
493
494 return nil, fmt.Errorf("ambiguous revision %s", rev)
495 }
496 if ref == "" || ref > k {
497 ref = k
498 }
499 rev = h
500 hash = h
501 }
502 }
503 if hash == "" && len(rev) == r.hexHashLen() {
504 hash = rev
505 }
506 } else {
507 return r.unknownRevisionInfo(refs), &UnknownRevisionError{Rev: rev}
508 }
509
510 defer func() {
511 if info != nil {
512 info.Origin.Hash = info.Name
513
514 if ref != info.Origin.Hash {
515 info.Origin.Ref = ref
516 }
517 }
518 }()
519
520
521 unlock, err := r.mu.Lock()
522 if err != nil {
523 return nil, err
524 }
525 defer unlock()
526
527
528
529
530
531 if !didStatLocal {
532 if info, err := r.statLocal(ctx, rev, hash); err == nil {
533 tag, fromTag := strings.CutPrefix(ref, "refs/tags/")
534 if fromTag && !slices.Contains(info.Tags, tag) {
535
536
537 _, err := r.runGit(ctx, "git", "tag", tag, hash)
538 if err != nil {
539 return nil, err
540 }
541 r.localTags.Store(tag, true)
542 return r.statLocal(ctx, rev, ref)
543 }
544 return info, err
545 }
546 }
547
548 if r.local {
549 return nil, fmt.Errorf("revision does not exist locally: %s", rev)
550 }
551
552
553
554
555
556
557
558
559 if r.fetchLevel <= fetchSome && ref != "" && hash != "" {
560 r.fetchLevel = fetchSome
561 var refspec string
562 if ref == "HEAD" {
563
564
565
566
567
568 ref = hash
569 refspec = hash + ":refs/dummy"
570 } else {
571
572
573
574
575 refspec = ref + ":" + ref
576 }
577
578 release, err := base.AcquireNet()
579 if err != nil {
580 return nil, err
581 }
582
583
584
585
586 _, err = r.runGit(ctx, "git", "-c", "protocol.version=2", "fetch", "-f", "--depth=1", r.remote, refspec)
587 release()
588
589 if err == nil {
590 return r.statLocal(ctx, rev, ref)
591 }
592
593
594
595 }
596
597
598
599 if err := r.fetchRefsLocked(ctx); err != nil {
600 return nil, err
601 }
602
603 return r.statLocal(ctx, rev, rev)
604 }
605
606
607
608
609
610
611
612
613
614
615 func (r *gitRepo) fetchRefsLocked(ctx context.Context) error {
616 if r.local {
617 panic("go: fetchRefsLocked called in local only mode.")
618 }
619 if r.fetchLevel < fetchAll {
620
621
622
623
624
625
626 release, err := base.AcquireNet()
627 if err != nil {
628 return err
629 }
630 defer release()
631
632 if _, err := r.runGit(ctx, "git", "fetch", "-f", r.remote, "refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"); err != nil {
633 return err
634 }
635
636 if _, err := os.Stat(filepath.Join(r.dir, "shallow")); err == nil {
637 if _, err := r.runGit(ctx, "git", "fetch", "--unshallow", "-f", r.remote); err != nil {
638 return err
639 }
640 }
641
642 r.fetchLevel = fetchAll
643 }
644 return nil
645 }
646
647
648
649 func (r *gitRepo) statLocal(ctx context.Context, version, rev string) (*RevInfo, error) {
650 out, err := r.runGit(ctx, "git", "-c", "log.showsignature=false", "log", "--no-decorate", "-n1", "--format=format:%H %ct %D", rev, "--")
651 if err != nil {
652
653 var info *RevInfo
654 if refs, err := r.loadRefs(ctx); err == nil {
655 info = r.unknownRevisionInfo(refs)
656 }
657 return info, &UnknownRevisionError{Rev: rev}
658 }
659 f := strings.Fields(string(out))
660 if len(f) < 2 {
661 return nil, fmt.Errorf("unexpected response from git log: %q", out)
662 }
663 hash := f[0]
664 if strings.HasPrefix(hash, version) {
665 version = hash
666 }
667 t, err := strconv.ParseInt(f[1], 10, 64)
668 if err != nil {
669 return nil, fmt.Errorf("invalid time from git log: %q", out)
670 }
671
672 info := &RevInfo{
673 Origin: &Origin{
674 VCS: "git",
675 URL: r.remoteURL,
676 Hash: hash,
677 },
678 Name: hash,
679 Short: r.shortenObjectHash(hash),
680 Time: time.Unix(t, 0).UTC(),
681 Version: hash,
682 }
683 if !strings.HasPrefix(hash, rev) {
684 info.Origin.Ref = rev
685 }
686
687
688
689 for i := 2; i < len(f); i++ {
690 if f[i] == "tag:" {
691 i++
692 if i < len(f) {
693 info.Tags = append(info.Tags, strings.TrimSuffix(f[i], ","))
694 }
695 }
696 }
697
698
699
700
701 if refs, err := r.loadRefs(ctx); err == nil {
702 for ref, h := range refs {
703 if h == hash {
704 if tag, found := strings.CutPrefix(ref, "refs/tags/"); found {
705 info.Tags = append(info.Tags, tag)
706 }
707 }
708 }
709 }
710 slices.Sort(info.Tags)
711 info.Tags = slices.Compact(info.Tags)
712
713
714
715
716 for _, tag := range info.Tags {
717 if version == tag {
718 info.Version = version
719 }
720 }
721
722 return info, nil
723 }
724
725 func (r *gitRepo) Stat(ctx context.Context, rev string) (*RevInfo, error) {
726 if rev == "latest" {
727 return r.Latest(ctx)
728 }
729 return r.statCache.Do(rev, func() (*RevInfo, error) {
730 return r.stat(ctx, rev)
731 })
732 }
733
734 func (r *gitRepo) ReadFile(ctx context.Context, rev, file string, maxSize int64) ([]byte, error) {
735
736 info, err := r.Stat(ctx, rev)
737 if err != nil {
738 return nil, err
739 }
740 out, err := r.runGit(ctx, "git", "cat-file", "blob", info.Name+":"+file)
741 if err != nil {
742 return nil, fs.ErrNotExist
743 }
744 return out, nil
745 }
746
747 func (r *gitRepo) RecentTag(ctx context.Context, rev, prefix string, allowed func(tag string) bool) (tag string, err error) {
748 info, err := r.Stat(ctx, rev)
749 if err != nil {
750 return "", err
751 }
752 rev = info.Name
753
754
755
756 describe := func() (definitive bool) {
757 var out []byte
758 out, err = r.runGit(ctx, "git", "for-each-ref", "--format", "%(refname)", "refs/tags", "--merged", rev)
759 if err != nil {
760 return true
761 }
762
763
764 var highest string
765 for _, line := range strings.Split(string(out), "\n") {
766 line = strings.TrimSpace(line)
767
768
769 if !strings.HasPrefix(line, "refs/tags/") {
770 continue
771 }
772 line = line[len("refs/tags/"):]
773
774 if !strings.HasPrefix(line, prefix) {
775 continue
776 }
777 if !allowed(line) {
778 continue
779 }
780
781 semtag := line[len(prefix):]
782 if semver.Compare(semtag, highest) > 0 {
783 highest = semtag
784 }
785 }
786
787 if highest != "" {
788 tag = prefix + highest
789 }
790
791 return tag != "" && !AllHex(tag)
792 }
793
794 if describe() {
795 return tag, err
796 }
797
798
799
800 tags, err := r.Tags(ctx, prefix+"v")
801 if err != nil {
802 return "", err
803 }
804 if len(tags.List) == 0 {
805 return "", nil
806 }
807
808 if r.local {
809 return "", fmt.Errorf("revision does not exist locally: %s", rev)
810 }
811
812
813
814 unlock, err := r.mu.Lock()
815 if err != nil {
816 return "", err
817 }
818 defer unlock()
819
820 if err := r.fetchRefsLocked(ctx); err != nil {
821 return "", err
822 }
823
824
825
826
827
828
829
830
831
832
833
834 describe()
835 return tag, err
836 }
837
838 func (r *gitRepo) DescendsFrom(ctx context.Context, rev, tag string) (bool, error) {
839
840
841
842
843
844
845 _, err := r.runGit(ctx, "git", "merge-base", "--is-ancestor", "--", tag, rev)
846
847
848
849
850
851
852 if err == nil {
853 return true, nil
854 }
855
856
857 tags, err := r.Tags(ctx, tag)
858 if err != nil {
859 return false, err
860 }
861 if len(tags.List) == 0 {
862 return false, nil
863 }
864
865
866
867
868 if _, err = r.stat(ctx, rev); err != nil {
869 return false, err
870 }
871
872 if r.local {
873 return false, fmt.Errorf("revision does not exist locally: %s", rev)
874 }
875
876
877 unlock, err := r.mu.Lock()
878 if err != nil {
879 return false, err
880 }
881 defer unlock()
882
883 if r.fetchLevel < fetchAll {
884
885
886
887
888 if err := r.fetchRefsLocked(ctx); err != nil {
889 return false, err
890 }
891 }
892
893 _, err = r.runGit(ctx, "git", "merge-base", "--is-ancestor", "--", tag, rev)
894 if err == nil {
895 return true, nil
896 }
897 if ee, ok := err.(*RunError).Err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
898 return false, nil
899 }
900 return false, err
901 }
902
903 func (r *gitRepo) ReadZip(ctx context.Context, rev, subdir string, maxSize int64) (zip io.ReadCloser, err error) {
904
905 args := []string{}
906 if subdir != "" {
907 args = append(args, "--", subdir)
908 }
909 info, err := r.Stat(ctx, rev)
910 if err != nil {
911 return nil, err
912 }
913
914 unlock, err := r.mu.Lock()
915 if err != nil {
916 return nil, err
917 }
918 defer unlock()
919
920 if err := ensureGitAttributes(r.dir); err != nil {
921 return nil, err
922 }
923
924
925
926
927
928
929 archive, err := r.runGit(ctx, "git", "-c", "core.autocrlf=input", "-c", "core.eol=lf", "archive", "--format=zip", "--prefix=prefix/", info.Name, args)
930 if err != nil {
931 if bytes.Contains(err.(*RunError).Stderr, []byte("did not match any files")) {
932 return nil, fs.ErrNotExist
933 }
934 return nil, err
935 }
936
937 return io.NopCloser(bytes.NewReader(archive)), nil
938 }
939
940
941
942
943
944
945
946
947 func ensureGitAttributes(repoDir string) (err error) {
948 const attr = "\n* -export-subst -export-ignore\n"
949
950 d := repoDir + "/info"
951 p := d + "/attributes"
952
953 if err := os.MkdirAll(d, 0755); err != nil {
954 return err
955 }
956
957 f, err := os.OpenFile(p, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0666)
958 if err != nil {
959 return err
960 }
961 defer func() {
962 closeErr := f.Close()
963 if closeErr != nil {
964 err = closeErr
965 }
966 }()
967
968 b, err := io.ReadAll(f)
969 if err != nil {
970 return err
971 }
972 if !bytes.HasSuffix(b, []byte(attr)) {
973 _, err := f.WriteString(attr)
974 return err
975 }
976
977 return nil
978 }
979
980 func (r *gitRepo) runGit(ctx context.Context, cmdline ...any) ([]byte, error) {
981 args := RunArgs{cmdline: cmdline, dir: r.dir, local: r.local}
982 if !r.local {
983
984
985 args.env = []string{"GIT_DIR=" + r.dir}
986 }
987 return RunWithArgs(ctx, args)
988 }
989
View as plain text