From ae23019277b35ca952caf5597f2c393b2572c4fe Mon Sep 17 00:00:00 2001 From: dimitar Date: Sun, 2 Aug 2026 08:35:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20phase=201=20=E2=80=94=20repository=20di?= =?UTF-8?q?scovery=20and=20status=20scanning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- go.mod | 2 + go.sum | 2 + internal/git/exec.go | 80 ++++++++++ internal/git/status.go | 161 ++++++++++++++++++++ internal/git/status_test.go | 241 ++++++++++++++++++++++++++++++ internal/scanner/discover.go | 165 ++++++++++++++++++++ internal/scanner/discover_test.go | 154 +++++++++++++++++++ internal/scanner/scanner.go | 74 +++++++++ internal/scanner/scanner_test.go | 100 +++++++++++++ pkg/status/status.go | 124 +++++++++++++++ pkg/status/status_test.go | 99 ++++++++++++ 11 files changed, 1202 insertions(+) create mode 100644 go.sum create mode 100644 internal/git/exec.go create mode 100644 internal/git/status.go create mode 100644 internal/git/status_test.go create mode 100644 internal/scanner/discover.go create mode 100644 internal/scanner/discover_test.go create mode 100644 internal/scanner/scanner.go create mode 100644 internal/scanner/scanner_test.go create mode 100644 pkg/status/status.go create mode 100644 pkg/status/status_test.go diff --git a/go.mod b/go.mod index 82c3d32..80fe06c 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module gitea.oblak.solutions/dimitar/gitFlow go 1.24 + +require golang.org/x/sync v0.12.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..15c1db6 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/git/exec.go b/internal/git/exec.go new file mode 100644 index 0000000..66a58a6 --- /dev/null +++ b/internal/git/exec.go @@ -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 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 +} diff --git a/internal/git/status.go b/internal/git/status.go new file mode 100644 index 0000000..6cc764a --- /dev/null +++ b/internal/git/status.go @@ -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 +// 2 +// +// 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 +} diff --git a/internal/git/status_test.go b/internal/git/status_test.go new file mode 100644 index 0000000..670830b --- /dev/null +++ b/internal/git/status_test.go @@ -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") + } +} diff --git a/internal/scanner/discover.go b/internal/scanner/discover.go new file mode 100644 index 0000000..2565a2e --- /dev/null +++ b/internal/scanner/discover.go @@ -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 +} diff --git a/internal/scanner/discover_test.go b/internal/scanner/discover_test.go new file mode 100644 index 0000000..77b5858 --- /dev/null +++ b/internal/scanner/discover_test.go @@ -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) + } + } +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go new file mode 100644 index 0000000..873c9ca --- /dev/null +++ b/internal/scanner/scanner.go @@ -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() +} diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go new file mode 100644 index 0000000..541971d --- /dev/null +++ b/internal/scanner/scanner_test.go @@ -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") + } +} diff --git a/pkg/status/status.go b/pkg/status/status.go new file mode 100644 index 0000000..689a63b --- /dev/null +++ b/pkg/status/status.go @@ -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 +} diff --git a/pkg/status/status_test.go b/pkg/status/status_test.go new file mode 100644 index 0000000..26b9e88 --- /dev/null +++ b/pkg/status/status_test.go @@ -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") + } + } +}