gitFlow/pkg/status/status.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

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
}