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", "-q", "--object-format="+objectFormat(ctx, cloneUri, ref), 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
}