gitFlow/internal/git/status_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

242 lines
6.8 KiB
Go

package git
import (
"context"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// nulp joins porcelain v2 tokens the way git does with -z: NUL separated.
func nulp(parts ...string) string { return strings.Join(parts, "\x00") }
// parseFixture parses canned porcelain v2 output and classifies it.
func parseFixture(t *testing.T, out string) status.RepoInfo {
t.Helper()
var info status.RepoInfo
parsePorcelainV2(out, &info)
info.Status = classify(&info)
return info
}
func TestParsePorcelainV2Clean(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.oid 85fe419ad0cb548d3288a2e73d9bb18f5ddc8863",
"# branch.head main",
"# branch.upstream origin/main",
"# branch.ab +0 -0",
))
if info.Branch != "main" || info.Detached {
t.Errorf("branch = %q detached=%v, want main/false", info.Branch, info.Detached)
}
if info.Status != status.StatusClean {
t.Errorf("Status = %v, want clean", info.Status)
}
if info.FileCount() != 0 {
t.Errorf("FileCount = %d, want 0", info.FileCount())
}
}
func TestParsePorcelainV2Modified(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.head main",
"1 .M N... 100644 100644 100644 aaa bbb a.txt",
"? new dir/",
))
if info.Status != status.StatusModified {
t.Errorf("Status = %v, want modified", info.Status)
}
if want := []string{"a.txt"}; !reflect.DeepEqual(info.ModifiedFiles, want) {
t.Errorf("ModifiedFiles = %v, want %v", info.ModifiedFiles, want)
}
if want := []string{"new dir/"}; !reflect.DeepEqual(info.UntrackedFiles, want) {
t.Errorf("UntrackedFiles = %v, want %v", info.UntrackedFiles, want)
}
}
func TestParsePorcelainV2StagedAndRenamed(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.head main",
"1 M. N... 100644 100644 100644 aaa bbb staged.txt",
"1 MM N... 100644 100644 100644 aaa bbb both.txt",
"2 R. N... 100644 100644 100644 aaa bbb R100 new file.txt",
))
if want := []string{"staged.txt", "both.txt", "new file.txt"}; !reflect.DeepEqual(info.StagedFiles, want) {
t.Errorf("StagedFiles = %v, want %v", info.StagedFiles, want)
}
if want := []string{"both.txt"}; !reflect.DeepEqual(info.ModifiedFiles, want) {
t.Errorf("ModifiedFiles = %v, want %v", info.ModifiedFiles, want)
}
}
func TestParsePorcelainV2Diverged(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.head main",
"# branch.ab +3 -5",
))
if info.AheadBy != 3 || info.BehindBy != 5 {
t.Errorf("ahead/behind = %d/%d, want 3/5", info.AheadBy, info.BehindBy)
}
if info.Status != status.StatusDiverged {
t.Errorf("Status = %v, want diverged", info.Status)
}
}
func TestParsePorcelainV2Behind(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.head main",
"# branch.ab +0 -2",
))
if info.Status != status.StatusBehind {
t.Errorf("Status = %v, want behind", info.Status)
}
}
func TestParsePorcelainV2Detached(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.oid 85fe419ad0cb548d3288a2e73d9bb18f5ddc8863",
"# branch.head (detached)",
))
if !info.Detached || info.Branch != "(detached)" {
t.Errorf("Detached = %v, Branch = %q; want true/(detached)", info.Detached, info.Branch)
}
if info.Status != status.StatusDetached {
t.Errorf("Status = %v, want detached", info.Status)
}
}
func TestParsePorcelainV2RawPaths(t *testing.T) {
// With -z, paths are raw: spaces, quotes, and tabs are never escaped.
info := parseFixture(t, nulp(
"? tab\tname.txt",
`? quote"name.txt`,
))
if want := []string{"tab\tname.txt", `quote"name.txt`}; !reflect.DeepEqual(info.UntrackedFiles, want) {
t.Errorf("UntrackedFiles = %v, want %v", info.UntrackedFiles, want)
}
}
func TestParsePorcelainV2ToleratesUnknownLines(t *testing.T) {
info := parseFixture(t, nulp(
"# future.header something",
"# branch.head main",
"9 custom record",
))
if info.Status != status.StatusClean || info.Branch != "main" {
t.Errorf("unexpected parse: %+v", info)
}
}
// --- Integration tests against a real git binary ---------------------------
func runGit(t *testing.T, 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)
}
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestExecutorStatusIntegration(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
dir := t.TempDir()
repo := filepath.Join(dir, "repo")
runGit(t, dir, "init", "-q", "-b", "main", filepath.Base(repo))
runGit(t, repo, "config", "user.email", "t@t")
runGit(t, repo, "config", "user.name", "t")
writeFile(t, repo, "a.txt", "hello")
runGit(t, repo, "add", "a.txt")
runGit(t, repo, "commit", "-qm", "init")
e := NewExecutor()
info, err := e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if info.Status != status.StatusClean {
t.Errorf("Status = %v, want clean", info.Status)
}
if info.Branch != "main" {
t.Errorf("Branch = %q, want main", info.Branch)
}
if info.Name != "repo" || info.Path != repo {
t.Errorf("Name/Path = %q/%q, want repo/%s", info.Name, info.Path, repo)
}
// Untracked + modified
writeFile(t, repo, "b.txt", "x")
writeFile(t, repo, "a.txt", "changed")
info, err = e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if info.Status != status.StatusModified {
t.Errorf("Status = %v, want modified", info.Status)
}
if len(info.UntrackedFiles) != 1 || len(info.ModifiedFiles) != 1 {
t.Errorf("untracked/modified = %d/%d, want 1/1", len(info.UntrackedFiles), len(info.ModifiedFiles))
}
// Staged
runGit(t, repo, "add", "b.txt")
info, err = e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if len(info.StagedFiles) != 1 {
t.Errorf("staged = %d, want 1", len(info.StagedFiles))
}
// Stash
runGit(t, repo, "stash", "push", "-q", "-m", "wip")
info, err = e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if info.StashCount != 1 {
t.Errorf("StashCount = %d, want 1", info.StashCount)
}
// Detached HEAD
runGit(t, repo, "checkout", "-q", "--detach")
info, err = e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if !info.Detached {
t.Errorf("Detached = false, want true")
}
// Bare repository
bare := filepath.Join(dir, "bare.git")
runGit(t, dir, "init", "-q", "--bare", filepath.Base(bare))
info, err = e.Status(context.Background(), bare)
if err != nil {
t.Fatalf("Status(bare): %v", err)
}
if info.Status != status.StatusBare {
t.Errorf("bare Status = %v, want bare", info.Status)
}
// Not a repository
if _, err := e.Status(context.Background(), dir); err == nil {
t.Fatal("Status(non-repo) succeeded, want error")
}
}