Files
fusion/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx
gsxdsm 743df98aa4 capacity, part 1: merge pinned at 1, worktrees-off mode, and one dead knob deleted (#2502)
First slice of the capacity simplification. Operator: *"just have two
capacity — overall per project agent count and max worktrees. Remove all
other capacities and counts."* Plus two later additions: **merge is
always 1, fixed**, and **worktrees off ⇒ limit by total agents only**.

Three independently revertable commits. No limiter is added anywhere;
one is deleted, one is made structurally absent, and one is pinned.

---

## 1. Merge concurrency ratcheted at 1 (test-only)

I was asked to add a limiter if merge concurrency could be raised. **It
cannot** — there is no setting, workflow property, pool or trait config
anywhere that raises it, so this adds no code and pins what already
holds.

Serialization lives in the **pump**: `drainMergeQueue`’s `mergeRunning`
re-entrancy latch, `activeMergeTaskId` as a single-slot identity, the
`mergeBodyInFlight` next-generation latch, and one `ProjectEngine` per
projectId.

**Not** in the merge-queue lease, which is a per-task ROW (`primaryKey
[projectId, taskId]`) — two tasks can hold leases simultaneously by
construction, and it has exactly one caller (the worktree-reuse
handoff). Ordinary merges never take it. A lease-level test would have
been describing an invariant that layer has never held.

The second half guards the other direction: a merge-concurrency
*setting* would not fail the pump ratchet — it would sit unread until
someone wired it up.

**Revert-proof:** deleting the latch → `expected 1 times, but got 2
times`; deleting the `finally` → latch-stuck; injecting
`maxConcurrentMerges: 2` → fails naming the key; injecting a
`maxParallelLanes` merge-trait field → fails naming the field. Sources
restored byte-identical after each injection.

## 2. `worktreesEnabled` — off means the worktree limit cannot bind

No worktrees-off mode existed (no
`worktreesEnabled`/`useWorktrees`/`worktreeMode` anywhere — only
worktree *configuration*).

**Why not `maxWorktrees: 0`, which needs no new key:** it deadlocks. `??
4` keeps `0` (not nullish), the gate is `used >= limit`, so `0 >= 0`
holds **on an empty board** and nothing ever dispatches — while the
operator-visible reason reads `gate=maxWorktrees; used=0/0`, a limiter
that looks like it is working while the board is dead. It also needs the
Command Center `{min:1}` clamp relaxed. So `0` costs the gate rewrite
*and* the clamp change *and* encodes a mode as a magic value.

**Off is absence, not a big number.** `resolveWorktreeCapacityLimit`
returns `number | null`; `ConcurrencyGateDiagnostic.maxWorktreesGate` is
now optional, so consulting a worktree limit in OFF mode does not
type-check. A gate holding `Infinity` can start binding again the moment
someone "fixes" a comparison; an absent gate cannot.

That paid for itself immediately: making it nullable surfaced a
**second, independent** worktree gate (`activeWorktrees >= maxWorktrees`
early-return) that a skip-by-convention approach would have missed
silently.

**Scope, deliberately:** this is a statement about *counting*, not
isolation. It does not make concurrent agents safe to share one checkout
and builds nothing toward that — the non-worktree paths that exist today
are fallbacks to the operator’s own tree, one of which caused FN-8600.

**Revert-proof:** a resolver ignoring the flag turns both OFF scheduler
tests red while every ON test stays green — they reuse the *same*
fixture (5 in-progress, limit 4) that pre-existing tests prove blocks,
so the pair moves in opposite directions. Removing `disabled:` reddens
the UI test.

## 3. `maxTriageConcurrent` deleted — it controlled nothing

**Measured: zero enforcement reads.** The only `.maxTriageConcurrent`
reference in the repo was a route echoing it back in `/config`. FN-8453
removed the pool it gated and left the knob shipping in
`DEFAULT_SETTINGS`, the settings type, the section registry, the API
response and six i18n catalogs, doing nothing, for releases.

Historical FNXC comments are **updated, not deleted** — they explain a
real past incident; they now say "planning admission slot" so they stop
implying a live setting. Tombstoned so it cannot return.

`/config` loses a field; safe in-repo since `fetchConfig`’s own return
type never declared it.

---

## Two corrections worth recording

- I earlier reported `maxWorktrees` had **no** Settings UI. Wrong —
`WorktreesSection.tsx:47`; my grep was truncated by `head`. It changed
the placement (toggle beside it, rather than a duplicate key in
Scheduling).
- I planned to assert the queued-reason string is rewritten in OFF mode.
Measured that it is **unreachable**: when `maxConcurrent` binds, the
sweep bails before the per-task reason and logs nothing. The test
asserts absence instead.

Two near-misses caught before commit: a pre-existing FN-7505 guard
caught my *new* key missing a description mapping; and editing i18n via
`json.load/dump` silently dropped unrelated duplicate keys
(`autoUpdateAndRestart` in `fr`) — Python keeps only the last of a
duplicated key. Redone textually, every catalog re-validated.

## Verification

`pnpm lint` clean · core/engine/dashboard/i18n typecheck clean · `pnpm
test:gate` green (309 + 10 + 71) · capacity/worktree suites 11/11 ·
engine merge-invariant + scheduler 45/45 · dashboard settings 114/114.
Rebased onto current main and re-verified.

Nothing was booted at any point.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a project setting to enable or disable running tasks in
worktrees.
* Disabling worktrees removes worktree capacity limits from task
scheduling.
* The “Max Worktrees” setting is disabled when worktree execution is
turned off.

* **Changes**
* Removed the unused triage concurrency setting from configuration and
dashboard responses.
* Updated scheduling diagnostics and queue messages to reflect disabled
worktree capacity limits.
  * Added localized labels and help text for the new setting.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:52:31 -07:00

337 lines
27 KiB
TypeScript

import { useTranslation } from "react-i18next";
import type { GitRemoteDetailed } from "../../../api";
import type { useWorktrunkInstallStatus } from "../../../hooks/useWorktrunkInstallStatus";
import { SettingsToggleRow } from "../SettingsToggleRow";
import { SettingsSelectRow } from "../SettingsSelectRow";
import { SettingsNumberRow } from "../SettingsNumberRow";
import { SettingsTextRow } from "../SettingsTextRow";
import { SettingsHelpTip } from "../SettingsHelpTip";
import type { SectionBaseProps, SettingsFormState } from "./context";
export interface WorktreesSectionProps extends SectionBaseProps {
gitRemotes: GitRemoteDetailed[];
worktrunkInstall: ReturnType<typeof useWorktrunkInstallStatus>;
worktrunkInstallVerified: boolean;
onOpenWorktreesDirPicker: () => void;
onWorktreeCopyFileChange: (index: number, value: string) => void;
onRemoveWorktreeCopyFile: (index: number) => void;
onAddWorktreeCopyFile: () => void;
onOpenWorktreeCopyFilePicker: (index: number) => void;
onOpenApprovals?: (approvalId?: string) => void;
}
/*
FNXC:SettingsStyling 2026-07-15-17:35:
The plain label+control+help rows here render through the shared settings primitives instead of hand-rolled `form-group` + `checkbox-label` markup, so their labels, help copy, and padding come from the one settings type scale. `.form-group` itself stays untouched and global — 35 non-settings files style forms with it, so settings migrate off it rather than restyle it underneath the rest of the dashboard.
FNXC:SettingsScope 2026-07-15-17:35:
Every migrated key in this section is project-scoped (`DEFAULT_PROJECT_SETTINGS`): worktree count, layout, naming, and rebase policy describe one repository's checkout strategy and must not follow the operator to another project. The badges restate that per row because settings search can land an operator on a single control with no section chrome in view.
FNXC:SettingsStyling 2026-07-15-17:35:
Four groups deliberately keep their bespoke markup because they are not plain label+control+help rows:
- The `worktreeCopyFiles` allowlist is a repeating row editor with per-row Browse/Remove buttons.
- `worktreesDir` pairs its input with a Browse button and swaps in rich `<code>`-bearing help.
- `executorAllowSiblingBranchRename` and `worktreeRebaseRemote` compose their help from several `t()` fragments interleaved with `<code>` elements; a descriptor `help` is a single string, and flattening that copy would reword operator-facing text.
- The whole worktrunk block edits one nested `worktrunk` object (not a top-level settings key), carries `<code>`-bearing help, cross-field disabled logic, and an install affordance.
FNXC:SettingsHelp 2026-07-15-21:40:
Those bespoke rows still hang their help off the same "?" as the migrated ones: each one's `<small>` moved into a `SettingsHelpTip` beside its label (`.settings-field-label-row`), so the section reads as one idiom instead of "rows with a help icon" next to "rows with a paragraph". The tip takes `ReactNode`, so the `<code>`-bearing copy above moves verbatim.
The worktrunk install affordance keeps its inline `<small>`s: install state, the installed path/version, and the "install the binary below to enable this" precondition are live status and operator next-steps, not a description of what a control does — deferring them behind a "?" would hide the reason a control is disabled.
*/
export function WorktreesSection({ form, setForm, gitRemotes, worktrunkInstall, worktrunkInstallVerified, onOpenWorktreesDirPicker, onWorktreeCopyFileChange, onRemoveWorktreeCopyFile, onAddWorktreeCopyFile, onOpenWorktreeCopyFilePicker, onOpenApprovals, }: WorktreesSectionProps) {
const { t } = useTranslation("app");
const worktreeCopyFileRows = (form.worktreeCopyFiles?.length ?? 0) > 0 ? form.worktreeCopyFiles ?? [] : [""];
return (<>
<h4 className="settings-section-heading">{t("settings.worktrees.worktrees", "Worktrees")}</h4>
{/*
FNXC:CapacityModel 2026-07-28-22:15 (PR #2502 review — coderabbit + greptile):
RENAMED from "Run tasks in worktrees", which was a lie.
This setting is CAPACITY SEMANTICS ONLY: it decides whether Max Worktrees
gates dispatch. It does NOT change where work executes — both scheduler
dispatch paths still allocate a worktree per task with it off, and planning
still runs in the task's own worktree. Two reviewers independently read the
old label as "turn off worktree execution", which is exactly the wrong
inference and worse than having no switch: an operator would flip it,
still get worktrees, and conclude the product is broken.
The label now names the limit rather than the mechanism, and the help says
outright that tasks run in a worktree either way. The setting KEY was renamed
with it (`worktreesEnabled` -> `worktreeLimitEnabled`) — a key persisted in
every settings blob misleads every future reader of the schema, not just the
operator reading the UI once.
*/}
<SettingsToggleRow
descriptor={{
key: "worktreeLimitEnabled",
label: t("settings.worktrees.worktreeLimitEnabled", "Limit concurrent worktrees"),
help: t("settings.worktrees.worktreeLimitEnabledHelp", "When on, Max Worktrees caps how many tasks may hold a worktree at once. When off, Max Concurrent Tasks is the only limit. Tasks always run in their own git worktree either way — this does not change where work executes. Default: on."),
scope: "project",
}}
value={form.worktreeLimitEnabled !== false}
onChange={(value) => setForm((f) => ({ ...f, worktreeLimitEnabled: value !== false } as SettingsFormState))}
/>
{/* FNXC:Worktrees 2026-07-15-17:35: An emptied Max Worktrees stores `undefined`, not 0 or "", so the key is absent from the settings blob and the scheduler falls back to the schema default of 4 rather than capping concurrency at nothing. */}
<SettingsNumberRow
descriptor={{
key: "maxWorktrees",
label: t("settings.worktrees.maxWorktrees", "Max Worktrees"),
help: t("settings.worktrees.limitsTotalGitWorktreesIncludingInReviewTasks", "Limits total git worktrees including in-review tasks. Ignored while \u201cLimit concurrent worktrees\u201d is off. Default: 4."),
scope: "project",
min: 1,
max: 20,
disabled: form.worktreeLimitEnabled === false,
}}
value={form.maxWorktrees ?? null}
onChange={(v) => setForm((f) => ({ ...f, maxWorktrees: v ?? undefined } as SettingsFormState))}
/>
<SettingsTextRow
descriptor={{
key: "worktreeInitCommand",
label: t("settings.worktrees.worktreeInitCommand", "Worktree Init Command"),
help: t("settings.worktrees.shellCommandToRunInEachNewWorktree", "Shell command to run in each new worktree after creation. No default \u2014 unset."),
scope: "project",
placeholder: t("settings.worktrees.pnpmInstallFrozenLockfile", "pnpm install --frozen-lockfile"),
}}
value={form.worktreeInitCommand ?? null}
onChange={(v) => setForm((f) => ({ ...f, worktreeInitCommand: v ?? "" }))}
/>
{/*
FNXC:TaskPinnedWorktrees 2026-07-16-00:00:
Recycling and Task-ID naming are MUTUALLY EXCLUSIVE (the settings API/store reject the combination):
"task-id" naming pins each task to its own worktree directory, which is incompatible with the cross-task
recycle pool. The exclusivity is enforced bidirectionally in the UI so a NEW conflict is unreachable —
this toggle is disabled while naming is "task-id" AND recycling is not already on, and the naming select
below is disabled while recycling is on. Together they prevent a save that the backend would 400.
FNXC:TaskPinnedWorktrees 2026-07-16-12:30:
Legacy-conflict escape hatch: when a stored config already carries BOTH (recycle on + "task-id"), do NOT
lock both controls — that would strand the operator (unchanged save preserves a state the runtime treats
as recycling). The runtime backstop makes recycling win in that conflict, so mirror it here: keep this
toggle ENABLED and CHECKED (its true value, un-coerced) so the operator can turn recycling off, which
then re-enables the naming select below. The toggle only greys out for the forward-prevention case
("task-id" naming while recycling is already off).
*/}
<SettingsToggleRow
descriptor={{
key: "recycleWorktrees",
label: t("settings.worktrees.recycleWorktrees", " Recycle worktrees "),
help: form.worktreeNaming === "task-id" && form.recycleWorktrees !== true
? t("settings.worktrees.recycleNotApplicableWithTaskIdNaming", "Not available with Task ID worktree naming — that mode pins each task to its own worktree directory, which is mutually exclusive with the recycle pool. Switch naming to Random or Task title to enable recycling.")
: t("settings.worktrees.offByDefaultOptInWhenEnabledCompleted", "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup. Mutually exclusive with Task ID worktree naming."),
scope: "project",
disabled: form.worktreeNaming === "task-id" && form.recycleWorktrees !== true,
}}
value={form.recycleWorktrees === true}
onChange={(v) => setForm((f) => ({ ...f, recycleWorktrees: v === true }))}
/>
<SettingsToggleRow
descriptor={{
key: "showWorktreeGrouping",
label: t("settings.worktrees.showWorktreeGrouping", " Show worktree grouping on the board "),
help: t("settings.worktrees.showWorktreeGroupingHelp", "Off by default. When enabled, WIP and processing columns always group tasks by worktree and show worktree names, including workflow-mode processing columns."),
scope: "project",
}}
value={form.showWorktreeGrouping === true}
onChange={(v) => setForm((f) => ({ ...f, showWorktreeGrouping: v === true }))}
/>
<div className="form-group">
{/* FNXC:SettingsHelp 2026-07-15-21:40: The allowlist is a repeating row editor, but it is still one settings key (`worktreeCopyFiles`), so its help hangs off the group's own label rather than any single path input. */}
<div className="settings-field-label-row">
<label>{t("settings.worktrees.filesToCopyIntoNewWorktrees", "Files to copy into new worktrees")}</label>
<SettingsHelpTip settingKey="worktreeCopyFiles">{t("settings.worktrees.copyFilesHelp", "Optional. Repository-root-relative regular files are copied into fresh or pooled task worktrees before init commands run. Missing files or directories are skipped without exposing contents. Default: empty (no files copied).")}</SettingsHelpTip>
</div>
{/*
FNXC:WorktreeCopyFiles 2026-06-24-00:00:
Users need a visible, editable allowlist for repository files such as `.env` that Fusion copies into freshly prepared task worktrees. The UI preserves blank rows while editing, but save normalization trims, removes blanks, and de-duplicates before persistence.
*/}
<div className="settings-overlap-ignore-list" data-testid="worktree-copy-files-list">
{worktreeCopyFileRows.map((path, index) => (
<div className="settings-overlap-ignore-row" key={index}>
<div className="settings-overlap-ignore-path-controls">
<input
id={`worktreeCopyFile-${index}`}
type="text"
className="input"
placeholder={t("settings.worktrees.copyFilePlaceholder", ".env")}
value={path}
onChange={(e) => onWorktreeCopyFileChange(index, e.target.value)}
aria-label={t("settings.worktrees.copyFilePathLabel", "File to copy into new worktrees")}
/>
<button
type="button"
className="btn btn-sm"
onClick={() => onOpenWorktreeCopyFilePicker(index)}
aria-label={t("settings.worktrees.browseCopyFile", "Browse file to copy into new worktrees")}
>
{t("settings.worktrees.browse", " Browse ")}
</button>
</div>
<button
type="button"
className="btn btn-sm"
onClick={() => onRemoveWorktreeCopyFile(index)}
aria-label={t("settings.worktrees.removeCopyFile", "Remove copied worktree file")}
>
{t("settings.worktrees.remove", "Remove")}
</button>
</div>
))}
</div>
<button type="button" className="btn btn-sm" onClick={onAddWorktreeCopyFile}>
{t("settings.worktrees.addCopyFile", "Add file")}
</button>
</div>
<div className="form-group">
<div className="settings-field-label-row">
<label htmlFor="executorAllowSiblingBranchRename" className="checkbox-label">
<input id="executorAllowSiblingBranchRename" type="checkbox" checked={form.executorAllowSiblingBranchRename === true} onChange={(e) => setForm((f) => ({ ...f, executorAllowSiblingBranchRename: e.target.checked }))}/>{t("settings.worktrees.allowSilentSiblingBranchRenameDuringExecutorConflicts", " Allow silent sibling branch rename during executor conflicts ")}</label>
<SettingsHelpTip settingKey="executorAllowSiblingBranchRename">{t("settings.worktrees.discouragedThisRestoresTheLegacyBehaviorWhereA", " Discouraged. This restores the legacy behavior where a live ")}<code>fusion/&lt;task-id&gt;</code>{t("settings.worktrees.branchCollisionSilentlyForksWorkOntoSiblingBranches", " branch collision silently forks work onto sibling branches like ")}<code>-2</code>{t("settings.worktrees.andCanHidePriorCommitsFromTheDefault", " and can hide prior commits from the default recovery flow. Default: disabled. ")}</SettingsHelpTip>
</div>
</div>
{/*
FNXC:Worktrees 2026-07-15-17:35:
Recycling and naming are coupled: pooled worktrees keep the names they were created with, so the naming select is disabled while `recycleWorktrees` is on and its help swaps to explain why rather than letting the operator pick a style that would be silently ignored.
FNXC:TaskPinnedWorktrees 2026-07-16-00:00:
"Task ID" additionally enables task-pinned worktrees (each task owns one derivable directory for its whole lifecycle), which is why it is mutually exclusive with recycling \u2014 the recycle toggle above is disabled while this is "task-id". The select stays disabled while recycling is on so the operator cannot cross into the conflicting state from this side either.
*/}
<SettingsSelectRow
descriptor={{
key: "worktreeNaming",
label: t("settings.worktrees.worktreeNamingStyle", "Worktree Naming Style"),
help: form.recycleWorktrees
? t("settings.worktrees.namingStyleNotApplicableWhenRecycling", "Naming style is not applicable when recycling worktrees \u2014 pooled worktrees retain their existing names. \"Task ID\" is unavailable here because task-pinned worktrees are mutually exclusive with recycling; turn off Recycle worktrees to use it.")
: t("settings.worktrees.howToNameFreshWorktreeDirectories", "How to name fresh worktree directories. Only applies when recycling is off. \"Task ID\" also pins each task to its own worktree directory for its whole lifecycle (mutually exclusive with recycling). Default: random."),
scope: "project",
disabled: form.recycleWorktrees,
options: [
{ value: "random", label: t("settings.worktrees.randomNamesEGSwiftFalcon", "Random names (e.g., swift-falcon)") },
{ value: "task-id", label: t("settings.worktrees.taskIDEGFN042", "Task ID (e.g., FN-042)") },
{ value: "task-title", label: t("settings.worktrees.taskTitleEGFixLoginBug", "Task title (e.g., fix-login-bug)") },
],
}}
value={form.worktreeNaming || "random"}
onChange={(v) => setForm((f) => ({ ...f, worktreeNaming: v as "random" | "task-id" | "task-title" }))}
/>
<div className="form-group">
{/* FNXC:SettingsHelp 2026-07-15-21:40: The help swaps to the worktrunk-disabled explanation, so the tip is what tells an operator why the input is greyed out; it stays on the same "?" as every other row rather than becoming a second inline idiom. */}
<div className="settings-field-label-row">
<label htmlFor="worktreesDir">{t("settings.worktrees.worktreesDirectory", "Worktrees Directory")}</label>
<SettingsHelpTip settingKey="worktreesDir">
{form.worktrunk?.enabled === true
? "Disabled because Worktrunk integration is enabled — worktrunk manages the worktree directory layout. Disable worktrunk integration to use a custom directory."
: <>{t("settings.worktrees.optionalSupports", " Optional. Supports ")}<code>~</code>{t("settings.worktrees.and", " and ")}<code>{"{repo}"}</code>{t("settings.worktrees.defaultsTo", ". Defaults to ")}<code>&lt;projectRoot&gt;/.worktrees</code>{t("settings.worktrees.whenUnsetOnlyAffectsNewlyCreatedWorktrees", " when unset. Only affects newly-created worktrees. ")}</>}
</SettingsHelpTip>
</div>
<div className="settings-overlap-ignore-path-controls">
<input id="worktreesDir" type="text" placeholder={t("settings.worktrees.defaultsToWorktreesLeaveEmptyUnlessOverriding", "Defaults to .worktrees \u2014 leave empty unless overriding")} value={form.worktreesDir || ""} disabled={form.worktrunk?.enabled === true} onChange={(e) => setForm((f) => ({ ...f, worktreesDir: e.target.value }))}/>
<button type="button" className="btn btn-sm" onClick={onOpenWorktreesDirPicker} aria-label={t("settings.worktrees.browseWorktreesDirectory", "Browse worktrees directory")} disabled={form.worktrunk?.enabled === true}>{t("settings.worktrees.browse", " Browse ")}</button>
</div>
</div>
{/* FNXC:Worktrees 2026-07-15-17:35: Defaults to on, so an absent key reads as enabled (`!== false`) rather than off \u2014 an unset settings blob must not silently skip the pre-merge rebase. */}
<SettingsToggleRow
descriptor={{
key: "worktreeRebaseBeforeMerge",
label: t("settings.worktrees.rebaseFromRemoteBeforeMerge", " Rebase from remote before merge "),
help: t("settings.worktrees.whenEnabledTheMergerFetchesFromTheConfigured", "When enabled, the merger fetches from the configured remote and rebases the task branch onto the latest default-branch tip before merging \u2014 catching concurrent pushes from other collaborators or fusion workers. Any conflicts the rebase surfaces flow into the existing smart/AI resolve pipeline. Default: enabled."),
scope: "project",
}}
value={form.worktreeRebaseBeforeMerge !== false}
onChange={(v) => setForm((f) => ({ ...f, worktreeRebaseBeforeMerge: v === true }))}
/>
{form.worktreeRebaseBeforeMerge !== false && (<div className="form-group">
<div className="settings-field-label-row">
<label htmlFor="worktreeRebaseRemote">{t("settings.worktrees.rebaseRemote", "Rebase Remote")}</label>
<SettingsHelpTip settingKey="worktreeRebaseRemote">{t("settings.worktrees.whichRemoteToFetchForThePreMerge", " Which remote to fetch for the pre-merge rebase. \"Use git default\" falls back to the remote configured for the default branch (typically ")}<code>origin</code>{t("settings.worktrees.closeParenPeriod", ").")}</SettingsHelpTip>
</div>
<select id="worktreeRebaseRemote" value={form.worktreeRebaseRemote ?? ""} onChange={(e) => setForm((f) => ({ ...f, worktreeRebaseRemote: e.target.value || undefined }))}>
<option value="">{t("settings.worktrees.useGitDefault", "Use git default")}</option>
{gitRemotes.map((remote) => (<option key={remote.name} value={remote.name}>
{remote.name} ({remote.fetchUrl})
</option>))}
</select>
</div>)}
<SettingsToggleRow
descriptor={{
key: "worktreeRebaseLocalBase",
label: t("settings.worktrees.alsoRebaseOntoLocalDefaultBranchHEAD", " Also rebase onto local default-branch HEAD "),
help: t("settings.worktrees.inAdditionToTheRemoteRebaseAboveAlso", " In addition to the remote rebase above, also rebase the task branch onto the local default-branch HEAD (rootDir). This catches sibling tasks that merged locally but haven't been pushed yet \u2014 without it, two concurrent tasks where one deletes code can have the other silently re-introduce it via the fallback strategy. Enabled by default; only disable if it causes issues with your workflow. "),
scope: "project",
}}
value={form.worktreeRebaseLocalBase !== false}
onChange={(v) => setForm((f) => ({ ...f, worktreeRebaseLocalBase: v === true }))}
/>
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.worktrees.worktrunkIntegration", "Worktrunk integration")}</h4>
<div className="form-group">
<div className="settings-field-label-row">
<label htmlFor="worktrunkEnabled" className="checkbox-label">
<input id="worktrunkEnabled" type="checkbox" checked={form.worktrunk?.enabled === true} disabled={!worktrunkInstallVerified && form.worktrunk?.enabled !== true} onChange={(e) => setForm((f) => ({
...f,
worktrunk: {
enabled: e.target.checked,
binaryPath: f.worktrunk?.binaryPath ?? "",
onFailure: f.worktrunk?.onFailure ?? "fail",
},
}))}/>{t("settings.worktrees.enableWorktrunkIntegration", " Enable worktrunk integration ")}</label>
<SettingsHelpTip settingKey="worktrunkEnabled">{t("settings.worktrees.disabledByDefaultOptInWhenEnabledFusion", " Disabled by default (opt-in). When enabled, Fusion shells out to ")}<code>worktrunk</code>{t("settings.worktrees.forWorktreeCreateSyncPruneAndRemoveOperations", " for worktree create, sync, prune, and remove operations and follows worktrunk's directory layout. ")}</SettingsHelpTip>
</div>
{/* FNXC:SettingsHelp 2026-07-15-21:40: Stays inline: this is the reason the checkbox above is disabled and the action that clears it, not a description of the setting. Behind a "?" the operator would see a dead toggle with no explanation in view. */}
{!worktrunkInstallVerified && form.worktrunk?.enabled !== true && (<small className="settings-muted">{t("settings.worktrees.installTheWorktrunkBinaryBelowToEnableThis", "Install the worktrunk binary below to enable this integration.")}</small>)}
</div>
<div className="form-group" data-testid="worktrunk-install-affordance">
{worktrunkInstall.status === "installed" && (<small className="settings-muted">{t("settings.worktrees.worktrunk", " worktrunk ")}{worktrunkInstall.version ?? ""}{t("settings.worktrees.installedAt", " installed at ")}{worktrunkInstall.installPath ?? "~/.fusion/bin/worktrunk"}
</small>)}
{(worktrunkInstall.status === "missing" || worktrunkInstall.status === "installing") && (<>
<button type="button" className="btn btn-primary" onClick={() => void worktrunkInstall.requestInstall()} disabled={worktrunkInstall.requesting || worktrunkInstall.status === "installing"}>
{t("settings.worktrees.installWorktrunk", "Install worktrunk binary")}
</button>
<small className="settings-muted">{t("settings.worktrees.enableWorktrunkAndRequestApprovalToInstallThe", "Enable worktrunk and request approval to install the pinned release.")}</small>
</>)}
{worktrunkInstall.status === "pending-approval" && (<>
<small className="settings-muted">{t("settings.worktrees.awaitingApproval", "Awaiting approval — open Approvals to continue.")}</small>
<button type="button" className="btn btn-secondary" onClick={() => onOpenApprovals?.(worktrunkInstall.pendingApprovalId)}>
{t("settings.worktrees.openApprovals", "Open Approvals")}
</button>
</>)}
{(worktrunkInstall.status === "denied" || worktrunkInstall.status === "failed") && (<>
<small style={{ color: "var(--color-error)" }}>{worktrunkInstall.error ?? "Worktrunk install failed."}</small>
<button type="button" className="btn btn-secondary" onClick={() => void worktrunkInstall.requestInstall()}>
{t("settings.worktrees.tryAgain", "Try again")}
</button>
</>)}
</div>
<div className="form-group">
<div className="settings-field-label-row">
<label htmlFor="worktrunkBinaryPath">{t("settings.worktrees.worktrunkBinaryPath", "Worktrunk binary path")}</label>
<SettingsHelpTip settingKey="worktrunkBinaryPath">{t("settings.worktrees.optionalLeaveBlankToAutoResolveFusionWill", "Optional. Leave blank to auto-resolve; Fusion will offer to install on first use.")}</SettingsHelpTip>
</div>
<input id="worktrunkBinaryPath" type="text" className="input" placeholder={t("settings.worktrees.autoDetectFusionBinWorktrunkOrPATH", "auto-detect (~/.fusion/bin/worktrunk or $PATH)")} value={form.worktrunk?.binaryPath ?? ""} disabled={form.worktrunk?.enabled !== true} onChange={(e) => setForm((f) => ({
...f,
worktrunk: {
enabled: f.worktrunk?.enabled === true,
binaryPath: e.target.value,
onFailure: f.worktrunk?.onFailure ?? "fail",
},
}))}/>
</div>
<div className="form-group">
<div className="settings-field-label-row">
<label htmlFor="worktrunkOnFailure">{t("settings.worktrees.worktrunkFailureBehavior", "Worktrunk failure behavior")}</label>
<SettingsHelpTip settingKey="worktrunkOnFailure"><code>fail</code>{t("settings.worktrees.stopsOnWorktrunkErrorsForExplicitOperatorRecovery", " stops on worktrunk errors for explicit operator recovery; ")}<code>fallback-native</code>{t("settings.worktrees.keepsProgressMovingBySwitchingToFusionApos", " keeps progress moving by switching to Fusion's built-in worktree backend. ")}</SettingsHelpTip>
</div>
<select id="worktrunkOnFailure" className="select" value={form.worktrunk?.onFailure ?? "fail"} disabled={form.worktrunk?.enabled !== true} onChange={(e) => setForm((f) => ({
...f,
worktrunk: {
enabled: f.worktrunk?.enabled === true,
binaryPath: f.worktrunk?.binaryPath ?? "",
onFailure: e.target.value as "fail" | "fallback-native",
},
}))}>
<option value="fail">{t("settings.worktrees.failAndPauseTheTaskDefault", "Fail and pause the task (default)")}</option>
<option value="fallback-native">{t("settings.worktrees.fallBackToFusionsNativeWorktreeBackend", "Fall back to Fusion's native worktree backend")}</option>
</select>
</div>
</>);
}
export default WorktreesSection;