From e5394a94b7d8e2dfa8a2a78edf6c182b2d42650e Mon Sep 17 00:00:00 2001 From: Dennis Zagiansky Date: Fri, 17 Jul 2026 23:37:24 +0300 Subject: [PATCH 1/4] feat(workspace): configurable worktree storage location Add a path template controlling where the built-in git provider places new worktrees, instead of always using the sibling - layout. Set globally via config.json `worktree_dir`, per-repo via .fleet.json `workspace.dir` (per-repo wins), or from Settings -> Behavior. Placeholders: {{parent}}, {{repo}}, {{name}}, and a leading ~/. Empty (default) keeps the classic sibling layout, so existing setups are unchanged. One resolver (resolveWorktreePath) drives Create, Destroy, and the dialog preview so they can't diverge; precedence lives in WorktreeDirTemplate; GitWorktreeProvider.WorktreeDir carries the template (set in ResolveProvider); Create mkdir -p's the parent for nested templates. The preview reads the already-resolved template off the provider, so no config disk I/O on render. --- CLAUDE.md | 3 +- changelog/unreleased/worktree-location.md | 5 ++ internal/config/config.go | 15 +++++ internal/ui/settings.go | 34 ++++++++++++ internal/ui/workspace_create.go | 2 +- internal/ui/workspace_picker.go | 2 +- internal/workspace/provider.go | 59 +++++++++++++++----- internal/workspace/provider_test.go | 67 +++++++++++++++++++++-- internal/workspace/repo_config.go | 27 ++++++++- internal/workspace/repo_config_test.go | 58 ++++++++++++++++++++ internal/workspace/worktree_path.go | 59 ++++++++++++++++++++ 11 files changed, 307 insertions(+), 24 deletions(-) create mode 100644 changelog/unreleased/worktree-location.md create mode 100644 internal/workspace/worktree_path.go diff --git a/CLAUDE.md b/CLAUDE.md index 65a70ed8..63ee2d5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,11 +123,12 @@ chrome-extension/ # Chrome MV3 extension (service worker, manifes - Hook handler: `fleet hook-handler` (invoked by Claude Code hooks, reads FLEET_INSTANCE_ID env) - Hooks auto-installed into `~/.claude/settings.json` on TUI launch - Debug log: `~/.config/fleet/debug.log` (slog, init in TUI and hook-handler) -- Config file: `~/.config/fleet/config.json` (tick_interval_sec, default_project_path, editor, theme, auto_name_sessions, copy_claude_settings, confirm_before_restart, origin_delete_removes_worktrees, drawer_height, session_suspend_mode) +- Config file: `~/.config/fleet/config.json` (tick_interval_sec, default_project_path, editor, theme, auto_name_sessions, copy_claude_settings, confirm_before_restart, origin_delete_removes_worktrees, drawer_height, session_suspend_mode, worktree_dir) - Workspace: built-in git worktree support (zero config), per-repo `.fleet.json` (or legacy `.bc.json`) overrides with custom shell commands - Workspace creation is non-blocking: dialog closes immediately, phantom "Creating..." entry with spinner appears in sidebar, user can keep navigating - Worktree creation copies `.claude/settings.local.json` from source repo (configurable via `copy_claude_settings`, default true) - `.fleet.json` / `.fleet.local.json` in repo root (legacy `.bc.json` / `.bc.local.json` still read): `{"workspace": {"list": "cmd", "create": "cmd {{name}} {{branch}}", "destroy": "cmd {{name}}"}}` +- Worktree location is configurable: `worktree_dir` (global config, Settings → Behavior → "Worktree location") or per-repo `.fleet.json` `{"workspace": {"dir": "..."}}` (per-repo wins) is a **path template** deciding where the built-in git provider places worktrees. Placeholders: `{{parent}}` (main repo's parent dir), `{{repo}}` (main repo basename), `{{name}}` (sanitized branch), leading `~`/`~/` (home); result is `filepath.Abs`'d. Empty (default) = classic sibling `-`; `{{parent}}/{{repo}}.worktrees/{{name}}` groups them under a `.worktrees/` folder. One resolver (`resolveWorktreePath` in `internal/workspace/worktree_path.go`) drives Create, Destroy, and the dialog preview so they can't diverge; precedence lives in `workspace.WorktreeDirTemplate`; `GitWorktreeProvider.WorktreeDir` carries the template (set in `ResolveProvider`); Create `os.MkdirAll`s the parent for nested templates. The Settings cycler offers Sibling / Subfolder presets and shows a hand-edited config.json template as "Custom" (injected into the ring like the Editor row). Ignored when a shell provider is configured. - `.fleet.json` / `.fleet.local.json` may also set `{"pr_checks": {"ignore": ["glob", ...]}}` to drop matching CI checks from the PR-badge rollup (path.Match globs; lists from both files merge additively; opt-in, empty by default) - `.fleet.json` / `.fleet.local.json` may also set `{"copy_files": {"paths": ["path", "dir", "glob/*", ...]}}` to copy gitignored files/dirs/globs from the source repo into each new worktree (filepath.Glob semantics, repo-relative only; lists from both files merge additively; opt-in, empty by default; applies to both git-worktree and shell providers; independent of `copy_claude_settings`) - Multi-agent: per-session agent (Claude, Codex, or OpenCode), chosen at creation (`A` key picker or `default_agent` config used by `a`). Stored in SQLite `agent` column; `internal/agent` owns binary name + launch command (`claude` / `codex resume ` / `codex fork ` / `opencode --session ` / `opencode --session --fork`). diff --git a/changelog/unreleased/worktree-location.md b/changelog/unreleased/worktree-location.md new file mode 100644 index 00000000..e28ef9f9 --- /dev/null +++ b/changelog/unreleased/worktree-location.md @@ -0,0 +1,5 @@ +--- +type: added +--- + +**Choose where worktrees live** — new worktrees no longer have to be siblings like `myrepo-feature`. Set `Worktree location` in Settings → Behavior (or `worktree_dir` in config, or per-repo `.fleet.json` `workspace.dir`) to a path template like `{{parent}}/{{repo}}.worktrees/{{name}}` to group them under a tidy `myrepo.worktrees/` folder. Placeholders: `{{parent}}`, `{{repo}}`, `{{name}}`, and `~`. diff --git a/internal/config/config.go b/internal/config/config.go index e2486944..919e09fc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,14 @@ type Config struct { // GetSessionSuspendMode. See internal/ui suspend sweep. SessionSuspendMode string `json:"session_suspend_mode,omitempty"` + // WorktreeDir is a path template controlling where the built-in git-worktree + // provider places new worktrees. Empty (default) means the classic sibling + // layout (-). Placeholders: {{parent}} (main repo's parent + // dir), {{repo}} (main repo basename), {{name}} (sanitized branch name), and + // a leading ~ / ~/ for the home dir. Read via GetWorktreeDir. A per-repo + // .fleet.json workspace.dir overrides this. See internal/workspace. + WorktreeDir string `json:"worktree_dir,omitempty"` + // Sidebar display toggles. All default to true (on) via the *bool nil // pattern, so an unconfigured fleet renders the full vocabulary. Each is // surfaced in the Appearance category of the Settings dialog and drives a @@ -563,6 +571,13 @@ func (c *Config) TelemetryConfigured() bool { return isValidTelemetryMode(c.TelemetryMode) || c.Telemetry != nil } +// GetWorktreeDir returns the worktree-location path template (trimmed). Empty +// means the built-in git provider uses the classic sibling layout. See the +// WorktreeDir field for placeholder semantics. +func (c *Config) GetWorktreeDir() string { + return strings.TrimSpace(c.WorktreeDir) +} + // GetEditor returns the configured editor, falling back to $EDITOR then "code". func (c *Config) GetEditor() string { if c.Editor != "" { diff --git a/internal/ui/settings.go b/internal/ui/settings.go index bdb9ceb3..e07608f5 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -32,8 +32,25 @@ var ( defaultAgentSet = []string{"claude", "codex", "opencode"} telemetryModeSet = []string{config.TelemetryFull, config.TelemetryMinimal, config.TelemetryOff} suspendModeSet = []string{config.SuspendOff, config.SuspendLight, config.SuspendBalanced, config.SuspendAggressive} + // worktreeDirSet holds the raw preset templates for the "Worktree location" + // cycler: "" = classic sibling layout, the second = a per-repo ".worktrees" + // subfolder. A custom template set in config.json is injected into the ring + // at cycle time (like the Editor row) so it's never hidden. + worktreeDirSet = []string{"", "{{parent}}/{{repo}}.worktrees/{{name}}"} ) +// worktreeDirLabel maps a raw worktree-dir template to its display label. +func worktreeDirLabel(template string) string { + switch strings.TrimSpace(template) { + case "": + return "Sibling (repo-branch)" + case "{{parent}}/{{repo}}.worktrees/{{name}}": + return "Subfolder (.worktrees)" + default: + return "Custom" + } +} + // settingsFocus tracks which pane (category rail or detail list) has the cursor. type settingsFocus int @@ -652,6 +669,23 @@ func buildSettingsCategories() []settingsCategory { withLabel("Origin forget removes worktrees", toggle( (*config.Config).GetOriginDeleteRemovesWorktrees, func(c *config.Config, v bool) { c.OriginDeleteRemovesWorktrees = &v }, false)), + { + // Where the built-in git provider puts new worktrees. Sibling = + // today's -; Subfolder = .worktrees/. + // A custom template edited in config.json shows as "Custom" and is + // kept in the ring so cycling never overwrites it blindly. + label: "Worktree location", + value: func(c *config.Config) string { return worktreeDirLabel(c.GetWorktreeDir()) }, + valueW: func() int { return maxStrW([]string{"Sibling (repo-branch)", "Subfolder (.worktrees)", "Custom"}) }, + cycle: func(d *SettingsDialog, dir int) { + cur := d.cfg.GetWorktreeDir() + presets := worktreeDirSet + if indexOf(presets, cur) < 0 { + presets = append([]string{cur}, presets...) + } + d.cfg.WorktreeDir = cycleString(cur, presets, dir) + }, + }, { label: "Enter mode", value: func(c *config.Config) string { return c.GetEnterMode() }, diff --git a/internal/ui/workspace_create.go b/internal/ui/workspace_create.go index 761af30c..cb871fe1 100644 --- a/internal/ui/workspace_create.go +++ b/internal/ui/workspace_create.go @@ -291,7 +291,7 @@ func (d *CreateWorkspaceDialog) View() string { branch := strings.TrimSpace(d.branchInput.Value()) if branch != "" { name := workspace.SanitizeBranchName(branch) - preview := workspace.DeriveWorktreePathPreview(d.repoPath, name) + preview := workspace.DeriveWorktreePathPreview(d.repoPath, name, workspace.GitWorktreeDirTemplate(d.provider)) b.WriteString(DimStyle.Render(" → " + preview)) b.WriteString("\n") } diff --git a/internal/ui/workspace_picker.go b/internal/ui/workspace_picker.go index 5de8d957..406dbb70 100644 --- a/internal/ui/workspace_picker.go +++ b/internal/ui/workspace_picker.go @@ -284,7 +284,7 @@ func (d *WorktreeDialog) View() string { newBranch := strings.TrimSpace(d.newBranchInput.Value()) if newBranch != "" { name := workspace.SanitizeBranchName(newBranch) - preview := workspace.DeriveWorktreePathPreview(d.repoPath, name) + preview := workspace.DeriveWorktreePathPreview(d.repoPath, name, workspace.GitWorktreeDirTemplate(d.provider)) b.WriteString(DimStyle.Render(" → " + preview)) b.WriteString("\n") } diff --git a/internal/workspace/provider.go b/internal/workspace/provider.go index a087f682..e44f3090 100644 --- a/internal/workspace/provider.go +++ b/internal/workspace/provider.go @@ -35,7 +35,12 @@ type Provider interface { // --- GitWorktreeProvider (built-in default) --- // GitWorktreeProvider uses git worktree commands for workspace management. -type GitWorktreeProvider struct{} +// WorktreeDir is the path template controlling where new worktrees are placed +// (empty = classic sibling layout); it is set by ResolveProvider from the +// per-repo .fleet.json workspace.dir or the global config worktree_dir. +type GitWorktreeProvider struct { + WorktreeDir string +} // worktreeRemoveMu serializes the remove/RemoveAll/prune region across Destroy // calls. Providers are created per-invocation (ResolveProvider doesn't cache), @@ -74,10 +79,20 @@ func (g *GitWorktreeProvider) List(repoPath string) ([]WorkspaceInfo, error) { } func (g *GitWorktreeProvider) Create(repoPath, name, branch, baseBranch string) (*WorkspaceInfo, error) { - path := deriveWorktreePath(repoPath, name) + path := resolveWorktreePath(repoPath, name, g.WorktreeDir) debuglog.Logger.Info("git worktree create", "repo", repoPath, "name", name, "branch", branch, "baseBranch", baseBranch, "path", path) + // A non-sibling template (e.g. ".worktrees/") may need an + // intermediate directory that git worktree add won't create. The sibling + // default's parent (the repo's parent) always exists, so this is a no-op there. + if parent := filepath.Dir(path); parent != "" { + if err := os.MkdirAll(parent, 0o755); err != nil { + debuglog.Logger.Error("git worktree create: mkdir parent failed", "name", name, "parent", parent, "err", err) + return nil, fmt.Errorf("create worktree parent dir %q: %w", parent, err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() @@ -232,7 +247,7 @@ func (g *GitWorktreeProvider) Destroy(repoPath, name string) error { // and as the -C target for git worktree remove. mainPath, _ := filepath.Abs(all[0].Path) absRepo, _ := filepath.Abs(repoPath) - derivedPath, _ := filepath.Abs(deriveWorktreePath(mainPath, name)) + derivedPath, _ := filepath.Abs(resolveWorktreePath(mainPath, name, g.WorktreeDir)) // Find the worktree to remove. Try multiple matching strategies: // 1. Exact name match (ws.Name == name) @@ -544,18 +559,32 @@ func ValidateBranchName(branch string) string { return "" } -// deriveWorktreePath computes the sibling worktree path. -// e.g. repoPath="/code/myrepo", name="feature-login" -> "/code/myrepo-feature-login" -func deriveWorktreePath(repoPath, name string) string { - absRepo, _ := filepath.Abs(repoPath) - parent := filepath.Dir(absRepo) - base := filepath.Base(absRepo) - return filepath.Join(parent, base+"-"+name) +// GitWorktreeDirTemplate returns the worktree-dir path template a provider will +// use, or "" for shell/other providers (and the git provider's sibling +// default). It lets the UI preview the target path from the already-resolved +// provider without re-reading config on every render. +func GitWorktreeDirTemplate(p Provider) string { + if g, ok := p.(*GitWorktreeProvider); ok { + return g.WorktreeDir + } + return "" } -// DeriveWorktreePathPreview returns a display-friendly relative path preview. -func DeriveWorktreePathPreview(repoPath, name string) string { - absRepo, _ := filepath.Abs(repoPath) - base := filepath.Base(absRepo) - return "../" + base + "-" + name +// DeriveWorktreePathPreview returns a display-friendly path preview for the +// worktree that would be created for `name` under `repoPath`, applying +// `template` (the effective worktree-dir template, e.g. from +// GitWorktreeDirTemplate; empty = classic sibling layout). The home dir is +// shortened to ~ for readability. Pure — no disk I/O — so it's safe to call +// from a render path. +func DeriveWorktreePathPreview(repoPath, name, template string) string { + p := resolveWorktreePath(repoPath, name, template) + if home, err := os.UserHomeDir(); err == nil && home != "" { + if p == home { + return "~" + } + if strings.HasPrefix(p, home+string(os.PathSeparator)) { + return "~" + strings.TrimPrefix(p, home) + } + } + return p } diff --git a/internal/workspace/provider_test.go b/internal/workspace/provider_test.go index 45dfaf61..95d43093 100644 --- a/internal/workspace/provider_test.go +++ b/internal/workspace/provider_test.go @@ -1,6 +1,7 @@ package workspace import ( + "os" "path/filepath" "testing" ) @@ -207,7 +208,7 @@ func TestSanitizeBranchName(t *testing.T) { } } -func TestDeriveWorktreePath(t *testing.T) { +func TestResolveWorktreePathSiblingDefault(t *testing.T) { tests := []struct { name string repoPath string @@ -216,17 +217,75 @@ func TestDeriveWorktreePath(t *testing.T) { }{ {"basic", "/code/myrepo", "feature-login", "myrepo-feature-login"}, {"nested repo", "/home/user/projects/app", "hotfix", "app-hotfix"}, + {"whitespace template treated as empty", "/code/myrepo", "x", "myrepo-x"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := deriveWorktreePath(tt.repoPath, tt.wtName) + template := "" + if tt.name == "whitespace template treated as empty" { + template = " " + } + got := resolveWorktreePath(tt.repoPath, tt.wtName, template) if filepath.Base(got) != tt.wantEnd { - t.Errorf("deriveWorktreePath(%q, %q) base = %q, want %q", tt.repoPath, tt.wtName, filepath.Base(got), tt.wantEnd) + t.Errorf("resolveWorktreePath(%q, %q) base = %q, want %q", tt.repoPath, tt.wtName, filepath.Base(got), tt.wantEnd) } // Should be a sibling directory (same parent). if filepath.Dir(got) != filepath.Dir(tt.repoPath) { - t.Errorf("deriveWorktreePath result parent = %q, want %q", filepath.Dir(got), filepath.Dir(tt.repoPath)) + t.Errorf("resolveWorktreePath result parent = %q, want %q", filepath.Dir(got), filepath.Dir(tt.repoPath)) + } + }) + } +} + +func TestResolveWorktreePathTemplate(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("UserHomeDir: %v", err) + } + + tests := []struct { + name string + repoPath string + wtName string + template string + want string + }{ + { + name: "subfolder", + repoPath: "/code/myrepo", + wtName: "feature-login", + template: "{{parent}}/{{repo}}.worktrees/{{name}}", + want: "/code/myrepo.worktrees/feature-login", + }, + { + name: "absolute central dir", + repoPath: "/code/myrepo", + wtName: "hotfix", + template: "/var/wt/{{repo}}/{{name}}", + want: "/var/wt/myrepo/hotfix", + }, + { + name: "tilde expansion", + repoPath: "/code/myrepo", + wtName: "hotfix", + template: "~/worktrees/{{repo}}/{{name}}", + want: filepath.Join(home, "worktrees", "myrepo", "hotfix"), + }, + { + name: "name only under parent", + repoPath: "/code/myrepo", + wtName: "feat", + template: "{{parent}}/wt/{{name}}", + want: "/code/wt/feat", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveWorktreePath(tt.repoPath, tt.wtName, tt.template) + if got != tt.want { + t.Errorf("resolveWorktreePath(%q, %q, %q) = %q, want %q", tt.repoPath, tt.wtName, tt.template, got, tt.want) } }) } diff --git a/internal/workspace/repo_config.go b/internal/workspace/repo_config.go index 9134f330..8bafab54 100644 --- a/internal/workspace/repo_config.go +++ b/internal/workspace/repo_config.go @@ -6,6 +6,7 @@ import ( "path" "path/filepath" + "github.com/brizzai/fleet/internal/config" "github.com/brizzai/fleet/internal/debuglog" ) @@ -21,6 +22,12 @@ type ShellConfig struct { List string `json:"list,omitempty"` Create string `json:"create,omitempty"` Destroy string `json:"destroy,omitempty"` + // Dir is a path template controlling where the built-in git-worktree + // provider places new worktrees for this repo. It overrides the global + // config worktree_dir. Empty means "no per-repo override" (fall back to + // global, then the classic sibling layout). See resolveWorktreePath for + // placeholder semantics. Ignored when a shell provider is configured. + Dir string `json:"dir,omitempty"` } // PRChecksConfig holds repo-level controls over how PR check rollup is computed. @@ -57,6 +64,9 @@ func loadMergedRepoConfig(repoPath string) RepoWorkspaceConfig { if local.Workspace.Destroy != "" { merged.Workspace.Destroy = local.Workspace.Destroy } + if local.Workspace.Dir != "" { + merged.Workspace.Dir = local.Workspace.Dir + } merged.PRChecks.Ignore = dedupeStrings(append(base.PRChecks.Ignore, local.PRChecks.Ignore...)) merged.CopyFiles.Paths = dedupeStrings(append(base.CopyFiles.Paths, local.CopyFiles.Paths...)) @@ -93,6 +103,18 @@ func CopyFilesPatterns(repoPath string) []string { return loadMergedRepoConfig(repoPath).CopyFiles.Paths } +// WorktreeDirTemplate returns the effective worktree-location path template for +// repoPath, resolving precedence in one place: the per-repo .fleet.json +// workspace.dir wins, else the global config worktree_dir, else "" (the classic +// sibling layout). Used by both ResolveProvider and DeriveWorktreePathPreview so +// creation, removal, and the UI preview never disagree. +func WorktreeDirTemplate(repoPath string) string { + if d := loadMergedRepoConfig(repoPath).Workspace.Dir; d != "" { + return d + } + return config.Load().GetWorktreeDir() +} + // ResolveProvider loads workspace config from repoPath. Preference is by file // presence, not contents: if .fleet.json exists it wins (even when empty — // that's how a user disables a stale legacy .bc.json without deleting it); @@ -111,8 +133,9 @@ func ResolveProvider(repoPath string) Provider { } } - // Default: built-in git worktree provider. - return &GitWorktreeProvider{} + // Default: built-in git worktree provider, honoring the configured + // worktree-location template (per-repo dir override, else global config). + return &GitWorktreeProvider{WorktreeDir: WorktreeDirTemplate(repoPath)} } // preferredConfig returns the config from preferredName if that file exists at diff --git a/internal/workspace/repo_config_test.go b/internal/workspace/repo_config_test.go index 8173769b..5b79f6e7 100644 --- a/internal/workspace/repo_config_test.go +++ b/internal/workspace/repo_config_test.go @@ -158,6 +158,64 @@ func TestIgnorePatterns(t *testing.T) { }) } +func TestWorktreeDirTemplate(t *testing.T) { + const globalTemplate = "{{parent}}/global.worktrees/{{name}}" + + // Redirect HOME so config.Load() reads a config.json we control instead of + // the developer's real one, making the global-fallback cases deterministic. + writeGlobalConfig := func(t *testing.T, worktreeDir string) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + dir := filepath.Join(home, ".config", "fleet") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + content := `{}` + if worktreeDir != "" { + content = `{"worktree_dir":"` + worktreeDir + `"}` + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + t.Run("per-repo .fleet.json dir wins over global", func(t *testing.T) { + writeGlobalConfig(t, globalTemplate) + repo := t.TempDir() + writeFile(t, repo, ".fleet.json", `{"workspace":{"dir":"{{parent}}/{{repo}}.worktrees/{{name}}"}}`) + if got := WorktreeDirTemplate(repo); got != "{{parent}}/{{repo}}.worktrees/{{name}}" { + t.Errorf("got %q, want per-repo template", got) + } + }) + + t.Run(".fleet.local.json dir overrides .fleet.json dir", func(t *testing.T) { + writeGlobalConfig(t, "") + repo := t.TempDir() + writeFile(t, repo, ".fleet.json", `{"workspace":{"dir":"base"}}`) + writeFile(t, repo, ".fleet.local.json", `{"workspace":{"dir":"local"}}`) + if got := WorktreeDirTemplate(repo); got != "local" { + t.Errorf("got %q, want %q", got, "local") + } + }) + + t.Run("falls back to global config when no per-repo dir", func(t *testing.T) { + writeGlobalConfig(t, globalTemplate) + repo := t.TempDir() + if got := WorktreeDirTemplate(repo); got != globalTemplate { + t.Errorf("got %q, want global template %q", got, globalTemplate) + } + }) + + t.Run("empty when nothing set", func(t *testing.T) { + writeGlobalConfig(t, "") + repo := t.TempDir() + if got := WorktreeDirTemplate(repo); got != "" { + t.Errorf("got %q, want empty", got) + } + }) +} + func equalStrings(a, b []string) bool { if len(a) != len(b) { return false diff --git a/internal/workspace/worktree_path.go b/internal/workspace/worktree_path.go new file mode 100644 index 00000000..9f2a4f0c --- /dev/null +++ b/internal/workspace/worktree_path.go @@ -0,0 +1,59 @@ +package workspace + +import ( + "os" + "path/filepath" + "strings" +) + +// resolveWorktreePath computes the on-disk path for a worktree named `name` +// belonging to the repo at `repoPath`, applying `template`. +// +// An empty template preserves the classic sibling layout: +// +// repoPath="/code/myrepo", name="feature-login" -> "/code/myrepo-feature-login" +// +// A non-empty template supports the placeholders {{parent}} (absolute parent +// dir of the repo), {{repo}} (repo basename), {{name}} (the worktree name), and +// a leading ~ / ~/ for the home dir. The result is passed through filepath.Abs. +// +// template="{{parent}}/{{repo}}.worktrees/{{name}}" +// -> "/code/myrepo.worktrees/feature-login" +func resolveWorktreePath(repoPath, name, template string) string { + absRepo, _ := filepath.Abs(repoPath) + parent := filepath.Dir(absRepo) + base := filepath.Base(absRepo) + + if strings.TrimSpace(template) == "" { + return filepath.Join(parent, base+"-"+name) + } + + p := template + p = strings.ReplaceAll(p, "{{parent}}", parent) + p = strings.ReplaceAll(p, "{{repo}}", base) + p = strings.ReplaceAll(p, "{{name}}", name) + p = expandTilde(p) + + abs, err := filepath.Abs(p) + if err != nil { + return p + } + return abs +} + +// expandTilde replaces a leading ~ / ~/ with the user's home dir. A bare "~" +// becomes the home dir; anything without a leading tilde is returned unchanged. +func expandTilde(p string) string { + p = strings.TrimSpace(p) + if p != "~" && !strings.HasPrefix(p, "~/") { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return p + } + if p == "~" { + return home + } + return filepath.Join(home, p[2:]) +} From e8a31b71f88b410316e02dec438575e35e16702d Mon Sep 17 00:00:00 2001 From: Dennis Zagiansky Date: Fri, 17 Jul 2026 23:54:39 +0300 Subject: [PATCH 2/4] refactor(workspace): plumb worktree_dir template instead of reloading config Address PR #206 review: - ResolveProvider / WorktreeDirTemplate now take the global worktree_dir as an argument instead of calling config.Load() on every invocation. This removes a per-call disk read + "config loaded" debug-log line from the ResolveProvider path (which runs on the Update loop, e.g. the d-key delete) and uses the UI's already-loaded in-memory config, so it can't diverge from it. Callers pass h.cfg.GetWorktreeDir(), captured on the Update loop before any goroutine (like the existing editor capture); destroyWorktree gains a worktreeDir param for the same reason. - Fix the WorktreeDirTemplate doc comment: DeriveWorktreePathPreview no longer calls it (the UI reads the resolved template off the provider via GitWorktreeDirTemplate), so it's used only by ResolveProvider now. --- internal/ui/app.go | 20 ++++++---- internal/workspace/repo_config.go | 20 +++++----- internal/workspace/repo_config_test.go | 51 +++++++------------------- 3 files changed, 37 insertions(+), 54 deletions(-) diff --git a/internal/ui/app.go b/internal/ui/app.go index 6fde0f08..45722195 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -2896,7 +2896,7 @@ func (h *Home) confirmDeleteSelected() tea.Cmd { // Discoverability nudge: when this is the last session in a destroyable // worktree, the worktree dir is kept — point the user at the header. if s.WorkspaceName != "" && h.countSessionsForRepo(repoPath) == 1 && - workspace.ResolveProvider(repoPath).CanDestroy() { + workspace.ResolveProvider(repoPath, h.cfg.GetWorktreeDir()).CanDestroy() { details = append(details, "Worktree kept — press d on its header to remove it") } @@ -3311,8 +3311,9 @@ func (h *Home) deferDeleteRepo(msg repoDeleteMsg) (tea.Model, tea.Cmd) { repoPath := msg.repoPath name := filepath.Base(repoPath) editor := h.cfg.GetEditor() + worktreeDir := h.cfg.GetWorktreeDir() destroyCmd := func() tea.Msg { - remaining, err := destroyWorktree(repoPath, name, editor, 2*time.Second) + remaining, err := destroyWorktree(repoPath, name, editor, worktreeDir, 2*time.Second) return deleteCleanupDoneMsg{ workspaceErr: err, repoPath: repoPath, @@ -4374,7 +4375,8 @@ func (h *Home) handlePendingDeleteExpire(msg pendingDeleteExpireMsg) (tea.Model, // duration of `tmux kill-session`. Always returns deleteCleanupDoneMsg so // the entry can be removed from finalizingDeletes. func (h *Home) finalizeDelete(pd PendingDelete) tea.Cmd { - editor := h.cfg.GetEditor() // capture on the Update loop; the goroutine must not touch h + editor := h.cfg.GetEditor() // capture on the Update loop; the goroutine must not touch h + worktreeDir := h.cfg.GetWorktreeDir() // ditto — worktree-location template for ResolveProvider return func() tea.Msg { debuglog.Logger.Info("finalizing delete", "id", pd.Session.ID, "title", pd.Session.Title) @@ -4392,7 +4394,7 @@ func (h *Home) finalizeDelete(pd PendingDelete) tea.Cmd { var remaining []string attempted := pd.DestroyWS && pd.WorkspaceName != "" if attempted { - remaining, workspaceErr = destroyWorktree(pd.RepoPath, pd.WorkspaceName, editor, 2*time.Second) + remaining, workspaceErr = destroyWorktree(pd.RepoPath, pd.WorkspaceName, editor, worktreeDir, 2*time.Second) } return deleteCleanupDoneMsg{ sessionID: pd.Session.ID, @@ -4412,8 +4414,8 @@ func (h *Home) finalizeDelete(pd PendingDelete) tea.Cmd { // re-scans and returns the remaining holder names so the caller can surface // what's still blocking removal. Runs on a background goroutine — must not // touch Home. -func destroyWorktree(repoPath, workspaceName, editor string, grace time.Duration) (remaining []string, err error) { - provider := workspace.ResolveProvider(repoPath) +func destroyWorktree(repoPath, workspaceName, editor, worktreeDir string, grace time.Duration) (remaining []string, err error) { + provider := workspace.ResolveProvider(repoPath, worktreeDir) if provider == nil || !provider.CanDestroy() { return nil, nil } @@ -4519,6 +4521,7 @@ func (h *Home) handleWorktreeDestroyResult(msg deleteCleanupDoneMsg) { // responsible for the destroy. func (h *Home) finalizeAllPendingDeletes() { editor := h.cfg.GetEditor() + worktreeDir := h.cfg.GetWorktreeDir() finalize := func(pd PendingDelete, destroyWorkspace bool) { debuglog.Logger.Info("finalizing pending delete on quit", "id", pd.Session.ID, "title", pd.Session.Title) if pd.Session.IsAlive() { @@ -4532,7 +4535,7 @@ func (h *Home) finalizeAllPendingDeletes() { if destroyWorkspace && pd.DestroyWS && pd.WorkspaceName != "" { // Route through destroyWorktree so leftover dev daemons are killed // before removal — grace 0 (immediate SIGKILL) so quit isn't delayed. - if _, err := destroyWorktree(pd.RepoPath, pd.WorkspaceName, editor, 0); err != nil { + if _, err := destroyWorktree(pd.RepoPath, pd.WorkspaceName, editor, worktreeDir, 0); err != nil { debuglog.Logger.Error("failed to destroy workspace on quit", "id", pd.Session.ID, "workspace", pd.WorkspaceName, "err", err) } } @@ -5631,8 +5634,9 @@ func (h *Home) fetchBranchList(repoPath string) tea.Cmd { } func (h *Home) fetchWorkspaceListForRepo(repoPath string) tea.Cmd { + worktreeDir := h.cfg.GetWorktreeDir() // capture on the Update loop; the goroutine must not touch h return func() tea.Msg { - provider := workspace.ResolveProvider(repoPath) + provider := workspace.ResolveProvider(repoPath, worktreeDir) // For the built-in git provider, resolve to the main worktree: a new // worktree is a sibling of the main repo, so its name must derive from the // main repo — not from whichever linked worktree is currently selected, diff --git a/internal/workspace/repo_config.go b/internal/workspace/repo_config.go index 8bafab54..042cef8d 100644 --- a/internal/workspace/repo_config.go +++ b/internal/workspace/repo_config.go @@ -6,7 +6,6 @@ import ( "path" "path/filepath" - "github.com/brizzai/fleet/internal/config" "github.com/brizzai/fleet/internal/debuglog" ) @@ -104,15 +103,18 @@ func CopyFilesPatterns(repoPath string) []string { } // WorktreeDirTemplate returns the effective worktree-location path template for -// repoPath, resolving precedence in one place: the per-repo .fleet.json -// workspace.dir wins, else the global config worktree_dir, else "" (the classic -// sibling layout). Used by both ResolveProvider and DeriveWorktreePathPreview so -// creation, removal, and the UI preview never disagree. -func WorktreeDirTemplate(repoPath string) string { +// repoPath: the per-repo .fleet.json workspace.dir wins, else globalDefault +// (the caller's already-loaded config worktree_dir), else "" (the classic +// sibling layout). ResolveProvider uses it to stamp GitWorktreeProvider.WorktreeDir, +// which Create/Destroy and the UI preview (via GitWorktreeDirTemplate) then read — +// so precedence lives in one place. The global default is passed in rather than +// reloaded from disk here, avoiding per-call config I/O and debug-log noise on +// the ResolveProvider path (which runs on the Update loop, e.g. the d-key delete). +func WorktreeDirTemplate(repoPath, globalDefault string) string { if d := loadMergedRepoConfig(repoPath).Workspace.Dir; d != "" { return d } - return config.Load().GetWorktreeDir() + return globalDefault } // ResolveProvider loads workspace config from repoPath. Preference is by file @@ -121,7 +123,7 @@ func WorktreeDirTemplate(repoPath string) string { // otherwise .bc.json is used. Same rule for .fleet.local.json over // .bc.local.json. Local overrides base field-by-field. Returns ShellProvider // if any command ends up set, otherwise GitWorktreeProvider. -func ResolveProvider(repoPath string) Provider { +func ResolveProvider(repoPath, worktreeDir string) Provider { merged := loadMergedRepoConfig(repoPath).Workspace // If any shell command is set, use ShellProvider. @@ -135,7 +137,7 @@ func ResolveProvider(repoPath string) Provider { // Default: built-in git worktree provider, honoring the configured // worktree-location template (per-repo dir override, else global config). - return &GitWorktreeProvider{WorktreeDir: WorktreeDirTemplate(repoPath)} + return &GitWorktreeProvider{WorktreeDir: WorktreeDirTemplate(repoPath, worktreeDir)} } // preferredConfig returns the config from preferredName if that file exists at diff --git a/internal/workspace/repo_config_test.go b/internal/workspace/repo_config_test.go index 5b79f6e7..f7eaa225 100644 --- a/internal/workspace/repo_config_test.go +++ b/internal/workspace/repo_config_test.go @@ -9,7 +9,7 @@ import ( func TestResolveProvider(t *testing.T) { t.Run("no config files returns GitWorktreeProvider", func(t *testing.T) { repo := t.TempDir() - got := ResolveProvider(repo) + got := ResolveProvider(repo, "") if _, ok := got.(*GitWorktreeProvider); !ok { t.Errorf("got %T, want *GitWorktreeProvider", got) } @@ -18,7 +18,7 @@ func TestResolveProvider(t *testing.T) { t.Run(".fleet.json with shell commands wins", func(t *testing.T) { repo := t.TempDir() writeFile(t, repo, ".fleet.json", `{"workspace":{"create":"new-fleet","destroy":"destroy-fleet"}}`) - got, ok := ResolveProvider(repo).(*ShellProvider) + got, ok := ResolveProvider(repo, "").(*ShellProvider) if !ok { t.Fatalf("got %T, want *ShellProvider", got) } @@ -30,7 +30,7 @@ func TestResolveProvider(t *testing.T) { t.Run("legacy .bc.json is used when .fleet.json absent", func(t *testing.T) { repo := t.TempDir() writeFile(t, repo, ".bc.json", `{"workspace":{"create":"new-bc"}}`) - got, ok := ResolveProvider(repo).(*ShellProvider) + got, ok := ResolveProvider(repo, "").(*ShellProvider) if !ok { t.Fatalf("got %T, want *ShellProvider", got) } @@ -43,7 +43,7 @@ func TestResolveProvider(t *testing.T) { repo := t.TempDir() writeFile(t, repo, ".fleet.json", `{}`) writeFile(t, repo, ".bc.json", `{"workspace":{"create":"new-bc"}}`) - got := ResolveProvider(repo) + got := ResolveProvider(repo, "") if _, ok := got.(*GitWorktreeProvider); !ok { t.Errorf("got %T, want *GitWorktreeProvider — empty .fleet.json should suppress .bc.json", got) } @@ -53,7 +53,7 @@ func TestResolveProvider(t *testing.T) { repo := t.TempDir() writeFile(t, repo, ".fleet.json", `{not valid json`) writeFile(t, repo, ".bc.json", `{"workspace":{"create":"new-bc"}}`) - got := ResolveProvider(repo) + got := ResolveProvider(repo, "") if _, ok := got.(*GitWorktreeProvider); !ok { t.Errorf("got %T, want *GitWorktreeProvider — malformed .fleet.json should still suppress .bc.json", got) } @@ -70,7 +70,7 @@ func TestResolveProvider(t *testing.T) { } t.Cleanup(func() { _ = os.Chmod(fleetPath, 0o644) }) - got := ResolveProvider(repo) + got := ResolveProvider(repo, "") if _, ok := got.(*GitWorktreeProvider); !ok { t.Errorf("got %T, want *GitWorktreeProvider — unreadable .fleet.json should still suppress .bc.json", got) } @@ -80,7 +80,7 @@ func TestResolveProvider(t *testing.T) { repo := t.TempDir() writeFile(t, repo, ".fleet.json", `{"workspace":{"create":"base-create","destroy":"base-destroy"}}`) writeFile(t, repo, ".fleet.local.json", `{"workspace":{"create":"local-create"}}`) - got, ok := ResolveProvider(repo).(*ShellProvider) + got, ok := ResolveProvider(repo, "").(*ShellProvider) if !ok { t.Fatalf("got %T, want *ShellProvider", got) } @@ -148,7 +148,7 @@ func TestIgnorePatterns(t *testing.T) { if got := IgnorePatterns(repo); !equalStrings(got, []string{"x"}) { t.Errorf("ignore: got %v, want [x]", got) } - sp, ok := ResolveProvider(repo).(*ShellProvider) + sp, ok := ResolveProvider(repo, "").(*ShellProvider) if !ok { t.Fatalf("got %T, want *ShellProvider", sp) } @@ -161,56 +161,33 @@ func TestIgnorePatterns(t *testing.T) { func TestWorktreeDirTemplate(t *testing.T) { const globalTemplate = "{{parent}}/global.worktrees/{{name}}" - // Redirect HOME so config.Load() reads a config.json we control instead of - // the developer's real one, making the global-fallback cases deterministic. - writeGlobalConfig := func(t *testing.T, worktreeDir string) { - t.Helper() - home := t.TempDir() - t.Setenv("HOME", home) - dir := filepath.Join(home, ".config", "fleet") - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - content := `{}` - if worktreeDir != "" { - content = `{"worktree_dir":"` + worktreeDir + `"}` - } - if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - } - t.Run("per-repo .fleet.json dir wins over global", func(t *testing.T) { - writeGlobalConfig(t, globalTemplate) repo := t.TempDir() writeFile(t, repo, ".fleet.json", `{"workspace":{"dir":"{{parent}}/{{repo}}.worktrees/{{name}}"}}`) - if got := WorktreeDirTemplate(repo); got != "{{parent}}/{{repo}}.worktrees/{{name}}" { + if got := WorktreeDirTemplate(repo, globalTemplate); got != "{{parent}}/{{repo}}.worktrees/{{name}}" { t.Errorf("got %q, want per-repo template", got) } }) t.Run(".fleet.local.json dir overrides .fleet.json dir", func(t *testing.T) { - writeGlobalConfig(t, "") repo := t.TempDir() writeFile(t, repo, ".fleet.json", `{"workspace":{"dir":"base"}}`) writeFile(t, repo, ".fleet.local.json", `{"workspace":{"dir":"local"}}`) - if got := WorktreeDirTemplate(repo); got != "local" { + if got := WorktreeDirTemplate(repo, ""); got != "local" { t.Errorf("got %q, want %q", got, "local") } }) - t.Run("falls back to global config when no per-repo dir", func(t *testing.T) { - writeGlobalConfig(t, globalTemplate) + t.Run("falls back to global default when no per-repo dir", func(t *testing.T) { repo := t.TempDir() - if got := WorktreeDirTemplate(repo); got != globalTemplate { - t.Errorf("got %q, want global template %q", got, globalTemplate) + if got := WorktreeDirTemplate(repo, globalTemplate); got != globalTemplate { + t.Errorf("got %q, want global default %q", got, globalTemplate) } }) t.Run("empty when nothing set", func(t *testing.T) { - writeGlobalConfig(t, "") repo := t.TempDir() - if got := WorktreeDirTemplate(repo); got != "" { + if got := WorktreeDirTemplate(repo, ""); got != "" { t.Errorf("got %q, want empty", got) } }) From 4272e52a4a2a4d9cfee364962ac5d65682f63734 Mon Sep 17 00:00:00 2001 From: Dennis Zagiansky Date: Sat, 18 Jul 2026 00:11:17 +0300 Subject: [PATCH 3/4] fix(workspace): trim per-repo worktree dir; trim changelog fragment Address PR #206 review: - WorktreeDirTemplate now trims the per-repo .fleet.json workspace.dir before the emptiness check, so a whitespace-only value defers to the global worktree_dir instead of silently shadowing it (and then falling back to the sibling layout). Matches the field doc and GetWorktreeDir, which also trims. Added a regression test. - Trim the worktree-location changelog fragment to the 1-2 concise sentences the project guidelines call for; placeholder details live in CLAUDE.md and the Settings UI. --- changelog/unreleased/worktree-location.md | 2 +- internal/workspace/repo_config.go | 6 +++++- internal/workspace/repo_config_test.go | 8 ++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/changelog/unreleased/worktree-location.md b/changelog/unreleased/worktree-location.md index e28ef9f9..0658fea5 100644 --- a/changelog/unreleased/worktree-location.md +++ b/changelog/unreleased/worktree-location.md @@ -2,4 +2,4 @@ type: added --- -**Choose where worktrees live** — new worktrees no longer have to be siblings like `myrepo-feature`. Set `Worktree location` in Settings → Behavior (or `worktree_dir` in config, or per-repo `.fleet.json` `workspace.dir`) to a path template like `{{parent}}/{{repo}}.worktrees/{{name}}` to group them under a tidy `myrepo.worktrees/` folder. Placeholders: `{{parent}}`, `{{repo}}`, `{{name}}`, and `~`. +**Choose where worktrees live** — new worktrees no longer have to be siblings like `myrepo-feature`. Point `Worktree location` (Settings → Behavior) at a path template to group them wherever you like, e.g. a tidy `myrepo.worktrees/` folder. diff --git a/internal/workspace/repo_config.go b/internal/workspace/repo_config.go index 042cef8d..1621dd2d 100644 --- a/internal/workspace/repo_config.go +++ b/internal/workspace/repo_config.go @@ -5,6 +5,7 @@ import ( "os" "path" "path/filepath" + "strings" "github.com/brizzai/fleet/internal/debuglog" ) @@ -111,7 +112,10 @@ func CopyFilesPatterns(repoPath string) []string { // reloaded from disk here, avoiding per-call config I/O and debug-log noise on // the ResolveProvider path (which runs on the Update loop, e.g. the d-key delete). func WorktreeDirTemplate(repoPath, globalDefault string) string { - if d := loadMergedRepoConfig(repoPath).Workspace.Dir; d != "" { + // Trim before the emptiness check so a whitespace-only per-repo dir behaves + // as "no override" (defer to globalDefault) rather than silently shadowing + // it — matching the field doc and GetWorktreeDir, which also trims. + if d := strings.TrimSpace(loadMergedRepoConfig(repoPath).Workspace.Dir); d != "" { return d } return globalDefault diff --git a/internal/workspace/repo_config_test.go b/internal/workspace/repo_config_test.go index f7eaa225..f7d4e1ab 100644 --- a/internal/workspace/repo_config_test.go +++ b/internal/workspace/repo_config_test.go @@ -191,6 +191,14 @@ func TestWorktreeDirTemplate(t *testing.T) { t.Errorf("got %q, want empty", got) } }) + + t.Run("whitespace-only per-repo dir defers to global", func(t *testing.T) { + repo := t.TempDir() + writeFile(t, repo, ".fleet.json", `{"workspace":{"dir":" "}}`) + if got := WorktreeDirTemplate(repo, globalTemplate); got != globalTemplate { + t.Errorf("got %q, want global default %q (whitespace dir should not override)", got, globalTemplate) + } + }) } func equalStrings(a, b []string) bool { From eaf0624b745f35a045e27c70675a216da319cf9e Mon Sep 17 00:00:00 2001 From: Dennis Zagiansky Date: Sat, 18 Jul 2026 00:22:43 +0300 Subject: [PATCH 4/4] fix(workspace,ui): stabilize Abs fallback; correct worktree preset label Address PR #206 review: - resolveWorktreePath now falls back to the original repoPath if filepath.Abs errors (cwd unavailable), so path derivation can't collapse to "." and emit a garbage path. - Fix the "Worktree location" preset label: the .worktrees template creates a .worktrees folder, so label it "Subfolder (.worktrees)" instead of "Subfolder (.worktrees)". - Derive the row's valueW width from worktreeDirLabel itself instead of a hardcoded duplicate list, so the width can never drift from the displayed label text. --- internal/ui/settings.go | 12 ++++++++---- internal/workspace/worktree_path.go | 7 ++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/ui/settings.go b/internal/ui/settings.go index e07608f5..90277a62 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -45,7 +45,7 @@ func worktreeDirLabel(template string) string { case "": return "Sibling (repo-branch)" case "{{parent}}/{{repo}}.worktrees/{{name}}": - return "Subfolder (.worktrees)" + return "Subfolder (.worktrees)" default: return "Custom" } @@ -674,9 +674,13 @@ func buildSettingsCategories() []settingsCategory { // today's -; Subfolder = .worktrees/. // A custom template edited in config.json shows as "Custom" and is // kept in the ring so cycling never overwrites it blindly. - label: "Worktree location", - value: func(c *config.Config) string { return worktreeDirLabel(c.GetWorktreeDir()) }, - valueW: func() int { return maxStrW([]string{"Sibling (repo-branch)", "Subfolder (.worktrees)", "Custom"}) }, + label: "Worktree location", + value: func(c *config.Config) string { return worktreeDirLabel(c.GetWorktreeDir()) }, + // Derive the width from worktreeDirLabel itself so it can never + // drift from the displayed text ("Custom" covers any injected value). + valueW: func() int { + return maxStrW([]string{worktreeDirLabel(worktreeDirSet[0]), worktreeDirLabel(worktreeDirSet[1]), "Custom"}) + }, cycle: func(d *SettingsDialog, dir int) { cur := d.cfg.GetWorktreeDir() presets := worktreeDirSet diff --git a/internal/workspace/worktree_path.go b/internal/workspace/worktree_path.go index 9f2a4f0c..6502984e 100644 --- a/internal/workspace/worktree_path.go +++ b/internal/workspace/worktree_path.go @@ -20,7 +20,12 @@ import ( // template="{{parent}}/{{repo}}.worktrees/{{name}}" // -> "/code/myrepo.worktrees/feature-login" func resolveWorktreePath(repoPath, name, template string) string { - absRepo, _ := filepath.Abs(repoPath) + absRepo, err := filepath.Abs(repoPath) + if err != nil { + // Abs only fails when the cwd is unavailable; fall back to the original + // path so derivation stays stable instead of collapsing to ".". + absRepo = repoPath + } parent := filepath.Dir(absRepo) base := filepath.Base(absRepo)