* docs(worktree): update design doc — split Phase C/D, add Future section
- Phase C: session persistence + hooksPath + StatusLine + WorktreeExitDialog
- Phase D: --worktree CLI flag + symlinkDirectories
- Future: sparse checkout, .worktreeinclude, tmux, PR reference parsing
- Feature comparison table updated with Phase A/B completion status
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(worktree): add Phase C implementation plan
8 tasks: WorktreeSession sidecar storage, hooksPath setup,
EnterWorktree/ExitWorktree session wiring, useWorktreeSession hook,
Footer display, --resume context injection, WorktreeExitDialog.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(worktree): update Phase C plan after claude-code comparison
- WorktreeSession: add originalHeadCommit field
- hooksPath: add .husky/ detection + skip-if-already-set logic
- StatusLine payload: expand worktree field to match claude-code schema
- WorktreeExitDialog: load dirty state on mount, display counts in dialog
- UIState.activeWorktree: add originalCwd, originalBranch, originalHeadCommit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(worktree): add WorktreeSession sidecar storage
New worktreeSessionService.ts exposes read/write/clear functions for the
sidecar JSON file at <chatsDir>/<sessionId>.worktree.json. SessionService
gains getWorktreeSessionPath() so callers don't need to know the layout.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(worktree): configure core.hooksPath after worktree creation
createUserWorktree() now sets `core.hooksPath` inside the new worktree to
the main repo's hooks directory (.husky preferred, .git/hooks fallback) so
commits inside the worktree run the same pre-commit checks as the main
repo. Mirrors claude-code's performPostCreationSetup logic — skips the
subprocess when the value already matches to avoid ~14ms spawn overhead.
Failures are non-fatal: the worktree is still usable without hooks.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(worktree): persist WorktreeSession sidecar in EnterWorktreeTool
After creating a worktree, EnterWorktreeTool now writes a sidecar JSON
file at <chatsDir>/<sessionId>.worktree.json with the full session state
(slug, paths, branches, original HEAD SHA). --resume reads this in Phase
C task 7 to restore worktree context. Best-effort: write failures don't
abort the creation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(worktree): clear WorktreeSession sidecar in ExitWorktreeTool
After successful keep or remove, ExitWorktreeTool now clears the sidecar
JSON file iff its slug matches the worktree being exited. The slug check
prevents wiping the sidecar when the user exits a worktree that isn't
currently tracked (multiple worktrees on disk, sidecar tracks one).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(worktree): expose active worktree via useWorktreeSession + UIState
New useWorktreeSession hook watches the sidecar JSON file (created by
EnterWorktreeTool, deleted by ExitWorktreeTool) and returns the current
WorktreeSession or null. AppContainer wires it into a new
UIState.activeWorktree field consumed by Footer (Task 6) and
WorktreeExitDialog (Task 8).
A showWorktreeExitDialog state placeholder is added too, hardcoded false
until Task 8 wires the dialog trigger.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(worktree): show active worktree in Footer + StatusLine payload
Footer renders `⎇ <branch> (<slug>)` when activeWorktree != null, but
only when the user has no custom statusline (their script likely
handles it from the stdin payload itself).
useStatusLine's StatusLineCommandInput gains a `worktree` field with
{name, path, branch, original_cwd, original_branch} — matches claude-code's
schema so statusline scripts can be shared across both CLIs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(worktree): inject context hint on --resume when worktree is active
On --resume, if the session has a WorktreeSession sidecar, append an
INFO history item pointing the model at the worktree path so it
continues using it for file operations. Stale sidecars (worktree dir
deleted out-of-band) are cleaned up so the Footer indicator doesn't
go stale.
qwen-code can't process.chdir() the way claude-code does because
Config.targetDir is immutable; the context hint is the equivalent
behavioral cue.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(worktree): add WorktreeExitDialog with dirty-state inspection
WorktreeExitDialog renders when the user double-presses Ctrl+C inside a
worktree. On mount it runs `git status --porcelain` and
`git rev-list --count <originalHeadCommit>..HEAD` to show how many
uncommitted files and new commits the user would discard by choosing
"Remove". The dialog never auto-removes — every exit goes through
explicit user confirmation per requirements.
handleExit in AppContainer intercepts the second-press quit when
activeWorktree is set and shows the dialog instead. A new UIAction
handleWorktreeExit(choice) routes the user's choice through removal
(via GitWorktreeService.removeUserWorktree) + sidecar cleanup + /quit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(worktree): add Phase C E2E test plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(worktree): fix E2E test plan sidecar path + jq selector
- sidecar lives at ~/.qwen/projects/<sanitized-cwd>/chats/, not ~/.qwen/tmp/<hash>/
- qwen --output-format json emits a JSON array, not NDJSON — jq needs .[]
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(worktree): add showWorktreeExitDialog to dialogsVisible
Phase C task 8 introduced showWorktreeExitDialog state and the dialog
render in DialogManager, but missed adding the flag to the dialogsVisible
OR expression. DefaultAppLayout only renders DialogManager when
dialogsVisible is true, so the dialog was never shown — second Ctrl+C
in a worktree silently absorbed instead of triggering the prompt.
Caught by Group E E2E tests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(worktree): extend --resume context restore to headless + ACP modes
Phase C task 7 originally placed the worktree-restore logic in
AppContainer.tsx (TUI only). E2E Group C exposed that headless and ACP
modes never run AppContainer, so stale sidecars accumulate and the model
loses worktree context after --resume.
Refactor to a shared `restoreWorktreeContext` helper in core, then wire
the three entry points:
- TUI (AppContainer): keep historyManager.addItem(INFO) UX, route via
the helper.
- Headless (nonInteractiveCli): prepend the notice as a system-reminder
block on the user prompt; emit a `worktree_restored` system message to
the JSON adapter so SDK consumers can react.
- ACP (Session.pendingWorktreeNotice): set by acpAgent.loadSession on
resume, consumed and cleared exactly once on the next #executePrompt.
All three modes call the same helper, so stale-sidecar cleanup is
consistent. Helper covers: missing sidecar, live worktree dir,
deleted worktree dir, regular file at worktreePath, malformed JSON.
5 new unit tests for restoreWorktreeContext (13/13 pass total).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(worktree): add ACP-mode integration tests for --resume context
Covers:
- acpAgent.worktree.test.ts (3 tests): loadSession sets
pendingWorktreeNotice only when worktree dir is live, clears
stale sidecar otherwise, swallows restoreWorktreeContext errors.
- Session.worktree.test.ts (4 tests): #executePrompt prepends the
system-reminder block exactly once on first prompt, clears the
pending notice, second prompt sees no leakage, no-op when nothing
was set.
E2E via real ACP protocol is impractical without a Zed client; these
tests cover the integration boundaries directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(worktree): clarify hooksPath comment + pendingWorktreeNotice one-shot rationale
Two doc-only fixes from PR #4174 review:
- gitWorktreeService.ts: previous hooksPath comment overstated the
optimization (claimed claude-code's ~14ms saving but we still do a
read subprocess). Rewrite to be explicit: write-skip only, read
retained, parseGitConfigValue's full optimization deliberately not
ported because the read happens once per worktree creation.
- Session.ts: pendingWorktreeNotice doc now explains why it's one-shot
(after the first prompt the worktree path is already in conversation
context; re-injecting would clutter history without adding signal).
No behavior change.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(test): add getResumedSessionData to nonInteractiveCli mock Config
CI surfaced TypeError: config.getResumedSessionData is not a function
across 12 tests in nonInteractiveCli.test.ts. The Phase C ada0837e2
commit added a worktree-restore call in the headless path that probes
config.getResumedSessionData(); the mock Config never had that method.
Return undefined to short-circuit the restore block — these tests
don't exercise --resume.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(worktree): address PR #4174 reviewer findings
Bundled response to the two review rounds. Per-thread replies follow.
CORE — worktree sidecar robustness (Findings 3252368644, 3252368651, 3255171690):
- atomicWriteJSON instead of fs.writeFile (no more half-written sidecar after a crash)
- readWorktreeSession now schema-validates the parsed object and returns null
on missing/wrong-type fields instead of propagating undefined into consumers
- restoreWorktreeContext clears the sidecar on JSON parse failure / read I/O
error so a corrupted file doesn't block every subsequent --resume
CORE — hooksPath setup (Finding 3252368645):
- configureHooksPath distinguishes ENOENT (benign "candidate not present")
from real stat errors (EACCES/EIO/ENOTDIR); the latter are warn-logged
so a silently-degraded hooksPath is visible to operators
CLI — handleWorktreeExit Remove path (Findings 3252368637, 3252368640 a+b):
- Anchor GitWorktreeService at activeWorktree.originalCwd (the captured
repo root), not config.getTargetDir() — fixes monorepo-subdirectory
launches where the worktree lives under the repo root but getTargetDir
points at a subpackage
- Check removeUserWorktree return value; on failure, leave the sidecar
intact so --resume can recover (previous code cleared it regardless)
- Pass forceDeleteBranch:true to honour the dialog's "discards N commits"
label — without it `git branch -d` refused unmerged commits and the
branch was silently preserved
CLI — useWorktreeSession watcher (Finding 3252368648):
- Normalize fs.watch filename via toString() so the Linux-Buffer code
path triggers reloads (previous comparison silently never matched)
- Treat null filename as "unknown, reload to be safe" (recursive watchers
on some platforms emit events without a payload)
CLI — WorktreeExitDialog (Findings 3252368650, 3255171694):
- execGit now correctly reads numeric exit codes from .code/.status
(NodeJS.ErrnoException.code is a string for spawn errors, number for
subprocess exits); previous typeof === 'number' check always missed
- Dialog body shows an "⚠ Could not measure worktree state (...)" banner
when git status / rev-list failed, so the user doesn't see a misleading
"0 files, 0 commits" before choosing Remove
CLI — closeAnyOpenDialog (Round 2 review body):
- Wire WorktreeExitDialog into the standard dialog-dismissal path so
Ctrl+C dismisses it the same way it dismisses every other dialog
TEST FIXES — vitest timeouts:
- Real git invocations + user-global hooks (e.g. trustup post-commit
webhooks) can take 10–20s per setUp on CI. Bump testTimeout +
hookTimeout to 30s for the three integ test suites that spawn git
(Phase B/C worktree integ tests) so the suite isn't flaky.
NEW TESTS:
- worktreeSessionService.test: 3 new cases covering malformed JSON,
missing required fields, wrong-type fields, malformed sidecar cleanup,
partial sidecar cleanup (16 total, up from 13).
- useWorktreeSession.test.tsx: 4 new cases — null when no sidecar,
parsed sidecar at mount, reacts to delete, reacts to creation.
- WorktreeExitDialog.test.tsx: 1 new case — loading frame renders before
git probes resolve. (Async dialog states tested via E2E — vi.mock of
execFile in ink-testing-library doesn't fire mock impl reliably.)
- nonInteractiveCli.test: 3 new "Phase C --resume" cases — system-reminder
injection on live worktree, no injection when sidecar absent, stale
sidecar cleanup when worktree dir is gone.
DECLINED FINDINGS (replied on threads):
- 3252368642 (Dialog Keep clears sidecar) — declined-design. Dialog
Keep = "exit app, keep worktree for next --resume"; tool Keep =
"I'm done with this worktree". Intentionally different semantics.
- 3252368643 (originalHeadCommit base branch) — false-positive. There
is no base_branch parameter; getCurrentCommitHash() returns HEAD which
equals the tip of the current branch (== baseBranch in createUserWorktree).
- 3252368640 part c (bypass safety guards) — declined-design. The
dialog IS the safety affordance for this path — it shows dirty-state
counts and asks for explicit user confirmation before removal.
- 3255171696 (DialogManager async fire-and-forget) — false-positive.
handleSlashCommand('/quit') is inside the await chain in
handleWorktreeExit, so the described race ("process.exit before remove
completes") cannot occur.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(test): correct linter-mangled imports in useWorktreeSession.test
Pre-commit hook auto-fixed imports collapsed value imports
(writeWorktreeSession, clearWorktreeSession) into an `import type`
block, breaking runtime resolution. Split back into value + type imports.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(test): normalize path separators for Windows in worktree session integ
Windows CI failure: `repoRoot` from Node's `fs.mkdtemp` returns
backslash-separated paths (`C:\Users\runneradmin\…`), but
`originalCwd` in the sidecar comes from `getRepoTopLevel()` which
delegates to `git rev-parse --show-toplevel` — git on Windows
returns forward slashes (`C:/Users/runneradmin/…`).
The Windows-only assertion `expect(originalCwd).toBe(repoRoot)` was
comparing two different representations of the same canonical path
and rightly failed on `Object.is` equality. Compare via path.normalize
on both sides so the assertion holds across platforms without
changing the runtime path (originalCwd still records git's output
verbatim, which is what consumers expect since other places in the
codebase that read `getRepoTopLevel()` also work with that shape).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(worktree): address PR #4174 round 4 findings
Finding #3256237933 (Critical, follow-up to #3252368640 part 1):
handleWorktreeExit silently /quit'd when removeUserWorktree returned
{success:false}, contradicting the user's intent after they clicked
"Remove worktree and branch (discards N commits, M files)". Now
surfaces an ERROR history item with the underlying error message
and STAYS in the session so the user can decide what to do
(retry via exit_worktree, fix the lock/permission/corruption issue,
or quit anyway). Same treatment applied to the hard-failure catch
block — previously it caught the throw and proceeded to /quit with
no log; now it emits the error and stays alive.
Finding #3256236050 (Nit): originalCwd field name implies "user's
launch cwd" but actually stores `getRepoTopLevel()` (different in
monorepo subdir launches — the gap closed by #3252368637). Renaming
the field would force on-disk migration of every existing sidecar
(every active --resume breaks until users wipe the old file).
Doc-only fix: WorktreeSession.originalCwd now carries an explicit
JSDoc explaining the semantics and warning consumers expecting
process.cwd() to NOT use this field.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(worktree): address PR #4174 round 5 findings
Finding #3256241831 (Nit, but awareness UX): the built-in `⎇`
indicator used to disappear whenever `statusLineLines.length > 0`,
on the assumption that the user's custom statusline rendered worktree
itself. That assumption is unsafe — scripts written before Phase C
don't know about `payload.worktree`, scripts can deliberately ignore
the field, and partial scripts may render some fields but not
worktree. In any of those cases the user sees no worktree UI while
having an active worktree, risking destructive operations in the
wrong cwd. New behavior: indicator shows by default regardless of
statusline. Added an opt-out setting `ui.hideBuiltinWorktreeIndicator`
(default false) for users whose custom statusline already renders
worktree and want to avoid duplication.
Finding #3256239608 (Nit): `fs.watch` in useWorktreeSession holds
an inode handle to `chatsDir` at mount time. If the directory is
deleted out-of-band (manual cleanup, antivirus quarantine, reset
scripts) and recreated, the watcher does NOT re-attach to the new
inode and the Footer indicator stops reacting to sidecar changes.
Reviewer explicitly accepted this as a documented limitation rather
than adding polling-fallback or error-event-handler complexity for
an edge case that doesn't arise in normal use. Added a JSDoc block
on the hook explaining the limitation and pointing to the future
fix shapes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(worktree): regenerate settings.schema.json for hideBuiltinWorktreeIndicator
CI Lint step caught that the JSON schema mirror in
packages/vscode-ide-companion was out of date after adding the new
ui.hideBuiltinWorktreeIndicator setting in 80f9cb495. Regenerated
via `npm run generate:settings-schema`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(worktree): address PR #4174 round 6 findings
Critical fixes:
- #3259975247: TUI dialog Remove now reads the in-worktree session
marker and refuses to delete a worktree owned by a different
session — same ownership guard ExitWorktreeTool already applies.
Stale/copied sidecars can no longer destroy another session's work.
- #3259975249: TUI --resume queues a one-shot pendingWorktreeNotice
ref consumed by handleFinalSubmit; the user's first prompt is
prefixed with the same <system-reminder> block headless/ACP use.
Previously only the INFO history item showed in the transcript
(UI-only), so resumed models could silently edit the parent
checkout.
- #3259975245: exit_worktree action='keep' no longer clears the
sidecar. `keep` means "preserve the worktree for later"; clearing
the persisted binding broke --resume / Footer / WorktreeExitDialog
for kept worktrees. Now matches the Dialog keep semantics. Test
updated to assert preservation instead of clearing.
- ACP unstable_resumeSession parity: factored the worktree restore
block into #restoreWorktreeOnResume() and called from both
loadSession() and unstable_resumeSession(). ACP clients using
resume no longer miss the worktree context.
Suggestion-level fixes:
- #3259975237: configureHooksPath now resolves the canonical hooks
dir via `git rev-parse --git-common-dir` instead of constructing
`<sourceRepoPath>/.git/hooks`. The construction assumed .git is a
directory, but when Qwen runs from a linked worktree it's a file
pointing at the real gitdir → ENOTDIR → silent no-hooks worktree.
- #3259975242: only writes core.hooksPath when the key is unset.
A non-empty inherited or user-configured value is preserved
instead of being silently replaced.
- #3256839787: restoreWorktreeContext adds a structural invariant
check — worktreePath must live under <originalCwd>/.qwen/worktrees/.
A tampered/copied sidecar pointing at an arbitrary existing dir
is rejected and cleared so the model can't be redirected.
Tests:
- worktreeSessionService.test: 17/17 (added prefix-escape rejection
case + restructured the existing live-worktree case to satisfy
the new structural invariant).
- exit-worktree.session.integ.test: rewrote keep test to assert
preservation (matches new behavior).
- nonInteractiveCli.test: updated fixture worktreeDir to live
under <originalCwd>/.qwen/worktrees/ for the prefix invariant.
- All other suites pass without modification.
Test coverage gap acknowledgement (no comment_id reply): per-handler
unit tests for handleWorktreeExit + dialog post-load states remain
covered by the E2E Group E suite in docs/e2e-tests/worktree-phase-c.md.
The execFile mock path in ink-testing-library still doesn't deliver
async useEffect state transitions reliably, so unit testing those
states adds more harness than signal; deferring.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>