Skip to content

feat: per-task worktree/branch strategy (engine + CLI + TUI + GUI)#599

Closed
bborn wants to merge 7 commits into
mainfrom
feat/per-task-worktree-strategy
Closed

feat: per-task worktree/branch strategy (engine + CLI + TUI + GUI)#599
bborn wants to merge 7 commits into
mainfrom
feat/per-task-worktree-strategy

Conversation

@bborn

@bborn bborn commented Jun 12, 2026

Copy link
Copy Markdown
Owner

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:

Nora option TaskYou expression
New worktree WorktreeMode = "worktree" (or inherit from a worktrees-on project)
Run in place on current branch WorktreeMode = "in-place"
Create new branch from a ref BaseBranch = "<ref>" (worktree branches from it instead of the default branch)
Checkout existing branch already covered by the existing SourceBranch field (ty create --branch) — untouched

Why 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 resolution

The 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: ShouldUseWorktree returns exactly project.UsesWorktrees() (or true when the project can't be loaded — identical to the previous taskUsesWorktrees/ProjectUsesWorktrees fallbacks), and ResolveWorktreeBase returns the default branch, so git 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

  • CLI: ty create --worktree | --in-place (mutually exclusive via cobra MarkFlagsMutuallyExclusive; absent = inherit) and --base-branch <ref>; help text + JSON output updated.
  • TUI: "Worktree" selector (project default / worktree / in place) in the advanced section of the task form, following the existing selector-field pattern; a "Base branch" input appears only when a fresh worktree will actually be created. Parity test passes (no new key bindings).
  • GUI: same three-option select + conditional base-branch input in TaskForm.tsx Advanced, wired through api/client.ts types, POST /api/tasks / PATCH /api/tasks/{id} handlers, and task JSON.

Tests

Package Result
internal/db ok (new: NormalizeWorktreeMode, ShouldUseWorktree, ResolveWorktreeBase table tests; column round-trip through Create/Get/List/Update; normalization on insert)
internal/executor new tests pass (TestTaskUsesWorktreesPerTaskOverride, TestSetupWorktreePerTaskOverride — see below). Two pre-existing, environment-dependent failures (TestBuildCommandIncludesProjectConfigDir, TestFindClaudeSessionID) fail identically on pristine main on this machine (local CLAUDE_CONFIG_DIR=~/.claude-ik and local Claude session files); unrelated to this change.
internal/ui ok (new: form default-inherit, ApplyTo carry, in-place clears base branch, base-branch visibility, edit-form prepopulation)
internal/web ok (new: TestHandleCreateTask_WorktreeFields)
internal/parity + all other packages ok
desktop pnpm build (tsc --noEmit + vite) clean

go build ./..., go vet ./... clean; gofmt -l clean on all touched packages (extensions/ty-qmd/cmd/main.go was already unformatted on main and is untouched).

Manual test transcript (scratch DB via WORKTREE_DB_PATH)

$ export WORKTREE_DB_PATH=/tmp/ty-qa-scratch/tasks.db
$ ty projects create wt-on --path /tmp/ty-qa-scratch/proj-on
Created project 'wt-on' at /tmp/ty-qa-scratch/proj-on
$ ty projects create wt-off --path /tmp/ty-qa-scratch/proj-off --no-git
Created project 'wt-off' at /tmp/ty-qa-scratch/proj-off (non-git, worktrees disabled)

$ ty create "QA in-place override" --project wt-on --in-place --json
{"executor":"claude","id":1,"project":"wt-on", ... "worktree_mode":"in-place"}
$ ty create "QA worktree override" --project wt-off --worktree --base-branch develop --json
{"base_branch":"develop","executor":"claude","id":2,"project":"wt-off", ... "worktree_mode":"worktree"}
$ ty create "QA inherit" --project wt-on --json
{"executor":"claude","id":3,"project":"wt-on", ...}            # no worktree_mode key — inherit
$ ty create "QA both" --project wt-on --worktree --in-place
Error: if any flags in the group [worktree in-place] are set none of the others can be

$ sqlite3 $WORKTREE_DB_PATH "SELECT id, project, worktree_mode, base_branch FROM tasks ORDER BY id;"
1|wt-on|in-place|
2|wt-off|worktree|develop
3|wt-on||

Resolution through the engine decision functions against that same scratch DB:

task #1 "QA in-place override"  project=wt-on(use_worktrees=true)  mode="in-place" -> worktree=false baseRef="main"
task #2 "QA worktree override"  project=wt-off(use_worktrees=false) mode="worktree" -> worktree=true  baseRef="develop"
task #3 "QA inherit"            project=wt-on(use_worktrees=true)  mode=""         -> worktree=true  baseRef="main"

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: TestSetupWorktreePerTaskOverride executes the real setupWorktree against temp git repos and asserts (a) an in-place task in a worktrees-on project resolves to the project directory with no .task-worktrees created, and (b) a forced-worktree task in a worktrees-off project gets a real git worktree whose HEAD equals the BaseBranch ref (develop), not main. Everything from there to the agent (tmux window creation, executor launch) is unchanged code that consumes the returned workDir.

🤖 Generated with Claude Code

bborn and others added 6 commits June 12, 2026 09:43
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>
@bborn

bborn commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

QA screenshots (TUI + CLI)

Captured on feat/per-task-worktree-strategy against an isolated QA instance (fresh DB, throwaway qa project) via the VHS harness.

TUI new-task form — Worktree selector. The advanced section now includes a Worktree selector (project default / worktree / in place), focused here with arrow-key selection; the conditional Base branch input renders below it because the qa project defaults to worktrees.

tui-form-worktree-selector

TUI new-task form — Base branch input. After selecting worktree in the selector, Tab moves into the conditional Base branch input — shown focused with release/2.0 typed.

tui-form-base-branch

CLI flags. ty create --worktree --base-branch develop and --in-place persist worktree_mode/base_branch (verified via sqlite3 against the isolated DB), and --worktree --in-place together is rejected by cobra's mutual-exclusion check.

cli-flags

🤖 Generated with Claude Code

@bborn

bborn commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

QA screenshots (GUI)

Captured against this branch's web UI (go build -tags ui, ty serve) in a browser at 1280×800, on a fully isolated instance (throwaway DB, fresh port).

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.

gui-taskform-worktree-select

2. Conditional Base branch input. With Worktree chosen, the Base branch field is shown (placeholder "Default branch if empty"; here filled with develop). The pair submits as worktree_mode / base_branch on create.

gui-taskform-base-branch

🤖 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>
@bborn

bborn commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

QA follow-up: the form-overflow issue flagged in the TUI screenshots comment is fixed in eb47074calculateBodyHeight now counts the conditional Effort/Permission/Worktree/Base-branch rows and attachment chips, so the advanced form no longer scrolls the title off-screen.

🤖 Generated with Claude Code

@bborn

bborn commented Jun 12, 2026

Copy link
Copy Markdown
Owner Author

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 inherit/worktree/in-place toggle is a solution looking for a problem (changing the project setting or using a separate project covers the rare cases), and base_branch overlaps the existing SourceBranch field. Not worth carrying five synced surfaces (db/engine/CLI/TUI/GUI + parity) for a speculative benefit. If a concrete 'start this task from branch X' need shows up later, we can add just that.

🤖 Generated with Claude Code

@bborn bborn closed this Jun 12, 2026
@bborn bborn deleted the feat/per-task-worktree-strategy branch June 12, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant