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.
242 lines
6.8 KiB
Go
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")
|
|
}
|
|
}
|