mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-08 08:15:38 +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.
345 lines
22 KiB
Markdown
345 lines
22 KiB
Markdown
# Worktrees
|
|
|
|
> Isolate experimental work in a temporary [git worktree](https://git-scm.com/docs/git-worktree) without leaving your current session. Useful when the model is about to make wide-ranging edits you want to keep separate from your main checkout, or when you want a subagent to work in a sandbox of its own.
|
|
|
|
## Quick Start
|
|
|
|
### Start the session inside a worktree (`--worktree` flag)
|
|
|
|
If you know up front that the entire session should run inside a worktree, pass `--worktree` at launch:
|
|
|
|
```bash
|
|
# Auto-generated slug (e.g. tender-jemison-037f0a)
|
|
qwen --worktree
|
|
|
|
# Explicit name
|
|
qwen --worktree my-feature
|
|
|
|
# `=` form (recommended when also passing a positional prompt — see tip below)
|
|
qwen --worktree=my-feature
|
|
|
|
# PR reference — fetches refs/pull/<N>/head from `origin`
|
|
qwen --worktree=#4174
|
|
qwen --worktree https://github.com/QwenLM/qwen-code/pull/4174
|
|
|
|
# Continue a previous --worktree session — re-attaches to the existing dir
|
|
qwen --resume <session-id> --worktree=my-feature
|
|
```
|
|
|
|
> **Tip — bare `--worktree` followed by a positional prompt is ambiguous.** Because `--worktree` takes an optional value, `qwen --worktree "say hi"` makes yargs consume `"say hi"` as the slug (and reject it because of the space). Use one of:
|
|
>
|
|
> - `qwen --worktree=my-feature "say hi"` (always works — explicit slug via `=`)
|
|
> - `qwen "say hi" --worktree` (positional first, flag at the end → auto slug)
|
|
> - `qwen --worktree --approval-mode yolo "say hi"` (any flag between them anchors the bare form)
|
|
|
|
> **Tip — `qwen --resume --worktree foo` (no session ID) shows an empty picker on first use.** The picker scopes to the chosen worktree's session storage; sessions started outside that worktree are not listed. To resume a session that was started inside `foo`, use `qwen --resume <id> --worktree foo` directly — the CLI re-attaches to the existing `foo/` directory rather than re-creating it.
|
|
|
|
`process.cwd()` and the model's workspace are switched to the worktree before the first turn runs. Exit with `Ctrl+C` twice and the [Exit Dialog](#exit-dialog-ctrlc--ctrld) prompts to keep or remove the worktree.
|
|
|
|
The `--worktree` flag cannot be combined with `--acp`/`--experimental-acp` — for ACP hosts (like Zed), pass the worktree path as the `cwd` of the `loadSession`/`newSession` request instead.
|
|
|
|
### Or ask mid-session
|
|
|
|
Alternatively, ask Qwen Code in plain language to create a worktree from inside an existing session:
|
|
|
|
```text
|
|
> start a worktree called experiment-a
|
|
Worktree experiment-a created on branch worktree-experiment-a
|
|
.qwen/worktrees/experiment-a
|
|
```
|
|
|
|
From this point on, the model routes every file edit and shell command through `.qwen/worktrees/experiment-a/`. Your original working directory is untouched.
|
|
|
|
When you are done:
|
|
|
|
```text
|
|
> exit the worktree and remove it
|
|
Removed worktree experiment-a (branch worktree-experiment-a)
|
|
```
|
|
|
|
If you want to come back later, ask to exit with the worktree kept on disk instead:
|
|
|
|
```text
|
|
> exit the worktree but keep it
|
|
Kept worktree experiment-a at .qwen/worktrees/experiment-a
|
|
```
|
|
|
|
## When Worktrees Are Used
|
|
|
|
Worktrees are activated in four independent paths:
|
|
|
|
| Trigger | What happens |
|
|
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
| You launch with `--worktree` | The CLI creates the worktree before any model turn runs and chdirs the session into it. PR forms (`#N`, full URL) fetch first. |
|
|
| You explicitly ask for a worktree mid-session | Model calls `enter_worktree`; subsequent file edits go inside it. |
|
|
| You explicitly ask to leave | Model calls `exit_worktree` with `keep` or `remove`. |
|
|
| Model spawns a sub-agent with isolation enabled | A throwaway worktree (`agent-<hex>`) is created automatically and cleaned up if the agent has no diffs. |
|
|
|
|
The two mid-session tools (`enter_worktree` / `exit_worktree`) are deliberately gated behind explicit phrasing — saying "fix this bug" or "create a branch" will **not** trigger them. You must say something like "use a worktree", "start a worktree", or "in a worktree". The `--worktree` CLI flag has no such guard; it always creates one when present.
|
|
|
|
## What Gets Created
|
|
|
|
Every Qwen-managed worktree is placed under your project's `.qwen` directory:
|
|
|
|
```
|
|
<repoRoot>/.qwen/worktrees/<slug>/ # Working directory
|
|
↳ branch worktree-<slug> # Created off your current branch
|
|
```
|
|
|
|
- **Slug** — letters, digits, dot, underscore, hyphen; max 64 chars. If you don't specify a name, an `<adjective>-<noun>-<6hex>` slug is auto-generated (e.g. `tender-jemison-037f0a`). PR references produce `pr-<N>`.
|
|
- **Branch** — always `worktree-<slug>`, branched from whichever branch you have checked out when you ask for the worktree (not necessarily the main working tree's `HEAD`). For PR worktrees the branch is `worktree-pr-<N>` and is based on `FETCH_HEAD` (the PR's tip on the GitHub side) rather than your local branch.
|
|
- **Hooks** — the worktree's `core.hooksPath` is automatically pointed at the main repo's `.husky/` (preferred) or `.git/hooks/` so commits inside the worktree still trigger your existing pre-commit / commit-msg hooks.
|
|
- **Optional symlinks** — directories listed in `worktree.symlinkDirectories` (see [Settings](#settings)) are symlinked from the main repo into the new worktree so heavy dirs like `node_modules` can be reused without reinstalling.
|
|
|
|
The general-purpose worktree path is **not configurable** — it must live under `<repoRoot>/.qwen/worktrees/` so the CLI can find it on restart and on stale-cleanup sweeps. (The unrelated `agents.arena.worktreeBaseDir` setting controls only [Agent Arena](./arena.md) worktrees, which use a separate path tree under `~/.qwen/arena/`.)
|
|
|
|
## Footer and Status Line
|
|
|
|
When a worktree is active, the Footer shows a dim indicator on its own row:
|
|
|
|
```
|
|
⎇ worktree-experiment-a (experiment-a)
|
|
```
|
|
|
|
If you use a [custom status line script](./status-line.md), it also receives a `worktree` object in the JSON payload piped to stdin:
|
|
|
|
```json
|
|
{
|
|
"worktree": {
|
|
"name": "experiment-a",
|
|
"path": "/path/to/repo/.qwen/worktrees/experiment-a",
|
|
"branch": "worktree-experiment-a",
|
|
"original_cwd": "/path/to/repo",
|
|
"original_branch": "main"
|
|
}
|
|
}
|
|
```
|
|
|
|
The payload field is present **only** when a worktree is active, so a `null`-check (`input.worktree?.name`) is enough.
|
|
|
|
If your custom status line already renders worktree info, you can hide the built-in Footer row to avoid duplication — see [Settings](#settings) below.
|
|
|
|
## Exit Dialog (Ctrl+C / Ctrl+D)
|
|
|
|
Pressing the quit shortcut twice while a worktree is active opens the **Worktree Exit Dialog** instead of closing the CLI:
|
|
|
|
```
|
|
⎇ Active worktree: "experiment-a" (worktree-experiment-a)
|
|
|
|
• 2 new commit(s) on worktree-experiment-a
|
|
• 3 uncommitted file(s)
|
|
Removing the worktree will discard everything above.
|
|
|
|
What would you like to do?
|
|
○ Keep worktree (exit without deleting)
|
|
○ Remove worktree and branch (discards 2 commit(s), 3 file(s))
|
|
○ Cancel (stay in session)
|
|
```
|
|
|
|
The dialog inspects the worktree on open (`git status --porcelain` + `git rev-list <baseHEAD>..HEAD`) and surfaces both counts so you know exactly what you'd be discarding. `ESC` cancels.
|
|
|
|
If `git status` itself fails (e.g. corrupt index, worktree directory was removed under the CLI), the dialog shows a `⚠ Could not measure worktree state` warning and the counts may be unreliable — choose **Keep** or **Cancel** until you've diagnosed the underlying repo problem.
|
|
|
|
## `--resume` Restore
|
|
|
|
The active worktree binding is persisted to a sidecar file alongside your session transcript:
|
|
|
|
```
|
|
<chatsDir>/<sessionId>.worktree.json
|
|
```
|
|
|
|
When you launch the CLI with `--resume <sessionId>` (or pick the session from `/resume`), three things happen consistently across **interactive TUI**, **headless `-p`**, and **ACP/Zed** modes:
|
|
|
|
1. The sidecar is loaded and the worktree directory is verified to still exist on disk.
|
|
2. If alive, the model receives a one-shot reminder on its very next prompt:
|
|
```
|
|
[Resumed] Active worktree: "<slug>" at <path> (branch: <branch>). Continue using this path for all file operations.
|
|
```
|
|
3. If the worktree directory was deleted between sessions, the stale sidecar is cleaned up automatically — no error, the resume just continues without worktree context.
|
|
|
|
Each mode chooses its own injection mechanism, but the user-visible behavior is identical:
|
|
|
|
| Mode | Mechanism |
|
|
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
|
| Interactive (TUI) | `INFO` history item + system-reminder prefix on the next user prompt. |
|
|
| Headless (`-p`) | `<system-reminder>` prefix on the prompt + `worktree_restored` JSON system event in the output stream. |
|
|
| ACP (e.g. Zed) | Pending notice attached to the next `prompt()` call. |
|
|
|
|
The model is **not** automatically `chdir`'d into the worktree — the reminder is what keeps it routing edits through the worktree path.
|
|
|
|
## Sub-Agent Isolation
|
|
|
|
The `agent` tool accepts an optional `isolation: "worktree"` parameter. When set, Qwen Code creates an ephemeral worktree at `<repoRoot>/.qwen/worktrees/agent-<7hex>/` before the sub-agent starts, and:
|
|
|
|
- **No changes** → the worktree is automatically removed when the agent finishes.
|
|
- **Has changes** → the worktree is preserved; its path and branch are appended to the agent's result, e.g.
|
|
```
|
|
…agent output…
|
|
[worktree preserved: /path/to/.qwen/worktrees/agent-3f2a1b9 (branch worktree-agent-3f2a1b9)]
|
|
```
|
|
Review the diff and merge or delete it manually.
|
|
|
|
Two constraints:
|
|
|
|
- `isolation: "worktree"` requires a `subagent_type` — forked sub-agents (no `subagent_type`) reuse the parent's full conversation context, so isolating them would split intent from working tree.
|
|
- Background agents (`run_in_background: true`) work fine with isolation; the cleanup runs when the agent reports completion.
|
|
|
|
### Automatic Stale Cleanup
|
|
|
|
Ephemeral agent worktrees that survived a crash or `--no-cleanup` shutdown are reaped on every CLI startup, with conservative fail-closed rules:
|
|
|
|
| Guard | Behavior |
|
|
| -------------------------------------- | ---------------------------------------------- |
|
|
| Slug must match `agent-<7hex>` pattern | Named worktrees you created are never touched. |
|
|
| Directory `mtime` > 30 days | Newer entries are skipped. |
|
|
| Any uncommitted tracked change | Skip the entry (don't delete). |
|
|
| Any commit not reachable from a remote | Skip the entry (don't delete). |
|
|
| Any error reading git state | Skip the entry (don't delete). |
|
|
|
|
Named user worktrees (`enter_worktree` slugs) are **never** auto-cleaned — you keep them around until you ask to remove them.
|
|
|
|
## Safety Guards on `exit_worktree action="remove"`
|
|
|
|
Three independent guards trigger before the directory and branch are deleted:
|
|
|
|
1. **Session ownership** — each worktree carries a sidecar marker with the session ID that created it. A different session trying to remove it is refused with a clear error pointing at `git worktree remove` for the manual escape hatch.
|
|
2. **Dirty working tree** — uncommitted tracked or untracked changes block removal. Pass `discard_changes: true` to override. (Bypass requires explicit user confirmation — `action: "remove"` is never auto-approved in AUTO_EDIT mode.)
|
|
3. **Unmerged commits** — commits on `worktree-<slug>` that no other local branch or remote ref points at block removal unconditionally; there is no "discard commits" flag because losing committed work is rarely what users mean. Merge, push, or rename the branch elsewhere first.
|
|
|
|
The same three guards apply to the `WorktreeExitDialog → Remove` button.
|
|
|
|
## Settings
|
|
|
|
Two settings shape the general-purpose worktree experience:
|
|
|
|
| Key | Type | Default | Effect |
|
|
| --------------------------------- | ---------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `ui.hideBuiltinWorktreeIndicator` | boolean | `false` | Hides the built-in `⎇ worktree-… (…)` Footer row. The `worktree` field is still delivered to custom status line scripts. Set to `true` only if your status line already renders the worktree — otherwise you lose all UI affordance. |
|
|
| `worktree.symlinkDirectories` | `string[]` | `undefined` | Directories under the main repo to symlink into every general-purpose worktree on creation. Paths are relative to the repo root; absolute paths and any entry containing `..` are rejected. Missing sources and existing destinations are silently skipped (no overwrite). |
|
|
|
|
Example:
|
|
|
|
```jsonc
|
|
// ~/.qwen/settings.json or <repo>/.qwen/settings.json
|
|
{
|
|
"worktree": {
|
|
"symlinkDirectories": ["node_modules", ".turbo", "dist"],
|
|
},
|
|
}
|
|
```
|
|
|
|
Applies to ALL worktree-creation paths: `--worktree` flag, `enter_worktree` tool, and `agent isolation: "worktree"`.
|
|
|
|
Settings unrelated to general worktrees but worth knowing about:
|
|
|
|
- `agents.arena.worktreeBaseDir` — controls **Agent Arena** worktree placement (default `~/.qwen/arena`). Does not affect general-purpose worktrees, which always live under `<repoRoot>/.qwen/worktrees/`.
|
|
|
|
There is no schema for `worktree.sparsePaths` yet — that's a roadmap item (see [Limitations](#limitations)).
|
|
|
|
## Tool Reference
|
|
|
|
### `enter_worktree`
|
|
|
|
```json
|
|
{ "name": "experiment-a" }
|
|
```
|
|
|
|
| Field | Type | Required | Notes |
|
|
| ------ | ------ | -------- | ------------------------------------------------------------------------------------------ |
|
|
| `name` | string | no | Slug. Letters, digits, dot, underscore, hyphen; max 64 chars. Auto-generated when omitted. |
|
|
|
|
Refuses to run when:
|
|
|
|
- The CLI is not in a git repository.
|
|
- The current working directory is already inside `.qwen/worktrees/` (no nested worktrees).
|
|
|
|
### `exit_worktree`
|
|
|
|
```json
|
|
{ "name": "experiment-a", "action": "remove", "discard_changes": false }
|
|
```
|
|
|
|
| Field | Type | Required | Notes |
|
|
| ----------------- | ---------------------- | ------------------------------------- | ------------------------------------------------------------------ |
|
|
| `name` | string | yes | Must match the slug used in `enter_worktree`. |
|
|
| `action` | `"keep"` \| `"remove"` | yes | `keep` preserves dir + branch; `remove` deletes both. |
|
|
| `discard_changes` | boolean | only when `action="remove"` and dirty | Overrides the dirty-tree guard. Has no effect for `action="keep"`. |
|
|
|
|
`action: "remove"` always prompts for confirmation, including under `AUTO_EDIT` approval mode — it is treated as a destructive shell operation, not an info-only tool.
|
|
|
|
### `agent` — `isolation` parameter
|
|
|
|
```json
|
|
{
|
|
"subagent_type": "my-agent",
|
|
"description": "…",
|
|
"prompt": "…",
|
|
"isolation": "worktree"
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Notes |
|
|
| ----------- | ------------ | -------- | ------------------------------------------------------------------------------------------------- |
|
|
| `isolation` | `"worktree"` | no | Runs the agent in a fresh `agent-<7hex>` worktree. Requires `subagent_type` to be set (no forks). |
|
|
|
|
See [Sub-Agents](./sub-agents.md) for the rest of the agent tool reference.
|
|
|
|
## CLI Reference
|
|
|
|
### `--worktree [name | #N | url]`
|
|
|
|
```bash
|
|
qwen --worktree # auto-generate slug
|
|
qwen --worktree my-feature # explicit slug
|
|
qwen --worktree=my-feature # = form
|
|
qwen --worktree=#123 # PR reference
|
|
qwen --worktree https://github.com/owner/repo/pull/123 # PR URL
|
|
```
|
|
|
|
| Input | Result |
|
|
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
| Bare flag (no value) | Auto slug `<adjective>-<noun>-<6hex>`, branch `worktree-<slug>`, base = current branch. |
|
|
| Plain slug | Branch `worktree-<slug>`, base = current branch. Slug validation: letters/digits/dot/underscore/hyphen, max 64 chars. |
|
|
| `#N` or `<github-url>/pull/N` | Slug `pr-<N>`, branch `worktree-pr-<N>`, base = `FETCH_HEAD` after `git fetch origin pull/<N>/head` (30s timeout). |
|
|
|
|
`--worktree` cannot be combined with `--acp` / `--experimental-acp`.
|
|
|
|
When `--worktree` is combined with `--resume <session-id>`, the worktree wins: the resumed session's saved worktree (if any) is overridden and a stderr line + first-prompt reminder report the override.
|
|
|
|
For interactive (TUI) and headless (`-p`) modes the worktree is automatically created and the session chdirs into it before the first turn.
|
|
|
|
PR-fetch failure modes (exit code != 0, no worktree created):
|
|
|
|
| Cause | Message excerpt |
|
|
| ----------------------------- | ---------------------------------------------------------- |
|
|
| Missing `origin` remote | `requires an "origin" remote that points at GitHub` |
|
|
| PR doesn't exist on origin | `Failed to fetch PR #<N>: the PR does not exist on origin` |
|
|
| 30s network timeout | `Failed to fetch PR #<N>: timed out after 30s` |
|
|
| PR number out of range / zero | `Invalid PR number` |
|
|
|
|
## Limitations
|
|
|
|
The following items are intentionally not implemented in the current phase:
|
|
|
|
- **No sparse checkout.** Large monorepos check out the full tree. (`worktree.sparsePaths` is a roadmap item.)
|
|
- **No tmux integration.** The CLI does not spawn worktree sessions in new tmux windows.
|
|
- **Worktrees are separate "projects" for session storage.** Sessions started with `--worktree foo` are saved under that worktree's chats dir; to resume them later you must pass `--worktree foo` again. Sessions started without `--worktree` are saved under the main checkout and won't appear in the worktree's resume picker.
|
|
- **No cross-slug session override.** `qwen --resume <sid> --worktree second` where `<sid>` was created with `--worktree first` will fail to find the session — sessions and worktrees are tightly bound by `projectHash(cwd)`. To switch worktrees on an existing session you must exit, then re-launch with the new `--worktree` and a fresh prompt. A future architectural change (anchoring storage at the repo root instead of `cwd`) would lift this constraint.
|
|
- **Mid-session `enter_worktree` does NOT switch `process.cwd()` or `Config.targetDir`.** That tool uses the model-context-only convention (see [Sub-Agents](./sub-agents.md)). Only the startup `--worktree` flag actually switches the process working directory.
|
|
- **Relative paths in other arg fields are resolved BEFORE the worktree chdir.** Path-taking flags (`--mcp-config`, `--openai-logging-dir`, `--json-file`, `--input-file`, `--telemetry-outfile`, `--include-directories`) are normalized to absolute paths against the launch cwd when `--worktree` is set. Other path-shaped argv fields not in this list still resolve against the worktree cwd — use absolute paths to be safe.
|
|
|
|
Track the roadmap in `docs/design/worktree.md`.
|
|
|
|
## Troubleshooting
|
|
|
|
**The Footer shows no worktree indicator even though I just created one.**
|
|
Check that `ui.hideBuiltinWorktreeIndicator` is not set to `true`. Also confirm the slug is non-empty in the tool's success message.
|
|
|
|
**`--resume` does not restore my worktree.**
|
|
Check `<chatsDir>/<sessionId>.worktree.json` exists. The CLI deletes the sidecar automatically when the worktree directory is gone, so a missing sidecar plus a missing directory is the normal "no worktree to restore" state — not a bug. Run with `--debug` and grep for `restoreWorktreeContext` to see the reason.
|
|
|
|
**`exit_worktree` says "created by a different session".**
|
|
This is the session-ownership guard. Resume the original session and exit from there, or run the suggested `git worktree remove …` command manually.
|
|
|
|
**Stale `agent-<hex>` worktrees keep piling up.**
|
|
The 30-day cutoff is conservative; sweep manually with `git worktree list && git worktree remove <path>`, or wait — the next CLI startup after the 30-day mark will reap them as long as they are clean and pushed.
|