diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a919b3..c5c5d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,3 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Opt-in support for linked git worktrees ([#24](https://github.com/Bharath-code/git-scope/issues/24)). + - Config: `includeWorktrees: false` (default) in `~/.config/git-scope/config.yml`. + - CLI flag: `--worktrees` for one-shot enable. + - TUI: press `W` to toggle live; the toggle controls both visibility and totals. + - Scan/JSON: each repo carries an `is_worktree` field; worktrees show a `⎇` marker in the TUI table. + - Submodules are excluded — only `gitdir:` pointers under `.git/worktrees/` are recognised. + diff --git a/README.md b/README.md index 1b799cd..8e883ae 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ Typical git workflows involve "tunnel vision"—working deep inside one reposito | `g` | Toggle **Contribution Graph** | | `d` | Toggle **Disk Usage** view | | `t` | Toggle **Timeline** view | +| `W` | Toggle **Worktree inclusion** (rescan; affects totals too) | | `q` | Quit | ----- @@ -172,6 +173,11 @@ ignore: - dist editor: code # options: code,nvim,lazygit,vim,cursor + +# Linked git worktrees are skipped by default. Set this to true (or pass +# --worktrees on the CLI, or press W in the TUI) to include them in the +# dashboard and stats. +includeWorktrees: false ``` ----- diff --git a/cmd/git-scope/main.go b/cmd/git-scope/main.go index 6b90468..77df09b 100644 --- a/cmd/git-scope/main.go +++ b/cmd/git-scope/main.go @@ -18,9 +18,11 @@ import ( const version = "1.0.1" type options struct { - ConfigPath string - ShowVersion bool - ShowHelp bool + ConfigPath string + ShowVersion bool + ShowHelp bool + IncludeWorktrees bool + WorktreesSet bool } func usage() { @@ -74,7 +76,7 @@ func main() { return } - if err := run(cmd, dirs, opts.ConfigPath); err != nil { + if err := run(cmd, dirs, opts); err != nil { log.Fatal(err) } } @@ -95,12 +97,24 @@ func parseFlags() options { flag.BoolVar(&showHelp, "h", false, "Help") flag.BoolVar(&showHelp, "help", false, "Help") + var includeWorktrees bool + flag.BoolVar(&includeWorktrees, "worktrees", false, "Include linked git worktrees in scan results") + flag.Parse() + worktreesSet := false + flag.Visit(func(f *flag.Flag) { + if f.Name == "worktrees" { + worktreesSet = true + } + }) + return options{ - ConfigPath: *configPath, - ShowVersion: showVersion, - ShowHelp: showHelp, + ConfigPath: *configPath, + ShowVersion: showVersion, + ShowHelp: showHelp, + IncludeWorktrees: includeWorktrees, + WorktreesSet: worktreesSet, } } @@ -121,7 +135,7 @@ func parseCommand(args []string) (cmd string, dirs []string) { // run executes the requested command using the provided configuration path // and directories. -func run(cmd string, dirs []string, configPath string) error { +func run(cmd string, dirs []string, opts options) error { switch cmd { case "init": runInit() @@ -135,20 +149,31 @@ func run(cmd string, dirs []string, configPath string) error { } // Only commands below need config - cfg, err := config.Load(configPath) + cfg, err := config.Load(opts.ConfigPath) if err != nil { return fmt.Errorf("failed to load config: %w", err) } if len(dirs) > 0 { cfg.Roots = expandDirs(dirs) - } else if !config.ConfigExists(configPath) { + } else if !config.ConfigExists(opts.ConfigPath) { cfg.Roots = getSmartDefaults() } + // Resolution order for IncludeWorktrees, low → high precedence: + // 1. Config file (`includeWorktrees: ...`) + // 2. State file (`~/.config/git-scope/state.json`) — runtime W toggle + // 3. CLI flag (`--worktrees`) + if state, err := config.LoadState(config.DefaultStatePath()); err == nil { + cfg.IncludeWorktrees = state.IncludeWorktrees + } + if opts.WorktreesSet { + cfg.IncludeWorktrees = opts.IncludeWorktrees + } + switch cmd { case "scan": - repos, err := scan.ScanRoots(cfg.Roots, cfg.Ignore) + repos, err := scan.ScanRootsWithOptions(cfg.Roots, cfg.Ignore, cfg.IncludeWorktrees) if err != nil { return fmt.Errorf("scan error: %w", err) } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index cde8976..efeac00 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -11,15 +11,16 @@ import ( // CacheData represents the cached scan results type CacheData struct { - Repos []model.Repo `json:"repos"` - Timestamp time.Time `json:"timestamp"` - Roots []string `json:"roots"` + Repos []model.Repo `json:"repos"` + Timestamp time.Time `json:"timestamp"` + Roots []string `json:"roots"` + IncludeWorktrees bool `json:"include_worktrees,omitempty"` } // Store interface for caching repo data type Store interface { Load() (*CacheData, error) - Save(repos []model.Repo, roots []string) error + Save(repos []model.Repo, roots []string, includeWorktrees bool) error IsValid(maxAge time.Duration) bool } @@ -62,11 +63,12 @@ func (s *FileStore) Load() (*CacheData, error) { } // Save writes repos to cache file -func (s *FileStore) Save(repos []model.Repo, roots []string) error { +func (s *FileStore) Save(repos []model.Repo, roots []string, includeWorktrees bool) error { cache := CacheData{ - Repos: repos, - Timestamp: time.Now(), - Roots: roots, + Repos: repos, + Timestamp: time.Now(), + Roots: roots, + IncludeWorktrees: includeWorktrees, } // Ensure cache directory exists @@ -104,6 +106,15 @@ func (s *FileStore) IsSameRoots(roots []string) bool { return true } +// IsSameIncludeWorktrees checks whether the cache was produced with the same +// worktree-inclusion setting. Toggling forces a rescan. +func (s *FileStore) IsSameIncludeWorktrees(includeWorktrees bool) bool { + if s.data == nil { + return false + } + return s.data.IncludeWorktrees == includeWorktrees +} + // GetTimestamp returns the cache timestamp func (s *FileStore) GetTimestamp() time.Time { if s.data == nil { diff --git a/internal/config/config.go b/internal/config/config.go index 1832429..81f039e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "fmt" "os" "path/filepath" @@ -11,10 +12,11 @@ import ( // Config holds the application configuration type Config struct { - Roots []string `yaml:"roots"` - Ignore []string `yaml:"ignore"` - Editor string `yaml:"editor"` - PageSize int `yaml:"pageSize,omitempty"` + Roots []string `yaml:"roots"` + Ignore []string `yaml:"ignore"` + Editor string `yaml:"editor"` + PageSize int `yaml:"pageSize,omitempty"` + IncludeWorktrees bool `yaml:"includeWorktrees,omitempty"` } // defaultConfig returns sensible defaults @@ -105,6 +107,55 @@ func DefaultConfigPath() string { return filepath.Join(home, ".config", "git-scope", "config.yml") } +// State holds user-toggled preferences that should persist across runs but +// don't belong in the human-edited YAML config (and would clobber its +// comments on rewrite). +type State struct { + IncludeWorktrees bool `json:"include_worktrees"` +} + +// DefaultStatePath returns the default location for the state file. +func DefaultStatePath() string { + home, err := os.UserHomeDir() + if err != nil { + return "./state.json" + } + return filepath.Join(home, ".config", "git-scope", "state.json") +} + +// LoadState reads the persisted state file. Returns a zero-value State and +// no error when the file is missing — first-run is not an error. +func LoadState(path string) (State, error) { + var s State + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return s, fmt.Errorf("read state: %w", err) + } + if err := json.Unmarshal(data, &s); err != nil { + return s, fmt.Errorf("parse state: %w", err) + } + return s, nil +} + +// SaveState writes the state file, creating the parent directory as needed. +func SaveState(path string, s State) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create state dir: %w", err) + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("marshal state: %w", err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("write state: %w", err) + } + return nil +} + // ConfigExists checks if a config file exists at the given path func ConfigExists(path string) bool { _, err := os.Stat(path) diff --git a/internal/model/repo.go b/internal/model/repo.go index 3f127b1..e90d7ec 100644 --- a/internal/model/repo.go +++ b/internal/model/repo.go @@ -17,7 +17,8 @@ type RepoStatus struct { // Repo represents a git repository with its metadata and status type Repo struct { - Name string `json:"name"` - Path string `json:"path"` - Status RepoStatus `json:"status"` + Name string `json:"name"` + Path string `json:"path"` + Status RepoStatus `json:"status"` + IsWorktree bool `json:"is_worktree,omitempty"` } diff --git a/internal/scan/scan.go b/internal/scan/scan.go index 060cac0..25af80f 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -1,11 +1,13 @@ package scan import ( + "bufio" "encoding/json" "fmt" "io" "os" "path/filepath" + "runtime" "strings" "sync" @@ -31,31 +33,54 @@ var smartIgnorePatterns = []string{ "Google Drive", "OneDrive", "Dropbox", "iCloud", } -// ScanRoots recursively scans the given root directories for git repositories -// It skips directories matching the ignore patterns +// ScanRoots recursively scans the given root directories for git repositories. +// Skips directories matching the ignore patterns. Worktrees are excluded. func ScanRoots(roots, ignore []string) ([]model.Repo, error) { + return ScanRootsWithOptions(roots, ignore, false) +} + +// repoFinding is a repo discovered by the directory walk, before its git +// status has been resolved. +type repoFinding struct { + path string + isWorktree bool +} + +// ScanRootsWithOptions is like ScanRoots but also accepts toggles. When +// includeWorktrees is true, linked worktrees (.git is a regular file +// containing a "gitdir:" pointer) are returned alongside regular repos. +// +// The scan runs in two phases: a parallel directory walk discovers repos, +// then a fixed worker pool resolves git status for each finding. The status +// phase is the bottleneck on large trees (every repo forks `git`), so +// parallelism here scales nearly linearly with CPU count on cold scans. +func ScanRootsWithOptions(roots, ignore []string, includeWorktrees bool) ([]model.Repo, error) { // Build ignore set from user config + smart defaults ignoreSet := make(map[string]struct{}, len(ignore)+len(smartIgnorePatterns)) - - // Add user-defined ignores for _, pattern := range ignore { ignoreSet[pattern] = struct{}{} } - - // Add smart defaults (always apply for performance) for _, pattern := range smartIgnorePatterns { ignoreSet[pattern] = struct{}{} } + // Phase 1: discover repos in parallel by root. + findings := discoverRepos(roots, ignoreSet, includeWorktrees) + + // Phase 2: resolve git status concurrently across a worker pool. + return resolveStatuses(findings), nil +} + +// discoverRepos walks each root in parallel and returns repo findings. +// Walks share an ignore set; results are deduplicated implicitly because +// `.git` directories are pruned from further traversal. +func discoverRepos(roots []string, ignoreSet map[string]struct{}, includeWorktrees bool) []repoFinding { var mu sync.Mutex - var repos []model.Repo + var findings []repoFinding var wg sync.WaitGroup for _, root := range roots { - // Expand ~ and environment variables root = expandPath(root) - - // Check if root exists if _, err := os.Stat(root); os.IsNotExist(err) { continue } @@ -63,59 +88,124 @@ func ScanRoots(roots, ignore []string) ([]model.Repo, error) { wg.Add(1) go func(r string) { defer wg.Done() - err := filepath.WalkDir(r, func(path string, d os.DirEntry, err error) error { - if err != nil { - // Skip directories we can't access - return nil - } - - // Skip ignored directories - if d.IsDir() && shouldIgnore(d.Name(), ignoreSet) { - return filepath.SkipDir - } - - // Found a .git directory - if d.IsDir() && d.Name() == ".git" { - repoPath := filepath.Dir(path) - - // Resolve to absolute path to get proper repo name - // This handles cases where path is "." or relative - absPath, err := filepath.Abs(repoPath) - if err == nil { - repoPath = absPath - } - repoName := filepath.Base(repoPath) - - status, serr := gitstatus.Status(repoPath) - - repo := model.Repo{ - Name: repoName, - Path: repoPath, - Status: status, - } - if serr != nil { - repo.Status.ScanError = serr.Error() - } - - mu.Lock() - repos = append(repos, repo) - mu.Unlock() - - // Don't walk into .git directory - return filepath.SkipDir - } - - return nil - }) - if err != nil { - // Log but don't fail - fmt.Fprintf(os.Stderr, "warning: scan error in %s: %v\n", r, err) + local := walkRoot(r, ignoreSet, includeWorktrees) + if len(local) == 0 { + return } + mu.Lock() + findings = append(findings, local...) + mu.Unlock() }(root) } wg.Wait() - return repos, nil + return findings +} + +// walkRoot walks one root and returns the repos it found. +func walkRoot(root string, ignoreSet map[string]struct{}, includeWorktrees bool) []repoFinding { + var findings []repoFinding + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() && shouldIgnore(d.Name(), ignoreSet) { + return filepath.SkipDir + } + if d.IsDir() && d.Name() == ".git" { + findings = append(findings, repoFinding{path: filepath.Dir(path), isWorktree: false}) + return filepath.SkipDir + } + if includeWorktrees && !d.IsDir() && d.Name() == ".git" && isWorktreeGitfile(path) { + findings = append(findings, repoFinding{path: filepath.Dir(path), isWorktree: true}) + } + return nil + }) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: scan error in %s: %v\n", root, err) + } + return findings +} + +// resolveStatuses runs gitstatus.Status across a worker pool. Order is not +// preserved; the TUI sorts results separately. +func resolveStatuses(findings []repoFinding) []model.Repo { + if len(findings) == 0 { + return nil + } + + workers := runtime.NumCPU() * 2 + if workers > len(findings) { + workers = len(findings) + } + + jobs := make(chan repoFinding, len(findings)) + for _, f := range findings { + jobs <- f + } + close(jobs) + + results := make(chan model.Repo, len(findings)) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for f := range jobs { + results <- buildRepo(f.path, f.isWorktree) + } + }() + } + wg.Wait() + close(results) + + repos := make([]model.Repo, 0, len(findings)) + for r := range results { + repos = append(repos, r) + } + return repos +} + +// buildRepo constructs a Repo from a working-tree path. Resolves the path to +// absolute form so the repo name uses the real basename even when the input +// was relative. +func buildRepo(repoPath string, isWorktree bool) model.Repo { + if abs, err := filepath.Abs(repoPath); err == nil { + repoPath = abs + } + status, serr := gitstatus.Status(repoPath) + repo := model.Repo{ + Name: filepath.Base(repoPath), + Path: repoPath, + Status: status, + IsWorktree: isWorktree, + } + if serr != nil { + repo.Status.ScanError = serr.Error() + } + return repo +} + +// isWorktreeGitfile checks whether a .git file is a linked-worktree pointer. +// Worktrees: `.git` is a file whose first line is `gitdir: /.git/worktrees/`. +// Submodules use the same gitdir-pointer format but point at `.git/modules/`, +// so we filter on the path segment to exclude them. +func isWorktreeGitfile(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + scanner := bufio.NewScanner(f) + if !scanner.Scan() { + return false + } + line := scanner.Text() + if !strings.HasPrefix(line, "gitdir:") { + return false + } + gitdir := strings.TrimSpace(strings.TrimPrefix(line, "gitdir:")) + return strings.Contains(gitdir, "/worktrees/") || strings.Contains(gitdir, `\worktrees\`) } // shouldIgnore checks if a directory name matches any ignore pattern diff --git a/internal/tui/app.go b/internal/tui/app.go index 016c1e3..52fd0e8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -21,43 +21,50 @@ func Run(cfg *config.Config) error { return err } -// scanReposCmd is a command that scans for repositories -// If forceRefresh is true, bypass cache and scan fresh -func scanReposCmd(cfg *config.Config, forceRefresh bool) tea.Cmd { +// scanReposCmd is a command that scans for repositories. +// If forceRefresh is true, bypass cache and scan fresh. +// includeWorktrees is the live toggle state (overrides cfg for this scan). +func scanReposCmd(cfg *config.Config, forceRefresh, includeWorktrees bool) tea.Cmd { return func() tea.Msg { cacheStore := cache.NewFileStore() // Try to load from cache first (unless forcing refresh) if !forceRefresh { cached, err := cacheStore.Load() - if err == nil && cacheStore.IsValid(cacheMaxAge) && cacheStore.IsSameRoots(cfg.Roots) { + if err == nil && + cacheStore.IsValid(cacheMaxAge) && + cacheStore.IsSameRoots(cfg.Roots) && + cacheStore.IsSameIncludeWorktrees(includeWorktrees) { return scanCompleteMsg{ - repos: cached.Repos, - fromCache: true, + repos: cached.Repos, + fromCache: true, + includedWorktrees: includeWorktrees, } } } // Scan fresh - repos, err := scan.ScanRoots(cfg.Roots, cfg.Ignore) + repos, err := scan.ScanRootsWithOptions(cfg.Roots, cfg.Ignore, includeWorktrees) if err != nil { return scanErrorMsg{err: err} } // Save to cache - _ = cacheStore.Save(repos, cfg.Roots) + _ = cacheStore.Save(repos, cfg.Roots, includeWorktrees) return scanCompleteMsg{ - repos: repos, - fromCache: false, + repos: repos, + fromCache: false, + includedWorktrees: includeWorktrees, } } } // scanCompleteMsg is sent when scanning is complete type scanCompleteMsg struct { - repos []model.Repo - fromCache bool + repos []model.Repo + fromCache bool + includedWorktrees bool } // scanErrorMsg is sent when scanning fails diff --git a/internal/tui/model.go b/internal/tui/model.go index cc9c2ce..9faf618 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -77,6 +77,13 @@ type Model struct { // Pagination state currentPage int pageSize int + // Live toggle for including linked worktrees in the displayed set. + // Initialised from cfg.IncludeWorktrees; can be flipped at runtime via 'W'. + includeWorktrees bool + // Whether the most recent scan that produced m.repos included worktrees. + // If true and the user toggles worktrees off, we can filter in-memory + // without rescanning. If false and the user toggles them on, we must rescan. + lastScanIncludesWorktrees bool } // NewModel creates a new TUI model @@ -140,22 +147,23 @@ func NewModel(cfg *config.Config) Model { sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#7C3AED")) return Model{ - cfg: cfg, - table: t, - textInput: ti, - workspaceInput: wi, - spinner: sp, - state: StateLoading, - sortMode: SortByDirty, - filterMode: FilterAll, - currentPage: 0, - pageSize: cfg.PageSize, + cfg: cfg, + table: t, + textInput: ti, + workspaceInput: wi, + spinner: sp, + state: StateLoading, + sortMode: SortByDirty, + filterMode: FilterAll, + currentPage: 0, + pageSize: cfg.PageSize, + includeWorktrees: cfg.IncludeWorktrees, } } // Init initializes the model func (m Model) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, scanReposCmd(m.cfg, false)) + return tea.Batch(m.spinner.Tick, scanReposCmd(m.cfg, false, m.includeWorktrees)) } // GetSelectedRepo returns the currently selected repo @@ -180,6 +188,11 @@ func (m *Model) applyFilter() { m.filteredRepos = make([]model.Repo, 0, len(m.repos)) for _, r := range m.repos { + // Worktree visibility — also affects stats totals. + if !m.includeWorktrees && r.IsWorktree { + continue + } + // Apply filter mode switch m.filterMode { case FilterDirty: @@ -330,9 +343,14 @@ func reposToRows(repos []model.Repo) []table.Row { status = "● Dirty" } + name := r.Name + if r.IsWorktree { + name = "⎇ " + name + } + rows = append(rows, table.Row{ status, - truncateString(r.Name, 18), + truncateString(name, 18), truncateString(r.Status.Branch, 14), formatNumber(r.Status.Staged), formatNumber(r.Status.Unstaged), diff --git a/internal/tui/update.go b/internal/tui/update.go index 31b313b..585b997 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -5,6 +5,7 @@ import ( "os/exec" "github.com/Bharath-code/git-scope/internal/browser" + "github.com/Bharath-code/git-scope/internal/config" "github.com/Bharath-code/git-scope/internal/model" "github.com/Bharath-code/git-scope/internal/nudge" "github.com/Bharath-code/git-scope/internal/scan" @@ -36,6 +37,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case scanCompleteMsg: m.repos = msg.repos + m.lastScanIncludesWorktrees = msg.includedWorktrees m.state = StateReady m.resetPage() m.updateTable() @@ -110,7 +112,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.statusMsg = "" } - return m, scanReposCmd(m.cfg, true) + return m, scanReposCmd(m.cfg, true, m.includeWorktrees) case grassDataLoadedMsg: m.grassData = msg.data @@ -182,7 +184,40 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "r": m.state = StateLoading m.statusMsg = "Rescanning..." - return m, scanReposCmd(m.cfg, true) + return m, scanReposCmd(m.cfg, true, m.includeWorktrees) + + case "W": + // Toggle linked-worktree inclusion. Single command — affects + // both visibility and totals. + // + // Fast path: if the current scan already covers the desired + // view (we have worktrees and just need to hide them, or we + // don't need worktrees), this is an instant in-memory filter. + // Slow path: only when going from "no worktrees scanned" to + // "show worktrees" — we genuinely don't have the data yet. + if m.state != StateReady { + break + } + m.includeWorktrees = !m.includeWorktrees + // Persist so the toggle survives restarts. Best-effort — + // failures don't surface; the toggle still works in-session. + _ = config.SaveState(config.DefaultStatePath(), config.State{ + IncludeWorktrees: m.includeWorktrees, + }) + needRescan := m.includeWorktrees && !m.lastScanIncludesWorktrees + if !needRescan { + m.resetPage() + m.updateTable() + if m.includeWorktrees { + m.statusMsg = "Worktrees: shown" + } else { + m.statusMsg = "Worktrees: hidden" + } + return m, nil + } + m.state = StateLoading + m.statusMsg = "Scanning worktrees..." + return m, scanReposCmd(m.cfg, true, m.includeWorktrees) case "f": // Cycle through filter modes diff --git a/internal/tui/view.go b/internal/tui/view.go index f88f2bf..bf4c2ce 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -204,25 +204,40 @@ func (m Model) renderSearchBadge() string { } func (m Model) renderStats() string { - total := len(m.repos) - shown := len(m.sortedRepos) + // "Total" reflects the effective base set: raw scan minus worktrees when + // the toggle is off. Keeps the count consistent with what the table shows + // before search/filter narrowing. + total := 0 dirty := 0 clean := 0 + worktrees := 0 for _, r := range m.repos { + if !m.includeWorktrees && r.IsWorktree { + continue + } + total++ if r.Status.IsDirty { dirty++ } else { clean++ } + if r.IsWorktree { + worktrees++ + } } + shown := len(m.sortedRepos) stats := []string{} // Show count with filter info + repoLabel := "repos" + if m.includeWorktrees && worktrees > 0 { + repoLabel = fmt.Sprintf("repos (%d wt)", worktrees) + } if shown == total { - stats = append(stats, statsBadgeStyle.Render(fmt.Sprintf("📁 %d repos", total))) + stats = append(stats, statsBadgeStyle.Render(fmt.Sprintf("📁 %d %s", total, repoLabel))) } else { - stats = append(stats, statsBadgeStyle.Render(fmt.Sprintf("📁 %d/%d repos", shown, total))) + stats = append(stats, statsBadgeStyle.Render(fmt.Sprintf("📁 %d/%d %s", shown, total, repoLabel))) } if dirty > 0 { @@ -309,12 +324,17 @@ func (m Model) renderHelp() string { } } else { // Normal mode help - Tuimorphic style + wtLabel := "worktrees off" + if m.includeWorktrees { + wtLabel = "worktrees on" + } items = []string{ keyBinding("↑↓", "nav"), keyBinding("[]", "page"), keyBinding("enter", "open"), keyBinding("/", "search"), keyBinding("w", "workspace"), + keyBinding("W", wtLabel), keyBinding("f", "filter"), keyBinding("s", "sort"), keyBinding("g", "grass"),