package gitutil import ( "context" "fmt" "os" "path/filepath" "strconv" "strings" "sync" "github.com/hashicorp/go-version" ) // don't let two requests mutate the same checkout at once var repoLocks keyedMutex type keyedMutex struct { mu sync.Mutex m map[string]*sync.Mutex } func (k *keyedMutex) lock(key string) func() { k.mu.Lock() if k.m == nil { k.m = make(map[string]*sync.Mutex) } mu, ok := k.m[key] if !ok { mu = &sync.Mutex{} k.m[key] = mu } k.mu.Unlock() mu.Lock() return mu.Unlock } func Version() (*version.Version, error) { out, err := Output(context.Background(), "version") if err != nil { return nil, err } fields := strings.Fields(string(out)) if len(fields) < 3 { return nil, fmt.Errorf("invalid git version: %s", out) } // git version 2.29.3.windows.1 versionString := fields[2] if pos := strings.Index(versionString, "windows"); pos >= 1 { versionString = versionString[:pos-1] } return version.NewVersion(versionString) } // SparseSync updates a sparse checkout, fetching it first if needed // this should only fail if the repo is completely empty! func SparseSync(ctx context.Context, cloneUri, path, rev string, sparse ...string) error { defer repoLocks.lock(path)() exist, err := isDir(path) if err != nil { return err } if exist { gitDirExist, err := isDir(path + "/.git") if err != nil { return err } if !gitDirExist { if err := os.RemoveAll(path); err != nil { return fmt.Errorf("cleanup invalid git dir: %w", err) } exist = false } } if rev == "" { rev = "HEAD" } setup := func() error { if err := os.MkdirAll(path, 0o755); err != nil { return fmt.Errorf("mkdir repo dir: %w", err) } if err := Run(ctx, "init", "-q", path); err != nil { return fmt.Errorf("git init: %w", err) } if err := Run(ctx, "-C", path, "remote", "add", "origin", cloneUri); err != nil { return fmt.Errorf("git remote add: %w", err) } if err := Run(ctx, append([]string{"-C", path, "sparse-checkout", "set", "--no-cone"}, sparse...)...); err != nil { return fmt.Errorf("git sparse-checkout set: %w", err) } return nil } fetch := func() error { return Run(ctx, "-C", path, "fetch", "--depth=1", "--filter=tree:0", "origin", rev) } if !exist { if err := setup(); err != nil { return err } if err := fetch(); err != nil { os.RemoveAll(path) return fmt.Errorf("git fetch: %w", err) } } else { if err := fetch(); err != nil { // a cancelled fetch can leave lock files behind removeStaleLocks(path) if retryErr := fetch(); retryErr != nil { if rmErr := os.RemoveAll(path); rmErr != nil { return fmt.Errorf("git fetch: %w (cleanup failed: %v)", retryErr, rmErr) } if setupErr := setup(); setupErr != nil { return fmt.Errorf("git fetch: %w (re-init failed: %v)", retryErr, setupErr) } if cloneErr := fetch(); cloneErr != nil { return fmt.Errorf("git fetch: %w (re-fetch failed: %v)", retryErr, cloneErr) } } } } removeStaleLocks(path) if err := Run(ctx, "-C", path, "checkout", "--detach", "FETCH_HEAD"); err != nil { return fmt.Errorf("git checkout: %w", err) } return nil } const maxAncestryDepth = 100 // reports whether ref's recent history includes sha // this doesnt use SparseSync bcz that is shallow and we need the partially-fully-graph func RefContainsCommit(ctx context.Context, cloneUri, ref, sha string) (bool, error) { path, err := os.MkdirTemp("", "ref-ancestry-") if err != nil { return false, fmt.Errorf("making a scratch dir: %w", err) } defer os.RemoveAll(path) if err := Run(ctx, "init", path); err != nil { return false, fmt.Errorf("git init: %w", err) } if err := Run(ctx, "-C", path, "remote", "add", "origin", cloneUri); err != nil { return false, fmt.Errorf("git remote add: %w", err) } if err := Run(ctx, "-C", path, "fetch", "--filter=tree:0", "--depth="+strconv.Itoa(maxAncestryDepth), "origin", ref); err != nil { return false, fmt.Errorf("git fetch %s: %w", ref, err) } // this exits 0 when the ref's history contains it // a sha outside the fetched window fails the command out, err := Output(ctx, "-C", path, "merge-base", sha, "FETCH_HEAD") if err != nil { return false, nil } return strings.TrimSpace(string(out)) == sha, nil } func removeStaleLocks(path string) { locks, _ := filepath.Glob(filepath.Join(path, ".git", "*.lock")) for _, lock := range locks { os.Remove(lock) } } func isDir(path string) (bool, error) { info, err := os.Stat(path) if err == nil && info.IsDir() { return true, nil } if os.IsNotExist(err) { return false, nil } return false, err }