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.
166 lines
4.6 KiB
Go
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
|
|
}
|