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  	"time"
    21  
    22  	"cmd/go/internal/base"
    23  	"cmd/go/internal/lockedfile"
    24  	"cmd/go/internal/str"
    25  	"cmd/internal/par"
    26  )
    27  
    28  // A VCSError indicates an error using a version control system.
    29  // The implication of a VCSError is that we know definitively where
    30  // to get the code, but we can't access it due to the error.
    31  // The caller should report this error instead of continuing to probe
    32  // other possible module paths.
    33  //
    34  // TODO(golang.org/issue/31730): See if we can invert this. (Return a
    35  // distinguished error for “repo not found” and treat everything else
    36  // as terminal.)
    37  type VCSError struct {
    38  	Err error
    39  }
    40  
    41  func (e *VCSError) Error() string { return e.Err.Error() }
    42  
    43  func (e *VCSError) Unwrap() error { return e.Err }
    44  
    45  func vcsErrorf(format string, a ...any) error {
    46  	return &VCSError{Err: fmt.Errorf(format, a...)}
    47  }
    48  
    49  type vcsCacheKey struct {
    50  	vcs    string
    51  	remote string
    52  	local  bool
    53  }
    54  
    55  func NewRepo(ctx context.Context, vcs, remote string, local bool) (Repo, error) {
    56  	return vcsRepoCache.Do(vcsCacheKey{vcs, remote, local}, func() (Repo, error) {
    57  		repo, err := newVCSRepo(ctx, vcs, remote, local)
    58  		if err != nil {
    59  			return nil, &VCSError{err}
    60  		}
    61  		return repo, nil
    62  	})
    63  }
    64  
    65  var vcsRepoCache par.ErrCache[vcsCacheKey, Repo]
    66  
    67  type vcsRepo struct {
    68  	mu lockedfile.Mutex // protects all commands, so we don't have to decide which are safe on a per-VCS basis
    69  
    70  	remote string
    71  	cmd    *vcsCmd
    72  	dir    string
    73  	local  bool
    74  
    75  	tagsOnce sync.Once
    76  	tags     map[string]bool
    77  
    78  	branchesOnce sync.Once
    79  	branches     map[string]bool
    80  
    81  	fetchOnce sync.Once
    82  	fetchErr  error
    83  }
    84  
    85  func newVCSRepo(ctx context.Context, vcs, remote string, local bool) (Repo, error) {
    86  	if vcs == "git" {
    87  		return newGitRepo(ctx, remote, local)
    88  	}
    89  	r := &vcsRepo{remote: remote, local: local}
    90  	cmd := vcsCmds[vcs]
    91  	if cmd == nil {
    92  		return nil, fmt.Errorf("unknown vcs: %s %s", vcs, remote)
    93  	}
    94  	r.cmd = cmd
    95  	if local {
    96  		info, err := os.Stat(remote)
    97  		if err != nil {
    98  			return nil, err
    99  		}
   100  		if !info.IsDir() {
   101  			return nil, fmt.Errorf("%s exists but is not a directory", remote)
   102  		}
   103  		r.dir = remote
   104  		r.mu.Path = r.dir + ".lock"
   105  		return r, nil
   106  	}
   107  	if !strings.Contains(remote, "://") {
   108  		return nil, fmt.Errorf("invalid vcs remote: %s %s", vcs, remote)
   109  	}
   110  	var err error
   111  	r.dir, r.mu.Path, err = WorkDir(ctx, vcsWorkDirType+vcs, r.remote)
   112  	if err != nil {
   113  		return nil, err
   114  	}
   115  
   116  	if cmd.init == nil {
   117  		return r, nil
   118  	}
   119  
   120  	unlock, err := r.mu.Lock()
   121  	if err != nil {
   122  		return nil, err
   123  	}
   124  	defer unlock()
   125  
   126  	if _, err := os.Stat(filepath.Join(r.dir, "."+vcs)); err != nil {
   127  		release, err := base.AcquireNet()
   128  		if err != nil {
   129  			return nil, err
   130  		}
   131  		_, err = Run(ctx, r.dir, cmd.init(r.remote))
   132  		release()
   133  
   134  		if err != nil {
   135  			os.RemoveAll(r.dir)
   136  			return nil, err
   137  		}
   138  	}
   139  	return r, nil
   140  }
   141  
   142  const vcsWorkDirType = "vcs1."
   143  
   144  type vcsCmd struct {
   145  	vcs           string                                                                              // vcs name "hg"
   146  	init          func(remote string) []string                                                        // cmd to init repo to track remote
   147  	tags          func(remote string) []string                                                        // cmd to list local tags
   148  	tagRE         *lazyregexp.Regexp                                                                  // regexp to extract tag names from output of tags cmd
   149  	branches      func(remote string) []string                                                        // cmd to list local branches
   150  	branchRE      *lazyregexp.Regexp                                                                  // regexp to extract branch names from output of tags cmd
   151  	badLocalRevRE *lazyregexp.Regexp                                                                  // regexp of names that must not be served out of local cache without doing fetch first
   152  	statLocal     func(rev, remote string) []string                                                   // cmd to stat local rev
   153  	parseStat     func(rev, out string) (*RevInfo, error)                                             // cmd to parse output of statLocal
   154  	fetch         []string                                                                            // cmd to fetch everything from remote
   155  	latest        string                                                                              // name of latest commit on remote (tip, HEAD, etc)
   156  	readFile      func(rev, file, remote string) []string                                             // cmd to read rev's file
   157  	readZip       func(rev, subdir, remote, target string) []string                                   // cmd to read rev's subdir as zip file
   158  	doReadZip     func(ctx context.Context, dst io.Writer, workDir, rev, subdir, remote string) error // arbitrary function to read rev's subdir as zip file
   159  }
   160  
   161  var re = lazyregexp.New
   162  
   163  var vcsCmds = map[string]*vcsCmd{
   164  	"hg": {
   165  		vcs: "hg",
   166  		init: func(remote string) []string {
   167  			return []string{"hg", "clone", "-U", "--", remote, "."}
   168  		},
   169  		tags: func(remote string) []string {
   170  			return []string{"hg", "tags", "-q"}
   171  		},
   172  		tagRE: re(`(?m)^[^\n]+$`),
   173  		branches: func(remote string) []string {
   174  			return []string{"hg", "branches", "-c", "-q"}
   175  		},
   176  		branchRE:      re(`(?m)^[^\n]+$`),
   177  		badLocalRevRE: re(`(?m)^(tip)$`),
   178  		statLocal: func(rev, remote string) []string {
   179  			return []string{"hg", "log", "-l1", "-r", rev, "--template", "{node} {date|hgdate} {tags}"}
   180  		},
   181  		parseStat: hgParseStat,
   182  		fetch:     []string{"hg", "pull", "-f"},
   183  		latest:    "tip",
   184  		readFile: func(rev, file, remote string) []string {
   185  			return []string{"hg", "cat", "-r", rev, file}
   186  		},
   187  		readZip: func(rev, subdir, remote, target string) []string {
   188  			pattern := []string{}
   189  			if subdir != "" {
   190  				pattern = []string{"-I", subdir + "/**"}
   191  			}
   192  			return str.StringList("hg", "archive", "-t", "zip", "--no-decode", "-r", rev, "--prefix=prefix/", pattern, "--", target)
   193  		},
   194  	},
   195  
   196  	"svn": {
   197  		vcs:  "svn",
   198  		init: nil, // no local checkout
   199  		tags: func(remote string) []string {
   200  			return []string{"svn", "list", "--", strings.TrimSuffix(remote, "/trunk") + "/tags"}
   201  		},
   202  		tagRE: re(`(?m)^(.*?)/?$`),
   203  		statLocal: func(rev, remote string) []string {
   204  			suffix := "@" + rev
   205  			if rev == "latest" {
   206  				suffix = ""
   207  			}
   208  			return []string{"svn", "log", "-l1", "--xml", "--", remote + suffix}
   209  		},
   210  		parseStat: svnParseStat,
   211  		latest:    "latest",
   212  		readFile: func(rev, file, remote string) []string {
   213  			return []string{"svn", "cat", "--", remote + "/" + file + "@" + rev}
   214  		},
   215  		doReadZip: svnReadZip,
   216  	},
   217  
   218  	"bzr": {
   219  		vcs: "bzr",
   220  		init: func(remote string) []string {
   221  			return []string{"bzr", "branch", "--use-existing-dir", "--", remote, "."}
   222  		},
   223  		fetch: []string{
   224  			"bzr", "pull", "--overwrite-tags",
   225  		},
   226  		tags: func(remote string) []string {
   227  			return []string{"bzr", "tags"}
   228  		},
   229  		tagRE:         re(`(?m)^\S+`),
   230  		badLocalRevRE: re(`^revno:-`),
   231  		statLocal: func(rev, remote string) []string {
   232  			return []string{"bzr", "log", "-l1", "--long", "--show-ids", "-r", rev}
   233  		},
   234  		parseStat: bzrParseStat,
   235  		latest:    "revno:-1",
   236  		readFile: func(rev, file, remote string) []string {
   237  			return []string{"bzr", "cat", "-r", rev, file}
   238  		},
   239  		readZip: func(rev, subdir, remote, target string) []string {
   240  			extra := []string{}
   241  			if subdir != "" {
   242  				extra = []string{"./" + subdir}
   243  			}
   244  			return str.StringList("bzr", "export", "--format=zip", "-r", rev, "--root=prefix/", "--", target, extra)
   245  		},
   246  	},
   247  
   248  	"fossil": {
   249  		vcs: "fossil",
   250  		init: func(remote string) []string {
   251  			return []string{"fossil", "clone", "--", remote, ".fossil"}
   252  		},
   253  		fetch: []string{"fossil", "pull", "-R", ".fossil"},
   254  		tags: func(remote string) []string {
   255  			return []string{"fossil", "tag", "-R", ".fossil", "list"}
   256  		},
   257  		tagRE: re(`XXXTODO`),
   258  		statLocal: func(rev, remote string) []string {
   259  			return []string{"fossil", "info", "-R", ".fossil", rev}
   260  		},
   261  		parseStat: fossilParseStat,
   262  		latest:    "trunk",
   263  		readFile: func(rev, file, remote string) []string {
   264  			return []string{"fossil", "cat", "-R", ".fossil", "-r", rev, file}
   265  		},
   266  		readZip: func(rev, subdir, remote, target string) []string {
   267  			extra := []string{}
   268  			if subdir != "" && !strings.ContainsAny(subdir, "*?[],") {
   269  				extra = []string{"--include", subdir}
   270  			}
   271  			// Note that vcsRepo.ReadZip below rewrites this command
   272  			// to run in a different directory, to work around a fossil bug.
   273  			return str.StringList("fossil", "zip", "-R", ".fossil", "--name", "prefix", extra, "--", rev, target)
   274  		},
   275  	},
   276  }
   277  
   278  func (r *vcsRepo) loadTags(ctx context.Context) {
   279  	out, err := Run(ctx, r.dir, r.cmd.tags(r.remote))
   280  	if err != nil {
   281  		return
   282  	}
   283  
   284  	// Run tag-listing command and extract tags.
   285  	r.tags = make(map[string]bool)
   286  	for _, tag := range r.cmd.tagRE.FindAllString(string(out), -1) {
   287  		if r.cmd.badLocalRevRE != nil && r.cmd.badLocalRevRE.MatchString(tag) {
   288  			continue
   289  		}
   290  		r.tags[tag] = true
   291  	}
   292  }
   293  
   294  func (r *vcsRepo) loadBranches(ctx context.Context) {
   295  	if r.cmd.branches == nil {
   296  		return
   297  	}
   298  
   299  	out, err := Run(ctx, r.dir, r.cmd.branches(r.remote))
   300  	if err != nil {
   301  		return
   302  	}
   303  
   304  	r.branches = make(map[string]bool)
   305  	for _, branch := range r.cmd.branchRE.FindAllString(string(out), -1) {
   306  		if r.cmd.badLocalRevRE != nil && r.cmd.badLocalRevRE.MatchString(branch) {
   307  			continue
   308  		}
   309  		r.branches[branch] = true
   310  	}
   311  }
   312  
   313  func (r *vcsRepo) CheckReuse(ctx context.Context, old *Origin, subdir string) error {
   314  	return fmt.Errorf("vcs %s: CheckReuse: %w", r.cmd.vcs, errors.ErrUnsupported)
   315  }
   316  
   317  func (r *vcsRepo) Tags(ctx context.Context, prefix string) (*Tags, error) {
   318  	unlock, err := r.mu.Lock()
   319  	if err != nil {
   320  		return nil, err
   321  	}
   322  	defer unlock()
   323  
   324  	r.tagsOnce.Do(func() { r.loadTags(ctx) })
   325  	tags := &Tags{
   326  		// None of the other VCS provide a reasonable way to compute TagSum
   327  		// without downloading the whole repo, so we only include VCS and URL
   328  		// in the Origin.
   329  		Origin: &Origin{
   330  			VCS: r.cmd.vcs,
   331  			URL: r.remote,
   332  		},
   333  		List: []Tag{},
   334  	}
   335  	for tag := range r.tags {
   336  		if strings.HasPrefix(tag, prefix) {
   337  			tags.List = append(tags.List, Tag{tag, ""})
   338  		}
   339  	}
   340  	sort.Slice(tags.List, func(i, j int) bool {
   341  		return tags.List[i].Name < tags.List[j].Name
   342  	})
   343  	return tags, nil
   344  }
   345  
   346  func (r *vcsRepo) Stat(ctx context.Context, rev string) (*RevInfo, error) {
   347  	unlock, err := r.mu.Lock()
   348  	if err != nil {
   349  		return nil, err
   350  	}
   351  	defer unlock()
   352  
   353  	if rev == "latest" {
   354  		rev = r.cmd.latest
   355  	}
   356  	r.branchesOnce.Do(func() { r.loadBranches(ctx) })
   357  	if r.local {
   358  		// Ignore the badLocalRevRE precondition in local only mode.
   359  		// We cannot fetch latest upstream changes so only serve what's in the local cache.
   360  		return r.statLocal(ctx, rev)
   361  	}
   362  	revOK := (r.cmd.badLocalRevRE == nil || !r.cmd.badLocalRevRE.MatchString(rev)) && !r.branches[rev]
   363  	if revOK {
   364  		if info, err := r.statLocal(ctx, rev); err == nil {
   365  			return info, nil
   366  		}
   367  	}
   368  
   369  	r.fetchOnce.Do(func() { r.fetch(ctx) })
   370  	if r.fetchErr != nil {
   371  		return nil, r.fetchErr
   372  	}
   373  	info, err := r.statLocal(ctx, rev)
   374  	if err != nil {
   375  		return nil, err
   376  	}
   377  	if !revOK {
   378  		info.Version = info.Name
   379  	}
   380  	return info, nil
   381  }
   382  
   383  func (r *vcsRepo) fetch(ctx context.Context) {
   384  	if len(r.cmd.fetch) > 0 {
   385  		release, err := base.AcquireNet()
   386  		if err != nil {
   387  			r.fetchErr = err
   388  			return
   389  		}
   390  		_, r.fetchErr = Run(ctx, r.dir, r.cmd.fetch)
   391  		release()
   392  	}
   393  }
   394  
   395  func (r *vcsRepo) statLocal(ctx context.Context, rev string) (*RevInfo, error) {
   396  	out, err := Run(ctx, r.dir, r.cmd.statLocal(rev, r.remote))
   397  	if err != nil {
   398  		return nil, &UnknownRevisionError{Rev: rev}
   399  	}
   400  	info, err := r.cmd.parseStat(rev, string(out))
   401  	if err != nil {
   402  		return nil, err
   403  	}
   404  	if info.Origin == nil {
   405  		info.Origin = new(Origin)
   406  	}
   407  	info.Origin.VCS = r.cmd.vcs
   408  	info.Origin.URL = r.remote
   409  	return info, nil
   410  }
   411  
   412  func (r *vcsRepo) Latest(ctx context.Context) (*RevInfo, error) {
   413  	return r.Stat(ctx, "latest")
   414  }
   415  
   416  func (r *vcsRepo) ReadFile(ctx context.Context, rev, file string, maxSize int64) ([]byte, error) {
   417  	if rev == "latest" {
   418  		rev = r.cmd.latest
   419  	}
   420  	_, err := r.Stat(ctx, rev) // download rev into local repo
   421  	if err != nil {
   422  		return nil, err
   423  	}
   424  
   425  	// r.Stat acquires r.mu, so lock after that.
   426  	unlock, err := r.mu.Lock()
   427  	if err != nil {
   428  		return nil, err
   429  	}
   430  	defer unlock()
   431  
   432  	out, err := Run(ctx, r.dir, r.cmd.readFile(rev, file, r.remote))
   433  	if err != nil {
   434  		return nil, fs.ErrNotExist
   435  	}
   436  	return out, nil
   437  }
   438  
   439  func (r *vcsRepo) RecentTag(ctx context.Context, rev, prefix string, allowed func(string) bool) (tag string, err error) {
   440  	// We don't technically need to lock here since we're returning an error
   441  	// unconditionally, but doing so anyway will help to avoid baking in
   442  	// lock-inversion bugs.
   443  	unlock, err := r.mu.Lock()
   444  	if err != nil {
   445  		return "", err
   446  	}
   447  	defer unlock()
   448  
   449  	return "", vcsErrorf("vcs %s: RecentTag: %w", r.cmd.vcs, errors.ErrUnsupported)
   450  }
   451  
   452  func (r *vcsRepo) DescendsFrom(ctx context.Context, rev, tag string) (bool, error) {
   453  	unlock, err := r.mu.Lock()
   454  	if err != nil {
   455  		return false, err
   456  	}
   457  	defer unlock()
   458  
   459  	return false, vcsErrorf("vcs %s: DescendsFrom: %w", r.cmd.vcs, errors.ErrUnsupported)
   460  }
   461  
   462  func (r *vcsRepo) ReadZip(ctx context.Context, rev, subdir string, maxSize int64) (zip io.ReadCloser, err error) {
   463  	if r.cmd.readZip == nil && r.cmd.doReadZip == nil {
   464  		return nil, vcsErrorf("vcs %s: ReadZip: %w", r.cmd.vcs, errors.ErrUnsupported)
   465  	}
   466  
   467  	unlock, err := r.mu.Lock()
   468  	if err != nil {
   469  		return nil, err
   470  	}
   471  	defer unlock()
   472  
   473  	if rev == "latest" {
   474  		rev = r.cmd.latest
   475  	}
   476  	f, err := os.CreateTemp("", "go-readzip-*.zip")
   477  	if err != nil {
   478  		return nil, err
   479  	}
   480  	if r.cmd.doReadZip != nil {
   481  		lw := &limitedWriter{
   482  			W:               f,
   483  			N:               maxSize,
   484  			ErrLimitReached: errors.New("ReadZip: encoded file exceeds allowed size"),
   485  		}
   486  		err = r.cmd.doReadZip(ctx, lw, r.dir, rev, subdir, r.remote)
   487  		if err == nil {
   488  			_, err = f.Seek(0, io.SeekStart)
   489  		}
   490  	} else if r.cmd.vcs == "fossil" {
   491  		// If you run
   492  		//	fossil zip -R .fossil --name prefix trunk /tmp/x.zip
   493  		// fossil fails with "unable to create directory /tmp" [sic].
   494  		// Change the command to run in /tmp instead,
   495  		// replacing the -R argument with an absolute path.
   496  		args := r.cmd.readZip(rev, subdir, r.remote, filepath.Base(f.Name()))
   497  		for i := range args {
   498  			if args[i] == ".fossil" {
   499  				args[i] = filepath.Join(r.dir, ".fossil")
   500  			}
   501  		}
   502  		_, err = Run(ctx, filepath.Dir(f.Name()), args)
   503  	} else {
   504  		_, err = Run(ctx, r.dir, r.cmd.readZip(rev, subdir, r.remote, f.Name()))
   505  	}
   506  	if err != nil {
   507  		f.Close()
   508  		os.Remove(f.Name())
   509  		return nil, err
   510  	}
   511  	return &deleteCloser{f}, nil
   512  }
   513  
   514  // deleteCloser is a file that gets deleted on Close.
   515  type deleteCloser struct {
   516  	*os.File
   517  }
   518  
   519  func (d *deleteCloser) Close() error {
   520  	defer os.Remove(d.File.Name())
   521  	return d.File.Close()
   522  }
   523  
   524  func hgParseStat(rev, out string) (*RevInfo, error) {
   525  	f := strings.Fields(out)
   526  	if len(f) < 3 {
   527  		return nil, vcsErrorf("unexpected response from hg log: %q", out)
   528  	}
   529  	hash := f[0]
   530  	version := rev
   531  	if strings.HasPrefix(hash, version) {
   532  		version = hash // extend to full hash
   533  	}
   534  	t, err := strconv.ParseInt(f[1], 10, 64)
   535  	if err != nil {
   536  		return nil, vcsErrorf("invalid time from hg log: %q", out)
   537  	}
   538  
   539  	var tags []string
   540  	for _, tag := range f[3:] {
   541  		if tag != "tip" {
   542  			tags = append(tags, tag)
   543  		}
   544  	}
   545  	sort.Strings(tags)
   546  
   547  	info := &RevInfo{
   548  		Origin: &Origin{
   549  			Hash: hash,
   550  		},
   551  		Name:    hash,
   552  		Short:   ShortenSHA1(hash),
   553  		Time:    time.Unix(t, 0).UTC(),
   554  		Version: version,
   555  		Tags:    tags,
   556  	}
   557  	return info, nil
   558  }
   559  
   560  func bzrParseStat(rev, out string) (*RevInfo, error) {
   561  	var revno int64
   562  	var tm time.Time
   563  	for _, line := range strings.Split(out, "\n") {
   564  		if line == "" || line[0] == ' ' || line[0] == '\t' {
   565  			// End of header, start of commit message.
   566  			break
   567  		}
   568  		if line[0] == '-' {
   569  			continue
   570  		}
   571  		before, after, found := strings.Cut(line, ":")
   572  		if !found {
   573  			// End of header, start of commit message.
   574  			break
   575  		}
   576  		key, val := before, strings.TrimSpace(after)
   577  		switch key {
   578  		case "revno":
   579  			if j := strings.Index(val, " "); j >= 0 {
   580  				val = val[:j]
   581  			}
   582  			i, err := strconv.ParseInt(val, 10, 64)
   583  			if err != nil {
   584  				return nil, vcsErrorf("unexpected revno from bzr log: %q", line)
   585  			}
   586  			revno = i
   587  		case "timestamp":
   588  			j := strings.Index(val, " ")
   589  			if j < 0 {
   590  				return nil, vcsErrorf("unexpected timestamp from bzr log: %q", line)
   591  			}
   592  			t, err := time.Parse("2006-01-02 15:04:05 -0700", val[j+1:])
   593  			if err != nil {
   594  				return nil, vcsErrorf("unexpected timestamp from bzr log: %q", line)
   595  			}
   596  			tm = t.UTC()
   597  		}
   598  	}
   599  	if revno == 0 || tm.IsZero() {
   600  		return nil, vcsErrorf("unexpected response from bzr log: %q", out)
   601  	}
   602  
   603  	info := &RevInfo{
   604  		Name:    strconv.FormatInt(revno, 10),
   605  		Short:   fmt.Sprintf("%012d", revno),
   606  		Time:    tm,
   607  		Version: rev,
   608  	}
   609  	return info, nil
   610  }
   611  
   612  func fossilParseStat(rev, out string) (*RevInfo, error) {
   613  	for _, line := range strings.Split(out, "\n") {
   614  		if strings.HasPrefix(line, "uuid:") || strings.HasPrefix(line, "hash:") {
   615  			f := strings.Fields(line)
   616  			if len(f) != 5 || len(f[1]) != 40 || f[4] != "UTC" {
   617  				return nil, vcsErrorf("unexpected response from fossil info: %q", line)
   618  			}
   619  			t, err := time.Parse(time.DateTime, f[2]+" "+f[3])
   620  			if err != nil {
   621  				return nil, vcsErrorf("unexpected response from fossil info: %q", line)
   622  			}
   623  			hash := f[1]
   624  			version := rev
   625  			if strings.HasPrefix(hash, version) {
   626  				version = hash // extend to full hash
   627  			}
   628  			info := &RevInfo{
   629  				Origin: &Origin{
   630  					Hash: hash,
   631  				},
   632  				Name:    hash,
   633  				Short:   ShortenSHA1(hash),
   634  				Time:    t,
   635  				Version: version,
   636  			}
   637  			return info, nil
   638  		}
   639  	}
   640  	return nil, vcsErrorf("unexpected response from fossil info: %q", out)
   641  }
   642  
   643  type limitedWriter struct {
   644  	W               io.Writer
   645  	N               int64
   646  	ErrLimitReached error
   647  }
   648  
   649  func (l *limitedWriter) Write(p []byte) (n int, err error) {
   650  	if l.N > 0 {
   651  		max := len(p)
   652  		if l.N < int64(max) {
   653  			max = int(l.N)
   654  		}
   655  		n, err = l.W.Write(p[:max])
   656  		l.N -= int64(n)
   657  		if err != nil || n >= len(p) {
   658  			return n, err
   659  		}
   660  	}
   661  
   662  	return n, l.ErrLimitReached
   663  }
   664  

View as plain text