Source file src/cmd/go/internal/modfetch/codehost/vcs.go

     1  // Copyright 2018 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     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  // A VCSError indicates an error using a version control system.
    33  // The implication of a VCSError is that we know definitively where
    34  // to get the code, but we can't access it due to the error.
    35  // The caller should report this error instead of continuing to probe
    36  // other possible module paths.
    37  //
    38  // TODO(golang.org/issue/31730): See if we can invert this. (Return a
    39  // distinguished error for “repo not found” and treat everything else
    40  // as terminal.)
    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 // protects all commands, so we don't have to decide which are safe on a per-VCS basis
    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                                            // vcs name "hg"
   157  	init               func(remote string) []string                      // cmd to init repo to track remote
   158  	postInit           func(context.Context, *vcsRepo) error             // func to init repo after .init runs
   159  	repoSum            func(remote string) []string                      // cmd to calculate reposum of remote repo
   160  	lookupRef          func(remote, ref string) []string                 // cmd to look up ref in remote repo
   161  	tags               func(remote string) []string                      // cmd to list local tags
   162  	tagsNeedsFetch     bool                                              // run fetch before tags
   163  	tagRE              *lazyregexp.Regexp                                // regexp to extract tag names from output of tags cmd
   164  	branches           func(remote string) []string                      // cmd to list local branches
   165  	branchesNeedsFetch bool                                              // run branches before tags
   166  	branchRE           *lazyregexp.Regexp                                // regexp to extract branch names from output of tags cmd
   167  	badLocalRevRE      *lazyregexp.Regexp                                // regexp of names that must not be served out of local cache without doing fetch first
   168  	statLocal          func(rev, remote string) []string                 // cmd to stat local rev
   169  	parseStat          func(rev, out string) (*RevInfo, error)           // func to parse output of statLocal
   170  	fetch              []string                                          // cmd to fetch everything from remote
   171  	latest             string                                            // name of latest commit on remote (tip, HEAD, etc)
   172  	descendsFrom       func(rev, tag string) []string                    // cmd to check whether rev descends from tag
   173  	recentTags         func(rev string) []string                         // cmd to print tag ancestors of rev
   174  	readFile           func(rev, file, remote string) []string           // cmd to read rev's file
   175  	readZip            func(rev, subdir, remote, target string) []string // cmd to read rev's subdir as zip file
   176  
   177  	// arbitrary function to read rev's subdir as zip file
   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, // no local checkout
   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  			// Note that vcsRepo.ReadZip below rewrites this command
   320  			// to run in a different directory, to work around a fossil bug.
   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  	// Run tag-listing command and extract tags.
   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 = "." // use local repo
   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  // repoSumOrigin returns an Origin containing a RepoSum.
   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  		// Hash has to remain in repo.
   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") // weird but maybe they made a tag
   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  		// Ignore the badLocalRevRE precondition in local only mode.
   486  		// We cannot fetch latest upstream changes so only serve what's in the local cache.
   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 = "" // duplicates Hash
   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) // download rev into local repo
   554  	if err != nil {
   555  		return nil, err
   556  	}
   557  
   558  	// r.Stat acquires r.mu, so lock after that.
   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  	// Only lock for the subprocess execution, not for the tag scan.
   574  	// allowed may call other methods that acquire the lock.
   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) // download rev into local repo
   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  		// If you run
   659  		//	fossil zip -R .fossil --name prefix trunk /tmp/x.zip
   660  		// fossil fails with "unable to create directory /tmp" [sic].
   661  		// Change the command to run in /tmp instead,
   662  		// replacing the -R argument with an absolute path.
   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  // deleteCloser is a file that gets deleted on Close.
   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  	// Write .hg/hgrc with remote URL in it.
   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 // extend to full 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  			// End of header, start of commit message.
   737  			break
   738  		}
   739  		if line[0] == '-' {
   740  			continue
   741  		}
   742  		before, after, found := strings.Cut(line, ":")
   743  		if !found {
   744  			// End of header, start of commit message.
   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 // extend to full 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