gitFlow/internal/scanner/discover.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

166 lines
4.6 KiB
Go

// Package scanner discovers Git repositories on disk and produces a status
// snapshot for each of them.
package scanner
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
// Discoverer walks a directory tree and reports the repository roots it finds.
type Discoverer struct {
excludePatterns []string
maxDepth int // 0 means unlimited
}
// DiscoverOption configures a Discoverer (functional options pattern).
type DiscoverOption func(*Discoverer)
// WithExclude adds glob patterns that are skipped during traversal. Patterns
// are matched against both the full path and the base name of each entry.
func WithExclude(patterns ...string) DiscoverOption {
return func(d *Discoverer) { d.excludePatterns = append(d.excludePatterns, patterns...) }
}
// WithMaxDepth limits how many directory levels below the root are scanned.
// A value of 0 means unlimited. Repositories nested deeper than the limit
// are ignored.
func WithMaxDepth(depth int) DiscoverOption {
return func(d *Discoverer) { d.maxDepth = depth }
}
// NewDiscoverer builds a Discoverer with the given options.
func NewDiscoverer(opts ...DiscoverOption) *Discoverer {
d := &Discoverer{}
for _, opt := range opts {
opt(d)
}
return d
}
// Discover returns the absolute paths of all repository roots under root.
//
// Working trees are detected via their .git directory or .git pointer file
// (linked worktrees and submodule checkouts), bare repositories via a
// directory named *.git that carries its own HEAD/objects/refs. .git
// internals are never descended into.
//
// Walk errors (e.g. permission denied) are aggregated and returned alongside
// whatever repositories were found, so a partially-scanned result is still
// useful to the caller.
func (d *Discoverer) Discover(ctx context.Context, root string) ([]string, error) {
info, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("discover %s: %w", root, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("discover %s: not a directory", root)
}
root, err = filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("discover %s: %w", root, err)
}
var (
repos []string
walkErrs []error
)
walkFn := func(path string, entry fs.DirEntry, err error) error {
if err != nil {
walkErrs = append(walkErrs, fmt.Errorf("walk %s: %w", path, err))
return nil // keep walking siblings
}
if err := ctx.Err(); err != nil {
return err
}
if !entry.IsDir() {
// A .git file marks a linked worktree or submodule checkout;
// its parent directory is the working tree root.
if entry.Name() == ".git" {
repos = append(repos, filepath.Dir(path))
}
return nil
}
name := entry.Name()
path = filepath.Clean(path)
// The .git directory inside a working tree: report the parent and
// never descend into git internals.
if name == ".git" {
repos = append(repos, filepath.Dir(path))
return filepath.SkipDir
}
// A bare repository: a directory named *.git (other than the .git
// handled above) that carries its own git metadata.
if strings.HasSuffix(name, ".git") && isBareRepoDir(path) {
repos = append(repos, path)
return filepath.SkipDir
}
if d.excluded(path, name) {
return filepath.SkipDir
}
if d.maxDepth > 0 && depthBelow(root, path) > d.maxDepth {
return filepath.SkipDir
}
return nil
}
if err := filepath.WalkDir(root, walkFn); err != nil {
// Distinguish cancellation from a walk-level failure.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return repos, err
}
walkErrs = append(walkErrs, err)
}
return repos, errors.Join(walkErrs...)
}
// isBareRepoDir reports whether path looks like a bare repository: it holds
// its own HEAD, objects, and refs entries.
func isBareRepoDir(path string) bool {
if _, err := os.Stat(filepath.Join(path, "HEAD")); err != nil {
return false
}
if info, err := os.Stat(filepath.Join(path, "objects")); err != nil || !info.IsDir() {
return false
}
if info, err := os.Stat(filepath.Join(path, "refs")); err != nil || !info.IsDir() {
return false
}
return true
}
// excluded reports whether path or its base name matches any pattern.
func (d *Discoverer) excluded(path, name string) bool {
for _, pat := range d.excludePatterns {
if ok, _ := filepath.Match(pat, name); ok {
return true
}
if ok, _ := filepath.Match(pat, path); ok {
return true
}
}
return false
}
// depthBelow returns how many directory levels path sits below root.
// The root itself is depth 0.
func depthBelow(root, path string) int {
rel, err := filepath.Rel(root, path)
if err != nil || rel == "." {
return 0
}
return strings.Count(rel, string(filepath.Separator)) + 1
}