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.
This commit is contained in:
dimitar 2026-08-02 08:35:41 +02:00
parent 48c6438ae7
commit ae23019277
11 changed files with 1202 additions and 0 deletions

2
go.mod
View File

@ -1,3 +1,5 @@
module gitea.oblak.solutions/dimitar/gitFlow
go 1.24
require golang.org/x/sync v0.12.0

2
go.sum Normal file
View File

@ -0,0 +1,2 @@
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=

80
internal/git/exec.go Normal file
View File

@ -0,0 +1,80 @@
// Package git is a thin wrapper around the git binary. It runs porcelain
// commands via os/exec with a per-command timeout so a scan can never hang
// on a broken or unresponsive repository.
package git
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// DefaultTimeout bounds every git invocation. A single hung git call would
// otherwise stall the whole scan.
const DefaultTimeout = 10 * time.Second
// Executor runs git commands against a repository working directory.
type Executor struct {
bin string
timeout time.Duration
env []string
}
// Option configures an Executor (functional options pattern).
type Option func(*Executor)
// WithBinary overrides the git executable path (default "git").
func WithBinary(bin string) Option {
return func(e *Executor) { e.bin = bin }
}
// WithTimeout overrides the per-command timeout (default 10s).
func WithTimeout(d time.Duration) Option {
return func(e *Executor) { e.timeout = d }
}
// WithEnv appends extra environment variables to every command.
func WithEnv(env ...string) Option {
return func(e *Executor) { e.env = append(e.env, env...) }
}
// NewExecutor builds an Executor with sensible defaults.
func NewExecutor(opts ...Option) *Executor {
e := &Executor{bin: "git", timeout: DefaultTimeout}
for _, opt := range opts {
opt(e)
}
return e
}
// Run executes git <args...> in dir with a context timeout and returns the
// trimmed stdout. Context cancellation is honoured; the configured timeout
// guards against a hanging repository.
func (e *Executor) Run(ctx context.Context, dir string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, e.timeout)
defer cancel()
cmd := exec.CommandContext(ctx, e.bin, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), e.env...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return "", fmt.Errorf("git %s: timed out after %s", strings.Join(args, " "), e.timeout)
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
}
return strings.TrimSpace(stdout.String()), nil
}

161
internal/git/status.go Normal file
View File

@ -0,0 +1,161 @@
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
}

241
internal/git/status_test.go Normal file
View File

