blob: 317e437a956833f25c760a03d8c6ae8c3719b9ef [file] [log] [blame]
// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package repo has functions to manage a checked out copy of
// a git repository.
package repo
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sync"
)
var (
// reposLock is a lock for the repos variable.
reposLock sync.Mutex
// repos holds released repos as a map from a url.
// It may only be accessed while reposLock is held.
repos = make(map[string][]*Repo)
)
// Repo is a clone of a git repo.
type Repo struct {
url string // repo URL
dir string // local temporary directory
subdir string // subdirectory holding git repo
}
// Dir returns the local directory holding the Repo.
func (repo *Repo) Dir() string {
return filepath.Join(repo.dir, repo.subdir)
}
// Release releases a Repo when it is no longer needed.
func (repo *Repo) Release() {
reposLock.Lock()
defer reposLock.Unlock()
repos[repo.url] = append(repos[repo.url], repo)
}
// acquire returns an unused Repo for a git repo, if there is one.
// If there isn't one, it returns nil.
func acquire(url string) *Repo {
reposLock.Lock()
defer reposLock.Unlock()
s := repos[url]
ln := len(s)
if ln == 0 {
return nil
}
ret := s[ln-1]
repos[url] = s[:ln-1]
return ret
}
// Clone returns a new clone of a git repo, or reuses an existing one.
// If executor is not nil, it is used to run commands.
func Clone(ctx context.Context, lg *slog.Logger, url string, executor Executor) (r *Repo, err error) {
if r := acquire(url); r != nil {
return r, nil
}
dir, err := os.MkdirTemp("", "gaby-git-repo")
if err != nil {
return nil, err
}
defer func() {
if err != nil {
os.RemoveAll(dir)
}
}()
r = &Repo{
url: url,
dir: dir,
}
lg.Debug("cloning git repo", "repo", url)
if executor == nil {
executor = stdExecutor{}
}
_, err = executor.Execute(ctx, lg, dir, "git", "clone", url)
if err != nil {
return nil, err
}
// We expect the "git clone" to create a single directory inside dir.
subdirs, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
if len(subdirs) != 1 {
return nil, fmt.Errorf("repo.Clone: ReadDir(%q) = %v, expected a single subdirectory", dir, subdirs)
}
r.subdir = subdirs[0].Name()
return r, nil
}
// Checkout checks out a specific version of a git repo.
// If executor is not nil, it is used to run commands.
func (repo *Repo) Checkout(ctx context.Context, lg *slog.Logger, version string, executor Executor) error {
if executor == nil {
executor = stdExecutor{}
}
_, err := executor.Execute(ctx, lg, filepath.Join(repo.dir, repo.subdir), "git", "checkout", version)
return err
}
// FreeAll frees all cached repositories and removes the directories.
func FreeAll() {
reposLock.Lock()
var dirs []string
for _, s := range repos {
for _, r := range s {
dirs = append(dirs, r.dir)
}
}
clear(repos)
reposLock.Unlock()
for _, dir := range dirs {
os.RemoveAll(dir)
}
}
// Executor is an optional interface that can be used to control
// how commands are executed.
type Executor interface {
// Execute runs the cmd, with args, in dir.
// It returns the standard output,
// and an error that may be [*os/exec.ExitError].
Execute(ctx context.Context, lg *slog.Logger, dir, cmd string, args ...string) ([]byte, error)
}
// stdExecutor implements Executor using os/exec.
type stdExecutor struct{}
// Execute implements Executor.Execute.
func (stdExecutor) Execute(ctx context.Context, lg *slog.Logger, dir, command string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, command, args...)
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
lg.Error("command failed", "cmd", cmd.String(), "err", err, "stdout", out, "stderr", ee.Stderr)
} else {
lg.Error("command failed", "cmd", cmd.String(), "err", err, "stdout", out)
}
return out, fmt.Errorf("%s failed: %v", cmd, err)
}
lg.Debug("command succeeded", "cmd", cmd.String(), "dir", cmd.Dir, "stdout", out)
return out, nil
}