Skip to content
Open
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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<repo>-<branch>`; `{{parent}}/{{repo}}.worktrees/{{name}}` groups them under a `<repo>.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 <id>` / `codex fork <id>` / `opencode --session <id>` / `opencode --session <id> --fork`).
Expand Down
5 changes: 5 additions & 0 deletions changelog/unreleased/worktree-location.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<repo>-<branch>). 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
Expand Down Expand Up @@ -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 != "" {
Expand Down
20 changes: 12 additions & 8 deletions internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand All @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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() {
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 38 additions & 0 deletions internal/ui/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +35 to +38
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 (<repo>.worktrees)"
default:
return "Custom"
}
Comment thread
denniszag marked this conversation as resolved.
}

// settingsFocus tracks which pane (category rail or detail list) has the cursor.
type settingsFocus int

Expand Down Expand Up @@ -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 <repo>-<branch>; Subfolder = <repo>.worktrees/<branch>.
// 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)
},
},
Comment on lines +672 to +688

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep the custom template available across repeated cycles.

The custom value is injected only while it is current. After one arrow press, the next invocation rebuilds presets from worktreeDirSet, permanently dropping the hand-edited value; Settings auto-save then overwrites it. Preserve the original custom value in dialog state and include it in every cycle.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/ui/settings.go` around lines 672 - 688, Update the worktree location
cycle handling in the SettingsDialog state so a hand-edited custom template is
stored separately when first detected, rather than only prepended to the local
presets slice. Ensure every subsequent invocation of the cycle callback includes
that preserved custom value alongside worktreeDirSet, while retaining the
existing preset cycling behavior and auto-save compatibility.

{
label: "Enter mode",
value: func(c *config.Config) string { return c.GetEnterMode() },
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/workspace_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/workspace_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
59 changes: 44 additions & 15 deletions internal/workspace/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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. "<repo>.worktrees/<name>") 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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
67 changes: 63 additions & 4 deletions internal/workspace/provider_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package workspace

import (
"os"
"path/filepath"
"testing"
)
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
})
}
Expand Down
Loading
Loading