@ -0,0 +1,241 @@
package git
import (
"context"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// nulp joins porcelain v2 tokens the way git does with -z: NUL separated.
func nulp(parts ...string) string { return strings.Join(parts, "\x00") }
// parseFixture parses canned porcelain v2 output and classifies it.
func parseFixture(t *testing.T, out string) status.RepoInfo {
t.Helper()
var info status.RepoInfo
parsePorcelainV2(out, &info)
info.Status = classify(&info)
return info
}
func TestParsePorcelainV2Clean(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.oid 85fe419ad0cb548d3288a2e73d9bb18f5ddc8863",
"# branch.head main",
"# branch.upstream origin/main",
"# branch.ab +0 -0",
))
if info.Branch != "main" || info.Detached {
t.Errorf("branch = %q detached=%v, want main/false", info.Branch, info.Detached)
}
if info.Status != status.StatusClean {
t.Errorf("Status = %v, want clean", info.Status)
}
if info.FileCount() != 0 {
t.Errorf("FileCount = %d, want 0", info.FileCount())
}
}
func TestParsePorcelainV2Modified(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.head main",
"1 .M N... 100644 100644 100644 aaa bbb a.txt",
"? new dir/",
))
if info.Status != status.StatusModified {
t.Errorf("Status = %v, want modified", info.Status)
}
if want := []string{"a.txt"}; !reflect.DeepEqual(info.ModifiedFiles, want) {
t.Errorf("ModifiedFiles = %v, want %v", info.ModifiedFiles, want)
}
if want := []string{"new dir/"}; !reflect.DeepEqual(info.UntrackedFiles, want) {
t.Errorf("UntrackedFiles = %v, want %v", info.UntrackedFiles, want)
}
}
func TestParsePorcelainV2StagedAndRenamed(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.head main",
"1 M. N... 100644 100644 100644 aaa bbb staged.txt",
"1 MM N... 100644 100644 100644 aaa bbb both.txt",
"2 R. N... 100644 100644 100644 aaa bbb R100 new file.txt",
))
if want := []string{"staged.txt", "both.txt", "new file.txt"}; !reflect.DeepEqual(info.StagedFiles, want) {
t.Errorf("StagedFiles = %v, want %v", info.StagedFiles, want)
}
if want := []string{"both.txt"}; !reflect.DeepEqual(info.ModifiedFiles, want) {
t.Errorf("ModifiedFiles = %v, want %v", info.ModifiedFiles, want)
}
}
func TestParsePorcelainV2Diverged(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.head main",
"# branch.ab +3 -5",
))
if info.AheadBy != 3 || info.BehindBy != 5 {
t.Errorf("ahead/behind = %d/%d, want 3/5", info.AheadBy, info.BehindBy)
}
if info.Status != status.StatusDiverged {
t.Errorf("Status = %v, want diverged", info.Status)
}
}
func TestParsePorcelainV2Behind(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.head main",
"# branch.ab +0 -2",
))
if info.Status != status.StatusBehind {
t.Errorf("Status = %v, want behind", info.Status)
}
}
func TestParsePorcelainV2Detached(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.oid 85fe419ad0cb548d3288a2e73d9bb18f5ddc8863",
"# branch.head (detached)",
))
if !info.Detached || info.Branch != "(detached)" {
t.Errorf("Detached = %v, Branch = %q; want true/(detached)", info.Detached, info.Branch)
}
if info.Status != status.StatusDetached {
t.Errorf("Status = %v, want detached", info.Status)
}
}
func TestParsePorcelainV2RawPaths(t *testing.T) {
// With -z, paths are raw: spaces, quotes, and tabs are never escaped.
info := parseFixture(t, nulp(
"? tab\tname.txt",
`? quote"name.txt`,
))
if want := []string{"tab\tname.txt", `quote"name.txt`}; !reflect.DeepEqual(info.UntrackedFiles, want) {
t.Errorf("UntrackedFiles = %v, want %v", info.UntrackedFiles, want)
}
}
func TestParsePorcelainV2ToleratesUnknownLines(t *testing.T) {
info := parseFixture(t, nulp(
"# future.header something",
"# branch.head main",
"9 custom record",
))
if info.Status != status.StatusClean || info.Branch != "main" {
t.Errorf("unexpected parse: %+v", info)
}
}
// --- Integration tests against a real git binary ---------------------------
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestExecutorStatusIntegration(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
dir := t.TempDir()
repo := filepath.Join(dir, "repo")
runGit(t, dir, "init", "-q", "-b", "main", filepath.Base(repo))
runGit(t, repo, "config", "user.email", "t@t")
runGit(t, repo, "config", "user.name", "t")
writeFile(t, repo, "a.txt", "hello")
runGit(t, repo, "add", "a.txt")
runGit(t, repo, "commit", "-qm", "init")
e := NewExecutor()
info, err := e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if info.Status != status.StatusClean {
t.Errorf("Status = %v, want clean", info.Status)
}
if info.Branch != "main" {
t.Errorf("Branch = %q, want main", info.Branch)
}
if info.Name != "repo" || info.Path != repo {
t.Errorf("Name/Path = %q/%q, want repo/%s", info.Name, info.Path, repo)
}
// Untracked + modified
writeFile(t, repo, "b.txt", "x")
writeFile(t, repo, "a.txt", "changed")
info, err = e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if info.Status != status.StatusModified {
t.Errorf("Status = %v, want modified", info.Status)
}
if len(info.UntrackedFiles) != 1 || len(info.ModifiedFiles) != 1 {
t.Errorf("untracked/modified = %d/%d, want 1/1", len(info.UntrackedFiles), len(info.ModifiedFiles))
}
// Staged
runGit(t, repo, "add", "b.txt")
info, err = e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if len(info.StagedFiles) != 1 {
t.Errorf("staged = %d, want 1", len(info.StagedFiles))
}
// Stash
runGit(t, repo, "stash", "push", "-q", "-m", "wip")
info, err = e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if info.StashCount != 1 {
t.Errorf("StashCount = %d, want 1", info.StashCount)
}
// Detached HEAD
runGit(t, repo, "checkout", "-q", "--detach")
info, err = e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if !info.Detached {
t.Errorf("Detached = false, want true")
}
// Bare repository
bare := filepath.Join(dir, "bare.git")
runGit(t, dir, "init", "-q", "--bare", filepath.Base(bare))
info, err = e.Status(context.Background(), bare)
if err != nil {
t.Fatalf("Status(bare): %v", err)
}
if info.Status != status.StatusBare {
t.Errorf("bare Status = %v, want bare", info.Status)
}
// Not a repository
if _, err := e.Status(context.Background(), dir); err == nil {
t.Fatal("Status(non-repo) succeeded, want error")
}
}

