diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 576a890..9c3cfef 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -30,4 +30,4 @@ jobs: run: | go test -v -cover -race -count 1 -timeout 20s --tags deadlock_test -run Test_mirror_detect_race_clone ./internal/integration_test/... go test -v -cover -race -count 1 -timeout 60s --tags deadlock_test -run Test_mirror_detect_race_repo_pool ./internal/integration_test/... - # go test -v -cover -race -count 1 -timeout 240s --tags deadlock_test -run Test_mirror_detect_race_slow_fetch ./internal/integration_test/... + go test -v -cover -race -count 1 -timeout 240s --tags deadlock_test -run Test_mirror_detect_race_slow_fetch ./internal/integration_test/... diff --git a/cleanup.go b/cleanup.go index 8dbfcb6..b67aec6 100644 --- a/cleanup.go +++ b/cleanup.go @@ -1,19 +1,18 @@ package main import ( + "context" "os" - "os/exec" "path/filepath" "slices" "strconv" "strings" + "github.com/utilitywarehouse/git-mirror/internal/utils" "github.com/utilitywarehouse/git-mirror/repopool" "github.com/utilitywarehouse/git-mirror/repository" ) -var gitExecutablePath = exec.Command("git").String() - // cleanupOrphanedRepos deletes directory of the repos from the default root // which are no longer referenced in config and it was removed while app was down. // Any removal while app is running is already handled by ensureConfig() hence @@ -88,10 +87,6 @@ func isBareRepo(cwd string) (bool, error) { // runGitCommand runs git command with given arguments on given CWD func runGitCommand(cwd string, args ...string) (string, error) { - cmd := exec.Command(gitExecutablePath, args...) - if cwd != "" { - cmd.Dir = cwd - } - output, err := cmd.CombinedOutput() - return strings.TrimSpace(string(output)), err + output, err := utils.RunCommand(context.TODO(), logger, nil, cwd, gitExecutablePath, args...) + return strings.TrimSpace(output), err } diff --git a/config_test.go b/config_test.go index 9274bda..ee610d3 100644 --- a/config_test.go +++ b/config_test.go @@ -74,7 +74,7 @@ func Test_diffRepositories(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { applyGitDefaults(tt.initialConfig) - repoPool, err := repopool.New(t.Context(), *tt.initialConfig, nil, nil) + repoPool, err := repopool.New(t.Context(), *tt.initialConfig, nil, "", nil) if err != nil { t.Fatalf("could not create git mirror pool err:%v", err) } @@ -203,7 +203,7 @@ func Test_diffWorktrees(t *testing.T) { t.Fatalf("failed to create repo error = %v", err) } - repo, err := repository.New(*tt.initialRepoConf, nil, slog.Default()) + repo, err := repository.New(*tt.initialRepoConf, "", nil, slog.Default()) if err != nil { t.Fatalf("failed to create repo error = %v", err) } diff --git a/internal/integration_test/e2e_race_test.go b/internal/integration_test/e2e_race_test.go index 3a9634f..ff0522c 100644 --- a/internal/integration_test/e2e_race_test.go +++ b/internal/integration_test/e2e_race_test.go @@ -113,11 +113,6 @@ func Test_mirror_detect_race_clone(t *testing.T) { } func Test_mirror_detect_race_slow_fetch(t *testing.T) { - // replace global git path with slower git wrapper script - cwd, _ := os.Getwd() - - os.Setenv("GIT_MIRROR_GIT_EXEC", exec.Command(path.Join(cwd, "git_slow_fetch.sh")).String()) - testTmpDir := mustTmpDir(t) defer os.RemoveAll(testTmpDir) @@ -132,16 +127,28 @@ func Test_mirror_detect_race_slow_fetch(t *testing.T) { t.Log("TEST-1: init upstream") fileSHA1 := mustInitRepo(t, upstream, "file", testName+"-1") - repo := mustCreateRepoAndMirror(t, upstream, root, "", "") - // repo.mirrorTimeout = 2 * time.Minute + rc := repository.Config{ + Remote: "file://" + upstream, + Root: root, + Interval: testInterval, + MirrorTimeout: 2 * time.Minute, // testing slow fetch + GitGC: "always", + } + + // replace global git path with slower git wrapper script + cwd, _ := os.Getwd() + repo, err := repository.New(rc, exec.Command(path.Join(cwd, "git_slow_fetch.sh")).String(), testENVs, testLog) + if err != nil { + t.Fatalf("unable to create new repo error: %v", err) + } + + if err := repo.Mirror(txtCtx); err != nil { + t.Fatalf("unable to mirror error: %v", err) + } // verify checkout files assertCommitLog(t, repo, "HEAD", "", fileSHA1, testName+"-1", []string{"file"}) - // start mirror loop - go repo.StartLoop(ctx) - defer repo.StopLoop() - t.Run("slow-fetch-without-timeout", func(t *testing.T) { // all following assertions will always be true @@ -267,7 +274,7 @@ func Test_mirror_detect_race_repo_pool(t *testing.T) { }, } - rp, err := repopool.New(t.Context(), rpc, testLog, testENVs) + rp, err := repopool.New(t.Context(), rpc, testLog, "", testENVs) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/integration_test/e2e_test.go b/internal/integration_test/e2e_test.go index 851b89e..39fec73 100644 --- a/internal/integration_test/e2e_test.go +++ b/internal/integration_test/e2e_test.go @@ -6,7 +6,6 @@ import ( "io/fs" "log/slog" "os" - "os/exec" "path/filepath" "slices" "strings" @@ -190,7 +189,7 @@ func Test_mirror_head_and_main(t *testing.T) { repo := mustCreateRepoAndMirror(t, upstream, root, link1, ref1) // add worktree for HEAD - if err := repo.AddWorktreeLink(repository.WorktreeConfig{link2, ref2, []string{}}); err != nil { + if err := repo.AddWorktreeLink(repository.WorktreeConfig{Link: link2, Ref: ref2, Pathspecs: []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // mirror again for 2nd worktree @@ -254,7 +253,7 @@ func Test_mirror_bad_ref(t *testing.T) { GitGC: "always", Worktrees: []repository.WorktreeConfig{{Link: link, Ref: ref}}, } - repo, err := repository.New(rc, testENVs, testLog) + repo, err := repository.New(rc, "", testENVs, testLog) if err != nil { t.Fatalf("unable to create new repo error: %v", err) } @@ -288,7 +287,7 @@ func Test_mirror_other_branch(t *testing.T) { repo := mustCreateRepoAndMirror(t, upstream, root, link1, ref1) // add 2nd worktree - if err := repo.AddWorktreeLink(repository.WorktreeConfig{link2, ref2, []string{}}); err != nil { + if err := repo.AddWorktreeLink(repository.WorktreeConfig{Link: link2, Ref: ref2, Pathspecs: []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // mirror again for 2nd worktree @@ -378,10 +377,10 @@ func Test_mirror_with_pathspec(t *testing.T) { mustCommit(t, upstream, filepath.Join("dir2", "file"), t.Name()+"-main-2") // add worktree for HEAD on dir2 - if err := repo.AddWorktreeLink(repository.WorktreeConfig{link2, ref2, []string{pathSpec2}}); err != nil { + if err := repo.AddWorktreeLink(repository.WorktreeConfig{Link: link2, Ref: ref2, Pathspecs: []string{pathSpec2}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } - if err := repo.AddWorktreeLink(repository.WorktreeConfig{link4, ref2, []string{pathSpec2}}); err != nil { + if err := repo.AddWorktreeLink(repository.WorktreeConfig{Link: link4, Ref: ref2, Pathspecs: []string{pathSpec2}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } @@ -408,14 +407,14 @@ func Test_mirror_with_pathspec(t *testing.T) { mustCommit(t, upstream, filepath.Join("dir3", "file"), t.Name()+"-main-3") // add worktree for HEAD on dir3 - if err := repo.AddWorktreeLink(repository.WorktreeConfig{link3, ref3, []string{pathSpec3}}); err != nil { + if err := repo.AddWorktreeLink(repository.WorktreeConfig{Link: link3, Ref: ref3, Pathspecs: []string{pathSpec3}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // update worktree link4 if err := repo.RemoveWorktreeLink(link4); err != nil { t.Fatalf("unable to add worktree error: %v", err) } - if err := repo.AddWorktreeLink(repository.WorktreeConfig{link4, ref2, []string{pathSpec3, pathSpec2}}); err != nil { + if err := repo.AddWorktreeLink(repository.WorktreeConfig{Link: link4, Ref: ref2, Pathspecs: []string{pathSpec3, pathSpec2}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // mirror new commits @@ -489,7 +488,7 @@ func Test_mirror_switch_branch_after_restart(t *testing.T) { repo1 := mustCreateRepoAndMirror(t, upstream, root, link1, ref1) // add 2nd worktree - if err := repo1.AddWorktreeLink(repository.WorktreeConfig{link2, ref2, []string{}}); err != nil { + if err := repo1.AddWorktreeLink(repository.WorktreeConfig{Link: link2, Ref: ref2, Pathspecs: []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // mirror again for 2nd worktree @@ -505,7 +504,7 @@ func Test_mirror_switch_branch_after_restart(t *testing.T) { repo2 := mustCreateRepoAndMirror(t, upstream, root, link1, ref2) // add 2nd worktree - if err := repo2.AddWorktreeLink(repository.WorktreeConfig{link2, ref1, []string{}}); err != nil { + if err := repo2.AddWorktreeLink(repository.WorktreeConfig{Link: link2, Ref: ref1, Pathspecs: []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // mirror again for 2nd worktree @@ -565,7 +564,7 @@ func Test_mirror_tag_sha(t *testing.T) { repo := mustCreateRepoAndMirror(t, upstream, root, link1, ref1) // add 2nd worktree - if err := repo.AddWorktreeLink(repository.WorktreeConfig{link2, ref2, []string{}}); err != nil { + if err := repo.AddWorktreeLink(repository.WorktreeConfig{Link: link2, Ref: ref2, Pathspecs: []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } // mirror again for 2nd worktree @@ -1292,7 +1291,7 @@ func Test_mirror_loop(t *testing.T) { repo := mustCreateRepoAndMirror(t, upstream, root, link1, ref1) // add worktree for HEAD - if err := repo.AddWorktreeLink(repository.WorktreeConfig{link2, ref2, []string{}}); err != nil { + if err := repo.AddWorktreeLink(repository.WorktreeConfig{Link: link2, Ref: ref2, Pathspecs: []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } @@ -1375,14 +1374,14 @@ func Test_RepoPool_Success(t *testing.T) { }, } - rp, err := repopool.New(t.Context(), rpc, testLog, testENVs) + rp, err := repopool.New(t.Context(), rpc, testLog, "", testENVs) if err != nil { t.Fatalf("unexpected error: %v", err) } // add worktree // we will verify this worktree in next mirror loop - if err := rp.AddWorktreeLink(remote1, repository.WorktreeConfig{"link3", "", []string{}}); err != nil { + if err := rp.AddWorktreeLink(remote1, repository.WorktreeConfig{Link: "link3", Ref: "", Pathspecs: []string{}}); err != nil { t.Fatalf("unexpected err:%s", err) } @@ -1571,7 +1570,7 @@ func Test_RepoPool_Error(t *testing.T) { }, } - rp, err := repopool.New(t.Context(), rpc, testLog, testENVs) + rp, err := repopool.New(t.Context(), rpc, testLog, "", testENVs) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1648,12 +1647,12 @@ func mustCreateRepoAndMirror(t *testing.T, upstream, root, link, ref string) *re MirrorTimeout: testTimeout, GitGC: "always", } - repo, err := repository.New(rc, testENVs, testLog) + repo, err := repository.New(rc, "", testENVs, testLog) if err != nil { t.Fatalf("unable to create new repo error: %v", err) } if link != "" { - if err := repo.AddWorktreeLink(repository.WorktreeConfig{link, ref, []string{}}); err != nil { + if err := repo.AddWorktreeLink(repository.WorktreeConfig{Link: link, Ref: ref, Pathspecs: []string{}}); err != nil { t.Fatalf("unable to add worktree error: %v", err) } } @@ -1792,19 +1791,12 @@ func assertCommitLog(t *testing.T, repo *repository.Repository, } } -func mustExec(t *testing.T, cwd string, name string, arg ...string) string { +func mustExec(t *testing.T, cwd string, command string, arg ...string) string { t.Helper() - cmd := exec.Command(name, arg...) - if cwd != "" { - cmd.Dir = cwd - } - - cmd.Env = testENVs - - stdoutStderr, err := cmd.CombinedOutput() + out, err := utils.RunCommand(context.TODO(), slog.Default(), testENVs, cwd, command, arg...) if err != nil { - t.Fatalf("err:%v run(%s): { stdoutStderr %q }", cmd.String(), err, stdoutStderr) + t.Fatal(err) } - return strings.TrimSpace(string(stdoutStderr)) + return strings.TrimSpace(out) } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 8a7e9c2..019a8d3 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -1,11 +1,16 @@ package utils import ( + "bytes" + "context" "fmt" "io/fs" + "log/slog" "os" + "os/exec" "path/filepath" "strings" + "time" ) const defaultDirMode fs.FileMode = os.FileMode(0755) // 'rwxr-xr-x' @@ -69,3 +74,44 @@ func AbsLink(root, link string) string { return linkAbs } + +// RunCommand runs given command with given arguments on given CWD +func RunCommand(ctx context.Context, log *slog.Logger, envs []string, cwd string, command string, args ...string) (string, error) { + + cmdStr := command + " " + strings.Join(args, " ") + log.Log(ctx, -8, "running command", "cwd", cwd, "cmd", cmdStr) + + cmd := exec.CommandContext(ctx, command, args...) + // force kill git & child process 5 seconds after sending it sigterm (when ctx is cancelled/timed out) + cmd.WaitDelay = 5 * time.Second + if cwd != "" { + cmd.Dir = cwd + } + outbuf := bytes.NewBuffer(nil) + errbuf := bytes.NewBuffer(nil) + cmd.Stdout = outbuf + cmd.Stderr = errbuf + + // If Env is nil, the new process uses the current process's environment. + cmd.Env = []string{} + + if len(envs) > 0 { + cmd.Env = append(cmd.Env, envs...) + } + + start := time.Now() + err := cmd.Run() + runTime := time.Since(start) + + stdout := strings.TrimSpace(outbuf.String()) + stderr := strings.TrimSpace(errbuf.String()) + if ctx.Err() == context.DeadlineExceeded { + err = ctx.Err() + } + if err != nil { + return "", fmt.Errorf("Run(%s): err:%w { stdout: %q, stderr: %q }", cmdStr, err, stdout, stderr) + } + log.Log(ctx, -8, "command result", "stdout", stdout, "stderr", stderr, "time", runTime) + + return stdout, nil +} diff --git a/main.go b/main.go index 45dfa72..47a7403 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/pprof" "os" + "os/exec" "os/signal" "runtime/debug" "strconv" @@ -33,6 +34,8 @@ var ( "warn": slog.LevelWarn, "error": slog.LevelError, } + + gitExecutablePath = exec.Command("git").String() ) func init() { @@ -133,10 +136,7 @@ func main() { applyGitDefaults(conf) - // path to resolve git - gitENV := []string{fmt.Sprintf("PATH=%s", os.Getenv("PATH"))} - - repoPool, err := repopool.New(ctx, *conf, logger.With("logger", "git-mirror"), gitENV) + repoPool, err := repopool.New(ctx, *conf, logger.With("logger", "git-mirror"), gitExecutablePath, nil) if err != nil { logger.Error("could not create git mirror pool", "err", err) os.Exit(1) diff --git a/repopool/example_noworktree_test.go b/repopool/example_noworktree_test.go index 87422a9..8491d6d 100644 --- a/repopool/example_noworktree_test.go +++ b/repopool/example_noworktree_test.go @@ -41,7 +41,7 @@ repositories: } conf.Defaults.Root = tmpRoot - repos, err := repopool.New(ctx, conf, slog.Default(), nil) + repos, err := repopool.New(ctx, conf, slog.Default(), "", nil) if err != nil { panic(err) } diff --git a/repopool/example_worktree_test.go b/repopool/example_worktree_test.go index db18454..7498d1c 100644 --- a/repopool/example_worktree_test.go +++ b/repopool/example_worktree_test.go @@ -44,7 +44,7 @@ repositories: conf.Defaults.Root = tmpRoot - repos, err := repopool.New(ctx, conf, slog.Default(), nil) + repos, err := repopool.New(ctx, conf, slog.Default(), "", nil) if err != nil { panic(err) } @@ -71,7 +71,7 @@ repositories: fmt.Println("last commit msg at main", "msg", msg) // make sure file exists in the tree - _, err = os.Stat(tmpRoot + "/main/pkg/mirror/repository.go") + _, err = os.Stat(tmpRoot + "/main/repository/repository.go") if err != nil { panic(err) } diff --git a/repopool/repo_pool.go b/repopool/repo_pool.go index 51eeba4..7f36e4e 100644 --- a/repopool/repo_pool.go +++ b/repopool/repo_pool.go @@ -27,13 +27,14 @@ type RepoPool struct { lock lock.RWMutex log *slog.Logger repos []*repository.Repository + cmd string commonENVs []string Stopped chan bool } // New will create repository pool based on given config. // Remote repo will not be mirrored until either Mirror() or StartLoop() is called -func New(ctx context.Context, conf Config, log *slog.Logger, commonENVs []string) (*RepoPool, error) { +func New(ctx context.Context, conf Config, log *slog.Logger, gitExec string, commonENVs []string) (*RepoPool, error) { if err := conf.ValidateAndApplyDefaults(); err != nil { return nil, err } @@ -46,6 +47,7 @@ func New(ctx context.Context, conf Config, log *slog.Logger, commonENVs []string rp := &RepoPool{ ctx: repoCtx, log: log, + cmd: gitExec, commonENVs: commonENVs, Stopped: make(chan bool), } @@ -102,7 +104,7 @@ func (rp *RepoPool) AddRepository(repoConf repository.Config) error { rp.lock.Lock() defer rp.lock.Unlock() - repo, err := repository.New(repoConf, rp.commonENVs, rp.log) + repo, err := repository.New(repoConf, rp.cmd, rp.commonENVs, rp.log) if err != nil { return err } diff --git a/repopool/repo_pool_test.go b/repopool/repo_pool_test.go index 6589e90..adf6711 100644 --- a/repopool/repo_pool_test.go +++ b/repopool/repo_pool_test.go @@ -35,7 +35,7 @@ func TestRepoPool_validateLinkPath(t *testing.T) { }, } - rp, err := New(t.Context(), rpc, nil, nil) + rp, err := New(t.Context(), rpc, nil, "", nil) if err != nil { t.Fatalf("unexpected err:%s", err) } diff --git a/repository/helper.go b/repository/helper.go index adab90d..620a684 100644 --- a/repository/helper.go +++ b/repository/helper.go @@ -1,17 +1,13 @@ package repository import ( - "bytes" - "context" "fmt" "log/slog" "math/rand" "os" - "os/exec" "path/filepath" "regexp" "strconv" - "strings" "time" "github.com/utilitywarehouse/git-mirror/internal/utils" @@ -135,47 +131,6 @@ func updatedRefs(output string) []string { return refs } -// runGitCommand runs git command with given arguments on given CWD -func runGitCommand(ctx context.Context, log *slog.Logger, envs []string, cwd string, args ...string) (string, error) { - - cmdStr := gitExecutablePath + " " + strings.Join(args, " ") - log.Log(ctx, -8, "running command", "cwd", cwd, "cmd", cmdStr) - - cmd := exec.CommandContext(ctx, gitExecutablePath, args...) - // force kill git & child process 5 seconds after sending it sigterm (when ctx is cancelled/timed out) - cmd.WaitDelay = 5 * time.Second - if cwd != "" { - cmd.Dir = cwd - } - outbuf := bytes.NewBuffer(nil) - errbuf := bytes.NewBuffer(nil) - cmd.Stdout = outbuf - cmd.Stderr = errbuf - - // If Env is nil, the new process uses the current process's environment. - cmd.Env = []string{} - - if len(envs) > 0 { - cmd.Env = append(cmd.Env, envs...) - } - - start := time.Now() - err := cmd.Run() - runTime := time.Since(start) - - stdout := strings.TrimSpace(outbuf.String()) - stderr := strings.TrimSpace(errbuf.String()) - if ctx.Err() == context.DeadlineExceeded { - err = ctx.Err() - } - if err != nil { - return "", fmt.Errorf("Run(%s): err:%w { stdout: %q, stderr: %q }", cmdStr, err, stdout, stderr) - } - log.Log(ctx, -8, "command result", "stdout", stdout, "stderr", stderr, "time", runTime) - - return stdout, nil -} - // jitter returns a time.Duration between duration and duration + maxFactor * duration. func jitter(duration time.Duration, maxFactor float64) time.Duration { return duration + time.Duration(rand.Float64()*maxFactor*float64(duration)) diff --git a/repository/repository.go b/repository/repository.go index 91a8aff..0c0db50 100644 --- a/repository/repository.go +++ b/repository/repository.go @@ -30,18 +30,13 @@ var ( ErrRepoMirrorFailed = errors.New("repository mirror failed") ErrRepoWTUpdateFailed = errors.New("repository worktree update failed") - gitExecutablePath string - staleTimeout time.Duration = 10 * time.Second // time for stale worktrees to be cleaned up + staleTimeout time.Duration = 10 * time.Second // time for stale worktrees to be cleaned up // to parse output of "git ls-remote --symref origin HEAD" // ref: refs/heads/xxxx HEAD remoteDefaultBranchRgx = regexp.MustCompile(`^ref:\s+([^\s]+)\s+HEAD`) ) -func init() { - gitExecutablePath = exec.Command("git").String() -} - type gcMode string const ( @@ -55,6 +50,7 @@ const ( // The implementation borrows heavily from https://github.com/kubernetes/git-sync. // A Repository is safe for concurrent use by multiple goroutines. type Repository struct { + cmd string // git exec path lock lock.RWMutex // repository will be locked during mirror gitURL *giturl.URL // parsed remote git URL remote string // remote repo to mirror @@ -77,7 +73,7 @@ type Repository struct { // New creates new repository from the given config. // Remote repo will not be mirrored until either Mirror() or StartLoop() is called. -func New(repoConf Config, envs []string, log *slog.Logger) (*Repository, error) { +func New(repoConf Config, gitExec string, envs []string, log *slog.Logger) (*Repository, error) { remoteURL := giturl.NormaliseURL(repoConf.Remote) gURL, err := giturl.Parse(remoteURL) @@ -91,6 +87,10 @@ func New(repoConf Config, envs []string, log *slog.Logger) (*Repository, error) log = log.With("repo", gURL.Repo) + if gitExec == "" { + gitExec = exec.Command("git").String() + } + if !filepath.IsAbs(repoConf.Root) { return nil, fmt.Errorf("repository root '%s' must be absolute", repoConf.Root) } @@ -125,6 +125,7 @@ func New(repoConf Config, envs []string, log *slog.Logger) (*Repository, error) repoDir = filepath.Join(DefaultRepoDir(repoConf.Root), repoDir) repo := &Repository{ + cmd: gitExec, gitURL: gURL, remote: remoteURL, root: repoConf.Root, @@ -244,7 +245,7 @@ func (r *Repository) Subject(ctx context.Context, hash string) (string, error) { defer r.lock.RUnlock() args := []string{"show", `--no-patch`, `--format=%s`, hash} - msg, err := runGitCommand(ctx, r.log, r.envs, r.dir, args...) + msg, err := r.git(ctx, nil, "", args...) if err != nil { return "", err } @@ -259,7 +260,7 @@ func (r *Repository) ChangedFiles(ctx context.Context, hash string) ([]string, e defer r.lock.RUnlock() args := []string{"show", `--name-only`, `--pretty=format:`, hash} - msg, err := runGitCommand(ctx, r.log, r.envs, r.dir, args...) + msg, err := r.git(ctx, nil, "", args...) if err != nil { return nil, err } @@ -293,7 +294,7 @@ func (r *Repository) ListCommitsWithChangedFiles(ctx context.Context, ref1, ref2 defer r.lock.RUnlock() args := []string{"log", `--name-only`, `--pretty=format:%H`, ref1 + ".." + ref2} - msg, err := runGitCommand(ctx, r.log, r.envs, r.dir, args...) + msg, err := r.git(ctx, nil, "", args...) if err != nil { return nil, err } @@ -341,7 +342,7 @@ func (r *Repository) ObjectExists(ctx context.Context, obj string) error { defer r.lock.RUnlock() args := []string{"cat-file", `-e`, obj} - _, err := runGitCommand(ctx, r.log, r.envs, r.dir, args...) + _, err := r.git(ctx, nil, "", args...) return err } @@ -384,7 +385,7 @@ func (r *Repository) Clone(ctx context.Context, dst, ref string, pathspecs []str // create a thin clone of a repository that only contains the history of the given revision // git clone --no-checkout --revision args := []string{"clone", "--no-checkout", "--revision", ref, r.dir, dst} - if _, err := runGitCommand(ctx, r.log, nil, "", args...); err != nil { + if _, err := r.git(ctx, nil, "", args...); err != nil { return "", err } @@ -394,14 +395,14 @@ func (r *Repository) Clone(ctx context.Context, dst, ref string, pathspecs []str args = append(args, pathspecs...) } // git checkout -- - if _, err := runGitCommand(ctx, r.log, nil, dst, args...); err != nil { + if _, err := r.git(ctx, nil, dst, args...); err != nil { return "", err } // get the hash of the repos HEAD args = []string{"log", "--pretty=format:%H", "-n", "1", "HEAD"} // git log --pretty=format:%H -n 1 HEAD - hash, err := runGitCommand(ctx, r.log, nil, dst, args...) + hash, err := r.git(ctx, nil, dst, args...) if err != nil { return "", err } @@ -599,7 +600,7 @@ func (r *Repository) init(ctx context.Context) error { // create bare repository as we will use worktrees to checkout files r.log.Info("initializing repo directory", "path", r.dir) // git init -q --bare - if _, err := runGitCommand(ctx, r.log, r.envs, r.dir, "init", "-q", "--bare"); err != nil { + if _, err := r.git(ctx, nil, "", "init", "-q", "--bare"); err != nil { return fmt.Errorf("unable to init repo err:%w", err) } @@ -608,7 +609,7 @@ func (r *Repository) init(ctx context.Context) error { // use --mirror=fetch as we want to create mirrored bare repository. it will make sure // everything in refs/* on the remote will be directly mirrored into refs/* in the local repository. // git remote add --mirror=fetch origin - if _, err := runGitCommand(ctx, r.log, r.envs, r.dir, "remote", "add", "--mirror=fetch", "origin", r.remote); err != nil { + if _, err := r.git(ctx, nil, "", "remote", "add", "--mirror=fetch", "origin", r.remote); err != nil { return fmt.Errorf("unable to set remote err:%w", err) } @@ -620,7 +621,7 @@ func (r *Repository) init(ctx context.Context) error { // set local HEAD to remote HEAD/default branch // git symbolic-ref HEAD (refs/heads/master) - if _, err := runGitCommand(ctx, r.log, r.envs, r.dir, "symbolic-ref", "HEAD", headBranch); err != nil { + if _, err := r.git(ctx, nil, "", "symbolic-ref", "HEAD", headBranch); err != nil { return fmt.Errorf("unable to set remote err:%w", err) } @@ -637,7 +638,7 @@ func (r *Repository) getRemoteDefaultBranch(ctx context.Context) (string, error) envs := r.authEnv(ctx) // git ls-remote --symref origin HEAD - out, err := runGitCommand(ctx, r.log, envs, r.dir, "ls-remote", "--symref", "origin", "HEAD") + out, err := r.git(ctx, envs, "", "ls-remote", "--symref", "origin", "HEAD") if err != nil { return "", fmt.Errorf("unable to get default branch err:%w", err) } @@ -665,7 +666,7 @@ func (r *Repository) sanityCheckRepo(ctx context.Context) bool { // make sure repo is bare repository // git rev-parse --is-bare-repository - if ok, err := runGitCommand(ctx, r.log, r.envs, r.dir, "rev-parse", "--is-bare-repository"); err != nil { + if ok, err := r.git(ctx, nil, "", "rev-parse", "--is-bare-repository"); err != nil { r.log.Error("unable to verify bare repo", "path", r.dir, "err", err) return false } else if ok != "true" { @@ -675,7 +676,7 @@ func (r *Repository) sanityCheckRepo(ctx context.Context) bool { // Check that this is actually the root of the repo. // git rev-parse --absolute-git-dir - if root, err := runGitCommand(ctx, r.log, r.envs, r.dir, "rev-parse", "--absolute-git-dir"); err != nil { + if root, err := r.git(ctx, nil, "", "rev-parse", "--absolute-git-dir"); err != nil { r.log.Error("can't get repo git dir", "path", r.dir, "err", err) return false } else { @@ -688,7 +689,7 @@ func (r *Repository) sanityCheckRepo(ctx context.Context) bool { // The "origin" remote has special meaning, like in relative-path submodules. // make sure origin exists with correct remote URL // git config --get remote.origin.url - if stdout, err := runGitCommand(ctx, r.log, r.envs, r.dir, "config", "--get", "remote.origin.url"); err != nil { + if stdout, err := r.git(ctx, nil, "", "config", "--get", "remote.origin.url"); err != nil { r.log.Error("can't get repo config remote.origin.url", "path", r.dir, "err", err) return false } else if stdout != r.remote { @@ -698,7 +699,7 @@ func (r *Repository) sanityCheckRepo(ctx context.Context) bool { // verify origin's fetch refspec // git config --get remote.origin.fetch - if stdout, err := runGitCommand(ctx, r.log, r.envs, r.dir, "config", "--get", "remote.origin.fetch"); err != nil { + if stdout, err := r.git(ctx, nil, "", "config", "--get", "remote.origin.fetch"); err != nil { r.log.Error("can't get repo config remote.origin.fetch", "path", r.dir, "err", err) return false } else if stdout != defaultRefSpec { @@ -709,7 +710,7 @@ func (r *Repository) sanityCheckRepo(ctx context.Context) bool { // Consistency-check the repo. Don't use --verbose because it can be // REALLY verbose. // git fsck --no-progress --connectivity-only - if _, err := runGitCommand(ctx, r.log, r.envs, r.dir, "fsck", "--no-progress", "--connectivity-only"); err != nil { + if _, err := r.git(ctx, nil, "", "fsck", "--no-progress", "--connectivity-only"); err != nil { r.log.Error("repo fsck failed", "path", r.dir, "err", err) return false } @@ -726,7 +727,7 @@ func (r *Repository) fetch(ctx context.Context) ([]string, error) { envs := r.authEnv(ctx) // git fetch origin --prune --no-progress --no-auto-gc - out, err := runGitCommand(ctx, r.log, envs, r.dir, args...) + out, err := r.git(ctx, envs, "", args...) return updatedRefs(out), err } @@ -737,7 +738,7 @@ func (r *Repository) hash(ctx context.Context, ref, path string) (string, error) args = append(args, "--", path) } // git log --pretty=format:%H -n 1 [-- ] - return runGitCommand(ctx, r.log, r.envs, r.dir, args...) + return r.git(ctx, nil, "", args...) } // ensureWorktreeLink will create / validate worktrees @@ -766,7 +767,7 @@ func (r *Repository) ensureWorktreeLink(ctx context.Context, wl *WorkTreeLink) e if currentPath != "" { // get hash from the worktree folder - currentHash, err = wl.workTreeHash(ctx, currentPath) + currentHash, err = r.workTreeHash(ctx, wl, currentPath) if err != nil { // in case of error we create new worktree wl.log.Error("unable to get current worktree hash", "err", err) @@ -774,7 +775,7 @@ func (r *Repository) ensureWorktreeLink(ctx context.Context, wl *WorkTreeLink) e } if currentHash == remoteHash { - if wl.sanityCheckWorktree(ctx) { + if r.sanityCheckWorktree(ctx, wl) { return nil } wl.log.Error("worktree failed checks, re-creating...", "path", currentPath) @@ -816,7 +817,7 @@ func (r *Repository) createWorktree(ctx context.Context, wl *WorkTreeLink, hash wl.log.Info("creating worktree", "path", wtPath, "hash", hash) // git worktree add --force --detach --no-checkout - _, err := runGitCommand(ctx, wl.log, nil, r.dir, "worktree", "add", "--force", "--detach", "--no-checkout", wtPath, hash) + _, err := r.git(ctx, nil, "", "worktree", "add", "--force", "--detach", "--no-checkout", wtPath, hash) if err != nil { return wtPath, err } @@ -828,7 +829,7 @@ func (r *Repository) createWorktree(ctx context.Context, wl *WorkTreeLink, hash args = append(args, wl.pathspecs...) } // git checkout -- - if _, err := runGitCommand(ctx, wl.log, nil, wtPath, args...); err != nil { + if _, err := r.git(ctx, nil, wtPath, args...); err != nil { return "", err } @@ -851,7 +852,7 @@ func (r *Repository) removeWorktree(ctx context.Context, path string) error { return fmt.Errorf("error removing directory: %w", err) } // git worktree prune -v - if _, err := runGitCommand(ctx, r.log, r.envs, r.dir, "worktree", "prune", "--verbose"); err != nil { + if _, err := r.git(ctx, nil, "", "worktree", "prune", "--verbose"); err != nil { return err } return nil @@ -868,13 +869,13 @@ func (r *Repository) cleanup(ctx context.Context) error { // Let git know we don't need those old commits any more. // git worktree prune -v - if _, err := runGitCommand(ctx, r.log, r.envs, r.dir, "worktree", "prune", "--verbose"); err != nil { + if _, err := r.git(ctx, nil, "", "worktree", "prune", "--verbose"); err != nil { cleanupErrs = append(cleanupErrs, err) } // Expire old refs. // git reflog expire --expire-unreachable=all --all - if _, err := runGitCommand(ctx, r.log, r.envs, r.dir, "reflog", "expire", "--expire-unreachable=all", "--all"); err != nil { + if _, err := r.git(ctx, nil, "", "reflog", "expire", "--expire-unreachable=all", "--all"); err != nil { cleanupErrs = append(cleanupErrs, err) } @@ -889,7 +890,7 @@ func (r *Repository) cleanup(ctx context.Context) error { case GCAggressive: args = append(args, "--aggressive") } - if _, err := runGitCommand(ctx, r.log, r.envs, r.dir, args...); err != nil { + if _, err := r.git(ctx, nil, "", args...); err != nil { cleanupErrs = append(cleanupErrs, err) } } @@ -930,3 +931,11 @@ func (r *Repository) removeStaleWorktrees() (int, error) { } return count, nil } + +// git runs git command with given arguments on given CWD +func (r *Repository) git(ctx context.Context, envs []string, cwd string, args ...string) (string, error) { + if cwd == "" { + cwd = r.dir + } + return utils.RunCommand(ctx, r.log, append(r.envs, envs...), cwd, r.cmd, args...) +} diff --git a/repository/repository_test.go b/repository/repository_test.go index 2a6d419..79cd61a 100644 --- a/repository/repository_test.go +++ b/repository/repository_test.go @@ -34,6 +34,7 @@ func TestNewRepo(t *testing.T) { gc: "always", }, &Repository{ + cmd: "git", gitURL: &giturl.URL{Scheme: "scp", User: "user", Host: "host.xz", Path: "path/to", Repo: "repo.git"}, remote: "user@host.xz:path/to/repo.git", root: "/tmp", @@ -107,7 +108,7 @@ func TestNewRepo(t *testing.T) { GitGC: tt.args.gc, Auth: tt.args.auth, } - got, err := New(rc, nil, slog.Default()) + got, err := New(rc, "git", nil, slog.Default()) if (err != nil) != tt.wantErr { t.Errorf("NewRepository() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/repository/worktree.go b/repository/worktree.go index 16787cb..e269df8 100644 --- a/repository/worktree.go +++ b/repository/worktree.go @@ -52,25 +52,25 @@ func (wl *WorkTreeLink) currentWorktree() (string, error) { } // workTreeHash returns the hash of the given revision and for the path if specified. -func (wl *WorkTreeLink) workTreeHash(ctx context.Context, wt string) (string, error) { +func (r *Repository) workTreeHash(ctx context.Context, wl *WorkTreeLink, wt string) (string, error) { // if worktree is not valid then command can return HEAD of the mirrored repo // instead of worktree - if !wl.isInsideWorkTree(ctx, wt) { + if !r.isInsideWorkTree(ctx, wl, wt) { return "", fmt.Errorf("worktree is not a valid git worktree") } // git rev-parse HEAD - return runGitCommand(ctx, wl.log, nil, wt, "rev-parse", "HEAD") + return r.git(ctx, nil, wt, "rev-parse", "HEAD") } // isInsideWorkTree will make sure given worktree dir is inside worktree dir // (.git file exists) -func (wl *WorkTreeLink) isInsideWorkTree(ctx context.Context, wt string) bool { +func (r *Repository) isInsideWorkTree(ctx context.Context, wl *WorkTreeLink, wt string) bool { // worktree path should not be empty and must be absolute if !filepath.IsAbs(wt) { return false } // git rev-parse --is-inside-work-tree - if ok, err := runGitCommand(ctx, wl.log, nil, wt, "rev-parse", "--is-inside-work-tree"); err != nil { + if ok, err := r.git(ctx, nil, wt, "rev-parse", "--is-inside-work-tree"); err != nil { wl.log.Error("unable to verify if is-inside-work-tree", "path", wt, "err", err) return false } else if ok != "true" { @@ -84,7 +84,7 @@ func (wl *WorkTreeLink) isInsideWorkTree(ctx context.Context, wt string) bool { // Note that this does not guarantee that the worktree has all the // files checked out - git could have died halfway through and the repo will // still pass this check. -func (wl *WorkTreeLink) sanityCheckWorktree(ctx context.Context) bool { +func (r *Repository) sanityCheckWorktree(ctx context.Context, wl *WorkTreeLink) bool { wt, err := wl.currentWorktree() if err != nil { wl.log.Error("can't get current worktree", "err", err) @@ -104,13 +104,13 @@ func (wl *WorkTreeLink) sanityCheckWorktree(ctx context.Context) bool { } // makes sure path is inside the work tree of the repository - if !wl.isInsideWorkTree(ctx, wt) { + if !r.isInsideWorkTree(ctx, wl, wt) { return false } // Check that this is actually the root of the worktree. // git rev-parse --show-toplevel - if root, err := runGitCommand(ctx, wl.log, nil, wt, "rev-parse", "--show-toplevel"); err != nil { + if root, err := r.git(ctx, nil, wt, "rev-parse", "--show-toplevel"); err != nil { wl.log.Error("can't get worktree git dir", "path", wt, "err", err) return false } else { @@ -122,7 +122,7 @@ func (wl *WorkTreeLink) sanityCheckWorktree(ctx context.Context) bool { // Consistency-check the repo. // git fsck --no-progress --connectivity-only - if _, err := runGitCommand(ctx, wl.log, nil, wt, "fsck", "--no-progress", "--connectivity-only"); err != nil { + if _, err := r.git(ctx, nil, wt, "fsck", "--no-progress", "--connectivity-only"); err != nil { wl.log.Error("repo fsck failed", "path", wt, "err", err) return false }