295d694d78
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.
203 lines
5.8 KiB
Go
203 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|