From c4799836bb1b791a824edefb8e0ecc8bf7f6cc97 Mon Sep 17 00:00:00 2001 From: Steven Prybylynskyi Date: Sun, 10 May 2026 19:16:15 +0200 Subject: [PATCH 1/3] feat: detect git worktrees (opt-in toggle, #24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linked git worktrees were previously skipped because the scanner only matched on `.git` directories β€” a worktree's `.git` is a regular file containing a `gitdir:` pointer. This change adds opt-in support so worktree-heavy workflows can see them in the dashboard alongside regular repos. - config: `includeWorktrees: false` (default) in config.yml - CLI: `--worktrees` flag for one-shot enable - TUI: uppercase `W` toggles live; flips visibility AND totals together - model: new `is_worktree` field on `Repo` (omitempty) - TUI: worktrees render with a `βŽ‡` marker in the Repository column, and the stats bar shows `πŸ“ N repos (M wt)` when the toggle is on - scan: filters submodules out (gitdir under `.git/modules/`); only `gitdir:` paths under `.git/worktrees/` are accepted - cache: include the toggle in the cache key so flipping it forces a fresh scan instead of returning the wrong set --- CHANGELOG.md | 10 +++++ README.md | 6 +++ cmd/git-scope/main.go | 41 +++++++++++++----- internal/cache/cache.go | 27 ++++++++---- internal/config/config.go | 9 ++-- internal/model/repo.go | 7 +-- internal/scan/scan.go | 91 ++++++++++++++++++++++++++++----------- internal/tui/app.go | 16 ++++--- internal/tui/model.go | 33 ++++++++------ internal/tui/update.go | 18 +++++++- internal/tui/view.go | 17 +++++++- 11 files changed, 201 insertions(+), 74 deletions(-) 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..94398e0 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,25 @@ 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() } + // CLI flag overrides the config value when explicitly set + 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..14cfb5f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,10 +11,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 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..0191066 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -1,6 +1,7 @@ package scan import ( + "bufio" "encoding/json" "fmt" "io" @@ -31,9 +32,16 @@ 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) +} + +// 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. +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)) @@ -74,37 +82,26 @@ func ScanRoots(roots, ignore []string) ([]model.Repo, error) { return filepath.SkipDir } - // Found a .git directory + // Found a .git directory β€” regular repository 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() - } - + repo := buildRepo(path, false) mu.Lock() repos = append(repos, repo) mu.Unlock() - - // Don't walk into .git directory return filepath.SkipDir } + // Found a .git file β€” linked worktree (opt-in) + if includeWorktrees && !d.IsDir() && d.Name() == ".git" { + if isWorktreeGitfile(path) { + repo := buildRepo(path, true) + mu.Lock() + repos = append(repos, repo) + mu.Unlock() + } + return nil + } + return nil }) if err != nil { @@ -118,6 +115,48 @@ func ScanRoots(roots, ignore []string) ([]model.Repo, error) { return repos, nil } +// buildRepo constructs a Repo from a .git path (directory for regular repos, +// file for worktrees). The repo's path is the parent of the .git entry. +func buildRepo(gitPath string, isWorktree bool) model.Repo { + repoPath := filepath.Dir(gitPath) + 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 func shouldIgnore(name string, ignoreSet map[string]struct{}) bool { // Exact match diff --git a/internal/tui/app.go b/internal/tui/app.go index 016c1e3..b83c19d 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -21,16 +21,20 @@ 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, @@ -39,13 +43,13 @@ func scanReposCmd(cfg *config.Config, forceRefresh bool) tea.Cmd { } // 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, diff --git a/internal/tui/model.go b/internal/tui/model.go index cc9c2ce..aa6af34 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -77,6 +77,9 @@ type Model struct { // Pagination state currentPage int pageSize int + // Live toggle for including linked worktrees in scan results. + // Initialised from cfg.IncludeWorktrees; can be flipped at runtime via 'W'. + includeWorktrees bool } // NewModel creates a new TUI model @@ -140,22 +143,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 @@ -330,9 +334,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..335378d 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -110,7 +110,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 +182,21 @@ 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 and rescan. + // Single command β€” flips visibility AND total/dirty/clean counts. + if m.state == StateReady { + m.includeWorktrees = !m.includeWorktrees + m.state = StateLoading + if m.includeWorktrees { + m.statusMsg = "Including worktrees β€” rescanning..." + } else { + m.statusMsg = "Excluding worktrees β€” rescanning..." + } + 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..288c3eb 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -208,21 +208,29 @@ func (m Model) renderStats() string { shown := len(m.sortedRepos) dirty := 0 clean := 0 + worktrees := 0 for _, r := range m.repos { if r.Status.IsDirty { dirty++ } else { clean++ } + if r.IsWorktree { + worktrees++ + } } 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 +317,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"), From 8c455a1fd91749e0e9fdfd8b0e9fb7a900dcdd2d Mon Sep 17 00:00:00 2001 From: Steven Prybylynskyi Date: Sun, 10 May 2026 19:31:57 +0200 Subject: [PATCH 2/3] perf: parallel gitstatus + instant in-memory worktree toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that together make the W toggle feel snappy. 1) Parallel git status during scan. The scanner walked roots in parallel but ran `git status --porcelain=v2 -b` and `git log -1` serially within each root. On a 1058-repo tree that's ~2,100 subprocess forks on a sequential path β€” measured at 3.6s on a typical config. Split scan into two phases: - Walk: discover repos in parallel by root (cheap; just dir traversal). - Resolve: fan out gitstatus.Status across runtime.NumCPU()*2 workers. Same tree now scans in ~1.1s β€” ~3x faster, bounded by fork/IO contention rather than serialisation. No correctness change; results are unordered but the TUI sorts separately. 2) Instant in-memory worktree toggle. Previously every `W` press triggered a full rescan, paying the cold-scan cost (~1s now, was ~3.6s) every time β€” even when toggling OFF, where the data we already have is a strict superset of what we want to display. Track whether the last scan included worktrees on the model. On toggle: - Fast path (in-memory filter): we already have the data we need β€” either we're hiding worktrees we already scanned, or we don't need worktrees at all. Instant. - Slow path (rescan): only when we need worktrees and the current scan doesn't have them. Once paid, all subsequent toggles in this session are instant. `applyFilter` and `renderStats` now treat the worktree toggle as part of the base set, so totals + dirty/clean counts stay consistent with the table when you flip it. --- internal/scan/scan.go | 149 +++++++++++++++++++++++++++-------------- internal/tui/app.go | 15 +++-- internal/tui/model.go | 11 ++- internal/tui/update.go | 31 ++++++--- internal/tui/view.go | 11 ++- 5 files changed, 151 insertions(+), 66 deletions(-) diff --git a/internal/scan/scan.go b/internal/scan/scan.go index 0191066..25af80f 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "sync" @@ -38,32 +39,48 @@ 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 } @@ -71,54 +88,88 @@ func ScanRootsWithOptions(roots, ignore []string, includeWorktrees bool) ([]mode 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 β€” regular repository - if d.IsDir() && d.Name() == ".git" { - repo := buildRepo(path, false) - mu.Lock() - repos = append(repos, repo) - mu.Unlock() - return filepath.SkipDir - } - - // Found a .git file β€” linked worktree (opt-in) - if includeWorktrees && !d.IsDir() && d.Name() == ".git" { - if isWorktreeGitfile(path) { - repo := buildRepo(path, true) - mu.Lock() - repos = append(repos, repo) - mu.Unlock() - } - return nil - } - - 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 .git path (directory for regular repos, -// file for worktrees). The repo's path is the parent of the .git entry. -func buildRepo(gitPath string, isWorktree bool) model.Repo { - repoPath := filepath.Dir(gitPath) +// 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 } diff --git a/internal/tui/app.go b/internal/tui/app.go index b83c19d..52fd0e8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -36,8 +36,9 @@ func scanReposCmd(cfg *config.Config, forceRefresh, includeWorktrees bool) tea.C cacheStore.IsSameRoots(cfg.Roots) && cacheStore.IsSameIncludeWorktrees(includeWorktrees) { return scanCompleteMsg{ - repos: cached.Repos, - fromCache: true, + repos: cached.Repos, + fromCache: true, + includedWorktrees: includeWorktrees, } } } @@ -52,16 +53,18 @@ func scanReposCmd(cfg *config.Config, forceRefresh, includeWorktrees bool) tea.C _ = 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 aa6af34..9faf618 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -77,9 +77,13 @@ type Model struct { // Pagination state currentPage int pageSize int - // Live toggle for including linked worktrees in scan results. + // 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 @@ -184,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: diff --git a/internal/tui/update.go b/internal/tui/update.go index 335378d..601a68a 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -36,6 +36,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() @@ -185,18 +186,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, scanReposCmd(m.cfg, true, m.includeWorktrees) case "W": - // Toggle linked-worktree inclusion and rescan. - // Single command β€” flips visibility AND total/dirty/clean counts. - if m.state == StateReady { - m.includeWorktrees = !m.includeWorktrees - m.state = StateLoading + // 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 + needRescan := m.includeWorktrees && !m.lastScanIncludesWorktrees + if !needRescan { + m.resetPage() + m.updateTable() if m.includeWorktrees { - m.statusMsg = "Including worktrees β€” rescanning..." + m.statusMsg = "Worktrees: shown" } else { - m.statusMsg = "Excluding worktrees β€” rescanning..." + m.statusMsg = "Worktrees: hidden" } - return m, scanReposCmd(m.cfg, true, m.includeWorktrees) + 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 288c3eb..bf4c2ce 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -204,12 +204,18 @@ 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 { @@ -219,6 +225,7 @@ func (m Model) renderStats() string { worktrees++ } } + shown := len(m.sortedRepos) stats := []string{} From 04e5e805f55ff116f0b61d32f9f54c7359061404 Mon Sep 17 00:00:00 2001 From: Steven Prybylynskyi Date: Sun, 10 May 2026 19:35:07 +0200 Subject: [PATCH 3/3] feat: persist worktree toggle across restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The W toggle was forgotten on relaunch β€” startup always read cfg.IncludeWorktrees from config.yml, so any in-session toggle was lost. Add a small JSON state file at ~/.config/git-scope/state.json that holds the runtime toggle. Resolution order, low β†’ high precedence: 1. Config file (`includeWorktrees:` in config.yml) 2. State file (last W toggle from previous session) 3. CLI flag (`--worktrees` for one-shot override) Kept separate from config.yml on purpose β€” yaml.Marshal would clobber the user's comments on every toggle. State writes are best-effort; a permission failure doesn't break the in-session toggle. --- cmd/git-scope/main.go | 8 ++++++- internal/config/config.go | 50 +++++++++++++++++++++++++++++++++++++++ internal/tui/update.go | 6 +++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/cmd/git-scope/main.go b/cmd/git-scope/main.go index 94398e0..77df09b 100644 --- a/cmd/git-scope/main.go +++ b/cmd/git-scope/main.go @@ -160,7 +160,13 @@ func run(cmd string, dirs []string, opts options) error { cfg.Roots = getSmartDefaults() } - // CLI flag overrides the config value when explicitly set + // 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 } diff --git a/internal/config/config.go b/internal/config/config.go index 14cfb5f..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" @@ -106,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/tui/update.go b/internal/tui/update.go index 601a68a..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" @@ -198,6 +199,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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()