merge: phase 1 discovery & scanning into main
This commit is contained in:
commit
263b02b846
2
go.mod
2
go.mod
@ -1,3 +1,5 @@
|
|||||||
module gitea.oblak.solutions/dimitar/gitFlow
|
module gitea.oblak.solutions/dimitar/gitFlow
|
||||||
|
|
||||||
go 1.24
|
go 1.24
|
||||||
|
|
||||||
|
require golang.org/x/sync v0.12.0
|
||||||
|
|||||||
2
go.sum
Normal file
2
go.sum
Normal 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
80
internal/git/exec.go
Normal 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
161
internal/git/status.go
Normal 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
241
internal/git/status_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
165
internal/scanner/discover.go
Normal file
165
internal/scanner/discover.go
Normal 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
|
||||||
|
}
|
||||||
154
internal/scanner/discover_test.go
Normal file
154
internal/scanner/discover_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
internal/scanner/scanner.go
Normal file
74
internal/scanner/scanner.go
Normal 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()
|
||||||
|
}
|
||||||
100
internal/scanner/scanner_test.go
Normal file
100
internal/scanner/scanner_test.go
Normal 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
124
pkg/status/status.go
Normal 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
99
pkg/status/status_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user