Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions internal/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
Expand Down
56 changes: 56 additions & 0 deletions internal/cache/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
14 changes: 14 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}

Expand Down
61 changes: 61 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
}
Loading
Loading