* feat(worktree): Phase D — startup --worktree flag + symlinkDirectories + PR refs
Three cross-cutting capabilities on top of the Phase A-C worktree
foundation (PRs #4073, #4174).
D-1: --worktree [name] CLI flag creates a worktree (or re-attaches to
one that already exists) before any model turn runs. Supports bare,
plain-slug, `=`, and PR-reference forms; --worktree + --acp rejected
with a clear error; --worktree + --resume overrides the resumed
session's saved sidecar and emits a stderr line.
D-2: worktree.symlinkDirectories: string[] settings key opts into
symlinking main-repo directories (e.g. node_modules) into every
newly-created general-purpose worktree. Applies to all three creation
paths: --worktree flag, EnterWorktreeTool, AgentTool isolation. Path
traversal, absolute paths, and existing destinations all guarded;
missing source dirs and EEXIST silently skipped (fail-open).
D-3: --worktree=#<N> / --worktree <github-url> resolves a PR number,
runs `git fetch origin pull/<N>/head` (30s timeout, no `gh` CLI
dependency, LANG=C for stable error-taxonomy matching), and creates
the worktree off FETCH_HEAD. URL regex tolerates /files, /commits,
/checks sub-paths so users can paste any GitHub PR URL.
Phase 6 verification fixes also included:
- Re-attach to an existing worktree instead of failing with "Worktree
already exists" — the common `qwen --resume <sid> --worktree foo`
workflow now succeeds. The session ownership marker is preserved on
re-attach so cross-session exit_worktree action="remove" still fails
for non-owners.
- Normalize path-taking argv fields (mcpConfig, jsonSchema @<path>,
openaiLoggingDir, jsonFile, inputFile, telemetryOutfile,
includeDirectories) to absolute paths against the launch cwd BEFORE
the worktree chdir. Otherwise downstream fs.existsSync('./mcp.json')
resolves into the worktree, where the file doesn't exist.
Phase 7 code-review fixes:
- buildStartupWorktreeNotice differentiates "Active worktree" (fresh
create) from "Re-attached to worktree" (re-attach path).
- Notice survives sidecar persist failure: set before the try block,
refreshed inside with override addendum if persist succeeded.
- getRegisteredWorktreeBranch verifies the candidate path's git
common-dir matches the source repo's — rejects sibling `git init`
directories that happen to be on a worktree-<slug> branch.
Three-mode parity for the startup notice: TUI consumes via
AppContainer effect, headless prepends a <system-reminder> + emits a
worktree_started JSON event. ACP path is mutually exclusive with
--worktree (ACP hosts supply per-session cwd separately).
Tests (66 + 15 new):
- 15 cli/src/startup/worktreeStartup.test.ts (slug forms, PR fetch
against local fake remote, re-attach happy + wrong-branch guard)
- 8 core/src/services/gitWorktreeService.test.ts (parsePRReference:
#N, URLs, malformed, traversal, leading zeros, non-string)
- 10 core/src/services/gitWorktreeService.symlinks.integ.test.ts
(symlink loop + fetchPullRequestRef error taxonomy)
Known limitations (documented in docs/users/features/worktree.md):
- Cross-slug --resume <sid> --worktree <different-new-slug> is
unsupported by design (sessions are bound to projectHash(cwd));
future Config refactor anchoring storage at repo root would lift this.
- Mid-session enter_worktree still does NOT switch cwd/targetDir
(Phase A's simplification); only the startup --worktree flag does.
- yargs ambiguity: `qwen --worktree "say hi"` consumes the prompt as
the slug. Quick Start shows the `=` form and reordering workarounds.
Docs:
- docs/users/features/worktree.md (new): Quick Start with --worktree
flag, CLI Reference table for all four input forms + error codes,
settings table, Limitations.
- docs/design/worktree.md: Phase D section expanded into D-1/D-2/D-3
with open questions resolved; capability table updated.
- docs/e2e-tests/worktree-phase-d.md (new): full E2E plan with Phase 4
dry-run baseline + Phase 6 post-impl reproduction tables.
Refs #4056
* refactor(worktree): apply self-review feedback on Phase D
Self-review pass over the Phase D commit (2636f59273) catching one real
typecheck regression plus a batch of small quality + efficiency
improvements. No user-visible behavior change beyond fixing the build.
Build fix:
- worktreeStartup.ts imports — pre-commit prettier had reorganized
`writeWorktreeSession` and `readWorktreeSession` under an
`import type { ... }` block, erasing them at compile time
(verbatimModuleSyntax). `tsc --noEmit` was failing with TS1361.
Bundle path still worked (esbuild is lenient) so this only surfaced
when running typecheck.
Startup-path efficiency (~10-25 ms saved per --worktree invocation on
macOS; more on Windows):
- Drop redundant `isGitRepository()` probe — `getRepoTopLevel()`
returns null on non-git paths and covers both gates in one
subprocess.
- Run `getCurrentBranch()` + `getCurrentCommitHash()` in parallel via
Promise.all (independent calls).
- Combine the two `git rev-parse` probes inside
`getRegisteredWorktreeBranch` into a single multi-arg call, and run
it in parallel with the source-repo common-dir lookup. Saves one
fork+exec on the re-attach path.
Quality:
- Extract `withReminder()` local helper in nonInteractiveCli.ts so the
startup-notice and resume-restore branches share the system-reminder
wrapping.
- Log `readWorktreeSession` failures in `persistStartupWorktreeSidecar`
with the sidecar path so operators can recover the previous slug
from a backup. Silent swallow was making "where did my worktree
binding go?" undebuggable.
- Drop the dead `Config.getWorktreeSettings()` accessor (only
`getWorktreeSymlinkDirectories()` has callers); keep the underlying
`WorktreeSettings` interface for future fields.
- Document the `pendingStartupWorktreeNotice` invariant: at most one
consumer per process; ACP path is gated out earlier so only TUI XOR
headless reads it.
- Add a maintainer note in the gemini.tsx path-normalization block:
the argv path-field allowlist is hand-maintained, register new
path-bearing flags there or `--worktree` silently breaks for them.
- Drop `Phase 6 fix (G1)/(G2)` parenthetical labels from inline
comments — internal review-cycle identifiers that decay to noise
post-merge. Substantive prose retained.
Tests: cli 15/15 (unchanged) + core 66/66 (unchanged); bundle smoke
verified fresh / re-attach / invalid slug / non-git cases.
Findings deliberately left for follow-up:
- Larger refactor extracting a shared `provisionUserWorktree` helper
for the EnterWorktreeTool / startup overlap (~80% duplicate).
- Splitting the re-attach branch out of `setupStartupWorktree` into
its own function.
- `isPathWithinRoot` / `isInsideManagedWorktree` shared utils.
- `symlinkConfiguredDirectories` loop concurrency (saves 5-15 ms on a
cold path that runs only when symlinkDirectories is configured).
* docs(worktree): refresh stale docstring in worktreeStartup
Top-of-file docstring still said `{adj}-{noun}-{4hex}` (actual format
is 6 hex chars) and described the PR form as "detected and rejected
with a clear 'coming in D-3' message" — but D-3 shipped in the same
PR. Tighten to reflect what the code actually does.
* fix(worktree): address findings from dual-reviewer self-check
Two real bugs surfaced by an independent dual-reviewer pass (Claude +
Codex) on the Phase D commits. Both correctness-affecting; both
escaped the earlier internal reviews.
P0 — re-attach captured the wrong baseline for the exit dialog
(Codex):
setupStartupWorktree captured `originalHeadCommit` from the launch
cwd (main checkout) before any chdir. On the re-attach path the
WorktreeExitDialog later runs `git rev-list <originalHeadCommit>..HEAD`
inside the worktree to count "new commits this session". With the
main-checkout baseline this counted every commit ever made in the
kept worktree as new work from the current session — misleading the
keep/remove prompt. Re-capture HEAD from inside the worktree after
chdir so the count means what the dialog text says it means.
P0 — getRegisteredWorktreeBranch mis-identified plain directories as
registered worktrees (Claude):
A plain directory at `<repo>/.qwen/worktrees/<slug>/` (e.g. a stale
artifact from a previous tool) had no `.git` file of its own, so
`git rev-parse --git-common-dir` walked up to the outer repo and
returned the outer common-dir — matching the source repo's
common-dir check and impersonating a registered worktree. If the
outer repo happened to be on `worktree-<slug>`, setupStartupWorktree
would silently chdir into the plain directory and treat it as
attached; subsequent `exit_worktree action="remove"` would then
delete a directory that was never registered.
Fix: also probe `--show-toplevel` and require it to equal the
candidate path (canonicalised via `realpath` so macOS /var → /private/var
doesn't break the equality check). A plain dir under the main repo
gets the outer repo's toplevel and is correctly rejected.
Smaller polish from the same review:
- Normalize the literal string `'HEAD'` returned by `getCurrentBranch`
on detached HEAD to `undefined`, so the `baseRef` handed to
`git worktree add -b … HEAD` does not implicitly anchor against
the loose commit when the launch cwd is detached.
- `symlinkConfiguredDirectories`: blocklist `.git` (any nested
ancestor) and `.qwen/worktrees` (any nested ancestor). Linking
`.git` would silently break commits inside the worktree; linking
`.qwen/worktrees` would create a worktrees-inside-worktrees loop
that confuses the startup sweep.
- `WorktreeSettings.symlinkDirectories` typed `readonly string[]` to
match the `createUserWorktree(options.symlinkDirectories)` contract
and the immutable-config convention elsewhere. `Config.getWorktreeSymlinkDirectories()`
return type updated to match.
Docs:
- design/worktree.md precedence table rewritten. The previous
`--worktree` 赢 row was unreachable in practice (sessions are bound
to `projectHash(cwd)`, and the chdir happens before session lookup).
New table reflects what actually happens for each combination of
`--resume` × `--worktree`, including the documented
cross-projectHash limitation. The `persistStartupWorktreeSidecar`
override branch is now annotated as dead-on-the-current-architecture
but kept so a future Config refactor (anchor storage at repo root)
picks it up for free.
Tests: cli 15/15 + core 66/66 unchanged. Bundle smoke confirms both
P0 fixes end-to-end (re-attach captures worktree HEAD = run-1 tip,
plain-dir attempt errors out without clobbering existing content).
* refactor(worktree): consolidate probe + name detached-HEAD sentinel
Second /simplify pass on the dual-reviewer fixes. Three convergent
findings; net effect is one fewer subprocess on the re-attach path
and clearer intent on string handling / blocklist guards.
Efficiency + quality:
- Fold the worktree HEAD SHA into `getRegisteredWorktreeBranch`'s
combined rev-parse. The probe already requests common-dir,
toplevel, and abbrev-ref HEAD in a single subprocess; adding a
leading `HEAD` positional (which must come BEFORE `--abbrev-ref` so
the flag doesn't apply to it) returns the SHA on its own line.
Return type widened to `{ branch, headCommit } | null`. Removes
the second `GitWorktreeService` instantiation and `getCurrentCommitHash`
call that `setupStartupWorktree`'s re-attach branch used to do.
Quality:
- Hoist `'HEAD'` to a module-level `DETACHED_HEAD` constant in
`worktreeStartup.ts`. Three uses, two meanings (input filter when
normalizing `getCurrentBranch` output, fallback metadata for the
sidecar's `originalBranch` field on detached state). Naming the
sentinel makes intent self-documenting and pre-empts the "why is
the value we just stripped re-appearing as a fallback?" reader stall
flagged by the round-3 quality review.
Reuse + quality:
- `symlinkConfiguredDirectories`: replace two hand-rolled containment
checks (`startsWith(prefix + sep)` for `.qwen/worktrees`; `path.relative(...).split(sep)[0]`
for `.git`) with `isWithinRoot` from `utils/fileUtils.ts`, which is
already imported in this file. Replace the hardcoded
`path.join(repoRootAbs, '.qwen', 'worktrees')` with `this.getUserWorktreesDir()`
so the layout lives in one place (the exported `WORKTREES_DIR`
constant). Split the misleading `sourceAbs === repoRootAbs` clause
out of the `.git` branch into its own dedicated "empty / repo-root
path" rejection with a clearer warn message.
Tests: cli 15/15 + core 66/66 unchanged. Bundle smoke verified the
folded probe still captures the worktree's HEAD on re-attach (not
the launch-cwd HEAD).
Skipped from this review pass:
- Moving `'HEAD'` normalization into `GitWorktreeService.getCurrentBranch()`
itself — would ripple through `enter-worktree.ts` and `agent.ts`
callers that hand the result verbatim to `git worktree add -b ...`.
Out of scope for a polish pass; the local const is enough.
* fix(worktree): broaden symlink blocklist from .qwen/worktrees to all of .qwen
Caught by a second pr-tracker dual-reviewer pass (Codex). The previous
guard at `symlinkConfiguredDirectories` only refused paths inside
`<repoRoot>/.qwen/worktrees/` — `.qwen` itself (the parent) sailed
through because `isWithinRoot` is a strict descendant check. A user
setting `symlinkDirectories: ['.qwen']` would therefore symlink the
entire CLI metadata tree into the new worktree, recursively pulling
in `.qwen/worktrees` and recreating the loop the guard was meant to
prevent. Other `.qwen/*` subtrees (`projects`, `tmp`, …) are CLI
state with no legitimate cross-worktree sharing use case either.
Fix: broaden the guard to reject the whole `<repoRoot>/.qwen` tree.
Both `.qwen` itself and any descendant fail closed.
Also synced the user-facing settings schema description (the in-IDE
help text and the published JSON schema) so it mentions the `.git`
and `.qwen` rejection rules. The `WorktreeSettings` interface JSDoc
already mentioned them; the schema description had not been updated.
Tests: cli 15/15 + core 66/66 unchanged. Smoke confirms `--worktree foo`
with `symlinkDirectories: ['.qwen']` configured leaves the worktree
free of any `.qwen` symlink (only the legitimate per-worktree
`.qwen-session` marker file appears).
* fix(worktree): guard fetchPullRequestRef against CodeQL command-injection alert
CodeQL flagged a "Second order command injection" finding (rule 235) on
the `git fetch origin pull/<N>/head` call in `fetchPullRequestRef`. The
taint analyzer doesn't see the type-narrowing at the function entry
(`Number.isSafeInteger(prNumber) && prNumber > 0 && prNumber <= 1e9`),
so it considers `prNumber` library input that could in principle reach
a `--upload-pack=…`-shaped flag and thereby execute an arbitrary
program. In practice the entry guard already prevents that, but the
alert blocks the CodeQL CI check.
Add `--end-of-options` between `origin` and the refspec — git's
canonical "stop parsing flags" marker (git ≥ 2.24). Tells git
definitively that every subsequent argv element is a positional, not
a flag, which (a) satisfies the analyzer, (b) adds defense-in-depth
against a future regression that might relax the entry guard, and
(c) has zero behavior change for any well-formed PR number.
Verified locally: `git fetch --end-of-options origin pull/<N>/head`
against a local bare-remote with a seeded `refs/pull/42/head` still
fetches the ref correctly; the `--worktree=#42` smoke test reads back
the PR content from the materialized worktree.
Tests: cli 15/15 + core 66/66 unchanged.
* fix(worktree): lexical sanitizer for CodeQL + missing test mock entry
Two fixes from the third CI round on PR #4381:
1. CodeQL re-fires (round 2 of the same finding).
`--end-of-options` is a git-runtime defense, not a lexical sanitizer
that CodeQL's `js/second-order-command-line-injection` taint tracker
recognises. The alert re-fired against the same call after the
previous fix.
Switch to a CodeQL-recognised sanitizer: validate the numeric
component against `/^[1-9][0-9]*$/` immediately at the sink. The
regex digit-only check is one of the documented sanitizer patterns
the rule looks for, and proves at the analyzer level that the
resulting argv element cannot resemble a flag (`--foo`). The entry
guard at the top of the function still establishes the same fact
at runtime; this layer makes the proof visible to static analysis.
Keep `--end-of-options` as a runtime fallback against any future
regression that loosens the entry guard.
2. `nonInteractiveCli.test.ts` mock was missing the new
`consumePendingStartupWorktreeNotice` Config method.
Phase D-1 added the method on `Config` and `nonInteractiveCli`
calls it on every prompt to pick up the one-shot startup-worktree
notice. The test file's `mockConfig` literal was not updated, so
all 19 `runNonInteractive` tests threw
`TypeError: config.consumePendingStartupWorktreeNotice is not a
function` on Ubuntu / macOS CI.
Add a stub returning `null` so the helper short-circuits, matching
the equivalent Phase C stub for `getResumedSessionData`.
Local: cli (worktreeStartup + nonInteractiveCli) 60 passed + 1
skipped; core (gitWorktreeService + symlinks + hooks +
enter-worktree) 66 passed.
* test(worktree): mock getWorktreeSymlinkDirectories in three more test files
Round 4 of the same Phase D-2 mock-drift class. CI surfaced 9 test
failures across three files whose `Config` mocks construct
`EnterWorktreeTool` for setup but lack the new
`getWorktreeSymlinkDirectories` method `createUserWorktree` now
calls:
- enter-worktree.session.integ.test.ts (2 tests)
- exit-worktree.session.integ.test.ts (3 tests) — provisions
worktrees via EnterWorktreeTool before exercising exit paths
- exit-worktree.test.ts (4 tests) — same provisioning pattern via
`provisionWorktree()` and the `makeMockConfig` helper
Add a `getWorktreeSymlinkDirectories: () => []` stub to each so
the symlink loop is a no-op in tests.
`enter-worktree.test.ts` and `agent/agent.test.ts` intentionally
skipped — they mock `GitWorktreeService.createUserWorktree` outright,
so the method call never fires in their code paths. Adding the stub
there would be defensive speculation. If a future test exercises
the real path, it'll surface there too and we'll add it then.
Local: core tools tests now 123 passed (was 9 failed / 114 passed
on CI run 26213122427 against commit 000c9f63).
* fix(worktree): normalize repoRoot path separators + disable autocrlf in tests
Round 5 of CI: Windows-only test failures on the latest HEAD. Two
unrelated Windows-specific bugs, both in / around worktreeStartup.
1. `setupStartupWorktree` stored the raw `getRepoTopLevel()` output
in `context.repoRoot`. git always emits POSIX paths via
`--show-toplevel` (`C:/Users/...`), so on Windows the value was
forward-slash where `fs.realpath` and `path.join` produce
backslash. The sidecar's `originalCwd` field got the
inconsistent format and a downstream `expect(...).toBe(tempRepo)`
in the round-trip test compared `C:/Users/.../tmp/...` against
`C:\Users\.../tmp/...`.
Wrap the value in `path.resolve()` to normalize to the
platform-native separator before storing. Downstream consumers
(`path.join(session.originalCwd, '.qwen', 'worktrees')` in
`restoreWorktreeContext`, `new GitWorktreeService(originalCwd)`
in `AppContainer`) already handle either format, so no migration
concern for older sidecars.
2. `makeTempRepo` in worktreeStartup.test.ts didn't configure
`core.autocrlf=false`. On Windows runners the default is `true`,
so files committed and pushed to the test's fake-remote `pull/<N>/head`
ref get CRLF-converted on the worktree's checkout. The PR-content
assertion `expect(prFile).toBe('from PR 42\n')` then failed with
`'from PR 42\r\n'`.
Add `core.autocrlf=false` + `core.eol=lf` to the temp-repo setup
so test files round-trip byte-for-byte regardless of host platform.
Local mac: cli worktreeStartup 15/15 still pass. Windows verification
deferred to CI.
* fix(worktree): reject '..' segments + use junction on Windows
Two Copilot findings on symlinkConfiguredDirectories (PR #4381 round 3):
1. The settingsSchema description, docs/users/features/worktree.md, and
WorktreeSettings JSDoc all promise that entries containing `..` are
rejected — but the post-resolve isWithinRoot check accepted
`foo/../bar` (resolves to `bar`, inside the repo). Add a literal `..`
segment check before path.resolve so the code matches the contract.
2. On Windows, fs.symlink(..., 'dir') requires
SeCreateSymbolicLinkPrivilege (admin / Developer Mode) and EPERMs on
default consumer installs. Use 'junction' for directory entries on
win32 — junctions are reparse points that achieve the same semantics
without elevation. Keep 'dir' on POSIX and 'file' for non-directory
sources (no junction-equivalent for files; rare path).
Adds an integration test exercising `foo/../bar` to lock in the
syntactic guard; existing absolute-path and traversal tests already
covered the other rejection forms.
* fix(worktree): PR-worktree HEAD-SHA capture + symlink guard tests
Three findings from wenshao round 4 (PR #4381):
1. For --worktree=#42 (PR worktrees), originalHeadCommit was captured
from the parent repo's HEAD via getCurrentCommitHash() — but the
worktree branches off FETCH_HEAD (the PR tip), not main. Downstream,
WorktreeExitDialog's `rev-list <originalHeadCommit>..HEAD` would
count every commit in the fetched PR as "new work this session"
alongside the user's actual commits.
Same root cause covers the FETCH_HEAD TOCTOU window: between
`git fetch origin pull/<N>/head` and `git worktree add ... FETCH_HEAD`,
a concurrent `git fetch` from any other process sharing this repo
could overwrite .git/FETCH_HEAD, causing the worktree to branch off
an unrelated commit.
Fix: add GitWorktreeService.resolveRef(ref) that returns a 40-char
SHA (or null). In setupStartupWorktree, immediately after
fetchPullRequestRef succeeds, resolve FETCH_HEAD to an immutable
SHA; pass that SHA both as the baseRef to createUserWorktree (closes
the TOCTOU) AND as originalHeadCommit in the returned context
(closes the exit-dialog miscount). Fail-close on null resolve.
2. Orphaned JSDoc block at gitWorktreeService.ts:1035-1048 — originally
wrote validateUserWorktreeSlug's docs, stranded above parsePRReference
after that function was inserted between them. Move the block down to
sit immediately above validateUserWorktreeSlug at its current line.
3. `.git` / `.qwen` symlink rejection guards (~20 lines of security-
critical code at gitWorktreeService.ts:1640-1655) had no regression
tests — only absolute paths, `..` traversal, isWithinRoot escapes,
and missing sources were covered. Add two integ tests in
gitWorktreeService.symlinks.integ.test.ts: one asserts `.git/hooks`
is refused, one asserts `.qwen/projects` is refused.
Also extends the existing PR-worktree integration test in
worktreeStartup.test.ts to assert originalHeadCommit equals the
resolved FETCH_HEAD SHA AND does NOT equal the parent repo's main HEAD
— the assertion would fail loudly if the new SHA-capture path were
reverted.
* fix(worktree): realpath check on symlinkDirectories source + dest paths
Security fix from PR #4381 round 7 (wenshao/qwen3.7-max). The lexical
isWithinRoot + .git/.qwen blocklist checks in symlinkConfiguredDirectories
all operated on path.resolve(repoRoot, raw) — a STRING operation that
doesn't follow symlinks. A committed (or out-of-band) symlink at
<repo>/node_modules pointing into .git would pass every gate:
1. path.resolve gives `<repo>/node_modules` (lexical, passes
isWithinRoot against repo root).
2. The .git/.qwen blocklists also see the lexical path — they don't
detect that the realpath chains into .git.
3. fs.stat() follows the symlink and succeeds against .git/.
4. fs.symlink writes `<worktree>/node_modules → <repo>/node_modules`,
which OS-side resolves through to <repo>/.git. Any tool inside the
worktree that writes to node_modules/hooks/post-merge then has RCE
on the next hook-firing git operation.
Fix: after fs.stat succeeds, fs.realpath the source and RE-RUN the three
containment checks against the realpath. Refuse on any escape. Use the
realpath (not the lexical sourceAbs) as the symlink target so the new
link is one-hop canonical rather than preserving the chain.
Also closes the dest-side variant of the same root cause — flagged in
round 4 thread #5 (declined then as overthinking) but now in scope per
the skill's iteration rule (two consecutive rounds raising the same
root-cause class). path.join(worktreePath, raw) is also lexical: if
git worktree add materialized a committed worktree-level symlink (e.g.
HEAD ships tools → /etc), then fs.mkdir / fs.symlink for a nested entry
like "tools/cache" writes OUTSIDE the worktree. Realpath the dest
parent before mkdir and refuse if it escapes the worktree.
New integ test covers both source-side variants (escape-to-git via
out-of-band symlink + escape-to-outside-dir) in one block. Was RED
against the pre-fix code: <wt>/escape-to-git was created as a symlink
that chained into the source repo's .git. GREEN after the fix.
* fix(worktree): canonicalise repo root before symlinkDirectories checks
Round-7's source-side realpath fix introduced a canonical-vs-lexical
mismatch: `repoRootAbs = path.resolve(this.sourceRepoPath)` is purely
lexical, while `realSource = await fs.realpath(sourceAbs)` is canonical.
On macOS where `/tmp → /private/tmp` and `/var → /private/var` are
ubiquitous, and on any Linux/Windows setup where the user's checkout
sits behind a symlink, the prefixes diverge at the symlink boundary and
`isWithinRoot(realSource, repoRootAbs)` silently rejects every
configured entry.
Production callers (worktreeStartup.ts, EnterWorktreeTool,
agent isolation) all pass the lexical path returned by
`git rev-parse --show-toplevel`. The integ tests masked the bug because
the shared `beforeEach` did `repoRoot = await fs.realpath(dir)` upfront.
Round 8 fix:
- Hoist `repoRootAbs`, `gitDirAbs`, `qwenDirAbs`, and `realWorktreePath`
outside the for-loop — they're loop invariants and were being
recomputed once per entry.
- `await fs.realpath(this.sourceRepoPath)` for `repoRootAbs` so every
containment check below is canonical-vs-canonical. The derived
`gitDirAbs` / `qwenDirAbs` blocklist paths inherit the canonical
prefix automatically. `sourceAbs = path.resolve(repoRootAbs, raw)`
inherits it too, so the early lexical reject paths (absolute, `..`,
repo-root equality, isWithinRoot) stay self-consistent.
- Fail-close: if the repo root itself doesn't realpath (deleted /
inaccessible), bail out of the entire symlink loop rather than
continuing with comparisons we can't trust. Non-destructive — the
worktree was created earlier by `git worktree add`.
New integ test provisions the production shape: a symlink path used
as `sourceRepoPath`, distinct from its canonical realpath. RED on the
pre-fix code (assertion fired with "symlinkDirectories entry was
silently rejected — canonical vs lexical isWithinRoot mismatch"),
GREEN after.
44 KiB
Worktree Phase D E2E Test Plan
Scope
End-to-end verification of Phase D features against the local build at
/Users/mochi/code/qwen-code/.claude/worktrees/tender-jemison-037f0a/dist/cli.js.
Phase D delivers three cross-cutting capabilities:
- D-1 —
--worktree [name]CLI startup flag (bare / explicit slug /=form), withprocess.cwd()+Config.targetDirswitch andWorktreeExitDialogreuse on exit - D-2 —
worktree.symlinkDirectories: string[]settings key, applied inperformPostCreationSetup()so it covers--worktree,EnterWorktreeTool, ANDAgentTool isolation: "worktree"paths - D-3 —
--worktree=#<N>and--worktree <github-url>PR-reference forms, viagit fetch origin pull/<N>/head(noghCLI dependency)
Binaries
- Local build (Phase 6 verification):
node /Users/mochi/code/qwen-code/.claude/worktrees/tender-jemison-037f0a/dist/cli.js - Phase 4 dry-run baseline: globally installed
qwen
For dry-runs the globally installed qwen is expected to fail Groups A / E / F
because the features don't exist yet — that's the validation that the plan
correctly detects implementation.
Baseline precondition for Group E
Tests E2 (EnterWorktreeTool symlink) and E3 (AgentTool isolation
symlink) require Phase A + B to be present in the baseline — they exercise
the existing enter_worktree tool and agent isolation: "worktree" parameter
to confirm the symlink loop fires on those code paths too.
The globally installed qwen may predate PR #4073 (Phase A+B, merged 2026-05-14)
and therefore lack these tools entirely. When that is the case, E2 / E3 cannot
validate "symlink absent because D-2 is absent" — they collapse to "tool
absent." Add this guard at the top of each:
HAS_ENTER_WORKTREE=$($QWEN "list your tools and stop" --approval-mode yolo --output-format json 2>/dev/null \
| jq -e '.[] | select(.type=="system") | .tools | index("enter_worktree")' >/dev/null && echo yes || echo no)
if [ "$HAS_ENTER_WORKTREE" != "yes" ]; then
echo "SKIP: enter_worktree absent in baseline — E2/E3 require Phase A+B"
exit 0
fi
For Phase 6 (post-impl) verification the local build inherently contains Phase A-C, so the guard is a no-op and the tests run in full.
Test environment template
Each group runs in its own temp git repo and tmux session:
TEST_DIR=$(mktemp -d -t qwen-wt-phd-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
PROJECT_ID=$(node -e "console.log(process.argv[1].replace(/[^a-zA-Z0-9]/g,'-'))" "$TEST_DIR")
QWEN="node /Users/mochi/code/qwen-code/.claude/worktrees/tender-jemison-037f0a/dist/cli.js"
PR-ref tests (Group F) additionally require a checked-out clone of a public
GitHub repo with at least one merged PR. Use this repo (qwen-code itself) as
the test target — PR #4174 (Phase C) is a guaranteed-present reference.
Group A: --worktree flag basic forms
Mode: headless, --approval-mode yolo, --output-format json
A1: bare --worktree (auto-slug)
$QWEN --worktree "say hello and stop" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/a1.out
# A `worktree_started` system event is emitted at startup. The `notice`
# field contains the slug (auto-generated `adj-noun-XXXXXX`) inside the
# rendered text. Use `jq -e` so a missing event is a non-zero exit
# (instead of silent `null`).
jq -e '.[] | select(.type=="system" and .subtype=="worktree_started") | .data.notice | test("\"[a-z]+-[a-z]+-[0-9a-f]{6}\"")' < /tmp/a1.out
# The init system message's `cwd` should also point inside the worktree.
jq -e '.[] | select(.type=="system" and .subtype=="init") | .cwd | test("/\\.qwen/worktrees/[a-z]+-[a-z]+-[0-9a-f]{6}$")' < /tmp/a1.out
ls -d "$TEST_DIR/.qwen/worktrees/"*
Expected (post-impl):
worktree_startedevent with.data.noticecontaining the auto slug- Init
.cwdends with.qwen/worktrees/<auto-slug> - Exactly one worktree directory under
.qwen/worktrees/ - Branch named
worktree-<slug>exists (git branch | grep worktree-)
Expected (pre-impl baseline): yargs rejects --worktree with
"Unknown argument" error and exit code != 0.
A2: --worktree my-feature (explicit slug)
$QWEN --worktree my-feature "say hello and stop" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/a2.out
ls -d "$TEST_DIR/.qwen/worktrees/my-feature"
git -C "$TEST_DIR" branch | grep "worktree-my-feature"
Expected (post-impl): worktree dir my-feature/ and branch
worktree-my-feature both exist.
A3: --worktree=my-feature (= form)
Identical to A2 with = form. Cleanup between A2 and A3 required (different
TEST_DIR).
$QWEN --worktree=my-feature "say hi" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/a3.out
Expected (post-impl): same as A2.
A4: invalid slug rejected before any git operation
$QWEN --worktree "../escape" "say hi" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/a4.out
echo "exit=$?"
ls "$TEST_DIR/.qwen/worktrees/" 2>/dev/null
Expected (post-impl):
- Process exits with non-zero status
- Stderr or final result message mentions "invalid slug" / "not allowed"
.qwen/worktrees/directory does not exist (worktree creation never started)
A5: not a git repository → fail-close
NON_GIT=$(mktemp -d)
cd "$NON_GIT"
$QWEN --worktree "say hi" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/a5.out
echo "exit=$?"
Expected (post-impl): exit != 0, message mentions "not a git repository" or "git init".
Group B: cwd + sidecar after --worktree
B1: sidecar written with all six fields
SESSION_ID=$(uuidgen)
$QWEN --worktree b1-test --session-id "$SESSION_ID" "say hi" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/b1.out
SIDECAR=~/.qwen/projects/$PROJECT_ID/chats/$SESSION_ID.worktree.json
jq '.slug, .worktreePath, .worktreeBranch, .originalCwd, .originalBranch, .originalHeadCommit' \
< "$SIDECAR"
Expected:
slug = "b1-test"worktreePathends with.qwen/worktrees/b1-testworktreeBranch = "worktree-b1-test"originalCwd=$TEST_DIR(resolved)originalBranch = "main"originalHeadCommitmatches[0-9a-f]{40}
B2: process.cwd() switched at startup
$QWEN --worktree b2-test "run the shell tool with command 'pwd', then stop" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/b2.out
# Extract the shell tool's stdout from the user-message tool_result
jq -r '.[] | select(.type=="user") | .message.content[] | select(.tool_use_id != null) | .content' \
< /tmp/b2.out | head -5
Expected (post-impl): the pwd output equals $TEST_DIR/.qwen/worktrees/b2-test.
B3: Config.targetDir switched (Footer / status payload)
$QWEN --worktree b3-test "run the shell tool with command 'pwd && git rev-parse --abbrev-ref HEAD', then stop" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/b3.out
jq -r '.[] | select(.type=="user") | .message.content[] | select(.tool_use_id != null) | .content' \
< /tmp/b3.out
Expected (post-impl): branch is worktree-b3-test AND working directory
is inside the worktree.
Group C: --worktree × --resume precedence
C1: --worktree wins over saved sidecar (different slug)
# Run 1: create a session with worktree "first"
SESSION_ID=$(uuidgen)
$QWEN --worktree first --session-id "$SESSION_ID" "say hi" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/c1-run1.out
# Run 2: resume the same session but request a different worktree
$QWEN --resume "$SESSION_ID" --worktree second "say hi again" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/c1-run2.out
# Sidecar should now point at "second"
SIDECAR=~/.qwen/projects/$PROJECT_ID/chats/$SESSION_ID.worktree.json
jq -r '.slug' < "$SIDECAR"
# Both worktree dirs should exist on disk (first was never removed, just unlinked)
ls -d "$TEST_DIR/.qwen/worktrees/"*
Expected (post-impl):
- Sidecar
.slug="second" - Both
first/andsecond/directories exist - Run 2's stderr or init
worktree_overriddenmessage mentions "--worktree overrides the resumed session's worktree"
C2: stale sidecar (manually deleted dir) + --worktree → fresh worktree
SESSION_ID=$(uuidgen)
$QWEN --worktree c2 --session-id "$SESSION_ID" "say hi" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/c2-run1.out
rm -rf "$TEST_DIR/.qwen/worktrees/c2" # simulate user-deleted dir
$QWEN --resume "$SESSION_ID" --worktree c2-fresh "say hi" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/c2-run2.out
ls -d "$TEST_DIR/.qwen/worktrees/"*
Expected (post-impl): only c2-fresh/ exists; sidecar updated to c2-fresh.
Group D: WorktreeExitDialog regression (--worktree-started session)
Mode: interactive (tmux). Verifies Phase C dialog still triggers when the
worktree was created by the CLI flag rather than EnterWorktreeTool.
D1: 2x Ctrl+C → dialog appears
tmux new-session -d -s d1 -x 200 -y 50 \
"cd $TEST_DIR && $QWEN --worktree d1-test --approval-mode yolo"
sleep 3
# Verify worktree is active (Footer indicator)
tmux capture-pane -t d1 -p -S -50 | grep -q "⎇ worktree-d1-test"
# Send Ctrl+C twice
tmux send-keys -t d1 C-c
sleep 0.3
tmux send-keys -t d1 C-c
sleep 1
tmux capture-pane -t d1 -p -S -50 | grep -E "Active worktree|Keep worktree|Remove worktree"
tmux kill-session -t d1
Expected (post-impl): dialog text "Active worktree: "d1-test" …" and the three radio options appear.
D2: Dialog → Cancel → session stays alive
tmux new-session -d -s d2 -x 200 -y 50 \
"cd $TEST_DIR && $QWEN --worktree d2-test --approval-mode yolo"
sleep 3
tmux send-keys -t d2 C-c; sleep 0.3; tmux send-keys -t d2 C-c; sleep 1
# Navigate to "Cancel" (third option) and select
tmux send-keys -t d2 Down Down Enter
sleep 1
tmux capture-pane -t d2 -p -S -10 | grep -q "Type your message"
ls -d "$TEST_DIR/.qwen/worktrees/d2-test" # still exists
tmux kill-session -t d2
Expected (post-impl): prompt input reappears; worktree dir is still on disk.
D3: Dialog → Remove → worktree + branch + sidecar all gone
SESSION_ID=$(uuidgen)
tmux new-session -d -s d3 -x 200 -y 50 \
"cd $TEST_DIR && $QWEN --worktree d3-test --session-id $SESSION_ID --approval-mode yolo"
sleep 3
tmux send-keys -t d3 C-c; sleep 0.3; tmux send-keys -t d3 C-c; sleep 1
tmux send-keys -t d3 Down Enter # select "Remove worktree and branch"
sleep 3
tmux kill-session -t d3
ls "$TEST_DIR/.qwen/worktrees/d3-test" 2>/dev/null && echo "FAIL: dir exists"
git -C "$TEST_DIR" branch | grep "worktree-d3-test" && echo "FAIL: branch exists"
test ! -f ~/.qwen/projects/$PROJECT_ID/chats/$SESSION_ID.worktree.json && echo "PASS: sidecar gone"
Expected (post-impl): dir, branch, and sidecar all removed.
Group E: worktree.symlinkDirectories
Mode: headless. Settings configured via temp settings file.
Setup template
mkdir -p "$TEST_DIR/node_modules"
echo "package.json" > "$TEST_DIR/node_modules/.placeholder"
mkdir -p "$TEST_DIR/.qwen"
cat > "$TEST_DIR/.qwen/settings.json" <<'EOF'
{
"worktree": {
"symlinkDirectories": ["node_modules"]
}
}
EOF
E1: --worktree path applies symlink
$QWEN --worktree e1-test "say hi" \
--approval-mode yolo --output-format json 2>/dev/null > /dev/null
ls -la "$TEST_DIR/.qwen/worktrees/e1-test/node_modules"
readlink "$TEST_DIR/.qwen/worktrees/e1-test/node_modules"
Expected (post-impl): node_modules inside the worktree is a symlink
pointing to $TEST_DIR/node_modules.
E2: EnterWorktreeTool path applies symlink
$QWEN "use enter_worktree to create a worktree named e2-test, then stop" \
--approval-mode yolo --output-format json 2>/dev/null > /dev/null
readlink "$TEST_DIR/.qwen/worktrees/e2-test/node_modules"
Expected (post-impl): same symlink target.
E3: AgentTool isolation path applies symlink
Requires a sub-agent definition. Use the built-in fork mechanism:
$QWEN "use the agent tool with subagent_type='general-purpose', isolation='worktree', description='check node_modules', prompt='run pwd and ls -la node_modules then exit'" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/e3.out
# Extract agent worktree dir from result message
jq -r '.[] | select(.type=="assistant") | .message.content[] | select(.type=="tool_use") | .input' \
< /tmp/e3.out | head -5
# After execution find the agent-<7hex> worktree
ls -la "$TEST_DIR/.qwen/worktrees/"agent-*/node_modules 2>/dev/null | head -3
Expected (post-impl): symlink exists inside the agent-<hex> worktree
(unless auto-cleaned because there were no changes — in that case the
"no changes" path doesn't validate symlink behavior, escalate to a forced
change test).
E4: missing source dir → silently skipped, worktree still created
cat > "$TEST_DIR/.qwen/settings.json" <<'EOF'
{ "worktree": { "symlinkDirectories": ["does-not-exist"] } }
EOF
$QWEN --worktree e4-test "say hi" --approval-mode yolo --output-format json 2>/dev/null > /tmp/e4.out
ls -d "$TEST_DIR/.qwen/worktrees/e4-test"
ls "$TEST_DIR/.qwen/worktrees/e4-test/does-not-exist" 2>/dev/null && echo "UNEXPECTED"
Expected (post-impl): worktree directory exists, the missing entry is not created inside it, process exit = 0.
E5: existing dest → silently skipped, no overwrite
# Pre-create a worktree at expected slug then re-create — this is contrived
# because Phase D paths should be fresh, but it exercises the EEXIST guard.
mkdir -p "$TEST_DIR/.qwen/worktrees/e5-test/node_modules"
echo "preexisting" > "$TEST_DIR/.qwen/worktrees/e5-test/node_modules/.marker"
# Force re-creation via EnterWorktreeTool (CLI would refuse "already exists")
$QWEN "use enter_worktree with name='e5-test' to retry" --approval-mode yolo 2>/dev/null
# either: tool errors out cleanly, OR symlink is skipped — both acceptable
test -f "$TEST_DIR/.qwen/worktrees/e5-test/node_modules/.marker" && echo "PASS: not overwritten"
Expected (post-impl): preexisting .marker survives; no symlink replaces
the dir.
E6: absolute path / ../ → rejected
cat > "$TEST_DIR/.qwen/settings.json" <<'EOF'
{ "worktree": { "symlinkDirectories": ["/etc", "../escape"] } }
EOF
$QWEN --worktree e6-test "say hi" --approval-mode yolo --output-format json 2>/dev/null > /tmp/e6.out
ls "$TEST_DIR/.qwen/worktrees/e6-test/" | head -10
Expected (post-impl): worktree exists; neither etc nor escape linked
inside it; debug log carries warn lines.
Group F: PR reference
Mode: headless. Requires origin remote pointing at a public GitHub repo.
Setup template
# Use qwen-code itself as the test repo
TEST_DIR=$(mktemp -d -t qwen-wt-phd-pr-XXXXXX)
TEST_DIR=$(cd "$TEST_DIR" && pwd -P)
cd "$TEST_DIR"
git clone --depth 1 https://github.com/QwenLM/qwen-code.git .
PROJECT_ID=$(node -e "console.log(process.argv[1].replace(/[^a-zA-Z0-9]/g,'-'))" "$TEST_DIR")
F1: --worktree=#4174 parses + fetches
$QWEN --worktree=#4174 "say hi" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/f1.out
ls -d "$TEST_DIR/.qwen/worktrees/pr-4174"
git -C "$TEST_DIR/.qwen/worktrees/pr-4174" rev-parse --abbrev-ref HEAD
Expected (post-impl):
- Worktree dir
pr-4174/exists - HEAD branch =
worktree-pr-4174 - The branch's tip resolves (git log -1) without error
F2: full URL form
$QWEN --worktree "https://github.com/QwenLM/qwen-code/pull/4174" "say hi" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/f2.out
ls -d "$TEST_DIR/.qwen/worktrees/pr-4174"
Expected (post-impl): same as F1.
F3: missing origin remote → fail-close
cd "$TEST_DIR" && git remote remove origin
$QWEN --worktree=#4174 "say hi" --approval-mode yolo --output-format json 2>/dev/null > /tmp/f3.out
echo "exit=$?"
Expected (post-impl): exit != 0; message mentions origin remote.
F4: invalid PR number → fail-close
$QWEN --worktree=#999999999 "say hi" --approval-mode yolo --output-format json 2>/dev/null > /tmp/f4.out
echo "exit=$?"
Expected (post-impl): exit != 0; message mentions "Failed to fetch PR". 30-second timeout cap respected (test runtime < 35s).
F5: malformed #abc falls through to slug validation
$QWEN --worktree=#abc "say hi" --approval-mode yolo --output-format json 2>/dev/null > /tmp/f5.out
echo "exit=$?"
Expected (post-impl): treated as literal slug #abc, rejected by
validateUserWorktreeSlug because # is not allowed. Exit != 0.
F6: PR worktree gets symlinks too (cross-cut with E)
cat > "$TEST_DIR/.qwen/settings.json" <<'EOF'
{ "worktree": { "symlinkDirectories": ["node_modules"] } }
EOF
mkdir -p "$TEST_DIR/node_modules" && echo x > "$TEST_DIR/node_modules/.marker"
$QWEN --worktree=#4174 "say hi" --approval-mode yolo --output-format json 2>/dev/null > /dev/null
readlink "$TEST_DIR/.qwen/worktrees/pr-4174/node_modules"
Expected (post-impl): symlink target = $TEST_DIR/node_modules.
Group G: Integration + edge cases
G1: full lifecycle — start → write → Keep → resume
Pre-impl note: Against the baseline this test exits before
sleep 3finishes (yargs rejects--worktreeimmediately and the tmux pane dies). Thecapture-panecall then errors with "can't find pane". This is expected — record as PASS-by-rejection. Wrap captures with|| truefor the dry-run, or skip G1 entirely in baseline mode.
SESSION_ID=$(uuidgen)
tmux new-session -d -s g1 -x 200 -y 50 \
"cd $TEST_DIR && $QWEN --worktree g1-test --session-id $SESSION_ID --approval-mode yolo 2>&1 | tee /tmp/g1-stderr.out"
sleep 3
tmux send-keys -t g1 "use the write_file tool to create file 'work.txt' with content 'phase d test'"
sleep 0.3; tmux send-keys -t g1 Enter
sleep 8
tmux send-keys -t g1 C-c; sleep 0.3; tmux send-keys -t g1 C-c; sleep 1
tmux send-keys -t g1 Enter # default = "Keep"
sleep 2
tmux kill-session -t g1
# File survived
cat "$TEST_DIR/.qwen/worktrees/g1-test/work.txt"
# Resume reattaches
tmux new-session -d -s g1b -x 200 -y 50 \
"cd $TEST_DIR && $QWEN --resume $SESSION_ID --approval-mode yolo"
sleep 4
tmux capture-pane -t g1b -p -S -50 | grep -E "⎇ worktree-g1-test|Resumed"
tmux kill-session -t g1b
Expected (post-impl):
work.txtinside the worktree contains the written content- Resumed session Footer shows
⎇ worktree-g1-test (g1-test) - INFO history item or
<system-reminder>mentions "Resumed"
G2: relative path arg resolved before cwd switch
# Create an mcp config in TEST_DIR and reference it relatively.
# --mcp-config takes a file path; if the test plan path is resolved AFTER
# the --worktree cwd switch, the file won't be found inside the worktree
# and the CLI will error out. If resolved BEFORE the switch (correct), the
# file is loaded from TEST_DIR.
cat > "$TEST_DIR/mcp.json" <<'EOF'
{ "mcpServers": {} }
EOF
cd "$TEST_DIR"
$QWEN --worktree g2-test --mcp-config ./mcp.json "say hi" \
--approval-mode yolo --output-format json 2>/dev/null > /tmp/g2.out
echo "exit=$?"
jq -r '.[] | select(.type=="result") | .result' < /tmp/g2.out | head -3
Expected (post-impl): exit = 0; the model responds normally (the empty mcp config means no MCP servers but no error either).
Expected (pre-impl baseline): yargs rejects --worktree (the test
cannot distinguish "worktree flag missing" from "mcp config resolution
broken" until the flag itself exists).
Run order + parallelism
| Group | Mode | Runtime | Parallel-safe? |
|---|---|---|---|
| A | headless | ~30s | yes (own TEST_DIR) |
| B | headless | ~20s | yes |
| C | headless | ~40s | yes |
| D | tmux | ~30s | yes (own session name) |
| E | headless | ~60s | yes |
| F | headless+net | ~60s | NO — shares the GitHub clone |
| G | mixed | ~60s | yes |
Run A/B/C/D/E/G in parallel; F serially after the clone setup.
Reproduction report
Phase 4 dry-run — baseline qwen v0.15.11 (2026-05-20)
Runtime: 3 parallel test-engineer agents, ~7 minutes total. Baseline lacks
both Phase D (expected) and Phase A+B (older binary than expected — see
E2/E3 caveat).
| Group | Result | Notes |
|---|---|---|
| A1 (bare flag) | ✅ | yargs Unknown argument: worktree, exit 1 |
| A2 (explicit slug) | ✅ | same |
| A3 (= form) | ✅ | same |
| A4 (invalid slug) | ✅ | yargs rejects before slug validation |
| A5 (non-git dir) | ✅ | same |
| B1 (sidecar fields) | ✅ | sidecar correctly absent; jq selector valid against sample data |
| B2 (cwd switch) | ✅ | shell-tool tool_result.content jq selector verified against real output |
| B3 (targetDir switch) | ✅ | same selector |
| C1 (--worktree beats sidecar) | ✅ | both runs exit 1, no sidecar |
| C2 (stale sidecar + fresh) | ✅ | same |
| E1 (--worktree symlink) | ✅ | flag rejected, no symlink — pre-impl confirmed |
| E2 (EnterWorktree symlink) | ⚠️ N/A | baseline lacks enter_worktree tool (older than PR #4073); guard now skips this case |
| E3 (AgentTool isolation symlink) | ⚠️ N/A | baseline agent schema silently drops isolation param; guard skips |
| E4 (missing source skip) | ✅ | flag rejected |
| E5 (existing dest not overwrite) | ⚠️ trivial | preexisting .marker survived but only because tool couldn't run |
| E6 (path traversal reject) | ✅ | flag rejected, no symlinks |
| F1 (--worktree=#4174 fetch) | ✅ | Unknown argument: worktree, no network call |
| F2 (full URL form) | ✅ | same |
| F3 (missing origin) | ✅ | rejected before git check |
| F4 (invalid PR number) | ✅ | rejected before fetch |
F5 (#abc malformed) |
✅ | same |
| F6 (PR + symlinkDirs) | ✅ | same |
| G1 (lifecycle tmux) | ⚠️ partial | tmux pane dies on flag rejection; record-by-exit-code works |
| G2 (relative path) | ✅ | (after switching to --mcp-config ./mcp.json) yargs rejects worktree first |
Conclusion: test scripts are fundamentally sound. 19 / 24 cases cleanly detect pre-impl baseline; 3 cases (E2/E3/E5) need the baseline to include Phase A+B (which the local Phase 6 build will provide); 2 cases (G1/G2) had script bugs that are now fixed. Ready to proceed to Phase 5 implementation.
Phase 6 verification — local build
Binary: node /Users/mochi/code/qwen-code/.claude/worktrees/tender-jemison-037f0a/dist/cli.js
Date: 2026-05-20
Scope: Groups A, B, C, E, F, G (6 parallel test-engineer agents)
| Group | Result | Notes |
|---|---|---|
| A1 (bare flag) | ✅ (with doc tip) | yargs consumes the next positional as the slug value when user passes qwen --worktree "say hi"; quickstart now tells users to use = form or put the prompt before the flag. Auto-slug feature itself confirmed via qwen --worktree --approval-mode yolo "say hi" → slug bright-elm-8a4c12, init .cwd ends with .qwen/worktrees/<auto-slug>. |
| A2 (explicit slug) | ✅ | dir .qwen/worktrees/my-feature + branch worktree-my-feature |
| A3 (= form) | ✅ | identical to A2 |
| A4 (invalid slug) | ✅ | exit=1, message: Worktree name may only contain letters, digits, dots, underscores, and hyphens., no worktree dir |
| A5 (non-git dir) | ✅ | exit=1, message: not a git repository. Run \git init` first or relaunch from inside one.` |
| B1 (sidecar fields) | ✅ | All 6 fields present and correct; sidecar lives under worktree projectHash as designed |
| B2 (cwd switch) | ✅ | pwd inside shell tool returned worktree path exactly |
| B3 (branch + cwd) | ✅ | pwd = worktree path, git rev-parse --abbrev-ref HEAD = worktree-b3-test |
| C1 (cross-slug override) | ❌ → known limitation | Sessions are bound to projectHash(cwd); --worktree second --resume <sid-from-first> can't find the session. Documented in user docs Limitations. A future Config refactor (anchor storage at repo root) would lift this. |
| C2 (stale sidecar + new worktree) | ❌ → same root cause | Same architectural constraint. |
E1 (--worktree symlink) |
✅ | node_modules symlinked into the new worktree |
E2 (enter_worktree symlink) |
✅ | same code path via createUserWorktree |
| E3 (agent isolation symlink) | ⚠️ test-setup | model committed node_modules (because the agent guard refused dirty state); EEXIST guard then correctly skipped the symlink. Code path is correct; for a clean E3 the test plan needs to pre-.gitignore node_modules. |
| E4 (missing source skip) | ✅ | worktree created, no entry, exit 0 |
| E5 (existing dest no overwrite) | ✅ | preexisting marker survived |
E6 (absolute / .. rejected) |
✅ | neither path linked |
F1 (--worktree=#4174 fetch) |
✅ | worktree dir pr-4174/, branch worktree-pr-4174, tip commit 8f4fe8e feat(cli): per-turn /diff…; local-remote substitute (sandbox blocks real GitHub) |
| F2 (full URL form) | ✅ | same result; URL parsed → PR #4174 → local origin fetch succeeded |
| F3 (missing origin) | ✅ | exit=1 in 2s; message mentions adding origin remote |
| F4 (invalid PR #999999999) | ✅ | exit=1 in 2s; "PR does not exist on origin"; well within 35s cap |
F5 (malformed #abc) |
✅ | slug validation rejects # |
| F6 (PR worktree + symlinks) | ✅ | symlink pr-4174/node_modules → $TEST_DIR/node_modules confirmed |
| G1.a (start + write + Keep) | ✅ | TUI flow, Footer indicator, dialog options, file persists |
G1.b (--resume … --worktree foo) |
❌ → fixed in this PR | Original: --worktree: Worktree already exists at …. Phase 6 fix added the re-attach branch in setupStartupWorktree. Verified post-fix via smoke test (--worktree foo twice → second emits the worktree_started notice, no error) + new unit tests in worktreeStartup.test.ts. |
G2 (relative --mcp-config) |
❌ → fixed in this PR | Original: exit=52, Invalid MCP configuration … is not valid JSON. Phase 6 fix normalizes path-taking argv fields (mcpConfig, openaiLoggingDir, jsonFile, inputFile, telemetryOutfile, includeDirectories) against the launch cwd BEFORE setupStartupWorktree chdirs. Verified post-fix via smoke test (--worktree foo --mcp-config ./mcp.json → model responds normally). |
Phase 6 net result: 22 / 24 cases passed post-fix; 2 cases (C1/C2) hit an architectural limitation now documented; 1 case (E3) is a test-setup quirk, not an implementation issue. Ready for Phase 7 code review.
Fix references (Phase 6 fixes that landed in this PR)
| Fix | File | Change |
|---|---|---|
| Re-attach to existing worktree (G1.b) | packages/cli/src/startup/worktreeStartup.ts |
Added pre-create check: if dir is a registered worktree on the expected branch, skip create + chdir |
getRegisteredWorktreeBranch() helper |
packages/core/src/services/gitWorktreeService.ts |
Probes git rev-parse --abbrev-ref HEAD against the candidate path |
| Path normalization before chdir (G2) | packages/cli/src/gemini.tsx |
Resolves mcpConfig, openaiLoggingDir, jsonFile, inputFile, telemetryOutfile, includeDirectories against launch cwd when --worktree is set |
| Documentation: yargs flag ordering tip + Limitations update | docs/users/features/worktree.md |
Quick Start tip + new Limitations bullets (cross-slug, path-arg behavior) |
| Unit tests for re-attach | packages/cli/src/startup/worktreeStartup.test.ts |
Added 2 tests: happy re-attach + "different branch occupies slot" guard |
Phase 6 Group F network note: The sandbox blocks git fetch to https://github.com with HTTP 403. F1/F2/F4/F6 were retested against a local bare repo (git init --bare) seeded with refs/pull/4174/head pointing at a commit whose message is feat(cli): per-turn /diff with interactive dialog (#4277). F3 and F5 are network-independent and were verified directly. The local-remote substitute fully exercises the parsing + fetch + worktree-creation code path.
Reproduction report — Phase 4 dry-run (Groups F + G), 2026-05-20
Binary: qwen (globally installed, v0.15.11 at /Users/mochi/.nvm/versions/node/v22.21.1/bin/qwen)
Override: QWEN="qwen"
Results table
| Test ID | Result | Evidence | Fix suggestion |
|---|---|---|---|
F1 --worktree=#4174 |
PASS | Unknown argument: worktree, exit=1 |
None — expected baseline failure |
F2 --worktree <url> |
PASS | Unknown argument: worktree, exit=1 |
None — expected baseline failure |
| F3 missing origin | PASS | Unknown argument: worktree, exit=1 — yargs rejected before any git op |
None |
| F4 invalid PR #999999999 | PASS | Unknown argument: worktree, exit=1 |
None |
F5 malformed #abc |
PASS | Unknown argument: worktree, exit=1 |
None |
| F6 PR + symlinkDirs | PASS | Unknown argument: worktree, exit=1 |
None |
| G1 lifecycle (tmux) | PASS | Unknown argument: worktree emitted to stdout captured in /tmp/g1_raw.out; tmux session exited immediately, pane was already dead by capture time |
SCRIPT-BUG: see note below |
| G2 relative path | PASS | Unknown arguments: worktree, prompt-file, promptFile, exit=1 |
SCRIPT-BUG: see note below |
Observed behavior (all cases)
Every invocation of --worktree (bare, = form, #<N> form, full URL, combined with --prompt-file) was rejected at the yargs argument-parsing layer with exit code 1 before any application logic ran. The exact error strings are:
Unknown argument: worktree(single unknown arg)Unknown arguments: worktree, prompt-file, promptFile(G2: both--worktreeand--prompt-fileare unknown, listed together)
No git operations, no network calls, no filesystem writes occurred in any test.
Expected behavior
Identical rejection — this is the correct pre-implementation baseline. All 8 tests PASS in the dry-run sense (the plan correctly detects that the features do not exist).
Key context
The failure mode is uniformly at the yargs layer, not downstream. This confirms the test plan's detection strategy is sound: once --worktree is wired into yargs, these tests will stop failing at this layer and will instead exercise the actual implementation paths (F1-F6 will hit git fetch, G1 will hit the TUI lifecycle, G2 will hit --prompt-file resolution).
SCRIPT-BUG notes for the test plan
G1 (tmux): The tmux session command pipes through tee with a subshell echo 'PROC_EXIT='$? that captures the exit of tee, not of qwen. When the process exits instantly (as with an Unknown argument error), the session terminates before sleep 3 finishes and the pane name g1dry is gone by the time tmux capture-pane runs, producing can't find pane: g1dry. Fix: use || true after tmux capture-pane, or add a || sleep 0 guard; better still, for the baseline-fail case redirect stderr+stdout to a file outside tmux and check the file directly (as done here via tee /tmp/g1_raw.out).
G2 (--prompt-file): The test plan uses --prompt-file ./relative.txt as a combined test with --worktree. In the baseline, --prompt-file is also an unknown argument (it does not exist in v0.15.11 yargs schema either — the flag is --prompt-interactive / -p). The error lists both unknown args together. The plan should note that --prompt-file will need to be implemented alongside --worktree, or use an existing flag (e.g. pipe via stdin or use --prompt) for the relative-path resolution test.