feat: per-task worktree/branch strategy (engine + CLI + TUI + GUI)#599
feat: per-task worktree/branch strategy (engine + CLI + TUI + GUI)#599bborn wants to merge 7 commits into
Conversation
Add two optional task columns: - worktree_mode: "" inherits the project's use_worktrees setting (the default, preserving current behavior exactly), "worktree" forces a fresh worktree even when the project default is off, "in-place" runs directly in the project directory even when the default is on. - base_branch: git ref a new worktree branches from; empty keeps using the project's default branch. The decision logic is pure and table-tested: ShouldUseWorktree(project, task) resolves inherit/force-on/force-off, ResolveWorktreeBase(task, defaultBranch) resolves the base ref. Unknown modes normalize to inherit so a bad value can never change where a task executes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Route every task-scoped worktree decision through taskUsesWorktrees, which now combines the task's WorktreeMode override with the project's UseWorktrees setting via db.ShouldUseWorktree. With no mode set the resolution is identical to the previous project-level lookup. Affected decision points: setupWorktree (worktree vs shared project dir), stale-worktree cleanup (auto and manual), ArchiveWorktree, and UnarchiveWorktree — so an in-place task in a worktrees-on project can never have the project directory archived or removed, and a forced worktree task in a worktrees-off project is cleaned up like any other worktree task. New worktrees branch from the task's BaseBranch when set (db.ResolveWorktreeBase), otherwise the default branch as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
--worktree and --in-place are mutually exclusive (enforced via cobra's MarkFlagsMutuallyExclusive); leaving both unset inherits the project's worktree setting as before. --base-branch sets the git ref a new worktree branches from. JSON output includes worktree_mode and base_branch when set. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a "Worktree" selector (project default / worktree / in place) to the advanced section of the new/edit task form, following the existing selector-field pattern (left/right cycling, type-to-select). A base branch text input appears only when a fresh worktree will actually be created (mode forced to worktree, or inheriting from a worktrees-on project). The selector defaults to "project default", so an untouched form produces exactly the same task as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the per-task worktree override end-to-end for the GUI: the create/update task handlers and task JSON carry worktree_mode and base_branch, the desktop API types/client expose them, and TaskForm gets a Worktree select (Project default / Worktree / In place) plus a base-branch input that only appears when a fresh worktree will be created (per-task worktree, or inheriting from a worktrees-on project). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Exercise setupWorktree against real temp git repos: an in-place task in a worktrees-on project resolves to the project directory (and creates no .task-worktrees), and a forced-worktree task in a worktrees-off project gets a real worktree whose HEAD matches the task's BaseBranch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
QA screenshots (TUI + CLI)Captured on TUI new-task form — Worktree selector. The advanced section now includes a TUI new-task form — Base branch input. After selecting CLI flags. 🤖 Generated with Claude Code |
QA screenshots (GUI)Captured against this branch's web UI ( 1. New-task form — Worktree select. Advanced section expanded; the new Worktree select is open showing all three modes: Project default (checked), Worktree, In place. 2. Conditional Base branch input. With Worktree chosen, the Base branch field is shown (placeholder "Default branch if empty"; here filled with 🤖 Generated with Claude Code |
QA screenshots for the worktree selector caught the new-task form overflowing vertically: calculateBodyHeight's advanced-mode overhead only counted project/attachments/type/executor, so the Effort, Permission, Worktree, and Base-branch rows (and attachment chips) pushed the form past the viewport and scrolled the title off-screen. Count each visible conditional row (line + blank) and attachment chips in the overhead, and mirror the visibility logic in the test expectation instead of a hard-coded constant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
QA follow-up: the form-overflow issue flagged in the TUI screenshots comment is fixed in eb47074 — 🤖 Generated with Claude Code |
|
Closing this. On reflection it was driven by copying Nora's new-agent dialog rather than a real TaskYou need, and a per-task worktree override is a decision the system should make, not push onto the user on every task — the opposite of the 'detection does the work' principle we're designing around elsewhere. The 🤖 Generated with Claude Code |





What
Per-task worktree/branch strategy across all four surfaces (engine, CLI, TUI, GUI). Today worktree behavior is a per-project boolean (
Project.UseWorktrees); this PR adds a per-task override matching the four options in Nora's new-agent dialog, with two small fields instead of a new model:WorktreeMode = "worktree"(or inherit from a worktrees-on project)WorktreeMode = "in-place"BaseBranch = "<ref>"(worktree branches from it instead of the default branch)SourceBranchfield (ty create --branch) — untouchedWhy two fields
worktree_mode TEXT DEFAULT ''—""= inherit the project setting (the default everywhere, preserving current behavior exactly),"worktree"= force a fresh worktree even if the project default is off,"in-place"= run directly in the project dir even if the default is on. Unknown values normalize to inherit, so a bad value can never change where a task executes.base_branch TEXT DEFAULT ''— git ref a new worktree branches from; empty keeps using the project's default branch.The decision logic is two pure, table-tested functions in
internal/db:ShouldUseWorktree(project, task)— inherit / force-on / force-off (nil project defaults on, matching the column default)ResolveWorktreeBase(task, defaultBranch)— base-ref resolutionThe executor routes every task-scoped worktree decision through
taskUsesWorktrees(now task-aware):setupWorktree, stale-worktree cleanup (auto + manual),ArchiveWorktree,UnarchiveWorktree. This also means an in-place task in a worktrees-on project can never have the project directory archived/removed, and a forced-worktree task in a worktrees-off project is cleaned up like any other worktree task.Default behavior unchanged
With no field set:
ShouldUseWorktreereturns exactlyproject.UsesWorktrees()(or true when the project can't be loaded — identical to the previoustaskUsesWorktrees/ProjectUsesWorktreesfallbacks), andResolveWorktreeBasereturns the default branch, sogit worktree add -b <branch> <path> <defaultBranch>is byte-identical to before. The TUI selector defaults to "Project default", the GUI select defaults to "Project default", and the CLI flags default to absent.Surfaces
ty create --worktree | --in-place(mutually exclusive via cobraMarkFlagsMutuallyExclusive; absent = inherit) and--base-branch <ref>; help text + JSON output updated.TaskForm.tsxAdvanced, wired throughapi/client.tstypes,POST /api/tasks/PATCH /api/tasks/{id}handlers, and task JSON.Tests
internal/dbNormalizeWorktreeMode,ShouldUseWorktree,ResolveWorktreeBasetable tests; column round-trip through Create/Get/List/Update; normalization on insert)internal/executorTestTaskUsesWorktreesPerTaskOverride,TestSetupWorktreePerTaskOverride— see below). Two pre-existing, environment-dependent failures (TestBuildCommandIncludesProjectConfigDir,TestFindClaudeSessionID) fail identically on pristinemainon this machine (localCLAUDE_CONFIG_DIR=~/.claude-ikand local Claude session files); unrelated to this change.internal/uiinternal/webTestHandleCreateTask_WorktreeFields)internal/parity+ all other packagesdesktoppnpm build(tsc --noEmit + vite) cleango build ./...,go vet ./...clean;gofmt -lclean on all touched packages (extensions/ty-qmd/cmd/main.gowas already unformatted onmainand is untouched).Manual test transcript (scratch DB via
WORKTREE_DB_PATH)Resolution through the engine decision functions against that same scratch DB:
Where verification stopped: the full agent path (tmux + a live Claude session) was not run in this environment. Verification goes one level past the decision-function boundary:
TestSetupWorktreePerTaskOverrideexecutes the realsetupWorktreeagainst temp git repos and asserts (a) an in-place task in a worktrees-on project resolves to the project directory with no.task-worktreescreated, and (b) a forced-worktree task in a worktrees-off project gets a realgit worktreewhoseHEADequals theBaseBranchref (develop), notmain. Everything from there to the agent (tmux window creation, executor launch) is unchanged code that consumes the returned workDir.🤖 Generated with Claude Code