* 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.
26 KiB
Worktree 通用能力设计
问题陈述
qwen-code 目前仅有面向 Arena 多模型对比场景的内部 worktree 实现(GitWorktreeService),用户无法在普通会话中使用 worktree 隔离工作,AgentTool 也不支持为 subagent 创建隔离的 worktree 环境。
目标是将 worktree 做成通用能力,支持用户会话级隔离和 Agent 级隔离,同时保证现有 Arena 功能体验完全不变。
现状对比
| 功能 | qwen-code | claude-code | 阶段 |
|---|---|---|---|
EnterWorktree 工具 |
✅(Phase A) | ✅ | — |
ExitWorktree 工具 |
✅(Phase A) | ✅ | — |
AgentTool isolation: 'worktree' |
✅(Phase B) | ✅ | — |
| 过期 worktree 自动清理 | ✅(Phase B) | ✅ | — |
| worktree 会话状态持久化与恢复 | ❌ | ✅ | Phase C |
| Post-creation setup(hooks 配置) | ❌ | ✅ | Phase C |
| StatusLine worktree 状态展示 | ❌ | ✅ | Phase C |
| WorktreeExitDialog(退出提示) | ❌ | ✅ | Phase C |
--worktree CLI 启动标志 |
✅(Phase D) | ✅ | — |
| 符号链接目录(node_modules 等) | ✅(Phase D) | ✅ | — |
PR 引用(--worktree=#123) |
✅(Phase D) | ✅ | — |
| sparse checkout | ❌ | ✅ | Future |
| tmux 集成 | ❌ | ✅ | Future |
| Arena 多模型 worktree 隔离 | ✅(qwen 独有) | ❌ | — |
| 脏状态覆盖(stash + copy) | ✅ | ✅ | — |
| Baseline commit 追踪 | ✅(qwen 独有) | ❌ | — |
设计原则
worktree 是通用能力,Arena 是其上层应用。
- 通用 worktree 层:
EnterWorktree/ExitWorktree工具、AgentToolisolation参数、会话状态管理、自动清理 - Arena 层:多模型并行调度、
worktreeBaseDir自定义路径、批量创建与 diff 对比,继续使用GitWorktreeService.setupWorktrees()的现有逻辑,不受通用层改动影响
AgentTool 的 isolation: 'worktree' 只走通用路径,Arena 内部不经过这个参数创建 worktree,两者路径独立。
路径与配置
通用 worktree 路径
由 EnterWorktree 工具或 AgentTool isolation: 'worktree' 创建的 worktree 固定存放在:
{git 仓库根}/.qwen/worktrees/{slug}
路径不可配置。slug 命名规则:
- 用户会话 worktree:用户指定名称,或自动生成(格式:
{形容词}-{名词}-{4位随机}) - Agent worktree:
agent-{7位随机 hex}
Arena worktree 路径(已有,保持不变)
Arena 的 worktree 路径由 agents.arena.worktreeBaseDir 控制,默认 ~/.qwen/arena(ArenaManager.ts:125),与通用路径完全独立,不做任何改动。
扩展配置
| 配置项 | 类型 | 用途 | 阶段 |
|---|---|---|---|
ui.hideBuiltinWorktreeIndicator |
boolean |
隐藏 Footer 中内置 ⎇ worktree-… (…) 行,留给 custom statusline |
Phase C |
worktree.symlinkDirectories |
string[] |
符号链接指定目录(如 node_modules)到 worktree,避免磁盘浪费 |
Phase D |
worktree.sparsePaths |
string[] |
git sparse-checkout cone 模式,大型 monorepo 只写入指定路径 | Future |
Phase A / B 不新增任何配置项。
工具设计
EnterWorktree
触发条件: 用户明确说 "start a worktree"、"use a worktree"、"create a worktree" 等词语。不应在用户说"修复 bug"、"开发功能"时自动触发。
输入 schema:
name?: string // 可选,slug 格式:字母/数字/点/下划线/破折号,最大 64 字符
行为:
- 验证当前未在 worktree 中(防止嵌套)
- 解析到 git 仓库根(处理已在子目录的情况)
- 调用
GitWorktreeService创建 worktree,路径为.qwen/worktrees/{slug} - 将 worktree 会话写入
SessionService - 切换工作目录到 worktree 路径
- 清除文件缓存
输出: worktreePath、worktreeBranch、message
ExitWorktree
触发条件: 用户说 "exit the worktree"、"leave the worktree"、"go back" 等。
输入 schema:
action: 'keep' | 'remove'
discard_changes?: boolean // 仅 action='remove' 时有效
安全守卫:
- 仅操作本会话通过
EnterWorktree创建的 worktree action='remove'且存在未提交变更时,拒绝执行(除非discard_changes: true)
行为:
keep:清空会话中的 worktree 状态,保留 worktree 目录和分支,恢复原始工作目录remove:删除 worktree 目录,删除对应 git 分支,清空会话状态,恢复原始工作目录
输出: action、originalCwd、worktreePath、worktreeBranch
用户触发方式
| 方式 | 示例 | 实现阶段 |
|---|---|---|
| 会话中明确请求 | 用户说 "在 worktree 中开始工作" → 模型调用 EnterWorktree | Phase A |
| Agent 隔离 | 模型为 subagent 设置 isolation: 'worktree' |
Phase B |
| CLI 启动标志 | qwen --worktree my-feature |
Phase D |
无斜杠命令。会话中 worktree 的触发依赖用户明确提及,isolation: 'worktree' 才是模型自主决策的场景。
分阶段实现计划
Phase A:核心工具(用户会话级 worktree)
目标: 用户能在会话中进入 / 退出 worktree。
要实现的功能:
EnterWorktree工具:创建 worktree,切换工作目录,记录会话状态ExitWorktree工具:keep / remove 两种退出方式,安全守卫GitWorktreeService扩展:新增面向单用户会话的createUserWorktree()/removeUserWorktree()方法,复用现有 git 操作逻辑,不改动 Arena 使用的批量接口SessionService扩展:新增WorktreeSession字段,记录{ slug, worktreePath, worktreeBranch, originalCwd, originalBranch };--resume时恢复 worktree 工作目录- 工具 prompt:为每个工具编写使用说明,明确何时调用、何时不调用
影响文件:
| 文件 | 变更类型 |
|---|---|
packages/core/src/tools/tool-names.ts |
新增 ENTER_WORKTREE、EXIT_WORKTREE 常量 |
packages/core/src/tools/EnterWorktreeTool/ |
新建目录:EnterWorktreeTool.ts、prompt.ts |
packages/core/src/tools/ExitWorktreeTool/ |
新建目录:ExitWorktreeTool.ts、prompt.ts |
packages/core/src/services/gitWorktreeService.ts |
新增用户会话级接口(不改动 Arena 接口) |
packages/core/src/services/sessionService.ts |
新增 WorktreeSession 字段及读写方法 |
packages/core/src/tools/ 注册入口 |
注册新工具 |
不在 Phase A 范围内:
- Agent 隔离(Phase B)
- hooks 配置等 post-creation setup(Phase C)
- UI 状态展示(Phase C)
Phase B:Agent 隔离(AgentTool isolation: 'worktree')+ 描述更新
目标: 模型可为 subagent 创建临时隔离 worktree,agent 结束后自动清理;同步更新受影响的工具描述和提示词。
要实现的功能:
Agent 隔离核心:
AgentTool新增isolation?: 'worktree'参数- Agent 启动时创建临时 worktree(slug:
agent-{7hex},路径:.qwen/worktrees/agent-{7hex}) - Agent 结束后:无变更则自动删除;有变更则保留,将路径和分支返回在结果中
- 过期 worktree 自动清理:扫描
.qwen/worktrees/,匹配agent-{7hex}模式,超过 30 天且无未推送提交则删除,fail-closed 策略
描述与提示词更新:
AgentTooldescription 补充isolation: 'worktree'参数说明(参考 claude-codeAgentTool/prompt.ts:272)- 新增
buildWorktreeNotice():当 fork subagent 在 worktree 中运行时,向其注入上下文提示,说明其处于隔离 worktree、路径继承自父 agent、编辑前需重新读取文件(参考 claude-codeforkSubagent.ts:buildWorktreeNotice)
无需改动:
- review skill(
SKILL.md):review 使用独立机制(路径.qwen/tmp/review-pr-<n>,通过qwen review fetch-pr命令创建),与通用 worktree 路径和机制完全不同,不存在混淆
Arena 兼容保证: Arena 内部不经过 isolation 参数创建 worktree,此改动不触碰 Arena 代码路径。
影响文件:
| 文件 | 变更类型 |
|---|---|
packages/core/src/tools/agent/agent.ts |
新增 isolation 参数及 worktree 创建/清理逻辑 |
packages/core/src/tools/agent/fork-subagent.ts |
新增 buildWorktreeNotice() 并在 worktree 模式下注入 |
packages/core/src/services/gitWorktreeService.ts |
新增 createAgentWorktree() / removeAgentWorktree() |
packages/core/src/services/worktreeCleanup.ts |
新建:过期 worktree 自动清理逻辑 |
Phase C:会话完整性(SessionService 持久化 + UI 安全网)
目标: worktree 状态在会话中断后可恢复,用户在界面上始终知道自己在哪个 worktree 里,退出会话时有安全提示。
要实现的功能:
SessionService worktree 状态持久化 + --resume 恢复:
SessionService扩展WorktreeSession字段,记录{ slug, worktreePath, worktreeBranch, originalCwd, originalBranch }EnterWorktreeTool调用sessionService.setWorktreeSession()写入状态ExitWorktreeTool调用sessionService.clearWorktreeSession()清除状态--resume启动路径读取该字段,恢复targetDir并向模型注入上下文提示
Post-creation setup:
- 创建 worktree 后自动执行
git config core.hooksPath <mainRepo>/.git/hooks,确保 worktree 内的提交与主仓库 hooks 行为一致
StatusLine worktree 展示:
UIStateContext新增activeWorktree字段(从 session 状态读取),在会话进入 / 退出 worktree 时更新StatusLineCommandInputpayload 新增worktree?: { slug: string; branch: string }字段,供用户 statusline 脚本使用Footer在activeWorktree非空时内置展示一行⎇ <branch> (<slug>),无需用户配置 statusline 脚本即可获得基本可见性
WorktreeExitDialog:
- 新增
WorktreeExitDialog.tsx组件,参考现有 Dialog 写法 - 修改退出键(Ctrl+C / Ctrl+D)处理逻辑:检测到
activeWorktree非空时,拦截第二次确认,展示 Dialog 提示用户选择 keep 或 remove - keep / remove 操作复用
ExitWorktreeTool的现有路径
影响文件:
| 文件 | 变更类型 |
|---|---|
packages/core/src/services/sessionService.ts |
新增 WorktreeSession 字段及读写方法 |
packages/core/src/tools/enter-worktree.ts |
调用 sessionService.setWorktreeSession() |
packages/core/src/tools/exit-worktree.ts |
调用 sessionService.clearWorktreeSession() |
packages/core/src/services/gitWorktreeService.ts |
createUserWorktree() / createAgentWorktree() 后追加 core.hooksPath 配置 |
packages/cli/src/ui/contexts/UIStateContext.tsx |
新增 activeWorktree 字段及 set/clear action |
packages/cli/src/ui/hooks/useStatusLine.ts |
StatusLineCommandInput 新增 worktree 字段 |
packages/cli/src/ui/components/Footer.tsx |
内置 worktree 行展示 |
packages/cli/src/ui/components/WorktreeExitDialog.tsx |
新建 |
packages/cli/src/ui/components/DialogManager.tsx |
注册 WorktreeExitDialog |
packages/cli/src/ui/components/ExitWarning.tsx 或退出键处理 |
检测 activeWorktree 并拦截退出 |
Phase D:启动时配置(--worktree CLI 标志 + 目录符号链接 + PR 引用)
目标: 支持在启动时直接进入 worktree、通过目录符号链接减少大型项目的磁盘开销,以及通过 PR 引用快速基于一个 pull request 创建 worktree。
范围: 三个功能在一个阶段一起落地,因为它们都挂在同一个启动入口上,且 symlink / PR fetch 两者都需要在 worktree 创建之后立即执行 — 单独拆分会重复改 bootstrap 序列。
D-1:--worktree [name] CLI 启动标志
参数形态: yargs 选项接受三种形式:
| 形式 | 行为 |
|---|---|
qwen --worktree |
bare flag,自动生成 slug({形容词}-{名词}-{6hex}) |
qwen --worktree my-name |
显式 slug,沿用 EnterWorktreeTool 的 slug 校验规则 |
qwen --worktree=my-name |
等价于上一种 |
不提供短别名 -w(qwen-code 短别名只保留给最高频参数,避免命名冲突)。
启动序列: worktree 在以下位置创建:
parseArguments()解析 argv(已有)- resume picker(已有,line 588-629 of
gemini.tsx) loadCliConfig()初始化 Config + auth(已有,line 643-653)- 新增: 若
argv.worktree !== undefined,调用createUserWorktree()- 写入 sidecar(
writeWorktreeSession()) - 设置
process.chdir(worktreePath)同时Config.setTargetDir(worktreePath) - 同一 worktree 的 re-attach 路径:跳过
git worktree add并就地 chdir(Phase 6 修复)。跨 projectHash 的--resume×--worktree组合在 session lookup 阶段会失败,详见下文"与--resume的优先级"。
- 写入 sidecar(
- 主循环(TUI / headless
-p/ ACP 三种入口都要走第 4 步)
与 Phase A 简化的差异: Phase A 的 EnterWorktreeTool 不修改 Config.targetDir,依赖模型从工具结果里读到绝对路径并继续使用。Phase D 的 CLI flag 在启动期就生效,没有运行中的模型上下文需要兼容,所以直接切换 targetDir 和 process.cwd() —— 这是更强的隔离保证。两条路径行为不同,需要在用户文档里说明。
退出行为: 复用现有 WorktreeExitDialog(Phase C 已实现)。Ctrl+C/D 两次触发 → 用户在 keep / remove / cancel 之间选择。不需要新代码路径。
与 --resume 的优先级:
由于 session 存储以 projectHash(process.cwd()) 为 key,而 --worktree 在 resume picker / loadCliConfig 之前就 chdir 到 worktree,所以"在 worktree X 启动的 session,从 worktree Y 内 resume"是架构上不可达的(两者的 projectHash 不同,session 文件落在不同目录)。下表反映 D-1 实现 + Phase 6 re-attach 修复后的实际行为:
--resume 状态 |
--worktree 状态 |
结果 |
|---|---|---|
| 无 | 无 | 普通会话,无 worktree |
| 无 | 有(新 slug) | 新建 worktree |
| 无 | 有(已存在的 slug) | re-attach 到已有 worktree(Phase 6 修复) |
| 有 | 无 | 恢复旧 worktree(Phase C 行为,sidecar 命中则注入 reminder) |
| 有(sid 出自同一 worktree) | 有(同一 slug,re-attach) | re-attach + session 命中:正常 resume |
| 有(sid 出自 main checkout) | 有(任意 slug) | session lookup 失败:No saved session found with ID …,exit 1。documented limitation |
| 有(sid 出自 worktree X) | 有(slug Y, X != Y) | 同上,session 跨 projectHash 不可寻 |
跨 projectHash override 的语义(--worktree 在不同 worktree / 主 checkout 的 session 之间转移)需要 storage 锚定到 repo root 而非 cwd-derived projectHash,属于未来 Config 重构范畴。persistStartupWorktreeSidecar 内的 overrodeResumedWorktree 分支代码保留是为该重构落地后能自动生效,目前在生产路径不会触发。
D-2:worktree.symlinkDirectories 配置项
schema:
{
"worktree": {
"symlinkDirectories": ["node_modules", "dist", ".turbo"],
},
}
- 类型:
string[],默认undefined(不开启,opt-in) - 顶层 namespace
worktree是新增的(在settingsSchema.ts中按字母序插在tools与ui之间) - 路径相对于主仓库根,绝对路径或包含
..的路径被路径遍历守卫拒绝
作用范围: 所有由通用层创建的 worktree,包括:
EnterWorktreeTool(Phase A)AgentToolisolation: 'worktree'(Phase B)--worktreeCLI flag(Phase D-1)
Arena 的 worktree 不走通用层,不受此配置影响。
实现位置: GitWorktreeService.performPostCreationSetup() —— 紧跟现有的 configureHooksPath()(Phase C 已建立的模式)。新增 symlinkConfiguredDirectories() 方法,遍历配置项调用 fs.symlink(absSource, absDest, 'dir')。
错误处理(fail-open):
| 场景 | 行为 |
|---|---|
| 源目录不存在(ENOENT) | 静默跳过,debug log |
| 目标路径已存在(EEXIST) | 静默跳过,debug log(不覆盖) |
路径遍历(../、绝对路径等) |
拒绝该项,debug log warn |
| 其他 I/O 错误 | debug log warn,继续处理后续项 |
worktree 创建本身不会因为 symlink 失败而中止 —— 与 configureHooksPath() 相同的"best-effort post-creation setup"原则。
D-3:PR 引用解析(--worktree=#<N> / 全 URL)
支持形式:
| 形式 | 解析后的 PR 号 |
|---|---|
--worktree=#123 |
123 |
--worktree '#123' |
123 |
--worktree https://github.com/foo/bar/pull/123 |
123 |
--worktree https://gh.enterprise.com/foo/bar/pull/123?baz=qux |
123 |
slug 与分支命名:
- slug:
pr-<N>(特殊保留前缀,与用户 slug 区分) - 分支:
worktree-pr-<N>(沿用 qwen-code 现有worktree-<slug>命名规则;不采用 claude-code 的pr-<N>直接命名,避免与本地pr-<N>分支冲突)
fetch 策略:
git fetch origin pull/<N>/head
→ 用 FETCH_HEAD 作为新 worktree 的 base
不依赖 gh CLI —— 纯 git fetch,支持任何 GitHub 实例(公网或企业版),只要 origin 远程指向 GitHub。
错误路径:
| 场景 | 错误消息 |
|---|---|
origin 远程缺失 |
--worktree=#<N> requires an "origin" remote that points at GitHub. |
git fetch 失败 |
Failed to fetch PR #<N>: PR may not exist or origin remote is unreachable. |
| 网络超时(30s) | 同上,加 (timeout) |
origin 远程不是 GitHub |
不做主动检查,由 git fetch 自然失败(PR 协议是 GitHub 特有的) |
与 D-2 的关系: PR worktree 同样应用 symlinkDirectories(用户期望在 PR 上立刻能跑测试,依赖目录需要复用)。
影响文件
| 文件 | 变更类型 |
|---|---|
packages/cli/src/config/config.ts |
yargs 新增 --worktree 选项;CliArgs 接口加 worktree?: string | boolean |
packages/cli/src/gemini.tsx |
loadCliConfig() 之后、主循环之前调用新的 setupStartupWorktree() helper |
packages/cli/src/startup/worktreeStartup.ts |
新建:setupStartupWorktree() 处理 slug 解析、PR fetch、sidecar 写入、cwd 切换 |
packages/cli/src/nonInteractiveCli.ts |
复用同一 helper(已有 restoreWorktreeContext 注入逻辑,无须改) |
packages/cli/src/acp-integration/acpAgent.ts |
复用同一 helper |
packages/core/src/services/gitWorktreeService.ts |
新增 parsePRReference()、fetchPullRequestRef()、symlinkConfiguredDirectories();createUserWorktree() 接受可选 baseBranchRef 参数 |
packages/cli/src/config/settingsSchema.ts |
新增 worktree.symlinkDirectories: string[] 顶层项 |
packages/vscode-ide-companion/schemas/settings.schema.json |
重新生成 |
docs/users/features/worktree.md |
新增 Quick Start CLI flag 章节、Settings 表新增一行 |
安全与回滚
- fail-open vs fail-close: symlink / hooks 失败 不 中止 worktree 创建(同 Phase C 既定模式);PR fetch 失败 中止 启动(无 base ref 就无法创建 worktree);slug 校验失败 中止 启动(与
EnterWorktreeTool一致)。 - path traversal:
symlinkDirectories项必须解析后仍在repoRoot内,否则拒绝该项并 log。 - PR fetch 超时: 30 秒硬超时,避免无响应的网络拖死启动。
- cwd 切换的副作用: 切
process.cwd()之后,相对路径(如--prompt-file ./foo.txt)的解析会受影响。对策: 在切 cwd 之前先解析所有相对路径参数(具体在setupStartupWorktree()入口处做一次 normalize)。
开放问题
--worktree-keep-on-exit? claude-code 没有,qwen-code 是否需要一个 CLI flag 让 Exit Dialog 默认选 keep?建议先不加,等用户反馈。worktree.symlinkDirectories是否需要 per-project override? 当前 settings 已经支持 user/workspace/project 三级合并,无需特殊处理。- PR fetch 是否要拉取
mergeref(pull/<N>/merge,即与 base 合并后的 ref)而非head? claude-code 选head,理由是用户通常想看 PR 的实际改动。沿用此选择。
Future:高级功能(按需实现)
以下功能面向更特定的使用场景,当前阶段不纳入排期,待用户需求明确后再评估实现。
| 功能 | 说明 |
|---|---|
| sparse checkout | worktree.sparsePaths 配置项,大型 monorepo 只 checkout 指定路径,缩短创建时间和磁盘占用 |
.worktreeinclude 文件 |
将 gitignore 的文件(.env、secrets.json 等)自动复制进 worktree |
| tmux 集成 | --worktree --tmux 在新 tmux 窗口启动 worktree 会话 |