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

162 lines
4.3 KiB
Go

package git
import (
"context"
"path/filepath"
"strconv"
"strings"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// Status snapshots one repository at path. Bare repositories are reported
// with StatusBare and no further detail, since they have no working tree.
func (e *Executor) Status(ctx context.Context, path string) (status.RepoInfo, error) {
info := status.RepoInfo{Path: path, Name: filepath.Base(path)}
bare, err := e.Run(ctx, path, "rev-parse", "--is-bare-repository")
if err != nil {
return info, err
}
if bare == "true" {
info.Status = status.StatusBare
return info, nil
}
out, err := e.Run(ctx, path, "status", "--porcelain=v2", "--branch", "-z")
if err != nil {
return info, err
}
parsePorcelainV2(out, &info)
info.RemoteURL = e.remoteURL(ctx, path)
info.StashCount = e.stashCount(ctx, path)
info.Status = classify(&info)
return info, nil
}
// classify derives the coarse-grained status from the parsed snapshot.
func classify(info *status.RepoInfo) status.RepoStatus {
switch {
case info.AheadBy > 0 && info.BehindBy > 0:
return status.StatusDiverged
case info.BehindBy > 0:
return status.StatusBehind
case info.AheadBy > 0:
return status.StatusAhead
case info.FileCount() > 0:
return status.StatusModified
case info.Detached:
return status.StatusDetached
default:
return status.StatusClean
}
}
// parsePorcelainV2 parses the NUL-separated output of
// `git status --porcelain=v2 --branch -z` into info. Unknown tokens are
// ignored so future git versions remain compatible.
func parsePorcelainV2(out string, info *status.RepoInfo) {
for _, token := range strings.Split(out, "\x00") {
token = strings.TrimSpace(token)
if token == "" {
continue
}
switch {
case strings.HasPrefix(token, "# branch.head "):
head := strings.TrimPrefix(token, "# branch.head ")
if head == "(detached)" {
info.Detached = true
info.Branch = "(detached)"
} else {
info.Branch = head
}
case strings.HasPrefix(token, "# branch.ab "):
parseAheadBehind(token, info)
case strings.HasPrefix(token, "1 "), strings.HasPrefix(token, "2 "):
parseChangeRecord(token, info)
case strings.HasPrefix(token, "?"):
info.UntrackedFiles = append(info.UntrackedFiles, strings.TrimSpace(strings.TrimPrefix(token, "?")))
}
}
}
// parseAheadBehind parses "# branch.ab +3 -5" into AheadBy/BehindBy.
func parseAheadBehind(token string, info *status.RepoInfo) {
fields := strings.Fields(token) // [#, branch.ab, +3, -5]
if len(fields) < 4 {
return
}
if n, err := strconv.Atoi(strings.TrimPrefix(fields[2], "+")); err == nil {
info.AheadBy = n
}
if n, err := strconv.Atoi(strings.TrimPrefix(fields[3], "-")); err == nil {
info.BehindBy = n
}
}
// parseChangeRecord handles porcelain v2 change records:
//
// 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
// 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>
//
// X is the staged status and Y the unstaged one; '.' means unmodified.
// With -z the path is raw, so only the fixed leading fields are split and
// the remainder (which may contain spaces) is taken as the path.
func parseChangeRecord(token string, info *status.RepoInfo) {
fields := strings.Fields(token)
if len(fields[1]) < 2 {
return
}
var pathIdx int
switch fields[0] {
case "1":
if len(fields) < 9 {
return
}
pathIdx = 8
case "2":
if len(fields) < 10 {
return
}
pathIdx = 9
default:
return
}
path := strings.Join(fields[pathIdx:], " ")
if xy := fields[1]; xy[0] != '.' {
info.StagedFiles = append(info.StagedFiles, path)
}
if xy := fields[1]; xy[1] != '.' {
info.ModifiedFiles = append(info.ModifiedFiles, path)
}
}
// remoteURL returns the fetch URL of the first configured remote, if any.
func (e *Executor) remoteURL(ctx context.Context, path string) string {
out, err := e.Run(ctx, path, "config", "--get-regexp", `^remote\..*\.url$`)
if err != nil {
return ""
}
for _, line := range strings.Split(out, "\n") {
if f := strings.Fields(line); len(f) >= 2 {
return f[1]
}
}
return ""
}
// stashCount returns the number of stashes in the repository.
func (e *Executor) stashCount(ctx context.Context, path string) int {
out, err := e.Run(ctx, path, "stash", "list")
if err != nil {
return 0
}
if strings.TrimSpace(out) == "" {
return 0
}
return strings.Count(strings.TrimSpace(out), "\n") + 1
}