gitFlow/internal/scanner/discover_test.go
dimitar ae23019277 feat: phase 1 — repository discovery and status scanning
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.
2026-08-02 08:35:41 +02:00

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)
}
}
}