feat(worktree): Phase C — session persistence, hooksPath, Footer + WorktreeExitDialog, three-mode --resume restore (#4174)

* 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>
This commit is contained in:
顾盼 2026-05-19 13:59:35 +08:00 committed by GitHub
parent b0ea9f4849
commit a7e05302e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 4718 additions and 39 deletions

View file

@ -0,0 +1,594 @@
# Worktree Phase C E2E Test Plan
## Scope
End-to-end verification of Phase C features against the local build at
`/Users/mochi/code/qwen-code/.claude/worktrees/romantic-burnell-b6e48c/dist/cli.js`.
Phase C delivers:
- **Task 1, 3, 4**`WorktreeSession` sidecar JSON file at
`~/.qwen/tmp/<projectHash>/chats/<sessionId>.worktree.json`
- **Task 2**`core.hooksPath` configured inside new worktrees
- **Task 56**`useWorktreeSession` hook, `UIState.activeWorktree`, Footer
worktree indicator, `StatusLineCommandInput.worktree` field
- **Task 7**`--resume` injects an INFO history item when active worktree
still exists; cleans up stale sidecar otherwise
- **Task 8**`WorktreeExitDialog` with dirty-state inspection, intercepts
second Ctrl+C in active worktree
## Binaries
- **Local build**: `node /Users/mochi/code/qwen-code/.claude/worktrees/romantic-burnell-b6e48c/dist/cli.js`
- **Baseline (for pre-impl comparison if needed)**: globally installed `qwen`
## Test environment template
Each group runs in its own temp git repo and tmux session:
```bash
TEST_DIR=$(mktemp -d -t qwen-wt-phc-XXXXXX)
TEST_DIR=$(cd "$TEST_DIR" && pwd -P) # resolve symlinks (macOS /var → /private/var)
cd "$TEST_DIR"
git init -q -b main
git config user.email t@e.com
git config user.name t
git config commit.gpgsign false
echo "hello" > README.md
git add README.md
git commit -q -m "initial" --no-verify
```
`QWEN=/Users/mochi/code/qwen-code/.claude/worktrees/romantic-burnell-b6e48c/dist/cli.js`
---
## Group A: WorktreeSession sidecar (headless)
**Mode:** headless, `--approval-mode yolo`, `--output-format json`
### A1: enter_worktree writes sidecar with all fields
**Steps:**
```bash
SESSION=$(node $QWEN "use the enter_worktree tool with name='a1-test' to create a worktree" \
--approval-mode yolo --output-format json 2>/dev/null \
| jq -r '.[] | select(.type=="system") | .session_id' | head -1)
PROJECT_ID=$(node -e "console.log(process.argv[1].replace(/[^a-zA-Z0-9]/g,'-'))" "$TEST_DIR")
SIDECAR=~/.qwen/projects/$PROJECT_ID/chats/$SESSION.worktree.json
# Verify all fields present
cat "$SIDECAR" | jq '.slug, .worktreePath, .worktreeBranch, .originalCwd, .originalBranch, .originalHeadCommit'
```
**Expected:**
- `slug` = "a1-test"
- `worktreePath` ends with `.qwen/worktrees/a1-test`
- `worktreeBranch` = "worktree-a1-test"
- `originalCwd` = `$TEST_DIR` (resolved)
- `originalBranch` = "main"
- `originalHeadCommit` matches `[0-9a-f]{40}`
### A2: exit_worktree (keep) clears sidecar
**Steps:**
```bash
SESSION=$(node $QWEN "create a worktree named 'a2-test' using enter_worktree, then immediately exit it with action='keep' using exit_worktree" \
--approval-mode yolo --output-format json 2>/dev/null \
| jq -r '.[] | select(.type=="system") | .session_id' | head -1)
SIDECAR=~/.qwen/projects/$PROJECT_ID/chats/$SESSION.worktree.json
test ! -f "$SIDECAR" && echo "PASS: sidecar removed" || echo "FAIL: sidecar still exists"
```
**Expected:** sidecar file does not exist after the exit_worktree call.
### A3: exit_worktree (remove) clears sidecar
**Steps:**
```bash
SESSION=$(node $QWEN "create a worktree named 'a3-test' using enter_worktree, then immediately exit it with action='remove' and discard_changes=true using exit_worktree" \
--approval-mode yolo --output-format json 2>/dev/null \
| jq -r '.[] | select(.type=="system") | .session_id' | head -1)
SIDECAR=~/.qwen/projects/$PROJECT_ID/chats/$SESSION.worktree.json
test ! -f "$SIDECAR" && echo "PASS: sidecar removed" || echo "FAIL: sidecar still exists"
# Also verify the worktree dir is gone
test ! -d "$TEST_DIR/.qwen/worktrees/a3-test" && echo "PASS: worktree dir removed"
```
**Expected:** both the sidecar AND the worktree directory are gone.
---
## Group B: hooksPath configuration (headless)
### B1: Without `.husky/`, hooksPath = `<repo>/.git/hooks`
**Steps:**
```bash
node $QWEN "use enter_worktree with name='b1-test' to create a worktree" \
--approval-mode yolo --output-format json 2>/dev/null > /dev/null
HOOKS_PATH=$(git -C "$TEST_DIR/.qwen/worktrees/b1-test" config --local core.hooksPath)
echo "Got hooksPath: $HOOKS_PATH"
test "$HOOKS_PATH" = "$TEST_DIR/.git/hooks" && echo "PASS" || echo "FAIL"
```
**Expected:** `$TEST_DIR/.git/hooks`
### B2: With `.husky/`, hooksPath = `<repo>/.husky`
**Steps:**
```bash
mkdir -p "$TEST_DIR/.husky"
echo '#!/bin/sh' > "$TEST_DIR/.husky/pre-commit"
chmod +x "$TEST_DIR/.husky/pre-commit"
node $QWEN "use enter_worktree with name='b2-test' to create a worktree" \
--approval-mode yolo --output-format json 2>/dev/null > /dev/null
HOOKS_PATH=$(git -C "$TEST_DIR/.qwen/worktrees/b2-test" config --local core.hooksPath)
test "$HOOKS_PATH" = "$TEST_DIR/.husky" && echo "PASS" || echo "FAIL got=$HOOKS_PATH"
```
**Expected:** `$TEST_DIR/.husky`
### B3: Hooks in main repo actually fire from inside worktree
**Steps:**
```bash
# Set up a hook that writes a marker file
mkdir -p "$TEST_DIR/.git/hooks"
cat > "$TEST_DIR/.git/hooks/pre-commit" <<'EOF'
#!/bin/sh
echo "hook-fired" > /tmp/qwen-wt-hook-marker
EOF
chmod +x "$TEST_DIR/.git/hooks/pre-commit"
node $QWEN "use enter_worktree with name='b3-test' to create a worktree" \
--approval-mode yolo --output-format json 2>/dev/null > /dev/null
# Commit something inside the worktree
WT="$TEST_DIR/.qwen/worktrees/b3-test"
echo "x" > "$WT/file.txt"
git -C "$WT" add file.txt
rm -f /tmp/qwen-wt-hook-marker
git -C "$WT" commit -m "trigger hook" 2>&1
test -f /tmp/qwen-wt-hook-marker && echo "PASS: hook fired" || echo "FAIL: hook did not fire"
rm -f /tmp/qwen-wt-hook-marker
```
**Expected:** `/tmp/qwen-wt-hook-marker` exists after the commit.
---
## Group C: --resume worktree restoration (headless)
### C1: --resume injects worktree context when sidecar present and dir alive
**Steps:**
```bash
# Create initial session with worktree
INIT_OUT=$(node $QWEN "use enter_worktree with name='c1-test' to create a worktree" \
--approval-mode yolo --output-format json 2>/dev/null)
SESSION=$(echo "$INIT_OUT" | jq -r '.[] | select(.type=="system") | .session_id' | head -1)
# Resume the session and ask "what's my context?"
RESUMED=$(node $QWEN --resume "$SESSION" "say SIDECAR-CONFIRM" \
--approval-mode yolo --output-format json 2>/dev/null)
# Look for the injected INFO message text in the conversation
echo "$RESUMED" | grep -q "Resumed.*Active worktree.*c1-test" && echo "PASS" || echo "FAIL: no context injection"
```
**Expected:** the JSON stream contains an INFO message referencing `c1-test`.
### C2: --resume cleans up stale sidecar when worktree dir is gone
**Steps:**
```bash
INIT_OUT=$(node $QWEN "use enter_worktree with name='c2-test' to create a worktree" \
--approval-mode yolo --output-format json 2>/dev/null)
SESSION=$(echo "$INIT_OUT" | jq -r '.[] | select(.type=="system") | .session_id' | head -1)
SIDECAR=~/.qwen/projects/$PROJECT_ID/chats/$SESSION.worktree.json
# Delete the worktree directory out-of-band
rm -rf "$TEST_DIR/.qwen/worktrees/c2-test"
test -f "$SIDECAR" || { echo "SKIP: sidecar was already gone"; exit 0; }
# Resume — should clean up the stale sidecar
node $QWEN --resume "$SESSION" "hello" --approval-mode yolo --output-format json 2>/dev/null > /dev/null
test ! -f "$SIDECAR" && echo "PASS: stale sidecar cleaned" || echo "FAIL: stale sidecar still present"
```
**Expected:** sidecar file is removed.
---
## Group D: Footer worktree indicator (interactive tmux)
### D1: Footer shows worktree indicator after enter_worktree
**Steps:**
```bash
tmux new-session -d -s wt-d1 -x 200 -y 50 \
"cd $TEST_DIR && node $QWEN --approval-mode yolo"
sleep 3
tmux send-keys -t wt-d1 "use enter_worktree with name='d1-test'"
sleep 0.5
tmux send-keys -t wt-d1 Enter
for i in $(seq 1 30); do
sleep 2
tmux capture-pane -t wt-d1 -p | grep -q "Type your message" && break
done
# Capture and look for the worktree indicator line in Footer area
tmux capture-pane -t wt-d1 -p -S -100 > /tmp/wt-d1.out
grep -E "⎇.*worktree-d1-test.*\(d1-test\)" /tmp/wt-d1.out && echo "PASS" || \
{ echo "FAIL — captured output:"; cat /tmp/wt-d1.out; }
tmux kill-session -t wt-d1
```
**Expected:** Footer contains a line like `⎇ worktree-d1-test (d1-test)`.
### D2: Footer indicator disappears after exit_worktree (keep)
**Steps:**
```bash
tmux new-session -d -s wt-d2 -x 200 -y 50 \
"cd $TEST_DIR && node $QWEN --approval-mode yolo"
sleep 3
tmux send-keys -t wt-d2 "use enter_worktree with name='d2-test'"
sleep 0.5
tmux send-keys -t wt-d2 Enter
for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-d2 -p | grep -q "Type your message" && break; done
# Verify indicator showed
tmux capture-pane -t wt-d2 -p -S -100 | grep -q "⎇.*d2-test" || { echo "FAIL: indicator missing before exit"; tmux kill-session -t wt-d2; exit 1; }
# Exit the worktree (keep)
tmux send-keys -t wt-d2 "use exit_worktree with name='d2-test' action='keep'"
sleep 0.5
tmux send-keys -t wt-d2 Enter
for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-d2 -p | grep -q "Kept worktree" && break; done
sleep 2 # give Footer a tick to refresh after sidecar removal
tmux capture-pane -t wt-d2 -p -S -100 > /tmp/wt-d2-after.out
# After exit, the indicator should be gone from the bottom panel area
tail -5 /tmp/wt-d2-after.out | grep -q "⎇.*d2-test" && \
echo "FAIL: indicator still showing" || echo "PASS"
tmux kill-session -t wt-d2
```
**Expected:** worktree indicator disappears from Footer within ~2s of `exit_worktree`.
---
## Group E: WorktreeExitDialog (interactive tmux)
### E1: Second Ctrl+C in worktree shows dialog instead of quitting
**Steps:**
```bash
tmux new-session -d -s wt-e1 -x 200 -y 50 \
"cd $TEST_DIR && node $QWEN --approval-mode yolo"
sleep 3
tmux send-keys -t wt-e1 "use enter_worktree with name='e1-test'"
sleep 0.5
tmux send-keys -t wt-e1 Enter
for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-e1 -p | grep -q "Type your message" && break; done
# First Ctrl+C (cleanup; should show "Press Ctrl+C again to exit")
tmux send-keys -t wt-e1 C-c
sleep 0.3
tmux capture-pane -t wt-e1 -p | grep -q "Press Ctrl+C again" || \
{ echo "FAIL: first Ctrl+C didn't show warning"; tmux kill-session -t wt-e1; exit 1; }
# Second Ctrl+C — should show the WorktreeExitDialog, NOT quit
tmux send-keys -t wt-e1 C-c
sleep 2
# Verify the dialog rendered
tmux capture-pane -t wt-e1 -p -S -50 > /tmp/wt-e1.out
grep -q "Active worktree.*e1-test" /tmp/wt-e1.out && \
grep -q "Keep worktree" /tmp/wt-e1.out && \
grep -q "Remove worktree" /tmp/wt-e1.out && \
echo "PASS" || { echo "FAIL — captured:"; cat /tmp/wt-e1.out; }
tmux kill-session -t wt-e1
```
**Expected:** dialog shows three options (Keep / Remove / Cancel) and process is still alive.
### E2: Dialog shows dirty-state counts (commits + files)
**Steps:**
```bash
tmux new-session -d -s wt-e2 -x 200 -y 50 \
"cd $TEST_DIR && node $QWEN --approval-mode yolo"
sleep 3
tmux send-keys -t wt-e2 "use enter_worktree with name='e2-test'"
sleep 0.5
tmux send-keys -t wt-e2 Enter
for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-e2 -p | grep -q "Type your message" && break; done
# Make the worktree dirty: 1 new commit + 1 uncommitted file
WT="$TEST_DIR/.qwen/worktrees/e2-test"
echo "new" > "$WT/new.txt"
git -C "$WT" add new.txt
git -C "$WT" commit -q -m "test commit" --no-verify
echo "dirty" > "$WT/uncommitted.txt"
# Trigger exit dialog via Ctrl+C double-press
tmux send-keys -t wt-e2 C-c
sleep 0.3
tmux send-keys -t wt-e2 C-c
sleep 3 # allow time for git status / rev-list
tmux capture-pane -t wt-e2 -p -S -50 > /tmp/wt-e2.out
grep -qE "new commit|uncommitted file" /tmp/wt-e2.out && echo "PASS" || \
{ echo "FAIL — captured:"; cat /tmp/wt-e2.out; }
tmux kill-session -t wt-e2
```
**Expected:** dialog body contains both "X new commit(s)" and "Y uncommitted file(s)".
### E3: Cancel option dismisses dialog without exiting
**Steps:**
```bash
tmux new-session -d -s wt-e3 -x 200 -y 50 \
"cd $TEST_DIR && node $QWEN --approval-mode yolo"
sleep 3
tmux send-keys -t wt-e3 "use enter_worktree with name='e3-test'"
sleep 0.5
tmux send-keys -t wt-e3 Enter
for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-e3 -p | grep -q "Type your message" && break; done
# Trigger dialog
tmux send-keys -t wt-e3 C-c
sleep 0.3
tmux send-keys -t wt-e3 C-c
sleep 3
# Navigate to Cancel (DOWN DOWN) and press Enter
tmux send-keys -t wt-e3 Down
sleep 0.2
tmux send-keys -t wt-e3 Down
sleep 0.2
tmux send-keys -t wt-e3 Enter
sleep 2
# Dialog should be gone; input prompt should be back
tmux capture-pane -t wt-e3 -p | grep -q "Type your message" && echo "PASS" || \
{ echo "FAIL — captured:"; tmux capture-pane -t wt-e3 -p; }
# Verify the worktree was NOT removed
test -d "$TEST_DIR/.qwen/worktrees/e3-test" && echo "worktree intact" || echo "FAIL: worktree gone"
tmux kill-session -t wt-e3
```
**Expected:** dialog closes, input prompt returns, worktree directory still exists.
### E4: Keep option exits session but preserves worktree
**Steps:**
```bash
tmux new-session -d -s wt-e4 -x 200 -y 50 \
"cd $TEST_DIR && node $QWEN --approval-mode yolo"
sleep 3
tmux send-keys -t wt-e4 "use enter_worktree with name='e4-test'"
sleep 0.5
tmux send-keys -t wt-e4 Enter
for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-e4 -p | grep -q "Type your message" && break; done
# Trigger dialog and pick Keep (first option, already selected)
tmux send-keys -t wt-e4 C-c
sleep 0.3
tmux send-keys -t wt-e4 C-c
sleep 3
tmux send-keys -t wt-e4 Enter
# Wait for process to exit
for i in $(seq 1 20); do
sleep 1
tmux has-session -t wt-e4 2>/dev/null || break
tmux capture-pane -t wt-e4 -p | grep -q "\$ " && break # shell prompt back
done
# Worktree directory should still exist
test -d "$TEST_DIR/.qwen/worktrees/e4-test" && echo "PASS: worktree preserved" || \
echo "FAIL: worktree was removed"
tmux kill-session -t wt-e4 2>/dev/null || true
```
**Expected:** process exits, worktree directory remains on disk.
### E5: Remove option exits session and deletes worktree
**Steps:**
```bash
tmux new-session -d -s wt-e5 -x 200 -y 50 \
"cd $TEST_DIR && node $QWEN --approval-mode yolo"
sleep 3
tmux send-keys -t wt-e5 "use enter_worktree with name='e5-test'"
sleep 0.5
tmux send-keys -t wt-e5 Enter
for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-e5 -p | grep -q "Type your message" && break; done
# Trigger dialog and pick Remove (DOWN, Enter)
tmux send-keys -t wt-e5 C-c
sleep 0.3
tmux send-keys -t wt-e5 C-c
sleep 3
tmux send-keys -t wt-e5 Down
sleep 0.2
tmux send-keys -t wt-e5 Enter
# Wait for exit
for i in $(seq 1 20); do
sleep 1
tmux has-session -t wt-e5 2>/dev/null || break
tmux capture-pane -t wt-e5 -p | grep -q "\$ " && break
done
# Worktree directory should be GONE
test ! -d "$TEST_DIR/.qwen/worktrees/e5-test" && echo "PASS: worktree removed" || \
echo "FAIL: worktree still on disk"
# Branch should also be deleted
git -C "$TEST_DIR" branch --list | grep -q "worktree-e5-test" && \
echo "FAIL: branch still present" || echo "PASS: branch removed"
tmux kill-session -t wt-e5 2>/dev/null || true
```
**Expected:** process exits, worktree directory deleted, branch `worktree-e5-test` deleted.
---
## Group F: Real-user workflow simulation (interactive tmux)
### F1: Full enter → edit → commit → resume → exit (keep) flow
**Steps:**
```bash
tmux new-session -d -s wt-f1 -x 200 -y 50 \
"cd $TEST_DIR && node $QWEN --approval-mode yolo"
sleep 3
# Step 1: enter worktree
tmux send-keys -t wt-f1 "use enter_worktree with name='f1-feature' to create a worktree"
sleep 0.5
tmux send-keys -t wt-f1 Enter
for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-f1 -p | grep -q "Type your message" && break; done
# Step 2: read the absolute worktree path so the model knows where to write
WT="$TEST_DIR/.qwen/worktrees/f1-feature"
tmux send-keys -t wt-f1 "write the file $WT/hello.txt with content 'hi from worktree'"
sleep 0.5
tmux send-keys -t wt-f1 Enter
for i in $(seq 1 60); do sleep 2; tmux capture-pane -t wt-f1 -p | grep -q "Type your message" && break; done
# Verify the file was actually written INSIDE the worktree
test -f "$WT/hello.txt" && grep -q "hi from worktree" "$WT/hello.txt" && \
echo "PASS: file written inside worktree" || echo "FAIL: file not in worktree"
# Step 3: Exit with keep via the tool
tmux send-keys -t wt-f1 "use exit_worktree with name='f1-feature' action='keep'"
sleep 0.5
tmux send-keys -t wt-f1 Enter
for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-f1 -p | grep -q "Kept worktree" && break; done
# Step 4: Verify worktree still on disk after exit
test -d "$WT" && echo "PASS: worktree kept" || echo "FAIL: worktree removed"
test -f "$WT/hello.txt" && echo "PASS: file persists" || echo "FAIL"
tmux kill-session -t wt-f1
```
**Expected:**
- File written to worktree directory (not main repo)
- After exit `keep`, both the worktree directory and the file remain
### F2: Custom statusline receives `worktree` payload
**Steps:**
```bash
# Create a statusline script that prints the JSON it receives via stdin
SETTINGS_DIR=~/.qwen
SETTINGS_FILE=$SETTINGS_DIR/settings.json
cp -f "$SETTINGS_FILE" /tmp/qwen-settings-backup.json 2>/dev/null || true
mkdir -p "$SETTINGS_DIR"
SL_SCRIPT=/tmp/qwen-wt-statusline.sh
cat > $SL_SCRIPT <<'EOF'
#!/bin/sh
INPUT=$(cat)
echo "$INPUT" > /tmp/qwen-wt-statusline-input.json
WT_NAME=$(echo "$INPUT" | jq -r '.worktree.name // "no-worktree"')
echo "WT=$WT_NAME"
EOF
chmod +x $SL_SCRIPT
cat > "$SETTINGS_FILE" <<EOF
{"ui":{"statusLine":{"type":"command","command":"$SL_SCRIPT"}}}
EOF
tmux new-session -d -s wt-f2 -x 200 -y 50 \
"cd $TEST_DIR && node $QWEN --approval-mode yolo"
sleep 5 # statusline needs an extra tick
tmux send-keys -t wt-f2 "use enter_worktree with name='f2-test'"
sleep 0.5
tmux send-keys -t wt-f2 Enter
for i in $(seq 1 30); do sleep 2; tmux capture-pane -t wt-f2 -p | grep -q "Type your message" && break; done
sleep 3 # let statusline refresh after sidecar change
# Inspect the captured payload
cat /tmp/qwen-wt-statusline-input.json | jq '.worktree.name, .worktree.path, .worktree.branch'
# Verify built-in Footer indicator is HIDDEN when custom statusline is active
tmux capture-pane -t wt-f2 -p -S -100 > /tmp/wt-f2.out
grep -q "WT=f2-test" /tmp/wt-f2.out && echo "PASS: custom statusline rendered" || echo "FAIL"
tmux kill-session -t wt-f2
# Restore settings
cp -f /tmp/qwen-settings-backup.json "$SETTINGS_FILE" 2>/dev/null || rm -f "$SETTINGS_FILE"
```
**Expected:**
- `/tmp/qwen-wt-statusline-input.json` has `.worktree.name == "f2-test"`, `.path`, `.branch` set
- Custom statusline output `WT=f2-test` appears in Footer
- Built-in `⎇ worktree-...` row is NOT rendered (suppressed by custom statusline)
---
## Pass criteria summary
| Group | Test | Expected |
| ----- | -------------------------- | -------------------------------------------- |
| A | A1 enter writes sidecar | all 6 fields populated |
| A | A2 keep clears sidecar | file removed |
| A | A3 remove clears sidecar | file + dir removed |
| B | B1 hooksPath fallback | `<repo>/.git/hooks` |
| B | B2 hooksPath husky | `<repo>/.husky` |
| B | B3 hook fires in wt | marker file written |
| C | C1 resume injects context | INFO message present |
| C | C2 stale sidecar cleanup | sidecar removed |
| D | D1 footer shows wt | `⎇ worktree-...` rendered |
| D | D2 footer hides after exit | indicator disappears |
| E | E1 dialog on 2nd Ctrl+C | dialog visible, alive |
| E | E2 dirty-state counts | commits + files shown |
| E | E3 Cancel | dialog gone, session alive |
| E | E4 Keep | session exits, wt preserved |
| E | E5 Remove | session exits, wt deleted |
| F | F1 full workflow | file in wt, persists after keep |
| F | F2 custom statusline | worktree payload received, footer suppressed |