View File

@ -0,0 +1,165 @@
// 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
}

View File

@ -0,0 +1,154 @@
package scanner
import (
"context"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
// buildTree creates a fixture directory tree with a mix of repository types
// and noise directories, and returns its root.
//
// root/
// ├── bare.git/ (bare repository)
// ├── deep/nested/repoC/ (working tree, depth 3)
// ├── nested/repoB/ (working tree)
// ├── node_modules/vendored/ (working tree, exclusion target)
// ├── plaindir/ (noise)
// ├── repoA/ (working tree)
// └── worktrees/wt1/ (linked worktree via .git file)
func buildTree(t *testing.T) string {
t.Helper()
root := t.TempDir()
mkdir := func(p string) string {
t.Helper()
full := filepath.Join(root, p)
if err := os.MkdirAll(full, 0o755); err != nil {
t.Fatal(err)
}
return full
}
runGit := func(dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
}
for _, p := range []string{"repoA", "nested/repoB", "deep/nested/repoC", "node_modules/vendored"} {
runGit(mkdir(p), "init", "-q", "-b", "main")
}
runGit(mkdir("bare.git"), "init", "-q", "--bare")
wt := mkdir("worktrees/wt1")
if err := os.WriteFile(filepath.Join(wt, ".git"), []byte("gitdir: /nonexistent/real\n"), 0o644); err != nil {
t.Fatal(err)
}
mkdir("plaindir")
return root
}
func TestDiscoverFindsAllRepos(t *testing.T) {
root := buildTree(t)
d := NewDiscoverer()
repos, err := d.Discover(context.Background(), root)
if err != nil {
t.Fatalf("Discover: %v", err)
}
want := []string{
filepath.Join(root, "bare.git"),
filepath.Join(root, "deep/nested/repoC"),
filepath.Join(root, "nested/repoB"),
filepath.Join(root, "node_modules/vendored"),
filepath.Join(root, "repoA"),
filepath.Join(root, "worktrees/wt1"),
}
if !reflect.DeepEqual(repos, want) {
t.Errorf("Discover() =\n %v\nwant\n %v", repos, want)
}
}
func TestDiscoverExcludesPatterns(t *testing.T) {
root := buildTree(t)
d := NewDiscoverer(WithExclude("node_modules"))
repos, err := d.Discover(context.Background(), root)
if err != nil {
t.Fatalf("Discover: %v", err)
}
for _, r := range repos {
if strings.Contains(r, "node_modules") {
t.Errorf("Discover() returned excluded repo %s", r)
}
}
if len(repos) != 5 {
t.Errorf("Discover() returned %d repos, want 5", len(repos))
}
}
func TestDiscoverMaxDepth(t *testing.T) {
root := buildTree(t)
d := NewDiscoverer(WithMaxDepth(2))
repos, err := d.Discover(context.Background(), root)
if err != nil {
t.Fatalf("Discover: %v", err)
}
for _, r := range repos {
if strings.Contains(r, "deep/nested/repoC") {
t.Errorf("Discover() returned repo beyond max depth: %s", r)
}
}
if len(repos) != 5 {
t.Errorf("Discover() returned %d repos, want 5 (deep/nested/repoC excluded)", len(repos))
}
}
func TestDiscoverBadRoot(t *testing.T) {
d := NewDiscoverer()
if _, err := d.Discover(context.Background(), filepath.Join(t.TempDir(), "missing")); err == nil {
t.Error("Discover(missing) succeeded, want error")
}
file := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := d.Discover(context.Background(), file); err == nil {
t.Error("Discover(file) succeeded, want error")
}
}
func TestDiscoverCancellation(t *testing.T) {
root := buildTree(t)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancelled up front
if _, err := NewDiscoverer().Discover(ctx, root); err == nil {
t.Error("Discover(cancelled ctx) succeeded, want error")
}
}
func TestDepthBelow(t *testing.T) {
root := "/a/b"
cases := []struct {
path string
want int
}{
{"/a/b", 0},
{"/a/b/c", 1},
{"/a/b/c/d", 2},
}
for _, tc := range cases {
if got := depthBelow(root, tc.path); got != tc.want {
t.Errorf("depthBelow(%s, %s) = %d, want %d", root, tc.path, got, tc.want)
}
}
}

View File

@ -0,0 +1,74 @@
package scanner
import (
"context"
"path/filepath"
"golang.org/x/sync/errgroup"
"gitea.oblak.solutions/dimitar/gitFlow/internal/git"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// Scanner produces status snapshots for a set of repositories, scanning them
// concurrently with a bounded worker pool.
type Scanner struct {
git *git.Executor
workers int
}
// ScannerOption configures a Scanner (functional options pattern).
type ScannerOption func(*Scanner)
// WithGitExecutor overrides the git executor used for scanning.
func WithGitExecutor(e *git.Executor) ScannerOption {
return func(s *Scanner) { s.git = e }
}
// WithWorkers sets the maximum number of concurrent git scans.
func WithWorkers(n int) ScannerOption {
return func(s *Scanner) {
if n > 0 {
s.workers = n
}
}
}
// NewScanner builds a Scanner with sane defaults.
func NewScanner(opts ...ScannerOption) *Scanner {
s := &Scanner{git: git.NewExecutor(), workers: 8}
for _, opt := range opts {
opt(s)
}
return s
}
// Scan snapshots every repository path. A repository that fails to scan is
// reported with StatusError rather than aborting the pass. The returned
// error is non-nil only when the context is cancelled mid-scan.
func (s *Scanner) Scan(ctx context.Context, paths []string) ([]status.RepoInfo, error) {
infos := make([]status.RepoInfo, len(paths))
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(s.workers)
for i, path := range paths {
i, path := i, path
g.Go(func() error {
info, err := s.git.Status(ctx, path)
if err != nil {
if ctx.Err() != nil {
return ctx.Err() // cancelled mid-scan, not a repo failure
}
info = status.RepoInfo{
Path: path,
Name: filepath.Base(path),
Status: status.StatusError,
Error: err.Error(),
}
}
infos[i] = info
return nil
})
}
return infos, g.Wait()
}

View File

@ -0,0 +1,100 @@
package scanner
import (
"context"
"os/exec"
"path/filepath"
"testing"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
func newGitRepo(t *testing.T, dir string) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
cmd := exec.Command("git", "init", "-q", "-b", "main", filepath.Base(dir))
cmd.Dir = filepath.Dir(dir)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init: %v\n%s", err, out)
}
cmd = exec.Command("git", "config", "user.email", "t@t")
cmd.Dir = dir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "t")
cmd.Dir = dir
_ = cmd.Run()
return dir
}
func TestScannerScanMixed(t *testing.T) {
root := t.TempDir()
repo1 := newGitRepo(t, filepath.Join(root, "one"))
repo2 := newGitRepo(t, filepath.Join(root, "two"))
bogus := filepath.Join(root, "not-a-repo")
s := NewScanner()
infos, err := s.Scan(context.Background(), []string{repo1, repo2, bogus})
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(infos) != 3 {
t.Fatalf("Scan returned %d infos, want 3", len(infos))
}
for _, info := range infos {
switch info.Path {
case repo1, repo2:
if info.Status != status.StatusClean {
t.Errorf("%s: Status = %v, want clean", info.Path, info.Status)
}
case bogus:
if info.Status != status.StatusError {
t.Errorf("bogus: Status = %v, want error", info.Status)
}
if info.Error == "" {
t.Errorf("bogus: Error is empty")
}
}
}
}
func TestScannerConcurrentErrors(t *testing.T) {
// Many failing paths at low worker count: everything must come back as
// StatusError and no goroutine may leak or race.
root := t.TempDir()
paths := make([]string, 20)
for i := range paths {
paths[i] = filepath.Join(root, "nope", "repo", string(rune('a'+i)))
}
s := NewScanner(WithWorkers(4))
infos, err := s.Scan(context.Background(), paths)
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(infos) != len(paths) {
t.Fatalf("Scan returned %d infos, want %d", len(infos), len(paths))
}
for i, info := range infos {
if info.Status != status.StatusError {
t.Errorf("path %d: Status = %v, want error", i, info.Status)
}
}
}
func TestScannerCancellation(t *testing.T) {
root := t.TempDir()
repo := newGitRepo(t, filepath.Join(root, "one"))
paths := make([]string, 0, 10)
for i := 0; i < 10; i++ {
paths = append(paths, repo)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := NewScanner().Scan(ctx, paths); err == nil {
t.Error("Scan(cancelled ctx) succeeded, want error")
}
}

124
pkg/status/status.go Normal file
View File

@ -0,0 +1,124 @@
// 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
}

