// 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 }