Implement the core data layer that everything else builds on: walking a parent directory for Git repositories and snapshotting each repository's state via porcelain commands. Domain model (pkg/status): - RepoStatus enum with zero value = StatusUnknown (invalid/unset), covering clean / modified / ahead / behind / diverged / detached / bare / error - RepoInfo snapshot: path, name, remote URL, branch, detached flag, staged / modified / untracked file lists, ahead/behind counts, stash count, error - ScanResult with computed Summary counters and NeedsAttention filter Git wrapper (internal/git): - Executor built with functional options (binary, timeout, env), defaults to "git" with a 10s per-command timeout so a hung repository can never stall a scan; context cancellation honoured via exec.CommandContext - Status() validates with rev-parse --is-bare-repository (bare repos are reported as StatusBare and skipped), then parses `git status --porcelain=v2 --branch -z` - Parsing uses the NUL-separated v2 format so paths with spaces, quotes, and tabs survive verbatim (no C-quoting to decode); rename records (type 2) and ahead/behind lines (# branch.ab) are handled, unknown tokens are ignored for forward compatibility - Best-effort remote URL (git config --get-regexp) and stash count Discovery (internal/scanner/discover.go): - WalkDir-based traversal that never descends into .git internals - Detects working trees via .git directory or .git pointer file (linked worktrees, submodule checkouts) and bare repos via a *.git directory carrying its own HEAD/objects/refs - WithExclude glob patterns (matched against full path and base name) and WithMaxDepth depth limiting as functional options - Walk errors (e.g. permission denied) are aggregated and returned alongside partial results via errors.Join; cancellation is propagated Scanner (internal/scanner/scanner.go): - Concurrent status scanning with a bounded worker pool (errgroup + SetLimit, default 8 workers) - Per-repo failures become StatusError entries instead of aborting the pass; a cancelled context aborts the whole scan Testing: - Table-driven parser tests with canned NUL-separated porcelain output - Integration tests against a real git binary (clean / modified / staged / stashed / detached / bare / non-repo) - Discovery tests over a fixture tree with nested repos, a bare repo, a linked worktree, and an exclusion target - Scanner tests for mixed success/failure, bounded concurrency, and cancellation; everything runs under -race Note: LastFetch from the original plan was dropped — the only reliable source is a reflog of the remote-tracking ref, which does not exist on fresh clones, so it would always be misleading. The model remains extensible if a fetch-history feature is wanted later.
155 lines
4.0 KiB
Go
155 lines
4.0 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// buildTree creates a fixture directory tree with a mix of repository types
|
|
// and noise directories, and returns its root.
|
|
//
|
|
// root/
|
|
// ├── bare.git/ (bare repository)
|
|
// ├── deep/nested/repoC/ (working tree, depth 3)
|
|
// ├── nested/repoB/ (working tree)
|
|
// ├── node_modules/vendored/ (working tree, exclusion target)
|
|
// ├── plaindir/ (noise)
|
|
// ├── repoA/ (working tree)
|
|
// └── worktrees/wt1/ (linked worktree via .git file)
|
|
func buildTree(t *testing.T) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
|
|
mkdir := func(p string) string {
|
|
t.Helper()
|
|
full := filepath.Join(root, p)
|
|
if err := os.MkdirAll(full, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return full
|
|
}
|
|
runGit := func(dir string, args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
|
}
|
|
}
|
|
|
|
for _, p := range []string{"repoA", "nested/repoB", "deep/nested/repoC", "node_modules/vendored"} {
|
|
runGit(mkdir(p), "init", "-q", "-b", "main")
|
|
}
|
|
runGit(mkdir("bare.git"), "init", "-q", "--bare")
|
|
|
|
wt := mkdir("worktrees/wt1")
|
|
if err := os.WriteFile(filepath.Join(wt, ".git"), []byte("gitdir: /nonexistent/real\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
mkdir("plaindir")
|
|
return root
|
|
}
|
|
|
|
func TestDiscoverFindsAllRepos(t *testing.T) {
|
|
root := buildTree(t)
|
|
d := NewDiscoverer()
|
|
repos, err := d.Discover(context.Background(), root)
|
|
if err != nil {
|
|
t.Fatalf("Discover: %v", err)
|
|
}
|
|
|
|
want := []string{
|
|
filepath.Join(root, "bare.git"),
|
|
filepath.Join(root, "deep/nested/repoC"),
|
|
filepath.Join(root, "nested/repoB"),
|
|
filepath.Join(root, "node_modules/vendored"),
|
|
filepath.Join(root, "repoA"),
|
|
filepath.Join(root, "worktrees/wt1"),
|
|
}
|
|
if !reflect.DeepEqual(repos, want) {
|
|
t.Errorf("Discover() =\n %v\nwant\n %v", repos, want)
|
|
}
|
|
}
|
|
|
|
func TestDiscoverExcludesPatterns(t *testing.T) {
|
|
root := buildTree(t)
|
|
d := NewDiscoverer(WithExclude("node_modules"))
|
|
repos, err := d.Discover(context.Background(), root)
|
|
if err != nil {
|
|
t.Fatalf("Discover: %v", err)
|
|
}
|
|
for _, r := range repos {
|
|
if strings.Contains(r, "node_modules") {
|
|
t.Errorf("Discover() returned excluded repo %s", r)
|
|
}
|
|
}
|
|
if len(repos) != 5 {
|
|
t.Errorf("Discover() returned %d repos, want 5", len(repos))
|
|
}
|
|
}
|
|
|
|
func TestDiscoverMaxDepth(t *testing.T) {
|
|
root := buildTree(t)
|
|
d := NewDiscoverer(WithMaxDepth(2))
|
|
repos, err := d.Discover(context.Background(), root)
|
|
if err != nil {
|
|
t.Fatalf("Discover: %v", err)
|
|
}
|
|
for _, r := range repos {
|
|
if strings.Contains(r, "deep/nested/repoC") {
|
|
t.Errorf("Discover() returned repo beyond max depth: %s", r)
|
|
}
|
|
}
|
|
if len(repos) != 5 {
|
|
t.Errorf("Discover() returned %d repos, want 5 (deep/nested/repoC excluded)", len(repos))
|
|
}
|
|
}
|
|
|
|
func TestDiscoverBadRoot(t *testing.T) {
|
|
d := NewDiscoverer()
|
|
|
|
if _, err := d.Discover(context.Background(), filepath.Join(t.TempDir(), "missing")); err == nil {
|
|
t.Error("Discover(missing) succeeded, want error")
|
|
}
|
|
|
|
file := filepath.Join(t.TempDir(), "file.txt")
|
|
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := d.Discover(context.Background(), file); err == nil {
|
|
t.Error("Discover(file) succeeded, want error")
|
|
}
|
|
}
|
|
|
|
func TestDiscoverCancellation(t *testing.T) {
|
|
root := buildTree(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancelled up front
|
|
if _, err := NewDiscoverer().Discover(ctx, root); err == nil {
|
|
t.Error("Discover(cancelled ctx) succeeded, want error")
|
|
}
|
|
}
|
|
|
|
func TestDepthBelow(t *testing.T) {
|
|
root := "/a/b"
|
|
cases := []struct {
|
|
path string
|
|
want int
|
|
}{
|
|
{"/a/b", 0},
|
|
{"/a/b/c", 1},
|
|
{"/a/b/c/d", 2},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := depthBelow(root, tc.path); got != tc.want {
|
|
t.Errorf("depthBelow(%s, %s) = %d, want %d", root, tc.path, got, tc.want)
|
|
}
|
|
}
|
|
}
|