mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 15:15:18 +00:00
* 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.
748 lines
44 KiB
Markdown
748 lines
44 KiB
Markdown
# 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),
|
||
with `process.cwd()` + `Config.targetDir` switch and `WorktreeExitDialog`
|
||
reuse on exit
|
||
- **D-2** — `worktree.symlinkDirectories: string[]` settings key, applied in
|
||
`performPostCreationSetup()` so it covers `--worktree`, `EnterWorktreeTool`,
|
||
AND `AgentTool isolation: "worktree"` paths
|
||
- **D-3** — `--worktree=#<N>` and `--worktree <github-url>` PR-reference forms,
|
||
via `git fetch origin pull/<N>/head` (no `gh` CLI 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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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)
|
||
|
||
```bash
|
||
$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_started` event with `.data.notice` containing the auto slug
|
||
- Init `.cwd` ends 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)
|
||
|
||
```bash
|
||
$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).
|
||
|
||
```bash
|
||
$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
|
||
|
||
```bash
|
||
$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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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"`
|
||
- `worktreePath` ends with `.qwen/worktrees/b1-test`
|
||
- `worktreeBranch = "worktree-b1-test"`
|
||
- `originalCwd` = `$TEST_DIR` (resolved)
|
||
- `originalBranch = "main"`
|
||
- `originalHeadCommit` matches `[0-9a-f]{40}`
|
||
|
||
### B2: `process.cwd()` switched at startup
|
||
|
||
```bash
|
||
$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)
|
||
|
||
```bash
|
||
$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)
|
||
|
||
```bash
|
||
# 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/` and `second/` directories exist
|
||
- Run 2's stderr or init `worktree_overridden` message mentions "--worktree
|
||
overrides the resumed session's worktree"
|
||
|
||
### C2: stale sidecar (manually deleted dir) + `--worktree` → fresh worktree
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
$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
|
||
|
||
```bash
|
||
$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:
|
||
|
||
```bash
|
||
$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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
# 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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
# 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
|
||
|
||
```bash
|
||
$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
|
||
|
||
```bash
|
||
$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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
$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
|
||
|
||
```bash
|
||
$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)
|
||
|
||
```bash
|
||
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 3`
|
||
> finishes (yargs rejects `--worktree` immediately and the tmux pane dies).
|
||
> The `capture-pane` call then errors with "can't find pane". This is
|
||
> expected — record as PASS-by-rejection. Wrap captures with `|| true` for
|
||
> the dry-run, or skip G1 entirely in baseline mode.
|
||
|
||
```bash
|
||
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.txt` inside 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
|
||
|
||
```bash
|
||
# 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 `--worktree` and `--prompt-file` are 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.
|