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.
125 lines
4.1 KiB
Go
125 lines
4.1 KiB
Go
// Package status defines the domain model shared across gitflow: how a Git
|
|
// repository's state is captured, classified, and summarised after a scan.
|
|
package status
|
|
|
|
import "time"
|
|
|
|
// RepoStatus classifies the overall health of a repository at scan time.
|
|
//
|
|
// The zero value is StatusUnknown, an invalid/unset state, so a zero-valued
|
|
// RepoInfo can never be mistaken for a real scan result.
|
|
type RepoStatus int
|
|
|
|
const (
|
|
StatusUnknown RepoStatus = iota // 0 — invalid/unset
|
|
StatusClean // 1 — working tree clean, in sync
|
|
StatusModified // 2 — staged, modified, or untracked files
|
|
StatusAhead // 3 — local commits not yet pushed
|
|
StatusBehind // 4 — remote commits not yet pulled
|
|
StatusDiverged // 5 — both ahead of and behind upstream
|
|
StatusDetached // 6 — HEAD points at a commit, not a branch
|
|
StatusBare // 7 — bare repository, no working tree
|
|
StatusError // 8 — could not be scanned
|
|
)
|
|
|
|
// String returns a lowercase, human-readable label for the status.
|
|
func (s RepoStatus) String() string {
|
|
switch s {
|
|
case StatusClean:
|
|
return "clean"
|
|
case StatusModified:
|
|
return "modified"
|
|
case StatusAhead:
|
|
return "ahead"
|
|
case StatusBehind:
|
|
return "behind"
|
|
case StatusDiverged:
|
|
return "diverged"
|
|
case StatusDetached:
|
|
return "detached"
|
|
case StatusBare:
|
|
return "bare"
|
|
case StatusError:
|
|
return "error"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// NeedsAttention reports whether the repository asks for human action.
|
|
func (s RepoStatus) NeedsAttention() bool {
|
|
return s != StatusClean && s != StatusUnknown
|
|
}
|
|
|
|
// RepoInfo is a full snapshot of a single repository at scan time.
|
|
type RepoInfo struct {
|
|
Path string // absolute path to the repository root
|
|
Name string // base directory name, for display
|
|
RemoteURL string // fetch URL of the first remote, if any
|
|
Branch string // current branch name; "(detached)" when detached
|
|
Detached bool // HEAD is detached from any branch
|
|
Status RepoStatus
|
|
StagedFiles []string // files with staged changes
|
|
ModifiedFiles []string // files with unstaged changes
|
|
UntrackedFiles []string // untracked files or directories
|
|
AheadBy int // commits ahead of upstream
|
|
BehindBy int // commits behind upstream
|
|
StashCount int // number of stashes
|
|
Error string // scan error detail; non-empty when Status is StatusError
|
|
}
|
|
|
|
// FileCount returns the total number of files with any local change.
|
|
func (r RepoInfo) FileCount() int {
|
|
return len(r.StagedFiles) + len(r.ModifiedFiles) + len(r.UntrackedFiles)
|
|
}
|
|
|
|
// ScanResult is the outcome of one scan pass over a set of repositories.
|
|
type ScanResult struct {
|
|
ScannedAt time.Time // when the scan ran
|
|
ParentDir string // the directory that was scanned
|
|
Repos []RepoInfo
|
|
}
|
|
|
|
// Summary aggregates per-repository counters for the whole result.
|
|
type Summary struct {
|
|
Total int // repositories scanned
|
|
Clean int // status clean, no action needed
|
|
Attention int // status not clean (modified, ahead, behind, diverged, detached, bare)
|
|
Errored int // status error (scan failed)
|
|
Staged int // files staged across all repos
|
|
Modified int // files modified across all repos
|
|
Untracked int // untracked files across all repos
|
|
}
|
|
|
|
// Summary computes the aggregate counters for the result.
|
|
func (r ScanResult) Summary() Summary {
|
|
s := Summary{Total: len(r.Repos)}
|
|
for _, repo := range r.Repos {
|
|
switch repo.Status {
|
|
case StatusClean:
|
|
s.Clean++
|
|
case StatusError:
|
|
s.Errored++
|
|
case StatusUnknown:
|
|
// Neither clean nor actionable; not counted.
|
|
default:
|
|
s.Attention++
|
|
}
|
|
s.Staged += len(repo.StagedFiles)
|
|
s.Modified += len(repo.ModifiedFiles)
|
|
s.Untracked += len(repo.UntrackedFiles)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// NeedsAttention lists repositories whose status is anything but clean.
|
|
func (r ScanResult) NeedsAttention() []RepoInfo {
|
|
out := make([]RepoInfo, 0, len(r.Repos))
|
|
for _, repo := range r.Repos {
|
|
if repo.Status.NeedsAttention() {
|
|
out = append(out, repo)
|
|
}
|
|
}
|
|
return out
|
|
}
|