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 // entries drop once nobody holds them since callers pick the keys and nothing else bounds the map type keyedMutex struct { mu sync.Mutex m map[string]*keyedLock } type keyedLock struct { mu sync.Mutex users int } func (k *keyedMutex) lock(key string) func() { k.mu.Lock() if k.m == nil { k.m = make(map[string]*keyedLock) } l, ok := k.m[key] if !ok { l = &keyedLock{} k.m[key] = l } l.users++ k.mu.Unlock() l.mu.Lock() return func() { l.mu.Unlock() k.mu.Lock() defer k.mu.Unlock() if l.users--; l.users == 0 { delete(k.m, key) } } } 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", "--object-format="+objectFormat(ctx, cloneUri, rev), 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 } // a clone can't fetch objects of the other hash, so match the remote's format // if ls-remote fails we guess sha1 and let the fetch report the real error func objectFormat(ctx context.Context, cloneUri, rev string) string { if !Rev(rev).IsObjectID() { out, err := Output(ctx, "ls-remote", cloneUri, rev) if err != nil { return "sha1" } rev, _, _ = strings.Cut(string(out), "\t") } if len(rev) == sha256HexLen { return "sha256" } return "sha1" } 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 }