diff --git a/CLAUDE.md b/CLAUDE.md index b83808e..568a572 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,8 @@ devbox run -- make clean **Early Termination**: When a project marker is found, that directory subtree is skipped (returns `fs.SkipDir`). This prevents redundant scanning and respects that a parent project marker takes precedence over child markers. +**Worktree Detection**: Git worktrees are detected via two paths: Path A detects `.git` files (vs directories) during the normal walk and parses the `gitdir` reference to identify the parent repo. Path B (enabled with `--worktrees`) reads `.git/worktrees/` entries from parent repos to discover linked worktrees outside search paths. `--no-worktrees` filters worktrees from results. Worktree metadata (`IsWorktree`, `WorktreeParent`) is exposed in JSON output, format strings (`%w`), and display labels. + ## Configuration System The config loading order is: diff --git a/README.md b/README.md index bafdc0e..727b468 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - **Icon Support**: Display pretty icons for different project types (Nerd Fonts required) - **Label Support**: Show marker labels like `go` or `Go` alongside project paths - **ANSI Color Support**: Colorize icons with ANSI codes for terminal tools like `fzf` and `television` +- **Git Worktree Support**: Detects git worktrees automatically, with optional discovery of worktrees outside search paths - **Custom Output Format**: Use `--format` with `%`-based placeholders for full control over output - **Unix Pipeline Support**: Pipe paths in and results out - works seamlessly in command chains - **Configurable**: YAML configuration file with sensible defaults @@ -214,6 +215,8 @@ pj --version | `--format FORMAT` | `-f` | Custom output format (see [Format Placeholders](#format-placeholders)) | | `--sort VALUE` | | Sort order: `alpha`, `priority`, `label` (default: `priority`) | | `--sort-direction VALUE` | | Sort direction: `asc`, `desc` (default: `desc`) | +| `--worktrees` | | Discover git worktrees from parent repos, even outside search paths | +| `--no-worktrees` | | Exclude git worktrees from results | | `--no-cache` | | Skip cache, force fresh search | | `--clear-cache` | | Clear cache and exit | | `--verbose` | `-v` | Enable debug output | @@ -270,6 +273,7 @@ The `--format` / `-f` flag gives you full control over the output format using ` | `%l` | Label (e.g., `go`, `nodejs`) | | `%L` | Display label (e.g., `Go`, `NodeJS`) | | `%c` | Color name (e.g., `cyan`, `blue`) | +| `%w` | Worktree parent path (empty if not a worktree) | | `%%` | Literal `%` | ```bash @@ -488,6 +492,37 @@ When a glob pattern matches, the actual matched filename is used as the marker ( Exact markers (like `.git`, `go.mod`) are checked first using fast `os.Stat` calls. Pattern markers are checked by reading directory contents, so they have slightly more overhead but are still efficient. +### Git Worktree Support + +`pj` automatically detects [git worktrees](https://git-scm.com/docs/git-worktree) found during its normal directory walk. Worktrees are tagged with metadata (`isWorktree`, `worktreeParent`) and display a `(worktree)` suffix when using `--labels display`. + +```bash +# Default: worktrees in search paths are detected automatically +pj --labels display +# Go (worktree) ~/development/my-project__worktrees/feature-branch + +# Actively discover worktrees even outside search paths +pj --worktrees + +# Exclude all worktrees from results +pj --no-worktrees + +# Show worktree parent in custom format +pj --format '%p (parent: %w)' +``` + +The `--worktrees` flag reads each parent repo's `.git/worktrees/` directory to find linked worktrees, even if they live outside your configured search paths. The `--no-worktrees` flag filters out any worktree from results entirely. These flags are mutually exclusive. + +In JSON output (`--json`), worktree projects include `isWorktree` and `worktreeParent` fields. + +```yaml +# Enable worktree discovery in config +worktrees: true + +# Or disable worktrees entirely +no_worktrees: true +``` + ### Config Priority CLI flags override config file settings, which override defaults. diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 997f602..1049d9c 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -114,6 +114,8 @@ func (m *Manager) computeConfigHash() string { h.Write([]byte(strconv.Itoa(m.config.MaxDepth))) h.Write([]byte(strconv.FormatBool(m.config.NoIgnore))) h.Write([]byte(strconv.FormatBool(m.config.Nested))) + h.Write([]byte(strconv.FormatBool(m.config.Worktrees))) + h.Write([]byte(strconv.FormatBool(m.config.NoWorktrees))) return fmt.Sprintf("%x", h.Sum(nil))[:16] } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index cd5d553..b53c25f 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -133,6 +133,62 @@ func TestComputeConfigHash(t *testing.T) { } }) + t.Run("different Worktrees produces different hash", func(t *testing.T) { + cfg1 := &config.Config{ + SearchPaths: []string{"/path1"}, + Markers: []string{".git"}, + Excludes: []string{}, + MaxDepth: 3, + Worktrees: false, + } + + cfg2 := &config.Config{ + SearchPaths: []string{"/path1"}, + Markers: []string{".git"}, + Excludes: []string{}, + MaxDepth: 3, + Worktrees: true, + } + + m1 := &Manager{config: cfg1} + m2 := &Manager{config: cfg2} + + hash1 := m1.computeConfigHash() + hash2 := m2.computeConfigHash() + + if hash1 == hash2 { + t.Error("Different Worktrees values should produce different hashes") + } + }) + + t.Run("different NoWorktrees produces different hash", func(t *testing.T) { + cfg1 := &config.Config{ + SearchPaths: []string{"/path1"}, + Markers: []string{".git"}, + Excludes: []string{}, + MaxDepth: 3, + NoWorktrees: false, + } + + cfg2 := &config.Config{ + SearchPaths: []string{"/path1"}, + Markers: []string{".git"}, + Excludes: []string{}, + MaxDepth: 3, + NoWorktrees: true, + } + + m1 := &Manager{config: cfg1} + m2 := &Manager{config: cfg2} + + hash1 := m1.computeConfigHash() + hash2 := m2.computeConfigHash() + + if hash1 == hash2 { + t.Error("Different NoWorktrees values should produce different hashes") + } + }) + t.Run("different Nested produces different hash", func(t *testing.T) { cfg1 := &config.Config{ SearchPaths: []string{"/path1"}, diff --git a/internal/config/config.go b/internal/config/config.go index df324c9..91cfcd9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -79,6 +79,8 @@ type Config struct { CacheTTL int `yaml:"cache_ttl"` // seconds NoIgnore bool `yaml:"no_ignore"` // Don't respect .gitignore and .ignore files Nested bool `yaml:"nested"` // Continue discovery inside projects + Worktrees bool `yaml:"worktrees"` // Actively discover worktrees from parent repos + NoWorktrees bool `yaml:"no_worktrees"` // Filter out worktrees even if found during walk // Deprecated: Use the new markers format with icon field instead. // This field is kept for backward compatibility. Icons map[string]string `yaml:"icons,omitempty"` @@ -506,6 +508,18 @@ func (c *Config) MergeFlags(cli interface{}) error { } } + if worktreesField := v.FieldByName("Worktrees"); worktreesField.IsValid() && worktreesField.Kind() == reflect.Bool { + if worktreesField.Bool() { + c.Worktrees = true + } + } + + if noWorktreesField := v.FieldByName("NoWorktrees"); noWorktreesField.IsValid() && noWorktreesField.Kind() == reflect.Bool { + if noWorktreesField.Bool() { + c.NoWorktrees = true + } + } + return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1b55b2f..7b7727c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1134,3 +1134,64 @@ func TestColorConfig(t *testing.T) { } }) } + +func TestWorktreeConfigDefaults(t *testing.T) { + cfg := defaults() + if cfg.Worktrees { + t.Error("Worktrees should default to false") + } + if cfg.NoWorktrees { + t.Error("NoWorktrees should default to false") + } +} + +func TestWorktreeConfigYAML(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + + yamlContent := `worktrees: true` + if err := os.WriteFile(configPath, []byte(yamlContent), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if !cfg.Worktrees { + t.Error("Worktrees should be true when set in YAML") + } +} + +func TestMergeFlagsWorktrees(t *testing.T) { + t.Run("Worktrees flag", func(t *testing.T) { + cfg := &Config{Worktrees: false} + flags := struct { + Worktrees bool + NoWorktrees bool + }{Worktrees: true} + + if err := cfg.MergeFlags(flags); err != nil { + t.Fatalf("MergeFlags() error = %v", err) + } + if !cfg.Worktrees { + t.Error("MergeFlags should set Worktrees=true") + } + }) + + t.Run("NoWorktrees flag", func(t *testing.T) { + cfg := &Config{NoWorktrees: false} + flags := struct { + Worktrees bool + NoWorktrees bool + }{NoWorktrees: true} + + if err := cfg.MergeFlags(flags); err != nil { + t.Fatalf("MergeFlags() error = %v", err) + } + if !cfg.NoWorktrees { + t.Error("MergeFlags should set NoWorktrees=true") + } + }) +} diff --git a/internal/discover/discover.go b/internal/discover/discover.go index b6be70c..ee1e3f5 100644 --- a/internal/discover/discover.go +++ b/internal/discover/discover.go @@ -1,6 +1,7 @@ package discover import ( + "bufio" "fmt" "io/fs" "os" @@ -14,9 +15,11 @@ import ( // Project represents a discovered project directory type Project struct { - Path string - Marker string - Priority int + Path string `json:"path"` + Marker string `json:"marker"` + Priority int `json:"priority"` + IsWorktree bool `json:"isWorktree,omitempty"` + WorktreeParent string `json:"worktreeParent,omitempty"` } // Discoverer handles project discovery @@ -155,37 +158,36 @@ func (d *Discoverer) walkPath(root string, results chan<- Project) { } // Check for project markers - find the highest priority marker - // Phase 1: Check exact markers using os.Stat (fast path) - var bestMarker string - var bestPriority int - for _, marker := range d.config.ExactMarkers { - markerPath := filepath.Join(path, marker) - if _, err := os.Stat(markerPath); err == nil { - priority := d.getMarkerPriority(marker) - if priority > bestPriority { - bestMarker = marker - bestPriority = priority - } - } - } - - // Phase 2: Check pattern markers using directory listing (only if configured) - if len(d.config.PatternMarkers) > 0 { - patternMarker, patternPriority := d.checkPatternMarkers(path) - if patternPriority > bestPriority { - bestMarker = patternMarker - bestPriority = patternPriority - } - } + bestMarker, bestPriority := d.findBestMarker(path) // If we found any marker, emit the project with the best one if bestMarker != "" { - results <- Project{ + project := Project{ Path: path, Marker: bestMarker, Priority: bestPriority, } + // Path A: detect if this is a worktree (.git is a file, not a directory) + gitPath := filepath.Join(path, ".git") + if info, err := os.Lstat(gitPath); err == nil && !info.IsDir() { + parent := parseWorktreeGitFile(gitPath) + if parent != "" { + if d.config.NoWorktrees { + return fs.SkipDir + } + project.IsWorktree = true + project.WorktreeParent = parent + } + } + + results <- project + + // Path B: discover linked worktrees from parent repos + if d.config.Worktrees && !project.IsWorktree { + d.discoverWorktrees(path, results) + } + // Skip subdirectories unless nested discovery is enabled if !d.config.Nested { return fs.SkipDir @@ -241,6 +243,152 @@ func (d *Discoverer) checkPatternMarkers(dir string) (string, int) { return bestMatch, bestPriority } +// parseWorktreeGitFile reads a .git file (not directory) and resolves the parent repo path. +// Worktree .git files contain "gitdir: " pointing to the parent's .git/worktrees//. +func parseWorktreeGitFile(gitFilePath string) string { + f, err := os.Open(gitFilePath) + if err != nil { + return "" + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + if !scanner.Scan() { + return "" + } + line := scanner.Text() + if !strings.HasPrefix(line, "gitdir: ") { + return "" + } + + gitdir := strings.TrimPrefix(line, "gitdir: ") + + // Resolve relative paths against the worktree directory + if !filepath.IsAbs(gitdir) { + gitdir = filepath.Join(filepath.Dir(gitFilePath), gitdir) + } + gitdir = filepath.Clean(gitdir) + + // gitdir points to e.g. /parent/.git/worktrees/ + // Walk up to find the parent repo: strip /.git/worktrees/ to get /parent + parts := strings.Split(gitdir, string(os.PathSeparator)) + for i := len(parts) - 1; i >= 1; i-- { + if parts[i] == "worktrees" && parts[i-1] == ".git" { + parentPath := strings.Join(parts[:i-1], string(os.PathSeparator)) + if parentPath == "" { + parentPath = "/" + } + return parentPath + } + } + return "" +} + +// discoverWorktrees finds git worktrees linked from a parent repo's .git/worktrees/ directory. +func (d *Discoverer) discoverWorktrees(repoPath string, results chan<- Project) { + worktreesDir := filepath.Join(repoPath, ".git", "worktrees") + entries, err := os.ReadDir(worktreesDir) + if err != nil { + return // No worktrees directory + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + gitdirPath := filepath.Join(worktreesDir, entry.Name(), "gitdir") + data, err := os.ReadFile(gitdirPath) + if err != nil { + if d.verbose { + fmt.Fprintf(os.Stderr, "Warning: couldn't read gitdir for worktree %s: %v\n", entry.Name(), err) + } + continue + } + + wtGitFile := strings.TrimSpace(string(data)) + + // Resolve relative paths + if !filepath.IsAbs(wtGitFile) { + wtGitFile = filepath.Join(worktreesDir, entry.Name(), wtGitFile) + } + wtGitFile = filepath.Clean(wtGitFile) + + // The gitdir file contains the path to the worktree's .git file + // The worktree root is its parent directory + wtPath := wtGitFile + if strings.HasSuffix(wtPath, string(os.PathSeparator)+".git") { + wtPath = filepath.Dir(wtPath) + } + + if _, err := os.Stat(wtPath); err != nil { + if d.verbose { + fmt.Fprintf(os.Stderr, "Warning: worktree path doesn't exist: %s\n", wtPath) + } + continue + } + + // Check excludes + wtName := filepath.Base(wtPath) + excluded := false + for _, exclude := range d.config.Excludes { + if matchPattern(wtName, exclude) { + excluded = true + break + } + } + if excluded { + continue + } + + // Find the best marker in the worktree directory + bestMarker, bestPriority := d.findBestMarker(wtPath) + if bestMarker == "" { + bestMarker = ".git" + bestPriority = d.getMarkerPriority(".git") + } + + results <- Project{ + Path: wtPath, + Marker: bestMarker, + Priority: bestPriority, + IsWorktree: true, + WorktreeParent: repoPath, + } + + if d.verbose { + fmt.Fprintf(os.Stderr, "Found worktree: %s (parent: %s)\n", wtPath, repoPath) + } + } +} + +// findBestMarker checks a directory for configured markers and returns the best one. +func (d *Discoverer) findBestMarker(dir string) (string, int) { + var bestMarker string + var bestPriority int + + for _, marker := range d.config.ExactMarkers { + markerPath := filepath.Join(dir, marker) + if _, err := os.Stat(markerPath); err == nil { + priority := d.getMarkerPriority(marker) + if priority > bestPriority { + bestMarker = marker + bestPriority = priority + } + } + } + + if len(d.config.PatternMarkers) > 0 { + patternMarker, patternPriority := d.checkPatternMarkers(dir) + if patternPriority > bestPriority { + bestMarker = patternMarker + bestPriority = patternPriority + } + } + + return bestMarker, bestPriority +} + // matchPattern checks if a name matches a pattern (simple glob support) func matchPattern(name, pattern string) bool { if name == pattern { diff --git a/internal/discover/discover_test.go b/internal/discover/discover_test.go index 42af80b..6d93d21 100644 --- a/internal/discover/discover_test.go +++ b/internal/discover/discover_test.go @@ -1387,3 +1387,476 @@ func TestDiscoverPatternMarkerSkipsDirectories(t *testing.T) { t.Errorf("Expected file marker 'App.csproj', got '%s'", projects[0].Marker) } } + +// createWorktreeSetup creates a parent git repo with worktrees linked to it. +// Returns (parentRepoPath, []worktreePaths). +func createWorktreeSetup(t *testing.T, base string, parentName string, worktreeNames ...string) (string, []string) { + t.Helper() + + // Create parent repo with .git directory + parentDir := createProject(t, base, parentName, ".git/") + + // Create .git/worktrees/ entries for each worktree + worktreesDir := filepath.Join(parentDir, ".git", "worktrees") + if err := os.MkdirAll(worktreesDir, 0755); err != nil { + t.Fatal(err) + } + + var wtPaths []string + for _, wtName := range worktreeNames { + // Create the worktree directory + wtDir := filepath.Join(base, wtName) + if err := os.MkdirAll(wtDir, 0755); err != nil { + t.Fatal(err) + } + + // Create the worktree's .git file (a file, not directory) pointing back + wtGitFile := filepath.Join(wtDir, ".git") + gitdirTarget := filepath.Join(worktreesDir, wtName) + if err := os.WriteFile(wtGitFile, []byte("gitdir: "+gitdirTarget+"\n"), 0644); err != nil { + t.Fatal(err) + } + + // Create the entry in parent's .git/worktrees// + wtEntryDir := filepath.Join(worktreesDir, wtName) + if err := os.MkdirAll(wtEntryDir, 0755); err != nil { + t.Fatal(err) + } + + // Write gitdir file pointing to the worktree's .git file + gitdirPath := filepath.Join(wtEntryDir, "gitdir") + if err := os.WriteFile(gitdirPath, []byte(filepath.Join(wtDir, ".git")+"\n"), 0644); err != nil { + t.Fatal(err) + } + + wtPaths = append(wtPaths, wtDir) + } + + return parentDir, wtPaths +} + +func TestWorktreeDetectionPathA(t *testing.T) { + tmpDir := t.TempDir() + + parentDir, wtPaths := createWorktreeSetup(t, tmpDir, "main-repo", "feature-wt") + + cfg := &config.Config{ + SearchPaths: []string{tmpDir}, + Markers: []string{".git"}, + MaxDepth: 3, + Excludes: []string{}, + } + + d := New(cfg, false) + projects, err := d.Discover() + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + + // Should find parent repo and worktree (both are in search path) + if len(projects) != 2 { + t.Errorf("Discover() found %d projects, want 2", len(projects)) + for _, p := range projects { + t.Logf(" Found: %s (worktree=%v)", p.Path, p.IsWorktree) + } + } + + for _, p := range projects { + switch p.Path { + case wtPaths[0]: + if !p.IsWorktree { + t.Error("Worktree project should have IsWorktree=true") + } + if p.WorktreeParent != parentDir { + t.Errorf("WorktreeParent = %q, want %q", p.WorktreeParent, parentDir) + } + case parentDir: + if p.IsWorktree { + t.Error("Parent repo should have IsWorktree=false") + } + } + } +} + +func TestWorktreeDiscoveryPathB(t *testing.T) { + tmpDir := t.TempDir() + + // Create parent in search path, worktree OUTSIDE search path + searchDir := filepath.Join(tmpDir, "search") + externalDir := filepath.Join(tmpDir, "external") + if err := os.MkdirAll(searchDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(externalDir, 0755); err != nil { + t.Fatal(err) + } + + // Create parent repo + parentDir := createProject(t, searchDir, "main-repo", ".git/") + + // Create worktree outside search path + wtDir := filepath.Join(externalDir, "feature-wt") + if err := os.MkdirAll(wtDir, 0755); err != nil { + t.Fatal(err) + } + + // Set up .git/worktrees/ in parent + worktreesDir := filepath.Join(parentDir, ".git", "worktrees") + wtEntryDir := filepath.Join(worktreesDir, "feature-wt") + if err := os.MkdirAll(wtEntryDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wtEntryDir, "gitdir"), []byte(filepath.Join(wtDir, ".git")+"\n"), 0644); err != nil { + t.Fatal(err) + } + + // Create worktree's .git file + if err := os.WriteFile(filepath.Join(wtDir, ".git"), []byte("gitdir: "+wtEntryDir+"\n"), 0644); err != nil { + t.Fatal(err) + } + + // With Worktrees=true, should find external worktree via Path B + cfg := &config.Config{ + SearchPaths: []string{searchDir}, + Markers: []string{".git"}, + MaxDepth: 3, + Excludes: []string{}, + Worktrees: true, + } + + d := New(cfg, false) + projects, err := d.Discover() + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + + if len(projects) != 2 { + t.Errorf("Discover() found %d projects, want 2", len(projects)) + for _, p := range projects { + t.Logf(" Found: %s (worktree=%v)", p.Path, p.IsWorktree) + } + } + + foundWorktree := false + for _, p := range projects { + if p.Path == wtDir { + foundWorktree = true + if !p.IsWorktree { + t.Error("External worktree should have IsWorktree=true") + } + if p.WorktreeParent != parentDir { + t.Errorf("WorktreeParent = %q, want %q", p.WorktreeParent, parentDir) + } + } + } + if !foundWorktree { + t.Error("External worktree not found (should be discovered via Path B)") + } +} + +func TestWorktreeDiscoveryDisabledByDefault(t *testing.T) { + tmpDir := t.TempDir() + + searchDir := filepath.Join(tmpDir, "search") + externalDir := filepath.Join(tmpDir, "external") + if err := os.MkdirAll(searchDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(externalDir, 0755); err != nil { + t.Fatal(err) + } + + parentDir := createProject(t, searchDir, "main-repo", ".git/") + + wtDir := filepath.Join(externalDir, "feature-wt") + if err := os.MkdirAll(wtDir, 0755); err != nil { + t.Fatal(err) + } + + worktreesDir := filepath.Join(parentDir, ".git", "worktrees") + wtEntryDir := filepath.Join(worktreesDir, "feature-wt") + if err := os.MkdirAll(wtEntryDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wtEntryDir, "gitdir"), []byte(filepath.Join(wtDir, ".git")+"\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wtDir, ".git"), []byte("gitdir: "+wtEntryDir+"\n"), 0644); err != nil { + t.Fatal(err) + } + + // Default: Worktrees=false — should NOT find external worktree + cfg := &config.Config{ + SearchPaths: []string{searchDir}, + Markers: []string{".git"}, + MaxDepth: 3, + Excludes: []string{}, + } + + d := New(cfg, false) + projects, err := d.Discover() + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + + if len(projects) != 1 { + t.Errorf("Discover() found %d projects, want 1 (only parent)", len(projects)) + for _, p := range projects { + t.Logf(" Found: %s (worktree=%v)", p.Path, p.IsWorktree) + } + } +} + +func TestNoWorktreesFiltersAll(t *testing.T) { + tmpDir := t.TempDir() + + createWorktreeSetup(t, tmpDir, "main-repo", "feature-wt") + + cfg := &config.Config{ + SearchPaths: []string{tmpDir}, + Markers: []string{".git"}, + MaxDepth: 3, + Excludes: []string{}, + NoWorktrees: true, + } + + d := New(cfg, false) + projects, err := d.Discover() + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + + // Should only find parent repo, worktree is filtered out + if len(projects) != 1 { + t.Errorf("Discover() found %d projects, want 1", len(projects)) + for _, p := range projects { + t.Logf(" Found: %s (worktree=%v)", p.Path, p.IsWorktree) + } + } + + for _, p := range projects { + if p.IsWorktree { + t.Error("Found worktree project despite NoWorktrees=true") + } + } +} + +func TestWorktreeExcluded(t *testing.T) { + tmpDir := t.TempDir() + + searchDir := filepath.Join(tmpDir, "search") + externalDir := filepath.Join(tmpDir, "external") + if err := os.MkdirAll(searchDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(externalDir, 0755); err != nil { + t.Fatal(err) + } + + parentDir := createProject(t, searchDir, "main-repo", ".git/") + + // Create worktree with excluded name + wtDir := filepath.Join(externalDir, "excluded-wt") + if err := os.MkdirAll(wtDir, 0755); err != nil { + t.Fatal(err) + } + + worktreesDir := filepath.Join(parentDir, ".git", "worktrees") + wtEntryDir := filepath.Join(worktreesDir, "excluded-wt") + if err := os.MkdirAll(wtEntryDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wtEntryDir, "gitdir"), []byte(filepath.Join(wtDir, ".git")+"\n"), 0644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + SearchPaths: []string{searchDir}, + Markers: []string{".git"}, + MaxDepth: 3, + Excludes: []string{"excluded-*"}, + Worktrees: true, + } + + d := New(cfg, false) + projects, err := d.Discover() + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + + if len(projects) != 1 { + t.Errorf("Discover() found %d projects, want 1 (only parent)", len(projects)) + for _, p := range projects { + t.Logf(" Found: %s", p.Path) + } + } +} + +func TestWorktreeStalePath(t *testing.T) { + tmpDir := t.TempDir() + + parentDir := createProject(t, tmpDir, "main-repo", ".git/") + + // Create worktrees entry pointing to non-existent path + worktreesDir := filepath.Join(parentDir, ".git", "worktrees", "stale") + if err := os.MkdirAll(worktreesDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(worktreesDir, "gitdir"), []byte("/nonexistent/path/.git\n"), 0644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + SearchPaths: []string{tmpDir}, + Markers: []string{".git"}, + MaxDepth: 3, + Excludes: []string{}, + Worktrees: true, + } + + d := New(cfg, false) + projects, err := d.Discover() + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + + // Should only find parent (stale worktree is skipped gracefully) + if len(projects) != 1 { + t.Errorf("Discover() found %d projects, want 1", len(projects)) + } +} + +func TestWorktreeMarkerInheritance(t *testing.T) { + tmpDir := t.TempDir() + + searchDir := filepath.Join(tmpDir, "search") + externalDir := filepath.Join(tmpDir, "external") + if err := os.MkdirAll(searchDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(externalDir, 0755); err != nil { + t.Fatal(err) + } + + parentDir := createProject(t, searchDir, "main-repo", ".git/", "go.mod") + + // Create worktree with go.mod too + wtDir := filepath.Join(externalDir, "feature-wt") + if err := os.MkdirAll(wtDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wtDir, "go.mod"), []byte("module test"), 0644); err != nil { + t.Fatal(err) + } + + worktreesDir := filepath.Join(parentDir, ".git", "worktrees") + wtEntryDir := filepath.Join(worktreesDir, "feature-wt") + if err := os.MkdirAll(wtEntryDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wtEntryDir, "gitdir"), []byte(filepath.Join(wtDir, ".git")+"\n"), 0644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + SearchPaths: []string{searchDir}, + Markers: []string{".git", "go.mod"}, + MaxDepth: 3, + Excludes: []string{}, + Worktrees: true, + } + + d := New(cfg, false) + projects, err := d.Discover() + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + + for _, p := range projects { + if p.Path == wtDir { + // Worktree should get go.mod (priority 10) not .git (priority 1) + if p.Marker != "go.mod" { + t.Errorf("Worktree marker = %q, want go.mod (higher priority)", p.Marker) + } + if p.Priority != 10 { + t.Errorf("Worktree priority = %d, want 10", p.Priority) + } + } + } +} + +func TestWorktreeDedup(t *testing.T) { + tmpDir := t.TempDir() + + // Worktree is within search path AND referenced from parent's .git/worktrees/ + createWorktreeSetup(t, tmpDir, "main-repo", "feature-wt") + + cfg := &config.Config{ + SearchPaths: []string{tmpDir}, + Markers: []string{".git"}, + MaxDepth: 3, + Excludes: []string{}, + Worktrees: true, + } + + d := New(cfg, false) + projects, err := d.Discover() + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + + // Should find exactly 2 (parent + worktree), not 3 (parent + worktree found twice) + if len(projects) != 2 { + t.Errorf("Discover() found %d projects, want 2 (dedup should prevent duplicates)", len(projects)) + for _, p := range projects { + t.Logf(" Found: %s (worktree=%v)", p.Path, p.IsWorktree) + } + } +} + +func TestParseWorktreeGitFile(t *testing.T) { + tmpDir := t.TempDir() + + t.Run("valid gitdir reference", func(t *testing.T) { + parentDir := filepath.Join(tmpDir, "parent") + worktreesDir := filepath.Join(parentDir, ".git", "worktrees", "feature") + if err := os.MkdirAll(worktreesDir, 0755); err != nil { + t.Fatal(err) + } + + gitFile := filepath.Join(tmpDir, "wt", ".git") + if err := os.MkdirAll(filepath.Dir(gitFile), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(gitFile, []byte("gitdir: "+worktreesDir+"\n"), 0644); err != nil { + t.Fatal(err) + } + + parent := parseWorktreeGitFile(gitFile) + if parent != parentDir { + t.Errorf("parseWorktreeGitFile() = %q, want %q", parent, parentDir) + } + }) + + t.Run("invalid content", func(t *testing.T) { + gitFile := filepath.Join(tmpDir, "bad-wt", ".git") + if err := os.MkdirAll(filepath.Dir(gitFile), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(gitFile, []byte("not a gitdir reference\n"), 0644); err != nil { + t.Fatal(err) + } + + parent := parseWorktreeGitFile(gitFile) + if parent != "" { + t.Errorf("parseWorktreeGitFile() = %q, want empty string", parent) + } + }) + + t.Run("nonexistent file", func(t *testing.T) { + parent := parseWorktreeGitFile(filepath.Join(tmpDir, "nonexistent")) + if parent != "" { + t.Errorf("parseWorktreeGitFile() = %q, want empty string", parent) + } + }) +} diff --git a/main.go b/main.go index 5164b81..fcdb39f 100644 --- a/main.go +++ b/main.go @@ -47,13 +47,15 @@ type CLI struct { MaxDepth int `short:"d" help:"Maximum search depth"` NoIgnore bool `help:"Don't respect .gitignore and .ignore files"` NoNested bool `help:"Don't search for projects inside other projects"` + Worktrees bool `help:"Discover git worktrees from parent repos, even outside search paths"` + NoWorktrees bool `help:"Exclude git worktrees from results" name:"no-worktrees"` Icons bool `help:"Show marker-based icons"` Strip bool `help:"Strip icons from output"` IconMap []string `help:"Override icon mapping (MARKER:ICON)"` Ansi bool `short:"a" help:"Colorize icons with ANSI codes"` ColorMap []string `help:"Override icon color (MARKER:COLOR)"` Labels LabelsFlag `short:"l" help:"Show marker label in output (label or display)"` - Format string `short:"f" help:"Custom output format (%p=path, %P=full-path, %n=name, %m=marker, %i=icon, %l=label, %L=display-label, %c=color)" default:""` + Format string `short:"f" help:"Custom output format (%p=path, %P=full-path, %n=name, %m=marker, %i=icon, %l=label, %L=display-label, %c=color, %w=worktree-parent)" default:""` Shorten bool `short:"s" help:"Shorten home directory to ~ in output paths"` NoCache bool `help:"Skip cache, force fresh search"` ClearCache bool `help:"Clear cache and exit"` @@ -113,7 +115,7 @@ func formatOutput(format string, values map[string]string) string { const sentinel = "\x00PCT\x00" result := strings.ReplaceAll(format, "%%", sentinel) // Replace %P before %p to avoid %P being partially matched as %p + "P" - for _, placeholder := range []string{"%P", "%p", "%n", "%m", "%i", "%L", "%l", "%c"} { + for _, placeholder := range []string{"%P", "%p", "%n", "%m", "%i", "%L", "%l", "%c", "%w"} { if val, ok := values[placeholder]; ok { result = strings.ReplaceAll(result, placeholder, val) } @@ -184,6 +186,11 @@ func main() { os.Exit(1) } + if cli.Worktrees && cli.NoWorktrees { + fmt.Fprintf(os.Stderr, "Error: --worktrees and --no-worktrees are mutually exclusive\n") + os.Exit(1) + } + stdinMode := false if stdinIsPiped() { stdinPaths := readPathsFromStdin(cli.Verbose) @@ -279,6 +286,8 @@ func main() { Icon string `json:"icon,omitempty"` AnsiIcon string `json:"ansiIcon,omitempty"` Color string `json:"color,omitempty"` + IsWorktree bool `json:"isWorktree,omitempty"` + WorktreeParent string `json:"worktreeParent,omitempty"` } type outputJSON struct { Projects []projectJSON `json:"projects"` @@ -300,16 +309,22 @@ func main() { if cli.Shorten { displayPath = shortenHome(p.Path, homeDir) } + displayLabel := iconMapper.GetDisplayLabel(p.Marker) + if p.IsWorktree && displayLabel != "" { + displayLabel += " (worktree)" + } jsonProjects[i] = projectJSON{ Path: p.Path, DisplayPath: displayPath, Name: filepath.Base(p.Path), Marker: p.Marker, MarkerLabel: iconMapper.GetLabel(p.Marker), - MarkerDisplayLabel: iconMapper.GetDisplayLabel(p.Marker), + MarkerDisplayLabel: displayLabel, Icon: icon, AnsiIcon: ansiIcon, Color: color, + IsWorktree: p.IsWorktree, + WorktreeParent: p.WorktreeParent, } } @@ -329,6 +344,10 @@ func main() { if cli.Shorten { displayPath = shortenHome(p.Path, homeDir) } + displayLabel := iconMapper.GetDisplayLabel(p.Marker) + if p.IsWorktree && displayLabel != "" { + displayLabel += " (worktree)" + } values := map[string]string{ "%p": displayPath, "%P": p.Path, @@ -336,8 +355,9 @@ func main() { "%m": p.Marker, "%i": icon, "%l": icons.FormatLabel(iconMapper.GetLabel(p.Marker), cli.Ansi), - "%L": icons.FormatLabel(iconMapper.GetDisplayLabel(p.Marker), cli.Ansi), + "%L": icons.FormatLabel(displayLabel, cli.Ansi), "%c": iconMapper.GetColor(p.Marker), + "%w": p.WorktreeParent, } fmt.Println(formatOutput(cli.Format, values)) } @@ -355,6 +375,9 @@ func main() { case "display": label = iconMapper.GetDisplayLabel(p.Marker) } + if p.IsWorktree && label != "" { + label += " (worktree)" + } if label != "" { output = fmt.Sprintf("%s %s", icons.FormatLabel(label, cli.Ansi), output) }