feat(cli): safely sync repositories across a workspace
Walk a workspace for Git repositories, including intentionally nested repos, while pruning dependency caches and build output directories. Inspect each branch, remote, operation state, and porcelain status before running hub sync. Recheck safety immediately before mutation, keep nested repositories from falsely dirtying their parent, and support concurrency, exclusions, strict mode, timeouts, JSON output, and explicit confirmation.
This commit is contained in:
+202
@@ -0,0 +1,202 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func git(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func makeRepo(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git(t, path, "init", "-q")
|
||||
git(t, path, "config", "user.name", "Test")
|
||||
git(t, path, "config", "user.email", "test@example.com")
|
||||
if err := os.WriteFile(filepath.Join(path, "tracked.txt"), []byte("clean\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
git(t, path, "add", "tracked.txt")
|
||||
git(t, path, "commit", "-qm", "initial")
|
||||
git(t, path, "remote", "add", "origin", "https://example.invalid/repo.git")
|
||||
}
|
||||
|
||||
func TestDiscoverNestedRepositories(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parent := filepath.Join(root, "parent")
|
||||
child := filepath.Join(parent, "apps", "child")
|
||||
makeRepo(t, parent)
|
||||
makeRepo(t, child)
|
||||
|
||||
paths, err := discover(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(paths) != 2 || paths[0] != parent || paths[1] != child {
|
||||
t.Fatalf("discover() = %#v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedRepoDoesNotDirtyParent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parent := filepath.Join(root, "parent")
|
||||
child := filepath.Join(parent, "child")
|
||||
makeRepo(t, parent)
|
||||
makeRepo(t, child)
|
||||
|
||||
paths, _ := discover(root, nil)
|
||||
got := inspect(root, parent, paths)
|
||||
if !got.Eligible {
|
||||
t.Fatalf("parent blocked by nested repo: %+v", got)
|
||||
}
|
||||
if len(got.NestedRepoEntries) == 0 {
|
||||
t.Fatalf("expected nested repository entry to be recorded: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirtyFileBlocksRepository(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "repo")
|
||||
makeRepo(t, path)
|
||||
if err := os.WriteFile(filepath.Join(path, "tracked.txt"), []byte("changed\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := inspect(root, path, []string{path})
|
||||
if got.Eligible || len(got.Dirty) != 1 {
|
||||
t.Fatalf("dirty repository was not blocked: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUntrackedFileAlongsideNestedRepoStillBlocksParent(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parent := filepath.Join(root, "parent")
|
||||
child := filepath.Join(parent, "child")
|
||||
makeRepo(t, parent)
|
||||
makeRepo(t, child)
|
||||
if err := os.WriteFile(filepath.Join(parent, "notes.txt"), []byte("work\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
paths, _ := discover(root, nil)
|
||||
got := inspect(root, parent, paths)
|
||||
if got.Eligible || len(got.Dirty) != 1 {
|
||||
t.Fatalf("untracked file should block parent: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkippedDirectoriesAreNotSearched(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRepo(t, filepath.Join(root, "node_modules", "dependency"))
|
||||
makeRepo(t, filepath.Join(root, "real"))
|
||||
paths, err := discover(root, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(paths) != 1 || paths[0] != filepath.Join(root, "real") {
|
||||
t.Fatalf("discover() = %#v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRechecksAndSkipsNewlyDirtyRepository(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "repo")
|
||||
makeRepo(t, path)
|
||||
planned := inspect(root, path, []string{path})
|
||||
if !planned.Eligible {
|
||||
t.Fatalf("fixture should initially be eligible: %+v", planned)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, "late-change.txt"), []byte("do not touch\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bin := filepath.Join(root, "bin")
|
||||
if err := os.Mkdir(bin, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(root, "hub-was-called")
|
||||
script := "#!/bin/sh\ntouch \"$STACK_SYNC_TEST_MARKER\"\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "hub"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("STACK_SYNC_TEST_MARKER", marker)
|
||||
|
||||
results := syncAll(root, []repo{planned}, 1, time.Minute, io.Discard, true)
|
||||
if len(results) != 1 || !results[0].Skipped || results[0].Success {
|
||||
t.Fatalf("syncAll() = %+v", results)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("hub ran despite the newly dirty worktree")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncInvokesHubForCleanRepository(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "repo")
|
||||
makeRepo(t, path)
|
||||
planned := inspect(root, path, []string{path})
|
||||
|
||||
bin := filepath.Join(root, "bin")
|
||||
if err := os.Mkdir(bin, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(root, "hub-was-called")
|
||||
script := "#!/bin/sh\nprintf '%s' \"$*\" > \"$STACK_SYNC_TEST_MARKER\"\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "hub"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("STACK_SYNC_TEST_MARKER", marker)
|
||||
|
||||
results := syncAll(root, []repo{planned}, 1, time.Minute, io.Discard, true)
|
||||
if len(results) != 1 || !results[0].Success || results[0].Skipped {
|
||||
t.Fatalf("syncAll() = %+v", results)
|
||||
}
|
||||
args, err := os.ReadFile(marker)
|
||||
if err != nil {
|
||||
t.Fatalf("hub was not called: %v", err)
|
||||
}
|
||||
if string(args) != "sync --color=never" {
|
||||
t.Fatalf("hub arguments = %q", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanWithBlockedRepositoryIsAValidReport(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "repo")
|
||||
makeRepo(t, path)
|
||||
if err := os.WriteFile(filepath.Join(path, "untracked.txt"), []byte("work\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run([]string{"scan", "--root", root}, bytes.NewReader(nil), &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("run() code = %d, stderr = %q", code, stderr.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte("BLOCKED: dirty worktree")) {
|
||||
t.Fatalf("scan did not report blocked repository:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndentNormalizesGitProgressOutput(t *testing.T) {
|
||||
got := indent("first\rsecond\r\nthird", " ")
|
||||
want := " first\n second\n third"
|
||||
if got != want {
|
||||
t.Fatalf("indent() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user