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..0658fea5 --- /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`. 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/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/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/ui/settings.go b/internal/ui/settings.go index bdb9ceb3..90277a62 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,27 @@ 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()) }, + // 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 + 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..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" ) @@ -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,13 +103,31 @@ func CopyFilesPatterns(repoPath string) []string { return loadMergedRepoConfig(repoPath).CopyFiles.Paths } +// WorktreeDirTemplate returns the effective worktree-location path template for +// 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 { + // 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 +} + // 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); // 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. @@ -111,8 +139,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, 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 8173769b..f7d4e1ab 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) } @@ -158,6 +158,49 @@ func TestIgnorePatterns(t *testing.T) { }) } +func TestWorktreeDirTemplate(t *testing.T) { + const globalTemplate = "{{parent}}/global.worktrees/{{name}}" + + t.Run("per-repo .fleet.json dir wins over global", func(t *testing.T) { + repo := t.TempDir() + writeFile(t, repo, ".fleet.json", `{"workspace":{"dir":"{{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) { + 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 default when no per-repo dir", func(t *testing.T) { + repo := t.TempDir() + 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) { + repo := t.TempDir() + if got := WorktreeDirTemplate(repo, ""); got != "" { + 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 { 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..6502984e --- /dev/null +++ b/internal/workspace/worktree_path.go @@ -0,0 +1,64 @@ +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, 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) + + 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:]) +}