99
pkg/status/status_test.go Normal file
View File

@ -0,0 +1,99 @@
package status
import (
"testing"
"time"
)
func TestRepoStatusString(t *testing.T) {
cases := []struct {
s RepoStatus
want string
}{
{StatusUnknown, "unknown"},
{StatusClean, "clean"},
{StatusModified, "modified"},
{StatusAhead, "ahead"},
{StatusBehind, "behind"},
{StatusDiverged, "diverged"},
{StatusDetached, "detached"},
{StatusBare, "bare"},
{StatusError, "error"},
}
for _, tc := range cases {
if got := tc.s.String(); got != tc.want {
t.Errorf("RepoStatus(%d).String() = %q, want %q", tc.s, got, tc.want)
}
}
}
func TestRepoStatusNeedsAttention(t *testing.T) {
cases := []struct {
s RepoStatus
want bool
}{
{StatusUnknown, false},
{StatusClean, false},
{StatusModified, true},
{StatusAhead, true},
{StatusBehind, true},
{StatusDiverged, true},
{StatusDetached, true},
{StatusBare, true},
{StatusError, true},
}
for _, tc := range cases {
if got := tc.s.NeedsAttention(); got != tc.want {
t.Errorf("RepoStatus(%d).NeedsAttention() = %v, want %v", tc.s, got, tc.want)
}
}
}
func TestRepoInfoFileCount(t *testing.T) {
info := RepoInfo{
StagedFiles: []string{"a", "b"},
ModifiedFiles: []string{"c"},
UntrackedFiles: []string{"d", "e", "f"},
}
if got := info.FileCount(); got != 6 {
t.Errorf("FileCount() = %d, want 6", got)
}
}
func TestScanResultSummary(t *testing.T) {
result := ScanResult{
ScannedAt: time.Now(),
ParentDir: "/tmp",
Repos: []RepoInfo{
{Status: StatusClean},
{Status: StatusModified, StagedFiles: []string{"a"}, ModifiedFiles: []string{"b"}},
{Status: StatusDiverged, UntrackedFiles: []string{"c"}},
{Status: StatusError, Error: "boom"},
{Status: StatusUnknown},
},
}
got := result.Summary()
want := Summary{Total: 5, Clean: 1, Attention: 2, Errored: 1, Staged: 1, Modified: 1, Untracked: 1}
if got != want {
t.Errorf("Summary() = %+v, want %+v", got, want)
}
}
func TestScanResultNeedsAttention(t *testing.T) {
result := ScanResult{
Repos: []RepoInfo{
{Path: "/a", Status: StatusClean},
{Path: "/b", Status: StatusBehind},
{Path: "/c", Status: StatusError},
},
}
got := result.NeedsAttention()
if len(got) != 2 {
t.Fatalf("NeedsAttention() returned %d repos, want 2", len(got))
}
for _, r := range got {
if r.Path == "/a" {
t.Errorf("clean repo /a listed as needing attention")
}
}
}