feat(worktree): Phase D — startup --worktree flag + symlinkDirectories + PR refs (#4381)

* 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.
This commit is contained in:
顾盼 authored and GitHub committed 2026-05-27 17:04:51 +08:00
1 parent bc1bac506a
commit 5ad5301805
22 files changed
+3719 -75

No files matched your search

+154 -31
View File
@@ -18,8 +18,9 @@ qwen-code 目前仅有面向 Arena 多模型对比场景的内部 worktree 实
| Post-creation setup(hooks 配置) | ❌ | ✅ | Phase C |
| StatusLine worktree 状态展示 | ❌ | ✅ | Phase C |
| WorktreeExitDialog(退出提示) | ❌ | ✅ | Phase C |
| `--worktree` CLI 启动标志 | ❌ | ✅ | Phase D |
| 符号链接目录(node_modules 等) | ❌ | ✅ | Phase D |
| `--worktree` CLI 启动标志 | ✅(Phase D) | ✅ | — |
| 符号链接目录(node_modules 等) | ✅(Phase D) | ✅ | — |
| PR 引用(`--worktree=#123`) | ✅(Phase D) | ✅ | — |
| sparse checkout | ❌ | ✅ | Future |
| tmux 集成 | ❌ | ✅ | Future |
| Arena 多模型 worktree 隔离 | ✅(qwen 独有) | ❌ | — |
@@ -56,12 +57,13 @@ Arena 的 worktree 路径由 `agents.arena.worktreeBaseDir` 控制,默认 `~/.
### 扩展配置
| 配置项 | 类型 | 用途 | 阶段 |
| ----------------------------- | ---------- | -------------------------------------------------------------- | ------- |
| `worktree.symlinkDirectories` | `string[]` | 符号链接指定目录(如 `node_modules`)到 worktree,避免磁盘浪费 | Phase D |
| `worktree.sparsePaths` | `string[]` | git sparse-checkout cone 模式,大型 monorepo 只写入指定路径 | Future |
| 配置项 | 类型 | 用途 | 阶段 |
| --------------------------------- | ---------- | ---------------------------------------------------------------- | ------- |
| `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 / C 不新增任何配置项。
Phase A / B 不新增任何配置项。
## 工具设计
@@ -233,32 +235,154 @@ _WorktreeExitDialog:_
---
### Phase D:启动时配置(`--worktree` CLI 标志 + 目录符号链接)
### Phase D:启动时配置(`--worktree` CLI 标志 + 目录符号链接 + PR 引用)
**目标:** 支持在启动时直接进入 worktree,并通过目录符号链接减少大型项目的磁盘开销。
**目标:** 支持在启动时直接进入 worktree、通过目录符号链接减少大型项目的磁盘开销,以及通过 PR 引用快速基于一个 pull request 创建 worktree。
**要实现的功能:**
**范围:** 三个功能在一个阶段一起落地,因为它们都挂在同一个启动入口上,且 symlink / PR fetch 两者都需要在 worktree 创建之后立即执行 — 单独拆分会重复改 bootstrap 序列。
_`--worktree [name]` CLI 启动标志:_
#### D-1:`--worktree [name]` CLI 启动标志
- `packages/cli/src/args.ts` 新增 `--worktree [name]` 参数
- 启动流程在进入主循环前调用 `createUserWorktree()`,将 `targetDir` 设为 worktree 路径,并写入 SessionService 状态
- 整个会话从启动即在 worktree 环境中运行,退出时触发 WorktreeExitDialog
**参数形态:** yargs 选项接受三种形式:
_`worktree.symlinkDirectories` 配置项:_
| 形式 | 行为 |
| ------------------------- | ---------------------------------------------------- |
| `qwen --worktree` | bare flag,自动生成 slug(`{形容词}-{名词}-{6hex}`) |
| `qwen --worktree my-name` | 显式 slug,沿用 `EnterWorktreeTool` 的 slug 校验规则 |
| `qwen --worktree=my-name` | 等价于上一种 |
- settings schema 新增 `worktree.symlinkDirectories: string[]`
- `createUserWorktree()` 后遍历配置,调用 `fs.symlink()` 将主仓库目录链接进 worktree
- 跳过目标不存在的项;目标已存在时跳过(不覆盖)
不提供短别名 `-w`(qwen-code 短别名只保留给最高频参数,避免命名冲突)。
**影响文件:**
**启动序列:** worktree 在以下位置创建:
| 文件 | 变更类型 |
| -------------------------------------------------- | ------------------------------------------- |
| `packages/cli/src/args.ts` | 新增 `--worktree [name]` 参数 |
| `packages/cli/src/main.ts`(或启动入口) | 解析 `--worktree` 并在主循环前创建 worktree |
| `packages/core/src/services/gitWorktreeService.ts` | `createUserWorktree()` 后追加 symlink 逻辑 |
| `packages/core/src/config/`(settings schema) | 新增 `worktree.symlinkDirectories` 字段 |
1. `parseArguments()` 解析 argv(已有)
2. resume picker(已有,line 588-629 of `gemini.tsx`)
3. `loadCliConfig()` 初始化 Config + auth(已有,line 643-653)
4. **新增:** 若 `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` 的优先级"。
5. 主循环(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:**
```jsonc
{
"worktree": {
"symlinkDirectories": ["node_modules", "dist", ".turbo"],
},
}
```
- 类型:`string[]`,默认 `undefined`(不开启,opt-in)
- 顶层 namespace `worktree` 是新增的(在 `settingsSchema.ts` 中按字母序插在 `tools` 与 `ui` 之间)
- 路径**相对于主仓库根**,绝对路径或包含 `..` 的路径被路径遍历守卫拒绝
**作用范围:** 所有由通用层创建的 worktree,包括:
- `EnterWorktreeTool`(Phase A)
- `AgentTool` `isolation: 'worktree'`(Phase B)
- `--worktree` CLI 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)。
#### 开放问题
1. **`--worktree-keep-on-exit`?** claude-code 没有,qwen-code 是否需要一个 CLI flag 让 Exit Dialog 默认选 keep?建议**先不加**,等用户反馈。
2. **`worktree.symlinkDirectories` 是否需要 per-project override?** 当前 settings 已经支持 user/workspace/project 三级合并,无需特殊处理。
3. **PR fetch 是否要拉取 `merge` ref(`pull/<N>/merge`,即与 base 合并后的 ref)而非 `head`?** claude-code 选 `head`,理由是用户通常想看 PR 的实际改动。沿用此选择。
---
@@ -266,9 +390,8 @@ _`worktree.symlinkDirectories` 配置项:_
以下功能面向更特定的使用场景,当前阶段不纳入排期,待用户需求明确后再评估实现。
| 功能 | 说明 |
| ----------------------- | ------------------------------------------------------------------------------------------- |
| sparse checkout | `worktree.sparsePaths` 配置项,大型 monorepo 只 checkout 指定路径,缩短创建时间和磁盘占用 |
| `.worktreeinclude` 文件 | 将 gitignore 的文件(`.env`、`secrets.json` 等)自动复制进 worktree |
| tmux 集成 | `--worktree --tmux` 在新 tmux 窗口启动 worktree 会话 |
| PR 引用解析 | `--worktree=#123` 自动 fetch PR 分支并基于它创建 worktree(依赖 Phase D `--worktree` 标志) |
| 功能 | 说明 |
| ----------------------- | ----------------------------------------------------------------------------------------- |
| sparse checkout | `worktree.sparsePaths` 配置项,大型 monorepo 只 checkout 指定路径,缩短创建时间和磁盘占用 |
| `.worktreeinclude` 文件 | 将 gitignore 的文件(`.env`、`secrets.json` 等)自动复制进 worktree |
| tmux 集成 | `--worktree --tmux` 在新 tmux 窗口启动 worktree 会话 |
+748
View File
@@ -0,0 +1,748 @@
# 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 [email protected]
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.
+1
View File
@@ -16,6 +16,7 @@ export default {
},
'approval-mode': 'Approval Mode',
'auto-mode': 'Auto Mode',
worktree: 'Worktrees',
mcp: 'MCP',
lsp: 'LSP (Language Server Protocol)',
'token-caching': 'Token Caching',
+345
View File
@@ -0,0 +1,345 @@
# 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.
+24
View File
@@ -175,6 +175,17 @@ export interface CliArgs {
forkSession?: boolean | undefined;
/** Internal: preserve the outer session ID when relaunching in a sandbox */
sandboxSessionId?: string | undefined;
/**
* Start the session inside a git worktree. Accepted forms:
* - bare `--worktree` (empty string from yargs) → auto-generated slug
* - `--worktree foo` / `--worktree=foo` → explicit slug
* - `--worktree=#123` / `--worktree https://github.com/o/r/pull/123` → PR ref
*
* Consumed by `setupStartupWorktree()` before `loadCliConfig()`. When set,
* the CLI chdirs into `<repoRoot>/.qwen/worktrees/<slug>/` and the entire
* session runs inside that worktree.
*/
worktree?: string | undefined;
maxSessionTurns: number | undefined;
maxWallTime: string | undefined;
maxToolCalls: number | undefined;
@@ -831,6 +842,14 @@ export async function parseArguments(): Promise<CliArgs> {
type: 'string',
hidden: true,
})
.option('worktree', {
type: 'string',
description:
'Start the session inside a git worktree at <repoRoot>/.qwen/worktrees/<slug>/. ' +
'Pass a slug (`--worktree my-feature`), a PR reference (`--worktree=#123` or a full ' +
'GitHub pull-request URL), or use bare `--worktree` to auto-generate a slug. ' +
'On exit, the WorktreeExitDialog prompts to keep or remove the worktree.',
})
.option('max-session-turns', {
type: 'number',
description: 'Maximum number of session turns',
@@ -1893,6 +1912,11 @@ export async function loadCliConfig(
: undefined,
}
: undefined,
worktree: settings.worktree
? {
symlinkDirectories: settings.worktree.symlinkDirectories,
}
: undefined,
};
const config = new Config(configParams);
+35
View File
@@ -2317,6 +2317,41 @@ const SETTINGS_SCHEMA = {
},
},
},
worktree: {
type: 'object',
label: 'Worktree',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Configuration for general-purpose git worktrees created by the ' +
'CLI (the `enter_worktree` tool, the `agent isolation: "worktree"` ' +
'parameter, and the startup `--worktree` flag). Does NOT affect ' +
'Agent Arena worktrees — see `agents.arena.worktreeBaseDir` for those.',
showInDialog: false,
properties: {
symlinkDirectories: {
type: 'array',
label: 'Symlink Directories Into Worktrees',
category: 'Advanced',
requiresRestart: false,
default: undefined as string[] | undefined,
description:
'Directories under the main repository to symlink into every ' +
'general-purpose worktree on creation. Useful for sharing ' +
'large opt-in dirs like `node_modules` so the model can run ' +
'tests / builds inside the worktree without a fresh install. ' +
'Paths must be relative to the repo root; absolute paths, ' +
'anything containing `..`, and any path inside `.git` or ' +
'`.qwen` (the CLI-managed metadata tree, which contains ' +
'the worktrees directory itself) are rejected. Missing ' +
'source dirs and existing destination paths are silently ' +
'skipped (no overwrite, no failure).',
showInDialog: false,
},
},
},
} as const satisfies SettingsSchema;
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
+132 -1
View File
@@ -21,7 +21,7 @@ import {
import { render } from 'ink';
import dns from 'node:dns';
import os from 'node:os';
import { basename } from 'node:path';
import path, { basename } from 'node:path';
import v8 from 'node:v8';
import React from 'react';
import { validateAuthMethod } from './config/auth.js';
@@ -39,6 +39,12 @@ import {
type InitializationResult,
} from './core/initializer.js';
import { runNonInteractive } from './nonInteractiveCli.js';
import {
setupStartupWorktree,
persistStartupWorktreeSidecar,
buildStartupWorktreeNotice,
type StartupWorktreeContext,
} from './startup/worktreeStartup.js';
import { runNonInteractiveStreamJson } from './nonInteractive/session.js';
import { AppContainer } from './ui/AppContainer.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
@@ -584,6 +590,86 @@ export async function main() {
}
}
// When --worktree is going to chdir us into a worktree below, resolve
// any relative-path argv fields to absolute paths now — BEFORE the
// chdir. Otherwise downstream `fs.existsSync('./mcp.json')` calls in
// `loadCliConfig` re-resolve against the worktree dir, where the file
// doesn't exist. Only touches values that look like paths (mcpConfig
// also accepts inline JSON — skip those).
//
// The list of fields below is hand-maintained. If you add a new
// CLI flag that takes a relative path, register it here too,
// otherwise --worktree silently breaks for that flag.
if (argv.worktree !== undefined) {
const launchCwdForPaths = process.cwd();
const looksLikeInlineJson = (v: string): boolean => {
const t = v.trim();
return t.startsWith('{') || t.startsWith('[');
};
const resolveIfPath = (v: string | undefined): string | undefined => {
if (typeof v !== 'string' || v.length === 0) return v;
if (looksLikeInlineJson(v)) return v;
return path.resolve(launchCwdForPaths, v);
};
argv.mcpConfig = resolveIfPath(argv.mcpConfig);
argv.openaiLoggingDir = resolveIfPath(argv.openaiLoggingDir);
argv.jsonFile = resolveIfPath(argv.jsonFile);
argv.inputFile = resolveIfPath(argv.inputFile);
argv.telemetryOutfile = resolveIfPath(argv.telemetryOutfile);
if (Array.isArray(argv.includeDirectories)) {
argv.includeDirectories = argv.includeDirectories.map((d) =>
typeof d === 'string' && d.length > 0
? path.resolve(launchCwdForPaths, d)
: d,
);
}
// `--json-schema` accepts either an inline schema or `@<path>`. The
// `@`-prefixed form is read from disk inside `resolveJsonSchemaArg`
// (`packages/cli/src/config/config.ts`), AFTER chdir, so a relative
// value would resolve against the worktree — fix the prefix path
// here.
if (typeof argv.jsonSchema === 'string') {
const trimmedSchema = argv.jsonSchema.trim();
if (trimmedSchema.startsWith('@')) {
const rel = trimmedSchema.slice(1);
if (rel.length > 0 && !path.isAbsolute(rel)) {
argv.jsonSchema = '@' + path.resolve(launchCwdForPaths, rel);
}
}
}
}
// Phase D-1: process --worktree before the resume picker so the picker
// (which uses process.cwd() to scope its session search) finds sessions
// saved inside the target worktree. Creates the worktree directory on
// disk and chdirs into it; on failure we emit to stderr and exit before
// any expensive initialization runs.
//
// ACP mode is exempt: the ACP host (Zed, etc.) supplies its own per-session
// cwd, and the startup-level chdir would not propagate. Reject the
// combination with a clear error rather than silently dropping --worktree.
let startupWorktreeContext: StartupWorktreeContext | null = null;
if (argv.worktree !== undefined && (argv.acp || argv.experimentalAcp)) {
writeStderrLine(
'--worktree cannot be combined with --acp / --experimental-acp. ' +
'Pass the worktree path as the cwd of the ACP loadSession / newSession ' +
'request instead.',
);
process.exit(1);
}
{
const startupRes = await setupStartupWorktree(argv.worktree, {
symlinkDirectories: settings.merged.worktree?.symlinkDirectories,
});
if (startupRes !== null) {
if (!startupRes.ok) {
writeStderrLine(startupRes.error);
process.exit(1);
}
startupWorktreeContext = startupRes.context;
}
}
// Handle --resume without a session ID, or with a custom title, by showing
// the session picker. Set the runtime output dir early so the picker can find
// sessions stored under a custom runtimeOutputDir (setRuntimeBaseDir is
@@ -656,6 +742,51 @@ export async function main() {
);
profileCheckpoint('after_load_cli_config');
// Phase D-1: persist the WorktreeSession sidecar so Phase C's restore
// machinery on a subsequent `--resume` picks the worktree back up, and
// capture any override of a previously-resumed session's worktree so
// we can emit a one-shot notice on the model's first prompt.
//
// The notice is set BEFORE the persist attempt and AGAIN inside the
// try block (so the override addendum can be appended on success).
// A persist failure must NOT silently drop the notice — the cwd is
// already switched, and the model needs to know which worktree it's
// operating in regardless of whether the sidecar landed.
if (startupWorktreeContext) {
config.setPendingStartupWorktreeNotice(
buildStartupWorktreeNotice(startupWorktreeContext),
);
try {
const startupWorktreePersist = await persistStartupWorktreeSidecar(
config,
startupWorktreeContext,
);
if (startupWorktreePersist.overrodeResumedWorktree) {
writeStderrLine(
`--worktree overrode the resumed session's previous worktree ` +
`"${startupWorktreePersist.overriddenSlug ?? '(unknown)'}". ` +
`That worktree directory was left intact on disk.`,
);
}
// Refresh the notice with the override addendum (if any). When
// there is no override this is a no-op text-wise; on override it
// gives the model the "you overrode <previous-slug>" hint. TUI
// and headless consume this via Config.consumePendingStartupWorktreeNotice();
// ACP is excluded above (`--worktree` × `--acp` is mutually
// exclusive — see the mutex check earlier in this function).
config.setPendingStartupWorktreeNotice(
buildStartupWorktreeNotice(
startupWorktreeContext,
startupWorktreePersist,
),
);
} catch (error) {
debugLogger.warn(
`--worktree sidecar persist failed (non-fatal, notice preserved): ${error instanceof Error ? error.message : String(error)}`,
);
}
}
// Register cleanup for MCP clients as early as possible
// This ensures MCP server subprocesses are properly terminated on exit
registerCleanup(() => config.shutdown());
@@ -213,6 +213,12 @@ describe('runNonInteractive', () => {
// restore worktree context. These tests don't exercise resume, so
// return undefined to short-circuit the helper.
getResumedSessionData: vi.fn().mockReturnValue(undefined),
// Phase D-1: nonInteractiveCli calls this on every prompt to pick
// up the one-shot startup-worktree notice (set by gemini.tsx
// when --worktree was passed). These tests don't exercise the
// --worktree flag, so return null to short-circuit injection
// and let the resume-restore branch run.
consumePendingStartupWorktreeNotice: vi.fn().mockReturnValue(null),
} as unknown as Config;
mockSettings = {
+30 -15
View File
@@ -415,27 +415,42 @@ export async function runNonInteractive(
initialPartList = [{ text: input }];
}
// Phase C: when --resume restored a session with an active worktree,
// prepend a system-reminder block to the user prompt so the model
// knows to keep using the worktree path. Stale sidecars (worktree
// dir deleted between sessions) are cleaned up inside the helper.
// TUI does this via historyManager.addItem(INFO); headless does it
// here because there is no UI history to write into.
if (config.getResumedSessionData()) {
// Inject a worktree context notice into the model's first prompt.
// Two sources: the `--worktree` startup flag (set by gemini.tsx
// before loadCliConfig) takes precedence over the Phase C resume
// restore. TUI does this via historyManager.addItem(INFO); here in
// headless we prepend a `<system-reminder>` block since there is
// no UI history to write into.
const withReminder = (
existing: PartListUnion,
text: string,
): PartListUnion => {
const reminderPart: Part = {
text: `<system-reminder>\n${text}\n</system-reminder>\n\n`,
};
return Array.isArray(existing)
? [reminderPart, ...existing]
: [reminderPart, existing];
};
const startupNotice = config.consumePendingStartupWorktreeNotice();
if (startupNotice) {
initialPartList = withReminder(initialPartList, startupNotice);
adapter.emitSystemMessage('worktree_started', {
notice: startupNotice,
});
} else if (config.getResumedSessionData()) {
try {
const sessionPath = config
.getSessionService()
.getWorktreeSessionPath(sessionId);
const restored = await restoreWorktreeContext(sessionPath);
if (restored.contextMessage) {
const reminderPart: Part = {
text: `<system-reminder>\n${restored.contextMessage}\n</system-reminder>\n\n`,
};
const partsArr = Array.isArray(initialPartList)
? initialPartList
: [initialPartList];
initialPartList = [reminderPart, ...partsArr];
// Also surface the notice in the JSON stream so SDK consumers
initialPartList = withReminder(
initialPartList,
restored.contextMessage,
);
// Surface the notice in the JSON stream so SDK consumers
// can react to it (logging, UI hints, etc.).
adapter.emitSystemMessage('worktree_restored', {
slug: restored.session?.slug,
@@ -0,0 +1,409 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import {
setupStartupWorktree,
buildStartupWorktreeNotice,
} from './worktreeStartup.js';
const exec = promisify(execFile);
async function makeTempRepo(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-startup-test-'));
// macOS resolves /var → /private/var; pwd -P is the cheapest way to
// normalise. Use realpath so subsequent string comparisons against
// process.cwd() match exactly.
const resolved = await fs.realpath(dir);
await exec('git', ['init', '-q', '-b', 'main'], { cwd: resolved });
await exec('git', ['config', 'user.email', '[email protected]'], { cwd: resolved });
await exec('git', ['config', 'user.name', 't'], { cwd: resolved });
await exec('git', ['config', 'commit.gpgsign', 'false'], { cwd: resolved });
// Disable autocrlf so file contents committed and read back via the
// test compare byte-for-byte on Windows runners (where the default
// `core.autocrlf=true` checks files out with `\r\n`, breaking
// assertions like `expect(content).toBe('foo\n')`).
await exec('git', ['config', 'core.autocrlf', 'false'], { cwd: resolved });
await exec('git', ['config', 'core.eol', 'lf'], { cwd: resolved });
await fs.writeFile(path.join(resolved, 'README.md'), 'hello\n');
await exec('git', ['add', 'README.md'], { cwd: resolved });
await exec('git', ['commit', '-q', '-m', 'initial', '--no-verify'], {
cwd: resolved,
});
return resolved;
}
describe('setupStartupWorktree', () => {
// Real git operations + fetch through a local bare remote can take
// 10–15s on slower runners; bump the per-test ceiling so the PR-ref
// happy-path test doesn't flake.
vi.setConfig({ testTimeout: 30000, hookTimeout: 30000 });
let prevCwd: string;
let tempRepo: string | null = null;
beforeEach(() => {
prevCwd = process.cwd();
});
afterEach(async () => {
// Restore cwd before cleanup so the test process can rm -rf the temp dir.
process.chdir(prevCwd);
if (tempRepo) {
await fs.rm(tempRepo, { recursive: true, force: true });
tempRepo = null;
}
});
it('returns null when --worktree was not passed', async () => {
const res = await setupStartupWorktree(undefined);
expect(res).toBeNull();
});
it('rejects when the launch cwd is not a git repo', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-nongit-'));
tempRepo = dir;
process.chdir(await fs.realpath(dir));
const res = await setupStartupWorktree('foo');
expect(res).not.toBeNull();
expect(res!.ok).toBe(false);
if (!res!.ok) {
expect(res!.error).toMatch(/not a git repository/i);
}
});
it('creates a worktree with an auto-generated slug for bare --worktree', async () => {
tempRepo = await makeTempRepo();
process.chdir(tempRepo);
const res = await setupStartupWorktree('');
expect(res).not.toBeNull();
expect(res!.ok).toBe(true);
if (res!.ok) {
// adj-noun-XXXXXX pattern from GitWorktreeService.generateAutoSlug
// (3 random bytes → 6 hex chars).
expect(res!.context.slug).toMatch(/^[a-z]+-[a-z]+-[0-9a-f]{6}$/);
expect(res!.context.branch).toBe(`worktree-${res!.context.slug}`);
expect(res!.context.worktreePath).toContain(
path.join('.qwen', 'worktrees', res!.context.slug),
);
expect(res!.context.repoRoot).toBe(tempRepo);
expect(res!.context.originalBranch).toBe('main');
// 40-char SHA
expect(res!.context.originalHeadCommit).toMatch(/^[0-9a-f]{40}$/);
expect(res!.context.isPullRequest).toBe(false);
// process.cwd() was switched into the worktree.
expect(process.cwd()).toBe(res!.context.worktreePath);
// The worktree directory exists on disk and is a real dir.
const stat = await fs.stat(res!.context.worktreePath);
expect(stat.isDirectory()).toBe(true);
}
});
it('creates a worktree with an explicit slug', async () => {
tempRepo = await makeTempRepo();
process.chdir(tempRepo);
const res = await setupStartupWorktree('my-feature');
expect(res).not.toBeNull();
expect(res!.ok).toBe(true);
if (res!.ok) {
expect(res!.context.slug).toBe('my-feature');
expect(res!.context.branch).toBe('worktree-my-feature');
expect(res!.context.worktreePath).toBe(
path.join(tempRepo, '.qwen', 'worktrees', 'my-feature'),
);
}
});
it('rejects invalid slug characters before any git operation', async () => {
tempRepo = await makeTempRepo();
process.chdir(tempRepo);
const res = await setupStartupWorktree('../escape');
expect(res).not.toBeNull();
expect(res!.ok).toBe(false);
if (!res!.ok) {
expect(res!.error.toLowerCase()).toMatch(
/letters|hyphens|invalid|may only/,
);
}
// No worktree directory was created.
const exists = await fs
.stat(path.join(tempRepo, '.qwen', 'worktrees'))
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
// cwd was not changed.
expect(process.cwd()).toBe(tempRepo);
});
it('rejects #N PR references when origin remote is missing', async () => {
tempRepo = await makeTempRepo();
process.chdir(tempRepo);
// Temp repo has no `origin` remote — fetch should fail-close with a
// clear hint about adding origin.
const res = await setupStartupWorktree('#123');
expect(res).not.toBeNull();
expect(res!.ok).toBe(false);
if (!res!.ok) {
expect(res!.error).toContain('#123');
expect(res!.error.toLowerCase()).toContain('origin');
}
// No worktree directory was created — fail-close means no side effect.
const exists = await fs
.stat(path.join(tempRepo, '.qwen', 'worktrees'))
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
it('rejects full GitHub PR URLs when origin remote is missing', async () => {
tempRepo = await makeTempRepo();
process.chdir(tempRepo);
const res = await setupStartupWorktree(
'https://github.com/QwenLM/qwen-code/pull/4174',
);
expect(res).not.toBeNull();
expect(res!.ok).toBe(false);
if (!res!.ok) {
expect(res!.error).toContain('#4174');
expect(res!.error.toLowerCase()).toContain('origin');
}
});
it('creates a pr-<N> worktree from FETCH_HEAD when fetch succeeds (local fake remote)', async () => {
// Set up a fake "origin" repo that exposes refs/pull/<N>/head — git
// fetch only cares that the refspec exists on the remote, not that
// the remote is github.com. update-ref lets us materialise the ref
// locally without an actual GitHub round-trip.
const upstream = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-wt-pr-upstream-'),
);
const upstreamResolved = await fs.realpath(upstream);
await exec('git', ['init', '-q', '--bare', '-b', 'main'], {
cwd: upstreamResolved,
});
tempRepo = await makeTempRepo();
process.chdir(tempRepo);
await exec('git', ['remote', 'add', 'origin', upstreamResolved], {
cwd: tempRepo,
});
await exec('git', ['push', '-q', 'origin', 'main'], { cwd: tempRepo });
// Author a "PR commit" on a feature branch in the local repo, push
// it to the upstream as refs/pull/42/head.
await exec('git', ['checkout', '-q', '-b', 'pr-source'], { cwd: tempRepo });
await fs.writeFile(path.join(tempRepo, 'pr-file.txt'), 'from PR 42\n');
await exec('git', ['add', 'pr-file.txt'], { cwd: tempRepo });
await exec('git', ['commit', '-q', '-m', 'PR 42 commit', '--no-verify'], {
cwd: tempRepo,
});
await exec('git', ['push', '-q', 'origin', 'HEAD:refs/pull/42/head'], {
cwd: tempRepo,
});
await exec('git', ['checkout', '-q', 'main'], { cwd: tempRepo });
// Drop the local pr-source branch so the worktree branch isn't
// confused with it.
await exec('git', ['branch', '-q', '-D', 'pr-source'], { cwd: tempRepo });
try {
const res = await setupStartupWorktree('#42');
expect(res).not.toBeNull();
expect(res!.ok).toBe(true);
if (res!.ok) {
expect(res!.context.slug).toBe('pr-42');
expect(res!.context.branch).toBe('worktree-pr-42');
expect(res!.context.isPullRequest).toBe(true);
expect(res!.context.worktreePath).toBe(
path.join(tempRepo, '.qwen', 'worktrees', 'pr-42'),
);
// The PR file lives inside the worktree (proving FETCH_HEAD was
// the base, not main).
const prFile = await fs.readFile(
path.join(res!.context.worktreePath, 'pr-file.txt'),
'utf8',
);
expect(prFile).toBe('from PR 42\n');
// Phase D-3 round 4: `originalHeadCommit` for PR worktrees must
// be the resolved FETCH_HEAD SHA (the PR tip), NOT the parent
// repo's HEAD. `WorktreeExitDialog`'s `rev-list <head>..HEAD`
// later relies on this to count only the user's own commits in
// the worktree, not the entire PR's history.
expect(res!.context.originalHeadCommit).toMatch(/^[0-9a-f]{40}$/);
// Resolve the PR ref directly and compare: must match.
const expectedSha = (
await exec('git', ['rev-parse', 'refs/pull/42/head'], {
cwd: upstreamResolved,
})
).stdout.trim();
expect(res!.context.originalHeadCommit).toBe(expectedSha);
// Sanity: must NOT equal the parent repo's main HEAD.
const parentHead = (
await exec('git', ['rev-parse', 'HEAD'], { cwd: tempRepo })
).stdout.trim();
expect(res!.context.originalHeadCommit).not.toBe(parentHead);
}
} finally {
// Restore cwd before rm so the upstream cleanup doesn't hit EBUSY.
process.chdir(prevCwd);
await fs.rm(upstreamResolved, { recursive: true, force: true });
}
});
it('re-attaches to an existing worktree instead of erroring (Phase 6 G1 fix)', async () => {
tempRepo = await makeTempRepo();
process.chdir(tempRepo);
// First call creates the worktree.
const first = await setupStartupWorktree('reattach-test');
expect(first).not.toBeNull();
expect(first!.ok).toBe(true);
if (!first!.ok) return;
expect(first!.context.wasReattached).toBe(false);
// Restore cwd so the second call starts from launch cwd, mirroring
// the real `qwen --resume <sid> --worktree foo` invocation flow.
process.chdir(tempRepo);
// Second call with the same slug now re-attaches, doesn't create.
const second = await setupStartupWorktree('reattach-test');
expect(second).not.toBeNull();
expect(second!.ok).toBe(true);
if (!second!.ok) return;
expect(second!.context.wasReattached).toBe(true);
expect(second!.context.slug).toBe('reattach-test');
expect(second!.context.branch).toBe('worktree-reattach-test');
expect(second!.context.worktreePath).toBe(first!.context.worktreePath);
expect(process.cwd()).toBe(first!.context.worktreePath);
});
it('refuses to re-attach when an existing dir occupies the slot on a different branch', async () => {
tempRepo = await makeTempRepo();
process.chdir(tempRepo);
// Manually create a directory at the would-be worktree path that
// is NOT a git worktree (just a plain dir with a file in it).
const slotPath = path.join(
tempRepo,
'.qwen',
'worktrees',
'plain-dir-conflict',
);
await fs.mkdir(slotPath, { recursive: true });
await fs.writeFile(path.join(slotPath, 'unexpected-content.txt'), 'oops');
// setupStartupWorktree should NOT silently re-attach (the dir is
// not a registered worktree). It also should NOT error — instead,
// it falls through to createUserWorktree which fails with the
// "already exists" branch.
const res = await setupStartupWorktree('plain-dir-conflict');
expect(res).not.toBeNull();
expect(res!.ok).toBe(false);
if (!res!.ok) {
// Either the re-attach branch error or createUserWorktree's
// "already exists" message is acceptable — both prevent clobbering.
expect(res!.error.toLowerCase()).toMatch(
/already exists|registered git worktree|expected/,
);
}
// Unexpected file survived.
const survived = await fs.readFile(
path.join(slotPath, 'unexpected-content.txt'),
'utf8',
);
expect(survived).toBe('oops');
});
it('refuses nested worktree creation from inside .qwen/worktrees/', async () => {
tempRepo = await makeTempRepo();
// Pre-create a fake worktree path and chdir into it. We don't need a
// real git worktree — the guard fires on path shape, not git state.
const nestedPath = path.join(tempRepo, '.qwen', 'worktrees', 'outer');
await fs.mkdir(nestedPath, { recursive: true });
process.chdir(nestedPath);
const res = await setupStartupWorktree('inner');
expect(res).not.toBeNull();
expect(res!.ok).toBe(false);
if (!res!.ok) {
expect(res!.error.toLowerCase()).toMatch(
/nested|inside another worktree/,
);
}
});
});
describe('buildStartupWorktreeNotice', () => {
// Only the four fields the function actually consumes — the `Pick<>`
// signature lets us keep the fixture minimal so adding new
// StartupWorktreeContext fields doesn't churn this file.
const baseContext = {
worktreePath: '/repo/.qwen/worktrees/foo',
slug: 'foo',
branch: 'worktree-foo',
wasReattached: false,
};
it('produces a single line for the no-override created case', () => {
const notice = buildStartupWorktreeNotice(baseContext);
expect(notice).toContain('[Startup]');
expect(notice).toContain('Active worktree');
expect(notice).toContain('"foo"');
expect(notice).toContain('/repo/.qwen/worktrees/foo');
expect(notice).toContain('worktree-foo');
expect(notice).not.toContain('Re-attached');
expect(notice).not.toContain('overrode');
});
it('uses "Re-attached" verb when wasReattached is true', () => {
const notice = buildStartupWorktreeNotice({
...baseContext,
wasReattached: true,
});
expect(notice).toContain('[Startup]');
expect(notice).toContain('Re-attached to worktree');
expect(notice).not.toContain('Active worktree');
});
it('appends an override hint when a previous worktree was overridden', () => {
const notice = buildStartupWorktreeNotice(baseContext, {
overrodeResumedWorktree: true,
overriddenSlug: 'old-slug',
sidecarPath: '/anywhere/sidecar.json',
});
expect(notice).toContain('[Startup]');
expect(notice).toContain('overrode');
expect(notice).toContain('"old-slug"');
expect(notice).toContain('qwen --worktree old-slug');
});
it('does NOT append the override hint when overrodeResumedWorktree is false', () => {
const notice = buildStartupWorktreeNotice(baseContext, {
overrodeResumedWorktree: false,
sidecarPath: '/anywhere/sidecar.json',
});
expect(notice).not.toContain('overrode');
});
});
+470
View File
@@ -0,0 +1,470 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Startup-time worktree setup for the `--worktree` CLI flag (Phase D-1).
*
* Runs after argv parsing and before `loadCliConfig()` / `Config` construction
* so the resulting `process.cwd()` change feeds directly into the Config's
* `targetDir`. Three entry forms are supported (see {@link setupStartupWorktree}):
*
* - Empty string (bare `--worktree`) → auto-generated `{adj}-{noun}-{6hex}` slug
* - Plain slug (`--worktree my-feature`) → that exact slug
* - PR reference (`--worktree=#123`, `--worktree https://github.com/o/r/pull/123`)
* → slug `pr-<N>`, fetched via `git fetch origin pull/<N>/head` and based
* off `FETCH_HEAD` (Phase D-3).
*
* Sidecar writing and `--resume` override accounting are NOT handled here —
* those need a constructed `Config` and live in {@link persistStartupWorktreeSidecar}.
*/
import * as path from 'node:path';
import {
createDebugLogger,
GitWorktreeService,
readWorktreeSession,
worktreeBranchForSlug,
writeWorktreeSession,
writeWorktreeSessionMarker,
} from '@qwen-code/qwen-code-core';
import type { Config, WorktreeSession } from '@qwen-code/qwen-code-core';
const debugLogger = createDebugLogger('WORKTREE_STARTUP');
/**
* `git rev-parse --abbrev-ref HEAD` returns this literal when the
* launch cwd has a detached HEAD checked out. Two related uses:
*
* 1. As an INPUT filter when normalizing `getCurrentBranch` output:
* we treat `'HEAD'` as "no real branch" and collapse to `undefined`
* so detached-state propagates uniformly through the slug/baseRef
* pipeline.
* 2. As the FALLBACK metadata string written to the sidecar's
* `originalBranch` field when the launch state was detached
* (no branch name to record).
*/
const DETACHED_HEAD = 'HEAD';
/**
* Resolved metadata for a startup worktree. Returned to the caller so the
* sidecar write (which needs `Config`) can happen after `loadCliConfig`.
*/
export interface StartupWorktreeContext {
/** Resolved absolute worktree path (where `process.cwd()` now points). */
worktreePath: string;
/** Slug, e.g. `my-feature` or `pr-123`. */
slug: string;
/** Branch name, e.g. `worktree-my-feature` or `worktree-pr-123`. */
branch: string;
/** Repo top level captured before chdir. */
repoRoot: string;
/** Branch that was checked out at worktree-creation time. */
originalBranch: string;
/** HEAD SHA captured at worktree-creation time (for WorktreeExitDialog). */
originalHeadCommit: string;
/** True iff the input was a PR reference. */
isPullRequest: boolean;
/**
* True when the worktree directory already existed at startup and we
* re-attached to it. PR fetch is skipped
* on re-attach since the ref was materialized previously, and
* commit-count semantics in `WorktreeExitDialog` will track only this
* session's new commits.
*/
wasReattached: boolean;
}
export type SetupStartupWorktreeResult =
| { ok: true; context: StartupWorktreeContext }
| { ok: false; error: string };
/**
* Resolves slug, creates the worktree, switches `process.cwd()`, and returns
* the metadata needed for the post-`loadCliConfig` sidecar write.
*
* Returns `null` when `rawInput === undefined` (no `--worktree` flag passed
* at all). Returns `{ ok: false, error }` for validation / git failures so
* the caller can print to stderr and exit with a controlled non-zero status.
*
* The caller is responsible for chdir-ing back if a later step fails — this
* helper does not roll back the worktree directory on a downstream error,
* matching `EnterWorktreeTool`'s "the worktree is yours now" semantics.
*/
export interface SetupStartupWorktreeOptions {
/**
* Mirrors `worktree.symlinkDirectories` (Phase D-2). Forwarded to
* `createUserWorktree` so the new worktree gets the same opt-in
* symlinks as `enter_worktree` and agent isolation worktrees do.
*/
symlinkDirectories?: readonly string[];
}
export async function setupStartupWorktree(
rawInput: string | undefined,
options?: SetupStartupWorktreeOptions,
): Promise<SetupStartupWorktreeResult | null> {
if (rawInput === undefined) return null;
// yargs delivers bare `--worktree` as an empty string (mirrors --resume).
// We accept it and fall through to auto-slug below.
const trimmed = rawInput.trim();
// Probe service rooted at the launch cwd so we can locate the repo top
// level before the chdir; the chdir target lives under that top level.
const launchCwd = process.cwd();
const probe = new GitWorktreeService(launchCwd);
const gitCheck = await probe.checkGitAvailable();
if (!gitCheck.available) {
return {
ok: false,
error: `--worktree: ${gitCheck.error ?? 'git is not available on PATH.'}`,
};
}
// Refuse nested creation: launching with --worktree from inside an existing
// worktree creates `<otherRepo>/.qwen/worktrees/<slug>/`, which is rarely
// what the user wants and corrupts ownership tracking.
if (/[\\/]\.qwen[\\/]worktrees[\\/]/.test(launchCwd)) {
return {
ok: false,
error: `--worktree: cannot start a new worktree from inside another worktree (cwd: ${launchCwd}). Run from the main checkout.`,
};
}
// `getRepoTopLevel()` returns null when cwd is not inside a git repo,
// so a single subprocess covers both the is-a-repo gate and the
// top-level resolution we need for the worktree path.
const rawRepoRoot = await probe.getRepoTopLevel();
if (rawRepoRoot === null) {
return {
ok: false,
error: `--worktree: ${launchCwd} is not a git repository. Run \`git init\` first or relaunch from inside one.`,
};
}
// git always emits POSIX-style paths (forward slashes) via
// `--show-toplevel`. Normalize to the platform-native separator
// before storing or comparing so the sidecar's `originalCwd` and
// downstream `startsWith` checks don't mix `/` and `\` on Windows.
const repoRoot = path.resolve(rawRepoRoot);
const service =
repoRoot === launchCwd ? probe : new GitWorktreeService(repoRoot);
// Resolve slug. Branch on PR reference first so `#123` / URLs don't fall
// through to slug validation (which would reject `#`). For PR refs we
// DEFER the fetch until we've checked whether the worktree already
// exists on disk — re-attach skips the fetch since the ref was
// materialized on the first run.
const prNumber = GitWorktreeService.parsePRReference(trimmed);
const isPullRequest = prNumber !== null;
let slug: string;
if (prNumber !== null) {
slug = `pr-${prNumber}`;
} else if (trimmed.length === 0) {
slug = GitWorktreeService.generateAutoSlug();
} else {
const validation = GitWorktreeService.validateUserWorktreeSlug(trimmed);
if (validation) {
return { ok: false, error: `--worktree: ${validation}` };
}
slug = trimmed;
}
// Capture the launch-time branch and HEAD. These feed the WorktreeSession
// sidecar's `originalBranch` / `originalHeadCommit` fields when we go
// through the CREATE path; on re-attach the HEAD baseline is re-captured
// from inside the worktree itself (see the re-attach branch below) so
// `WorktreeExitDialog`'s `rev-list <originalHeadCommit>..HEAD` counts
// only this session's new commits — not every commit the kept worktree
// accumulated across prior sessions.
//
// The two probes are independent — run in parallel to shave one
// subprocess off the critical path. Each is individually try-wrapped
// so a failure in one (unborn HEAD, partial init) doesn't poison
// the other. Detached-HEAD normalization via DETACHED_HEAD const.
const [originalBranchRaw, originalHeadCommit] = await Promise.all([
service.getCurrentBranch().catch(() => undefined),
service.getCurrentCommitHash().catch(() => ''),
]);
const originalBranch =
originalBranchRaw && originalBranchRaw !== DETACHED_HEAD
? originalBranchRaw
: undefined;
// Re-attach to an existing worktree instead of erroring out. Common
// case: user did `qwen --worktree foo` previously, exited with Keep,
// and now runs `qwen --resume <sid> --worktree foo` to continue. The
// directory + branch are already on disk; we just chdir into them.
//
// `getRegisteredWorktreeBranch` returns the worktree's HEAD commit
// alongside the branch (single rev-parse). Using THAT as
// `originalHeadCommit` instead of the launch-cwd capture is critical:
// `WorktreeExitDialog` later runs `rev-list <head>..HEAD` inside the
// worktree, so the launch-cwd HEAD would make it count every commit
// accumulated in the worktree across all prior sessions as "new work
// this session".
const expectedWorktreePath = service.getUserWorktreePath(slug);
const expectedBranch = worktreeBranchForSlug(slug);
let registered: { branch: string; headCommit: string } | null = null;
try {
registered =
await service.getRegisteredWorktreeBranch(expectedWorktreePath);
} catch {
registered = null;
}
if (registered !== null) {
if (registered.branch !== expectedBranch) {
// SOMETHING ELSE is occupying the path on a different branch —
// refuse to clobber it.
return {
ok: false,
error:
`--worktree: ${expectedWorktreePath} is already a git worktree, but its branch ` +
`is ${registered.branch} (expected ${expectedBranch}). Refusing to re-attach. ` +
`Resolve the conflict manually (e.g. \`git worktree remove ${expectedWorktreePath}\`).`,
};
}
const worktreePath = path.resolve(expectedWorktreePath);
try {
process.chdir(worktreePath);
} catch (error) {
return {
ok: false,
error: `--worktree: failed to chdir into ${worktreePath} (${error instanceof Error ? error.message : String(error)}).`,
};
}
debugLogger.debug(
`setupStartupWorktree: re-attached to existing worktree at ${worktreePath} (branch=${registered.branch})`,
);
return {
ok: true,
context: {
worktreePath,
slug,
branch: registered.branch,
repoRoot,
originalBranch: originalBranch ?? DETACHED_HEAD,
originalHeadCommit: registered.headCommit,
isPullRequest,
wasReattached: true,
},
};
}
// Phase D-3: fetch the PR ref BEFORE creating the worktree, so the
// base ref (FETCH_HEAD) is available to `git worktree add`. Skipped
// on re-attach above. Fail-close: any fetch error stops startup before
// we create disk state.
//
// Lock FETCH_HEAD to an immutable SHA *immediately* after the fetch:
// - closes a TOCTOU window in which a concurrent `git fetch` from
// any other process sharing this repo would overwrite FETCH_HEAD
// before `git worktree add` reads it, branching the new worktree
// off an unrelated commit;
// - lets us pass that same SHA back as `originalHeadCommit`, so
// `WorktreeExitDialog`'s `rev-list <head>..HEAD` later inside the
// worktree counts only the user's own new commits — not the
// entire fetched PR's history.
let pullRequestHeadSha: string | null = null;
if (prNumber !== null) {
const fetchRes = await service.fetchPullRequestRef(prNumber);
if (!fetchRes.success) {
return { ok: false, error: `--worktree: ${fetchRes.error}` };
}
pullRequestHeadSha = await service.resolveRef('FETCH_HEAD');
if (pullRequestHeadSha === null) {
return {
ok: false,
error: `--worktree: fetched PR #${prNumber} but FETCH_HEAD did not resolve to a commit SHA. Refusing to proceed (the worktree would otherwise branch off an unknown commit).`,
};
}
}
// For PR worktrees the base ref is the SHA we just locked in (NOT the
// literal `FETCH_HEAD`, which is mutable); for regular slugs we anchor
// at the parent session's currently checked-out branch.
const baseRef = isPullRequest ? pullRequestHeadSha! : originalBranch;
const result = await service.createUserWorktree(slug, baseRef, {
symlinkDirectories: options?.symlinkDirectories,
});
if (!result.success || !result.worktree) {
return {
ok: false,
error: `--worktree: ${result.error ?? 'failed to create worktree.'}`,
};
}
// Switch the process working directory so loadCliConfig() picks up the
// worktree as targetDir, and subsequent shell / file operations land
// inside it. Mirror the convention used elsewhere in the codebase by
// working with the resolved absolute path.
const worktreePath = path.resolve(result.worktree.path);
try {
process.chdir(worktreePath);
} catch (error) {
return {
ok: false,
error: `--worktree: created worktree at ${worktreePath} but failed to chdir into it (${error instanceof Error ? error.message : String(error)}). Run \`cd ${worktreePath}\` manually.`,
};
}
return {
ok: true,
context: {
worktreePath,
slug,
branch: result.worktree.branch,
repoRoot,
originalBranch: originalBranch ?? DETACHED_HEAD,
// For PR worktrees, the worktree's HEAD starts at the fetched PR
// tip — not at the parent repo's HEAD. Use the SHA we locked in
// post-fetch so the exit-dialog rev-list counts only the user's
// new commits, not the entire PR history.
originalHeadCommit: isPullRequest
? pullRequestHeadSha!
: originalHeadCommit,
isPullRequest,
wasReattached: false,
},
};
}
/**
* Result of the post-`loadCliConfig` sidecar persist step. Callers use the
* boolean fields to decide whether to surface an INFO line in TUI / a
* `<system-reminder>` in headless / a `pendingWorktreeNotice` in ACP.
*/
export interface PersistStartupWorktreeResult {
/** True when a pre-existing sidecar was found and overridden. */
overrodeResumedWorktree: boolean;
/**
* Slug of the worktree that was overridden, when {@link overrodeResumedWorktree}
* is true. Used in the INFO message so users can re-attach to it if they
* launched with `--worktree` by mistake.
*/
overriddenSlug?: string;
/** Path to the sidecar file just written. */
sidecarPath: string;
}
/**
* Writes the `WorktreeSession` sidecar that Phase C's `--resume` restore
* machinery consumes, and tags the worktree directory with the current
* session ID so cross-session `exit_worktree action="remove"` is refused.
*
* Handles the `--worktree` × `--resume` precedence: when a sidecar already
* exists (the user resumed a session that previously had a different
* worktree), the new context wins and the previous slug is reported back
* so callers can show an INFO line.
*/
export async function persistStartupWorktreeSidecar(
config: Config,
context: StartupWorktreeContext,
): Promise<PersistStartupWorktreeResult> {
const sessionId = config.getSessionId();
const sidecarPath = config
.getSessionService()
.getWorktreeSessionPath(sessionId);
// Read whatever sidecar exists before we clobber it, so we can detect
// and report an override. A read failure (corrupt JSON, permission)
// collapses to "no previous worktree" — the new sidecar still wins.
// Log the failure with the sidecar path so an operator can recover the
// previous slug from a backup if they care; silent loss would make
// "where did my previous worktree binding go?" undebuggable.
let overrodeResumedWorktree = false;
let overriddenSlug: string | undefined;
let previous: WorktreeSession | null = null;
try {
previous = await readWorktreeSession(sidecarPath);
} catch (error) {
debugLogger.warn(
`persistStartupWorktreeSidecar: failed to read existing sidecar at ${sidecarPath} — treating as "no previous worktree" and proceeding: ${error}`,
);
previous = null;
}
if (previous && previous.slug !== context.slug) {
overrodeResumedWorktree = true;
overriddenSlug = previous.slug;
}
// Best-effort marker write — same policy as EnterWorktreeTool: a failure
// here does not abort the session, the worktree is usable, ownership
// checks just treat the worktree as "owner unknown" for future
// exit_worktree calls.
//
// SKIP on re-attach: the marker was written by whichever session
// ORIGINALLY created this worktree. Overwriting with the current
// session id would let `exit_worktree action="remove"` succeed across
// sessions, bypassing Phase A's cross-session ownership guard. The
// existing marker stays so the original owner remains canonical; the
// current session can still operate INSIDE the worktree (file ops,
// commits) — ownership only governs the destructive remove.
if (!context.wasReattached) {
await writeWorktreeSessionMarker(context.worktreePath, sessionId).catch(
() => {},
);
}
await writeWorktreeSession(sidecarPath, {
slug: context.slug,
worktreePath: context.worktreePath,
worktreeBranch: context.branch,
originalCwd: context.repoRoot,
originalBranch: context.originalBranch,
originalHeadCommit: context.originalHeadCommit,
});
// The previous worktree directory (if any) is intentionally left on
// disk — the user retains the ability to re-attach by launching again
// with `--worktree <previous-slug>`. We only swap the sidecar's slug.
return { overrodeResumedWorktree, overriddenSlug, sidecarPath };
}
/**
* Builds the one-shot context message that gets injected into the model on
* the first user prompt (TUI: INFO history item + reminder prefix; headless:
* `<system-reminder>` prefix + JSON event; ACP currently exits before
* reaching this code path — see the `--worktree` × `--acp` mutex check
* in `gemini.tsx`).
*
* Mirrors `restoreWorktreeContext`'s contextMessage shape so resumed-with-
* worktree and started-with-worktree sessions read identically to the model.
*
* Differentiates the verb based on whether the worktree was just created
* or the CLI re-attached to a pre-existing one — same slug + branch but
* meaningfully different user intent. The override addendum (when
* `--worktree` clobbered a resumed session's prior worktree) is shown
* regardless of created/reattached state.
*
* Parameter type is `Pick<StartupWorktreeContext, …>` rather than the full
* context so test fixtures can construct minimal literals without
* tracking every internal field. Adding fields to {@link
* StartupWorktreeContext} should NOT force test-fixture churn here.
*/
export function buildStartupWorktreeNotice(
context: Pick<
StartupWorktreeContext,
'slug' | 'worktreePath' | 'branch' | 'wasReattached'
>,
override?: PersistStartupWorktreeResult,
): string {
const verb = context.wasReattached
? 'Re-attached to worktree'
: 'Active worktree';
const base =
`[Startup] ${verb}: "${context.slug}" at ${context.worktreePath} ` +
`(branch: ${context.branch}). Continue using this path for all file operations.`;
if (override?.overrodeResumedWorktree && override.overriddenSlug) {
return (
`${base}\n` +
`Note: --worktree overrode the resumed session's previous worktree "${override.overriddenSlug}". ` +
`That worktree directory was left intact; re-attach with \`qwen --worktree ${override.overriddenSlug}\` if needed.`
);
}
return base;
}
+46 -24
View File
@@ -519,6 +519,21 @@ export const AppContainer = (props: AppContainerProps) => {
// the profile captures the full MCP timeline without holding back
// the user-facing TTI.
// Phase D-1: when launched with --worktree, gemini.tsx stashes a
// one-shot notice on Config. Consume it here so it surfaces in the
// transcript AND gets injected into the next user prompt. This
// wins over the Phase C resume-restore path below — startup beats
// resume on the same prompt.
const startupWorktreeNotice =
config.consumePendingStartupWorktreeNotice();
if (startupWorktreeNotice) {
historyManager.addItem(
{ type: MessageType.INFO, text: startupWorktreeNotice },
Date.now(),
);
pendingWorktreeNoticeRef.current = startupWorktreeNotice;
}
const resumedSessionData = config.getResumedSessionData();
if (resumedSessionData) {
const historyItems = buildResumedHistoryItems(
@@ -560,32 +575,39 @@ export const AppContainer = (props: AppContainerProps) => {
// Restore worktree context (shared logic — headless and ACP use
// the same helper). Stale sidecars get cleaned up; live ones
// produce an INFO message the model sees on the next turn.
try {
const sessionPath = config
.getSessionService()
.getWorktreeSessionPath(config.getSessionId());
const restored = await restoreWorktreeContext(sessionPath, (err) => {
// eslint-disable-next-line no-console
console.debug('worktree session restore warning:', err);
});
if (restored.contextMessage) {
// UI: show the notice in the transcript so the user knows.
historyManager.addItem(
{ type: MessageType.INFO, text: restored.contextMessage },
Date.now(),
// Skipped when Phase D-1 already injected a --worktree startup
// notice above (startup wins over resume on the same prompt).
if (!startupWorktreeNotice) {
try {
const sessionPath = config
.getSessionService()
.getWorktreeSessionPath(config.getSessionId());
const restored = await restoreWorktreeContext(
sessionPath,
(err) => {
// eslint-disable-next-line no-console
console.debug('worktree session restore warning:', err);
},
);
// Model: queue the notice for one-shot injection into the
// next user prompt (consumed by handleFinalSubmit). The INFO
// history item alone is UI-only — the model never sees it,
// so without this it could resume editing the parent
// checkout despite the user seeing the worktree path.
pendingWorktreeNoticeRef.current = restored.contextMessage;
if (restored.contextMessage) {
// UI: show the notice in the transcript so the user knows.
historyManager.addItem(
{ type: MessageType.INFO, text: restored.contextMessage },
Date.now(),
);
// Model: queue the notice for one-shot injection into the
// next user prompt (consumed by handleFinalSubmit). The INFO
// history item alone is UI-only — the model never sees it,
// so without this it could resume editing the parent
// checkout despite the user seeing the worktree path.
pendingWorktreeNoticeRef.current = restored.contextMessage;
}
} catch (error) {
// Best-effort: failures here only affect UI hint visibility,
// not the resumed conversation itself.
// eslint-disable-next-line no-console
console.debug('worktree session restore failed:', error);
}
} catch (error) {
// Best-effort: failures here only affect UI hint visibility,
// not the resumed conversation itself.
// eslint-disable-next-line no-console
console.debug('worktree session restore failed:', error);
}
}
})();
+74
View File
@@ -528,6 +528,27 @@ export interface SandboxConfig {
* Settings shared across multi-agent collaboration features
* (Arena, Team, Swarm).
*/
/**
* General-purpose worktree settings (Phase D-2). Distinct from
* {@link AgentsCollabSettings.arena.worktreeBaseDir}, which only governs
* Arena multi-model worktrees.
*/
export interface WorktreeSettings {
/**
* Directories under the main repository to symlink into every
* general-purpose worktree on creation (the `enter_worktree` tool,
* `agent isolation: "worktree"`, and the `--worktree` startup flag).
*
* Paths must be relative to the repo root; absolute paths and any
* entry containing `..` are rejected by the service. Entries that
* resolve to git-internal paths (`.git`, `.qwen`) are also rejected
* — symlinking those would either break git inside the worktree or
* create a worktrees-inside-worktrees loop. Missing source dirs and
* pre-existing destinations are silently skipped.
*/
symlinkDirectories?: readonly string[];
}
export interface AgentsCollabSettings {
/** Display mode for multi-agent sessions ('in-process' | 'tmux' | 'iterm2') */
displayMode?: string;
@@ -718,6 +739,8 @@ export interface ConfigParameters {
modelProvidersConfig?: ModelProvidersConfig;
/** Multi-agent collaboration settings (Arena, Team, Swarm) */
agents?: AgentsCollabSettings;
/** General-purpose worktree settings (Phase D-2). */
worktree?: WorktreeSettings;
/** Enable managed auto-memory background extraction and dream. Defaults to true. */
enableManagedAutoMemory?: boolean;
/** Enable managed auto-dream consolidation separately from extraction. Defaults to true. */
@@ -823,6 +846,20 @@ const DEFAULT_BARE_CORE_TOOLS = [
export class Config {
private sessionId: string;
private sessionData?: ResumedSessionData;
/**
* One-shot notice produced by `setupStartupWorktree` (Phase D-1) when the
* CLI was launched with `--worktree`. The active entry point (TUI XOR
* headless) reads it via {@link consumePendingStartupWorktreeNotice} on
* the model's first prompt and skips Phase C's `restoreWorktreeContext`
* for that turn — startup wins over the resumed-session sidecar. ACP is
* gated out earlier in `gemini.tsx` (mutex with `--worktree`) so it
* never reaches this slot.
*
* @invariant At most one consumer per process. If a future entry path
* sets this slot without ever consuming, the string persists until
* process exit (which dies with the process — no leak).
*/
private pendingStartupWorktreeNotice: string | null = null;
private debugLogger: DebugLogger;
private toolRegistry!: ToolRegistry;
/**
@@ -963,6 +1000,7 @@ export class Config {
| null = null;
private readonly arenaAgentClient: ArenaAgentClient | null;
private readonly agentsSettings: AgentsCollabSettings;
private readonly worktreeSettings: WorktreeSettings;
private readonly skipLoopDetection: boolean;
private readonly skipStartupContext: boolean;
private readonly bareMode: boolean;
@@ -1188,6 +1226,7 @@ export class Config {
this.eventEmitter = params.eventEmitter;
this.arenaAgentClient = ArenaAgentClient.create();
this.agentsSettings = params.agents ?? {};
this.worktreeSettings = params.worktree ?? {};
if (params.contextFileName) {
setGeminiMdFilename(params.contextFileName);
}
@@ -2279,6 +2318,29 @@ export class Config {
return this.targetDir;
}
/**
* Stashes a one-shot context message that the next user prompt will
* inject into the model (see {@link pendingStartupWorktreeNotice}). Called
* from `gemini.tsx` right after `loadCliConfig` when `--worktree` produced
* a valid worktree. Pass `null` to clear (rarely needed).
*/
setPendingStartupWorktreeNotice(notice: string | null): void {
this.pendingStartupWorktreeNotice = notice;
}
/**
* Reads and clears the pending startup-worktree notice. Returns `null`
* when nothing is stashed (the common case). Each entry point (TUI /
* headless / ACP) calls this on the model's first prompt; a non-null
* return means the entry point should NOT additionally call
* `restoreWorktreeContext()` for that prompt — startup overrides resume.
*/
consumePendingStartupWorktreeNotice(): string | null {
const v = this.pendingStartupWorktreeNotice;
this.pendingStartupWorktreeNotice = null;
return v;
}
getProjectRoot(): string {
return this.targetDir;
}
@@ -2629,6 +2691,18 @@ export class Config {
return this.agentsSettings;
}
/**
* Convenience accessor for `worktree.symlinkDirectories` — returns an
* empty array when the setting is unset, so callers can pass the
* result directly into the GitWorktreeService loop without nullchecks.
*
* (No general `getWorktreeSettings()` getter yet — add one when a
* second field on `WorktreeSettings` justifies the broader API.)
*/
getWorktreeSymlinkDirectories(): readonly string[] {
return this.worktreeSettings.symlinkDirectories ?? [];
}
/**
* Clean up Arena runtime. When `force` is true (e.g., /arena select --discard),
* always removes worktrees regardless of preserveArtifacts.
@@ -0,0 +1,539 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Integration tests for `GitWorktreeService.symlinkConfiguredDirectories()`
* (Phase D-2). Uses real git invocations + real `fs.symlink` against a
* temp repo because the unit-test file mocks simple-git too heavily to
* exercise the actual symlink loop.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { GitWorktreeService } from './gitWorktreeService.js';
describe('GitWorktreeService.createUserWorktree() — symlinkDirectories', () => {
vi.setConfig({ testTimeout: 30000, hookTimeout: 30000 });
let repoRoot: string;
beforeEach(async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-wt-symlinks-'));
// Resolve symlinks (macOS /var → /private/var) so path comparisons
// line up with what GitWorktreeService produces internally.
repoRoot = await fs.realpath(dir);
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repoRoot });
execFileSync('git', ['config', 'user.email', '[email protected]'], { cwd: repoRoot });
execFileSync('git', ['config', 'user.name', 't'], { cwd: repoRoot });
execFileSync('git', ['config', 'commit.gpgsign', 'false'], {
cwd: repoRoot,
});
await fs.writeFile(path.join(repoRoot, 'README.md'), 'hi\n');
execFileSync('git', ['add', '.'], { cwd: repoRoot });
execFileSync('git', ['commit', '-q', '-m', 'init', '--no-verify'], {
cwd: repoRoot,
});
});
afterEach(async () => {
await fs.rm(repoRoot, { recursive: true, force: true });
});
it('symlinks a configured directory into the new worktree', async () => {
// Create a fake node_modules in the main repo so there's something
// to link.
const nm = path.join(repoRoot, 'node_modules');
await fs.mkdir(nm);
await fs.writeFile(path.join(nm, 'marker'), 'real');
const service = new GitWorktreeService(repoRoot);
const result = await service.createUserWorktree('linked', 'main', {
symlinkDirectories: ['node_modules'],
});
expect(result.success).toBe(true);
expect(result.worktree).toBeDefined();
const dest = path.join(result.worktree!.path, 'node_modules');
const linkTarget = await fs.readlink(dest);
expect(linkTarget).toBe(nm);
// Reading through the symlink resolves to the real file.
const marker = await fs.readFile(path.join(dest, 'marker'), 'utf8');
expect(marker).toBe('real');
});
it('silently skips a missing source directory', async () => {
const service = new GitWorktreeService(repoRoot);
const result = await service.createUserWorktree('missing-source', 'main', {
symlinkDirectories: ['does-not-exist'],
});
// Worktree creation still succeeds.
expect(result.success).toBe(true);
expect(result.worktree).toBeDefined();
// Nothing was created at the would-be destination.
const dest = path.join(result.worktree!.path, 'does-not-exist');
const exists = await fs
.lstat(dest)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
it('silently skips an existing destination (no overwrite)', async () => {
const nm = path.join(repoRoot, 'node_modules');
await fs.mkdir(nm);
await fs.writeFile(path.join(nm, 'marker'), 'real');
const service = new GitWorktreeService(repoRoot);
// Pre-create the destination inside what will become the worktree.
// We can't pre-populate the worktree (it doesn't exist yet), so we
// exploit the fact that `git worktree add` creates the dir — we set
// the symlinkDirectories to the empty array first to create the
// worktree, then drop a file at the dest, then exercise the symlink
// path via a second call on a new slug.
//
// Actually, simpler: just test that on a second create attempt at
// the same slug, the create itself fails (because branch exists),
// so this case is only reachable in practice if a user pre-populates
// the worktree (e.g. via a custom checkout hook). Simulate by
// creating the worktree first with no symlinks, then dropping a
// marker file, then running the symlink loop manually via a fresh
// service instance pointed at a SECOND slug that pre-fills the dest.
// Pre-create the worktree path so `createUserWorktree` errors out
// on its "already exists" guard — this is the wrong shape. Instead,
// create the worktree, hand-place a node_modules dir under it (the
// tool's pre-populated state), then call symlinkConfiguredDirectories
// directly. The method is private but accessible via prototype here
// because tests run in the same package.
const first = await service.createUserWorktree('preexisting', 'main', {
symlinkDirectories: [],
});
expect(first.success).toBe(true);
const wt = first.worktree!.path;
await fs.mkdir(path.join(wt, 'node_modules'));
await fs.writeFile(path.join(wt, 'node_modules', 'preexisting'), 'wins');
// Invoke the private symlink loop directly.
// Probe the `private symlinkConfiguredDirectories` method directly.
// We can't intersect `GitWorktreeService` with a `public` version of
// the same name (TypeScript collapses class + redeclared-as-public
// intersection to `never`), so describe ONLY the method's shape and
// double-cast through `unknown` to bypass the private check at
// test-time.
type SymlinkProbe = {
symlinkConfiguredDirectories: (
worktreePath: string,
configured: readonly string[],
) => Promise<void>;
};
await (service as unknown as SymlinkProbe).symlinkConfiguredDirectories(
wt,
['node_modules'],
);
// The preexisting file survived — no overwrite happened.
const marker = await fs.readFile(
path.join(wt, 'node_modules', 'preexisting'),
'utf8',
);
expect(marker).toBe('wins');
// And the directory at `wt/node_modules` is still the original dir,
// not a symlink to the main repo's node_modules.
const stat = await fs.lstat(path.join(wt, 'node_modules'));
expect(stat.isSymbolicLink()).toBe(false);
});
it('rejects absolute paths', async () => {
const service = new GitWorktreeService(repoRoot);
const result = await service.createUserWorktree('abs', 'main', {
symlinkDirectories: ['/etc'],
});
expect(result.success).toBe(true);
// Nothing at /etc-named inside the worktree.
const dest = path.join(result.worktree!.path, 'etc');
const exists = await fs
.lstat(dest)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
it('rejects paths that traverse outside the repo root', async () => {
// Put a sibling directory next to the repo so `../sibling` resolves to
// something real — proving the guard fires on traversal shape rather
// than on "source missing".
const siblingDir = path.join(path.dirname(repoRoot), 'qwen-wt-sibling');
await fs.mkdir(siblingDir);
await fs.writeFile(path.join(siblingDir, 'marker'), 'outside');
const service = new GitWorktreeService(repoRoot);
const result = await service.createUserWorktree('traverse', 'main', {
symlinkDirectories: ['../qwen-wt-sibling'],
});
try {
expect(result.success).toBe(true);
const dest = path.join(result.worktree!.path, '..', 'qwen-wt-sibling');
// No symlink was created inside the worktree directory.
const stat = await fs
.lstat(path.join(result.worktree!.path, 'qwen-wt-sibling'))
.catch(() => null);
expect(stat).toBeNull();
// Sibling itself is untouched.
const marker = await fs.readFile(path.join(siblingDir, 'marker'), 'utf8');
expect(marker).toBe('outside');
// The variable `dest` is not used for assertion — silence unused warning.
void dest;
} finally {
await fs.rm(siblingDir, { recursive: true, force: true });
}
});
it('rejects paths inside .git (security guard)', async () => {
// `.git` is git-internal; symlinking any of it into the worktree
// would shadow the worktree's gitlink file and silently break
// commits / status / diff. Verify the guard fires.
const service = new GitWorktreeService(repoRoot);
const result = await service.createUserWorktree('reject-git', 'main', {
symlinkDirectories: ['.git/hooks'],
});
expect(result.success).toBe(true);
const wt = result.worktree!.path;
// Nothing at <worktree>/.git/hooks beyond what `git worktree add`
// itself populates — and certainly NOT a symlink that we wrote.
// The guard rejects pre-mkdir, so no `hooks` entry should exist
// (the worktree gets its own per-worktree .git file, not directory).
const wrote = await fs
.lstat(path.join(wt, '.git', 'hooks'))
.then((s) => s.isSymbolicLink())
.catch(() => false);
expect(wrote).toBe(false);
});
it('rejects paths inside .qwen (security guard)', async () => {
// `.qwen` is CLI metadata: symlinking `.qwen/worktrees` would create
// a worktrees-inside-worktrees loop; symlinking `.qwen/projects` or
// `.qwen/tmp` would alias session metadata users have no legitimate
// reason to share across worktrees. Guard rejects the whole subtree.
await fs.mkdir(path.join(repoRoot, '.qwen'), { recursive: true });
await fs.writeFile(path.join(repoRoot, '.qwen', 'projects'), 'data');
const service = new GitWorktreeService(repoRoot);
const result = await service.createUserWorktree('reject-qwen', 'main', {
symlinkDirectories: ['.qwen/projects'],
});
expect(result.success).toBe(true);
const wt = result.worktree!.path;
// No symlink at <worktree>/.qwen/projects.
const wrote = await fs
.lstat(path.join(wt, '.qwen', 'projects'))
.then((s) => s.isSymbolicLink())
.catch(() => false);
expect(wrote).toBe(false);
});
it('works when the repo path itself contains a symlink boundary (round-7 self-inflicted regression guard)', async () => {
// Round 7 introduced `realSource = await fs.realpath(sourceAbs)` and
// compared it against `repoRootAbs = path.resolve(sourceRepoPath)` —
// canonical vs lexical. On any system where the user's repo path
// contains a symlink component (macOS /tmp → /private/tmp, or a
// user-symlinked source tree on Linux/Windows), the prefixes diverge
// and `isWithinRoot` silently rejects EVERY configured entry.
//
// This guard provisions the same shape independently of the
// shared beforeEach (which realpaths `repoRoot` upfront, masking
// the bug). We point `GitWorktreeService` at a symlink path so
// `sourceRepoPath` differs from its canonical realpath.
const realDirRaw = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-wt-realdir-'),
);
const realDir = await fs.realpath(realDirRaw);
const linkParentRaw = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-wt-linkdir-'),
);
const linkParent = await fs.realpath(linkParentRaw);
const repoViaSymlink = path.join(linkParent, 'repo-via-symlink');
await fs.symlink(realDir, repoViaSymlink);
try {
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: realDir });
execFileSync('git', ['config', 'user.email', '[email protected]'], {
cwd: realDir,
});
execFileSync('git', ['config', 'user.name', 't'], { cwd: realDir });
execFileSync('git', ['config', 'commit.gpgsign', 'false'], {
cwd: realDir,
});
await fs.writeFile(path.join(realDir, 'README.md'), 'hi\n');
execFileSync('git', ['add', '.'], { cwd: realDir });
execFileSync('git', ['commit', '-q', '-m', 'init', '--no-verify'], {
cwd: realDir,
});
// Create node_modules in the real dir so realpath resolves to a
// canonical path under realDir, NOT repoViaSymlink.
const nm = path.join(realDir, 'node_modules');
await fs.mkdir(nm);
await fs.writeFile(path.join(nm, 'marker'), 'real');
// Service rooted at the SYMLINK path — that's the production shape
// since git rev-parse --show-toplevel returns the user-supplied
// path, not the canonical realpath.
const service = new GitWorktreeService(repoViaSymlink);
const result = await service.createUserWorktree('symlinkedrepo', 'main', {
symlinkDirectories: ['node_modules'],
});
expect(result.success).toBe(true);
// The configured entry must have been linked. Pre-fix: realSource
// = realDir/node_modules, repoRootAbs = repoViaSymlink (lexical) →
// isWithinRoot fails → entry silently rejected → dest absent.
const dest = path.join(result.worktree!.path, 'node_modules');
const lst = await fs.lstat(dest).catch(() => null);
expect(
lst,
'symlinkDirectories entry was silently rejected — canonical vs lexical isWithinRoot mismatch',
).not.toBeNull();
expect(lst!.isSymbolicLink()).toBe(true);
// Reading through the link reaches the real file.
const marker = await fs.readFile(path.join(dest, 'marker'), 'utf8');
expect(marker).toBe('real');
} finally {
// Remove via the realpath, not the symlink, so rm-rf clears the
// backing directory cleanly. The dangling symlink in linkParent
// gets removed when we rm-rf linkParent.
await fs.rm(realDir, { recursive: true, force: true });
await fs.rm(linkParent, { recursive: true, force: true });
}
});
it('refuses sources whose realpath escapes the repo root or lands in .git/.qwen (committed-symlink bypass)', async () => {
// Round-7 security fix: the lexical `isWithinRoot(sourceAbs, …)` and
// `.git`/`.qwen` blocklist checks DON'T resolve symlinks, so a symlink
// committed into the source repo HEAD (or set up out-of-band by a
// malicious post-install script / repo tarball) can chain through to
// arbitrary targets. Two flavours covered here:
//
// 1. `escape-to-git` is an OUT-OF-BAND symlink pointing at .git.
// `fs.stat(<repo>/escape-to-git)` follows the symlink and
// succeeds against the .git directory; without the realpath
// guard, we'd happily create `<wt>/escape-to-git →
// <repo>/escape-to-git → <repo>/.git`, giving any tool inside
// the worktree read/write access to .git/hooks, .git/config,
// etc.
//
// 2. `escape-to-outside` is an OUT-OF-BAND symlink pointing at a
// sibling dir OUTSIDE the repo. Same bypass shape; targets
// whatever lives at the other end (e.g. /etc, ~/.aws, etc.).
//
// Both are intentionally set up out-of-band (no `git add`) so we
// don't rely on EEXIST-from-checkout masking the issue; the
// worktree's dest path is empty when the symlink loop runs, so
// without the realpath guard `fs.symlink` would succeed.
await fs.symlink('.git', path.join(repoRoot, 'escape-to-git'));
const outsideDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-wt-outside-'),
);
const outsideResolved = await fs.realpath(outsideDir);
await fs.writeFile(path.join(outsideResolved, 'secret'), 'should-not-leak');
await fs.symlink(outsideResolved, path.join(repoRoot, 'escape-to-outside'));
try {
const service = new GitWorktreeService(repoRoot);
const result = await service.createUserWorktree('bypass', 'main', {
symlinkDirectories: ['escape-to-git', 'escape-to-outside'],
});
expect(result.success).toBe(true);
const wt = result.worktree!.path;
// Neither entry should have produced a symlink we wrote: the
// realpath check refuses .git-chain and out-of-repo targets.
for (const name of ['escape-to-git', 'escape-to-outside']) {
const dest = path.join(wt, name);
const exists = await fs
.lstat(dest)
.then(() => true)
.catch(() => false);
expect(
exists,
`realpath guard must refuse to create <wt>/${name} — committed symlink escape would chain through to a sensitive location`,
).toBe(false);
}
// Belt-and-suspenders: the outside file remains unreachable from
// the worktree (no path to it via any symlink we created).
const leak = await fs
.readFile(path.join(wt, 'escape-to-outside', 'secret'), 'utf8')
.catch(() => null);
expect(leak).toBeNull();
} finally {
await fs.rm(outsideResolved, { recursive: true, force: true });
}
});
it("rejects any entry containing a '..' segment (docs contract)", async () => {
// `foo/../bar` resolves to `bar` (inside the repo), so the
// post-resolve isWithinRoot check would accept it. But the
// user-facing description for `worktree.symlinkDirectories`
// promises rejection of any entry containing `..`. Verify the
// contract is enforced syntactically, before path.resolve.
//
// Provision a real `bar/` source so this test would fail loudly
// if the syntactic guard were removed (we'd see a symlink at
// `<worktree>/bar` pointing back to the source).
await fs.mkdir(path.join(repoRoot, 'bar'));
await fs.writeFile(path.join(repoRoot, 'bar', 'marker'), 'bar');
const service = new GitWorktreeService(repoRoot);
const result = await service.createUserWorktree('dotdot', 'main', {
symlinkDirectories: ['foo/../bar'],
});
expect(result.success).toBe(true);
const wt = result.worktree!.path;
// Nothing at <worktree>/bar (the resolved name)…
const bar = await fs
.lstat(path.join(wt, 'bar'))
.then(() => true)
.catch(() => false);
expect(bar).toBe(false);
// …nor at <worktree>/foo (the raw first segment).
const foo = await fs
.lstat(path.join(wt, 'foo'))
.then(() => true)
.catch(() => false);
expect(foo).toBe(false);
});
it('handles multiple entries — some present, some missing', async () => {
await fs.mkdir(path.join(repoRoot, 'present-a'));
await fs.writeFile(path.join(repoRoot, 'present-a', 'x'), 'a');
await fs.mkdir(path.join(repoRoot, 'present-b'));
await fs.writeFile(path.join(repoRoot, 'present-b', 'y'), 'b');
const service = new GitWorktreeService(repoRoot);
const result = await service.createUserWorktree('multi', 'main', {
symlinkDirectories: ['present-a', 'absent', 'present-b'],
});
expect(result.success).toBe(true);
const wt = result.worktree!.path;
expect(await fs.readlink(path.join(wt, 'present-a'))).toBe(
path.join(repoRoot, 'present-a'),
);
expect(await fs.readlink(path.join(wt, 'present-b'))).toBe(
path.join(repoRoot, 'present-b'),
);
// Absent: nothing created.
const absent = await fs
.lstat(path.join(wt, 'absent'))
.then(() => true)
.catch(() => false);
expect(absent).toBe(false);
});
// Phase D-3 sanity check: fetchPullRequestRef's error taxonomy. We
// keep happy-path PR-worktree coverage in cli/src/startup/worktreeStartup.test.ts
// (which exercises the full setupStartupWorktree → createUserWorktree
// flow against a local fake remote); here we just verify the error
// messages so reviewers can grep them in this file.
describe('Phase D-3: fetchPullRequestRef error messages', () => {
it('returns the "origin remote" error when origin is missing', async () => {
const service = new GitWorktreeService(repoRoot);
const res = await service.fetchPullRequestRef(1, { timeoutMs: 10000 });
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error).toContain('#1');
expect(res.error.toLowerCase()).toContain('origin');
}
});
it('rejects out-of-range PR numbers without firing git', async () => {
const service = new GitWorktreeService(repoRoot);
// 0
let res = await service.fetchPullRequestRef(0);
expect(res.success).toBe(false);
if (!res.success) expect(res.error.toLowerCase()).toContain('invalid');
// negative
res = await service.fetchPullRequestRef(-5);
expect(res.success).toBe(false);
// absurdly large
res = await service.fetchPullRequestRef(9_999_999_999);
expect(res.success).toBe(false);
});
it('handles "no such ref" when origin is reachable but the PR does not exist', async () => {
// Set up a bare upstream with only main — no pull/<N>/head refs.
const upstream = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-wt-pr-no-such-ref-'),
);
const upstreamResolved = await fs.realpath(upstream);
execFileSync('git', ['init', '-q', '--bare', '-b', 'main'], {
cwd: upstreamResolved,
});
execFileSync('git', ['remote', 'add', 'origin', upstreamResolved], {
cwd: repoRoot,
});
execFileSync('git', ['push', '-q', 'origin', 'main'], { cwd: repoRoot });
try {
const service = new GitWorktreeService(repoRoot);
const res = await service.fetchPullRequestRef(99999, {
timeoutMs: 10000,
});
expect(res.success).toBe(false);
if (!res.success) {
expect(res.error).toContain('#99999');
// Either the "PR does not exist" branch fired (preferred) or
// the generic "PR may not exist or origin unreachable"
// fallback — both are acceptable depending on the git version.
expect(res.error.toLowerCase()).toMatch(
/pr.*not exist|origin.*unreachable/,
);
}
} finally {
await fs.rm(upstreamResolved, { recursive: true, force: true });
}
});
});
it('is a no-op when symlinkDirectories is omitted or empty', async () => {
await fs.mkdir(path.join(repoRoot, 'node_modules'));
const service = new GitWorktreeService(repoRoot);
const noOpts = await service.createUserWorktree('no-opts', 'main');
expect(noOpts.success).toBe(true);
const noOptsDest = path.join(noOpts.worktree!.path, 'node_modules');
const noOptsExists = await fs
.lstat(noOptsDest)
.then(() => true)
.catch(() => false);
expect(noOptsExists).toBe(false);
const emptyArr = await service.createUserWorktree('empty-arr', 'main', {
symlinkDirectories: [],
});
expect(emptyArr.success).toBe(true);
const emptyArrDest = path.join(emptyArr.worktree!.path, 'node_modules');
const emptyArrExists = await fs
.lstat(emptyArrDest)
.then(() => true)
.catch(() => false);
expect(emptyArrExists).toBe(false);
});
});
@@ -537,4 +537,74 @@ describe('GitWorktreeService', () => {
expect(result.errors).toHaveLength(0);
});
});
describe('parsePRReference', () => {
it('recognises #N shorthand', () => {
expect(GitWorktreeService.parsePRReference('#123')).toBe(123);
expect(GitWorktreeService.parsePRReference('#1')).toBe(1);
expect(GitWorktreeService.parsePRReference('#99999')).toBe(99999);
});
it('trims surrounding whitespace before matching', () => {
expect(GitWorktreeService.parsePRReference(' #42 ')).toBe(42);
});
it('rejects leading zeros to keep round-trips unambiguous', () => {
expect(GitWorktreeService.parsePRReference('#0123')).toBeNull();
expect(GitWorktreeService.parsePRReference('#0')).toBeNull();
});
it('recognises full GitHub PR URLs (any host)', () => {
expect(
GitWorktreeService.parsePRReference(
'https://github.com/QwenLM/qwen-code/pull/4174',
),
).toBe(4174);
expect(
GitWorktreeService.parsePRReference(
'http://gh.enterprise.example.com/team/repo/pull/9',
),
).toBe(9);
});
it('tolerates trailing slash, query string, and fragment', () => {
expect(
GitWorktreeService.parsePRReference('https://github.com/o/r/pull/123/'),
).toBe(123);
expect(
GitWorktreeService.parsePRReference(
'https://github.com/o/r/pull/123?foo=bar',
),
).toBe(123);
expect(
GitWorktreeService.parsePRReference(
'https://github.com/o/r/pull/123#discussion_r999',
),
).toBe(123);
});
it('returns null for plain slugs and malformed inputs', () => {
expect(GitWorktreeService.parsePRReference('my-feature')).toBeNull();
expect(GitWorktreeService.parsePRReference('#abc')).toBeNull();
expect(GitWorktreeService.parsePRReference('123')).toBeNull();
expect(
GitWorktreeService.parsePRReference('https://example.com/'),
).toBeNull();
expect(
GitWorktreeService.parsePRReference(
'https://github.com/o/r/issues/123',
),
).toBeNull();
expect(GitWorktreeService.parsePRReference('')).toBeNull();
});
it('safely handles non-string input', () => {
expect(
GitWorktreeService.parsePRReference(undefined as unknown as string),
).toBeNull();
expect(
GitWorktreeService.parsePRReference(null as unknown as string),
).toBeNull();
});
});
});
@@ -7,14 +7,17 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { randomBytes, randomInt } from 'node:crypto';
import { execSync } from 'node:child_process';
import { execFile, execSync } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
import { simpleGit, CheckRepoActions } from 'simple-git';
import type { SimpleGit } from 'simple-git';
import { Storage } from '../config/storage.js';
import { isCommandAvailable } from '../utils/shell-utils.js';
import { isNodeError } from '../utils/errors.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { fileExists } from '../utils/fileUtils.js';
import { fileExists, isWithinRoot } from '../utils/fileUtils.js';
import { initRepositoryWithMainBranch } from './gitInit.js';
const debugLogger = createDebugLogger('GIT_WORKTREE_SERVICE');
@@ -372,6 +375,26 @@ export class GitWorktreeService {
return hash.trim();
}
/**
* Resolves a git ref name to a 40-char commit SHA. Returns `null` when
* the ref is unknown / unborn / not a commit.
*
* Used by Phase D-3 to lock in `FETCH_HEAD` immediately after
* `fetchPullRequestRef` succeeds, so the SHA passed to
* `git worktree add` is immutable against a concurrent `git fetch` from
* another process sharing the same repo, AND so `WorktreeExitDialog`'s
* `rev-list <originalHeadCommit>..HEAD` counts only THIS session's new
* work rather than every commit in the fetched PR.
*/
async resolveRef(ref: string): Promise<string | null> {
try {
const out = (await this.git.raw(['rev-parse', '--verify', ref])).trim();
return /^[0-9a-f]{40}$/.test(out) ? out : null;
} catch {
return null;
}
}
/**
* Creates a single worktree.
*/
@@ -1029,6 +1052,299 @@ export class GitWorktreeService {
return `${adj}-${noun}-${suffix}`;
}
/**
* Parses a PR reference from a string. Recognised forms:
*
* - `#123` — shorthand PR number
* - `https://github.com/<owner>/<repo>/pull/123` — full GitHub URL
* (any host, any query string, any fragment)
*
* Returns the parsed PR number on match, `null` otherwise. The slug for
* a PR worktree is derived by callers as `pr-<N>` and the branch as
* `worktree-pr-<N>` (see `createUserWorktree`).
*
* Mirrors claude-code's `parsePRReference` (utils/worktree.ts:633) so
* cross-CLI muscle memory transfers.
*/
static parsePRReference(input: string): number | null {
if (typeof input !== 'string') return null;
const trimmed = input.trim();
// GitHub-style PR URL: https://<host>/owner/repo/pull/<N>
// - any host (public github.com or enterprise)
// - optional trailing slash, query string, or fragment
// - optional sub-path after `/pull/<N>/` (`/files`, `/commits`,
// `/checks`, etc.) — users routinely copy URLs while browsing
// files on a PR, and the PR number is still unambiguous
const urlMatch = trimmed.match(
/^https?:\/\/[^/]+\/[^/]+\/[^/]+\/pull\/(\d+)(?:\/[^?#]*)?(?:[?#].*)?$/i,
);
if (urlMatch?.[1]) {
const n = parseInt(urlMatch[1], 10);
return Number.isSafeInteger(n) && n > 0 ? n : null;
}
// `#N` shorthand. Reject leading zeros (`#0123`) to keep round-trips
// unambiguous — `gh pr view 0123` errors out anyway.
const hashMatch = trimmed.match(/^#([1-9]\d*)$/);
if (hashMatch?.[1]) {
const n = parseInt(hashMatch[1], 10);
return Number.isSafeInteger(n) && n > 0 ? n : null;
}
return null;
}
/**
* Identifies the registered worktree at `worktreePath` as a member of
* THIS repository (`sourceRepoPath`). Returns the branch + HEAD commit
* SHA on success, or `null` when the path is not a worktree of this
* repo.
*
* Used by Phase D-1's re-attach path: when `--worktree foo` is passed
* and `<repoRoot>/.qwen/worktrees/foo` already exists on disk, we
* verify it really IS a Qwen-managed worktree of the current repo (not
* a standalone `git init` someone dropped at that path) before
* assuming it's safe to chdir into. Returning the HEAD SHA in the
* same call avoids a second subprocess to recapture it after chdir.
*
* Implementation — a single `git rev-parse` returning four lines:
* 1. `HEAD` → the worktree's HEAD commit SHA (must come BEFORE
* `--abbrev-ref` since the flag sticks for all subsequent refs).
* 2. `--abbrev-ref HEAD` → the branch name. A detached HEAD produces
* `HEAD` here, which we treat as "no real branch" and return null
* — the caller's re-attach gate will then refuse, since the
* slug-derived branch couldn't possibly be `HEAD`.
* 3. `--git-common-dir` → the common `.git` directory. For a real
* linked worktree of this repo that's `<sourceRepoPath>/.git`;
* for a sibling `git init` it resolves to `<worktreePath>/.git`.
* We compare against this repo's own common-dir to reject the
* latter.
* 4. `--show-toplevel` → git's idea of the worktree top. For a real
* linked worktree this equals `worktreePath`; for a plain
* directory living UNDER the main repo (e.g. `mkdir
* <repo>/.qwen/worktrees/foo`) git walks up to the outer `.git`
* and returns the OUTER repo's root — which would otherwise pass
* the common-dir check and let us "re-attach" to a non-worktree
* directory. Compare paths to reject this.
*/
async getRegisteredWorktreeBranch(
worktreePath: string,
): Promise<{ branch: string; headCommit: string } | null> {
let resolvedWorktreePath: string;
try {
const stat = await fs.stat(worktreePath);
if (!stat.isDirectory()) return null;
// `realpath` so macOS /var → /private/var canonicalises before
// the toplevel comparison below — otherwise a real worktree
// under /var/folders compares unequal to git's `/private/var/…`
// answer and we'd reject every legitimate re-attach on macOS.
resolvedWorktreePath = await fs.realpath(worktreePath);
} catch {
return null;
}
// Run the two probes in parallel: this repo's common-dir comes from
// `this.git`, the candidate's HEAD-SHA + branch + common-dir +
// toplevel come from a fresh simple-git rooted at `worktreePath`
// via a single combined rev-parse.
const probeGit = simpleGit(worktreePath);
let ourCommonDir: string;
let headCommit: string;
let branch: string;
let probeCommonDir: string;
let probeToplevel: string;
try {
const [ourRaw, probeRaw] = await Promise.all([
this.git.raw(['rev-parse', '--git-common-dir']),
probeGit.raw([
'rev-parse',
'HEAD',
'--abbrev-ref',
'HEAD',
'--git-common-dir',
'--show-toplevel',
]),
]);
ourCommonDir = path.resolve(this.sourceRepoPath, ourRaw.trim());
const lines = probeRaw
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length < 4) return null;
headCommit = lines[0]!;
branch = lines[1]!;
probeCommonDir = path.resolve(worktreePath, lines[2]!);
probeToplevel = path.resolve(lines[3]!);
} catch (error) {
debugLogger.debug(
`getRegisteredWorktreeBranch: probe at ${worktreePath} failed: ${error}`,
);
return null;
}
if (probeCommonDir !== ourCommonDir) {
debugLogger.debug(
`getRegisteredWorktreeBranch: ${worktreePath} belongs to a different repo (common-dir=${probeCommonDir}, expected ${ourCommonDir})`,
);
return null;
}
if (probeToplevel !== resolvedWorktreePath) {
// Plain directory under the main repo — git walked up and
// returned the outer repo's toplevel. Refuse to treat as a
// worktree.
debugLogger.debug(
`getRegisteredWorktreeBranch: ${worktreePath} is not a registered worktree (toplevel=${probeToplevel}, expected ${resolvedWorktreePath})`,
);
return null;
}
if (!branch || branch === 'HEAD') return null;
return { branch, headCommit };
}
/**
* Fetches the GitHub PR ref `refs/pull/<N>/head` from the `origin` remote
* so a subsequent `createUserWorktree(..., 'FETCH_HEAD')` call can branch
* off the PR's tip (Phase D-3). Returns `{ success: true }` on success,
* or `{ success: false, error }` with a user-facing reason on failure.
*
* Implementation notes:
*
* - Uses `git fetch origin pull/<N>/head` (no `gh` CLI dependency).
* - Hard timeout of 30s by default — overridable for tests. A hung git
* process on a misconfigured corporate proxy would otherwise stall
* the entire startup sequence.
* - Does NOT create a local branch — leaves the ref accessible only
* via `FETCH_HEAD`. Subsequent `git worktree add -b <branch> <wt>
* FETCH_HEAD` materialises the worktree branch off it.
*
* Error message taxonomy is friendly because this is the user's first
* impression when their `--worktree=#<N>` fails:
* - missing `origin` → tell them the remote is required + how to fix
* - timeout → mention the configured timeout so they can blame the network
* - generic failure → "PR may not exist or origin is unreachable"
*/
async fetchPullRequestRef(
prNumber: number,
options?: { timeoutMs?: number },
): Promise<{ success: true } | { success: false; error: string }> {
if (
!Number.isSafeInteger(prNumber) ||
prNumber <= 0 ||
prNumber > 1_000_000_000
) {
// Out-of-range PR numbers can't sensibly hit GitHub. Reject locally
// rather than firing a doomed network call.
return {
success: false,
error: `Invalid PR number: ${prNumber}.`,
};
}
const timeoutMs = options?.timeoutMs ?? 30_000;
// Two-layer defense for the refspec argv element:
//
// 1. Regex digit-only validation at the call site — CodeQL's
// `js/second-order-command-line-injection` rule recognises
// `/^[1-9][0-9]*$/.test(x)` as a lexical sanitizer, which proves
// `prNumber` cannot resemble a `--upload-pack=…` flag. The
// entry guard above already establishes this at runtime, but
// CodeQL's interprocedural taint tracker doesn't see through
// that guard; the regex check IS the pattern its sanitizer
// library recognises.
// 2. `--end-of-options` as a git-runtime marker. Even though
// layer 1 makes a flag-shaped refspec impossible, the marker
// tells git definitively that every subsequent argv element
// is positional — defense-in-depth against a future
// regression that loosens the entry guard.
const prNumberStr = String(prNumber);
if (!/^[1-9][0-9]*$/.test(prNumberStr)) {
// Unreachable given the entry guard; here to make the
// lexical sanitizer visible to static analyzers.
return {
success: false,
error: `Invalid PR number: ${prNumber}.`,
};
}
const refspec = `pull/${prNumberStr}/head`;
try {
// Force English git stderr so the error-taxonomy regexes below
// match. Without this, users with non-English locales fall
// through to the generic "PR may not exist" branch even for
// well-known cases like missing-origin. The git binary itself is
// unaffected by LANG/LC_ALL beyond message strings.
await execFileAsync(
'git',
['fetch', '--end-of-options', 'origin', refspec],
{
cwd: this.sourceRepoPath,
timeout: timeoutMs,
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
},
);
return { success: true };
} catch (error) {
// execFile reports timeouts via `signal: 'SIGTERM'` on the
// error object; the stderr text gives us the underlying git error.
const err = error as NodeJS.ErrnoException & {
stderr?: string | Buffer;
signal?: string;
};
const stderr =
typeof err.stderr === 'string'
? err.stderr
: err.stderr instanceof Buffer
? err.stderr.toString('utf8')
: '';
const lower = stderr.toLowerCase();
if (err.signal === 'SIGTERM') {
return {
success: false,
error:
`Failed to fetch PR #${prNumber}: timed out after ${Math.round(timeoutMs / 1000)}s. ` +
`Check network connectivity and any HTTP(S) proxy settings.`,
};
}
if (
lower.includes('does not appear to be a git repository') ||
lower.includes('could not read from remote repository') ||
lower.includes("'origin' does not appear")
) {
return {
success: false,
error:
`--worktree=#${prNumber} requires an "origin" remote that points at GitHub. ` +
`Add one with \`git remote add origin <url>\` and retry.`,
};
}
if (
lower.includes('no such ref') ||
lower.includes("couldn't find remote ref") ||
lower.includes("couldn't find remote ref pull/")
) {
return {
success: false,
error:
`Failed to fetch PR #${prNumber}: the PR does not exist on origin, ` +
`or origin is not a GitHub repository (only GitHub exposes refs/pull/<N>/head).`,
};
}
// Generic fallback. Include the stderr first line so an operator
// running with --debug can correlate, but keep it terse.
const firstLine = stderr.split('\n').find((l) => l.trim().length > 0);
const detail = firstLine ? ` (${firstLine.trim()})` : '';
debugLogger.warn(
`fetchPullRequestRef: git fetch pull/${prNumber}/head failed: ${error}`,
);
return {
success: false,
error: `Failed to fetch PR #${prNumber}: PR may not exist, or origin remote is unreachable${detail}.`,
};
}
}
/**
* Validates a worktree slug. Returns null on success, or an error message.
*
@@ -1089,6 +1405,7 @@ export class GitWorktreeService {
async createUserWorktree(
slug: string,
baseBranch?: string,
options?: { symlinkDirectories?: readonly string[] },
): Promise<CreateWorktreeResult> {
const validationError = GitWorktreeService.validateUserWorktreeSlug(slug);
if (validationError) {
@@ -1152,6 +1469,22 @@ export class GitWorktreeService {
);
});
// Phase D-2: symlink user-configured directories from the main
// repo into the new worktree (e.g. node_modules) so the model can
// run tests / builds without a fresh install. Same fail-open
// policy as hooksPath — failures log and continue.
const symlinkPaths = options?.symlinkDirectories ?? [];
if (symlinkPaths.length > 0) {
await this.symlinkConfiguredDirectories(
worktreePath,
symlinkPaths,
).catch((error) => {
debugLogger.warn(
`createUserWorktree: symlinkConfiguredDirectories failed for ${slug}: ${error}`,
);
});
}
const worktree: WorktreeInfo = {
id: slug,
name: slug,
@@ -1252,6 +1585,277 @@ export class GitWorktreeService {
}
}
/**
* Phase D-2 symlink loop. For each configured directory under the main
* repository, creates a symbolic link from the new worktree to the
* main-repo location (`<worktreePath>/<dir>` → `<repoRoot>/<dir>`).
*
* Fail-open semantics — the worktree IS already on disk and usable by
* the time this runs, so a symlink failure must NOT abort the parent
* `createUserWorktree` call. Per-entry failures are logged at debug or
* warn level depending on cause:
*
* - **ENOENT on source** (the main repo does not have the directory):
* debug log, skip. Typical for users who configure `node_modules`
* but launch from a fresh clone where `npm install` hasn't run yet.
* - **EEXIST on destination** (something already lives at the symlink
* target inside the worktree): debug log, skip. No overwrite; the
* existing content (whether file, dir, or stale link) wins.
* - **Absolute path or path traversal in the configured value**:
* warn log, skip the entry. Configured values must stay relative to
* the repo root to prevent a setting from redirecting writes onto
* `/etc`, `~`, or anywhere outside the repo subtree.
* - **Other I/O errors**: warn log, continue to the next entry.
*
* Mirrors claude-code's `symlinkDirectories` helper (utils/worktree.ts).
*/
private async symlinkConfiguredDirectories(
worktreePath: string,
configured: readonly string[],
): Promise<void> {
// Loop-invariant canonical paths, hoisted out of the per-entry loop.
//
// We must `fs.realpath` the repo root (rather than `path.resolve`,
// which is purely lexical) so every containment check below compares
// canonical paths to canonical paths. The post-stat `realSource =
// fs.realpath(sourceAbs)` produces a canonical path, and on any
// system where the repo path contains a symlink component (macOS
// `/tmp → /private/tmp` is ubiquitous; user-symlinked source trees on
// Linux/Windows too) the lexical `path.resolve(sourceRepoPath)` does
// not share a prefix with that canonical realpath. Without this hoist
// `isWithinRoot(realSource, repoRootAbs)` silently rejects EVERY
// configured entry — cf. PR #4381 round 8 regression.
let repoRootAbs: string;
try {
repoRootAbs = await fs.realpath(this.sourceRepoPath);
} catch {
// realpath of a non-existent / inaccessible repo root is fatal for
// the symlink loop's containment checks (we can't validate against
// a path we can't canonicalise). Bail out — the worktree itself is
// already on disk so this is non-destructive; we just skip the
// opt-in symlink step.
debugLogger.warn(
`symlinkConfiguredDirectories: cannot realpath sourceRepoPath "${this.sourceRepoPath}", skipping all entries`,
);
return;
}
const gitDirAbs = path.join(repoRootAbs, '.git');
const qwenDirAbs = path.join(repoRootAbs, '.qwen');
// Same canonical-vs-canonical requirement for the dest side. The
// worktree was just created by `git worktree add`, so the path
// should exist; fall back to the input path on realpath error so a
// weird-but-extant worktree path doesn't deadlock the whole loop.
const realWorktreePath = await fs
.realpath(worktreePath)
.catch(() => worktreePath);
for (const raw of configured) {
if (typeof raw !== 'string' || raw.length === 0) {
debugLogger.warn(
`symlinkConfiguredDirectories: skipping non-string / empty entry: ${JSON.stringify(raw)}`,
);
continue;
}
// Reject absolute paths and any traversal-prone form. Resolve first
// to catch `./foo/../../etc` style escapes that look relative.
if (path.isAbsolute(raw)) {
debugLogger.warn(
`symlinkConfiguredDirectories: refusing absolute path "${raw}"`,
);
continue;
}
// Reject any literal `..` segment up front. The post-resolve
// `isWithinRoot` check below would still accept `foo/../bar`
// (resolves to `bar`, which is inside the repo), but the public
// contract — settingsSchema description, docs/users/features/
// worktree.md, WorktreeSettings JSDoc — promises rejection of
// any entry containing `..`. Enforce that promise here.
if (raw.split(/[\\/]/).includes('..')) {
debugLogger.warn(
`symlinkConfiguredDirectories: refusing path "${raw}" — contains '..' segment`,
);
continue;
}
const sourceAbs = path.resolve(repoRootAbs, raw);
if (sourceAbs === repoRootAbs) {
// `""` / `"."` / `"./"` etc. — pointless and would alias the
// entire repo into itself. Reject explicitly so the path-prefix
// checks below don't have to handle this degenerate case.
debugLogger.warn(
`symlinkConfiguredDirectories: refusing empty / repo-root path "${raw}"`,
);
continue;
}
if (!isWithinRoot(sourceAbs, repoRootAbs)) {
debugLogger.warn(
`symlinkConfiguredDirectories: refusing path "${raw}" — resolves outside repo root (${sourceAbs} vs ${repoRootAbs})`,
);
continue;
}
// Refuse to symlink git-internal paths into the worktree. `.git`
// would silently break commits / status / diff inside the
// worktree (the worktree's own gitlink file points at the parent
// common-dir, and a symlink would shadow it). The whole `.qwen`
// tree is also off-limits: linking `.qwen` (parent) would
// recursively pull `.qwen/worktrees` into the new worktree,
// recreating the loop; linking `.qwen/worktrees` directly
// creates the same loop more obviously; and `.qwen/projects`
// / `.qwen/tmp` are CLI metadata users have no legitimate
// reason to share across worktrees.
// `gitDirAbs` / `qwenDirAbs` are canonical (derived from the
// realpath'd `repoRootAbs` hoisted above the loop), so these
// comparisons stay consistent with the post-stat realpath check.
if (isWithinRoot(sourceAbs, gitDirAbs)) {
debugLogger.warn(
`symlinkConfiguredDirectories: refusing git-internal path "${raw}"`,
);
continue;
}
if (isWithinRoot(sourceAbs, qwenDirAbs)) {
debugLogger.warn(
`symlinkConfiguredDirectories: refusing path "${raw}" — ` +
`the .qwen tree is CLI-managed; symlinking any of it could ` +
`create a worktrees-inside-worktrees loop or alias CLI metadata.`,
);
continue;
}
// Confirm the source exists. We don't insist on it being a directory
// specifically — `node_modules` is canonically a dir, but a user
// who wants to share a single file (`.env`, `secrets.json`) via
// `symlinkDirectories` should still get the link.
let sourceStat: { isDirectory: () => boolean } | null = null;
try {
sourceStat = await fs.stat(sourceAbs);
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
debugLogger.debug(
`symlinkConfiguredDirectories: source missing, skipping: ${sourceAbs}`,
);
} else {
debugLogger.warn(
`symlinkConfiguredDirectories: cannot stat ${sourceAbs}: ${error}`,
);
}
continue;
}
// Resolve through any symlinks in the source path and RE-RUN the
// containment + blocklist checks against the realpath. The lexical
// checks above only see `path.resolve(repoRoot, raw)` — they can't
// tell that `<repo>/node_modules` is actually a symlink chaining
// into `.git`, an outside dir, or `.qwen`. Without this step a
// committed-or-out-of-band source symlink bypasses every guard the
// lexical loop set up. Use the realpath as the symlink target so
// the new link points canonically rather than preserving the chain.
let realSource: string;
try {
realSource = await fs.realpath(sourceAbs);
} catch (error) {
debugLogger.warn(
`symlinkConfiguredDirectories: cannot realpath source "${sourceAbs}": ${error}`,
);
continue;
}
if (!isWithinRoot(realSource, repoRootAbs)) {
debugLogger.warn(
`symlinkConfiguredDirectories: refusing path "${raw}" — real source ${realSource} escapes repo root ${repoRootAbs}`,
);
continue;
}
if (isWithinRoot(realSource, gitDirAbs)) {
debugLogger.warn(
`symlinkConfiguredDirectories: refusing path "${raw}" — real source ${realSource} resolves inside .git`,
);
continue;
}
if (isWithinRoot(realSource, qwenDirAbs)) {
debugLogger.warn(
`symlinkConfiguredDirectories: refusing path "${raw}" — real source ${realSource} resolves inside .qwen`,
);
continue;
}
const destAbs = path.join(worktreePath, raw);
// Ensure the parent directory of `destAbs` exists. For top-level
// entries (`node_modules`) this is a no-op against the worktree
// root, but for nested values (`tools/cache`) we may need to
// create the intermediate dirs first — git worktree add does NOT
// create them.
try {
await fs.mkdir(path.dirname(destAbs), { recursive: true });
} catch (error) {
debugLogger.warn(
`symlinkConfiguredDirectories: cannot mkdir parent of ${destAbs}: ${error}`,
);
continue;
}
// Sibling-drift defense to the round-7 source-side realpath check:
// `path.join(worktreePath, raw)` is lexical too. If `git worktree
// add` materialized a committed symlink under the worktree
// (e.g. HEAD ships `tools → /etc`), then the OS-side resolution
// of `<worktree>/tools/cache` traverses through the committed
// symlink and our `fs.mkdir` / `fs.symlink` write OUTSIDE the
// worktree. Realpath the dest parent and refuse if it escapes.
let realDestParent: string;
try {
realDestParent = await fs.realpath(path.dirname(destAbs));
} catch (error) {
debugLogger.warn(
`symlinkConfiguredDirectories: cannot realpath dest parent for "${raw}" (${path.dirname(destAbs)}): ${error}`,
);
continue;
}
if (!isWithinRoot(realDestParent, realWorktreePath)) {
debugLogger.warn(
`symlinkConfiguredDirectories: refusing path "${raw}" — dest parent ${realDestParent} escapes worktree root ${realWorktreePath} (committed-symlink chain)`,
);
continue;
}
// `fs.symlink` rejects with EEXIST when the destination already
// exists. Treat that as "user already populated this slot, leave
// it alone" — same as claude-code's behavior.
try {
// On Windows, `fs.symlink(..., 'dir')` requires
// SeCreateSymbolicLinkPrivilege (administrator rights, or
// Developer Mode + unprivileged-symlink-creation enabled) and
// EPERMs on default consumer installs. A junction is a reparse
// point that achieves the same "this path resolves over there"
// semantics for directories without elevation. `'file'` symlinks
// on Windows also need the same privilege but there's no
// junction-equivalent for files, so we leave `'file'` as-is and
// accept the EPERM fall-through for the rare file-symlink case.
const symlinkType = sourceStat.isDirectory()
? process.platform === 'win32'
? 'junction'
: 'dir'
: 'file';
// Point at the canonical realpath rather than the lexical
// `sourceAbs` so the new link is one-hop and doesn't preserve
// the chain we just validated.
await fs.symlink(realSource, destAbs, symlinkType);
debugLogger.debug(
`symlinkConfiguredDirectories: linked ${destAbs} → ${realSource} (${symlinkType})`,
);
} catch (error) {
if (isNodeError(error) && error.code === 'EEXIST') {
debugLogger.debug(
`symlinkConfiguredDirectories: destination exists, skipping: ${destAbs}`,
);
} else {
debugLogger.warn(
`symlinkConfiguredDirectories: failed to link ${destAbs} → ${realSource}: ${error}`,
);
}
}
}
}
/**
* Returns true if a local branch with the given name exists.
*
+3 -1
View File
@@ -1582,7 +1582,9 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
`[Agent] getCurrentBranch failed at ${projectRoot}: ${error}`,
);
}
const created = await wtService.createUserWorktree(slug, parentBranch);
const created = await wtService.createUserWorktree(slug, parentBranch, {
symlinkDirectories: this.config.getWorktreeSymlinkDirectories(),
});
if (!created.success || !created.worktree) {
return failWorktreeProvisioning(
`Failed to create isolation worktree: ${created.error ?? 'unknown error'}`,
@@ -58,6 +58,9 @@ describe('EnterWorktreeTool — WorktreeSession sidecar', () => {
getTargetDir: () => repoRoot,
getSessionId: () => sessionId,
getSessionService: () => sessionService,
// Phase D-2: createUserWorktree reads this for the symlink loop.
// Return empty so the loop is a no-op in these tests.
getWorktreeSymlinkDirectories: () => [],
} as unknown as Config;
}
+3 -1
View File
@@ -161,7 +161,9 @@ class EnterWorktreeInvocation extends BaseToolInvocation<
);
}
const result = await service.createUserWorktree(slug, baseBranch);
const result = await service.createUserWorktree(slug, baseBranch, {
symlinkDirectories: this.config.getWorktreeSymlinkDirectories(),
});
if (!result.success || !result.worktree) {
const reason = result.error ?? 'Failed to create worktree.';
debugLogger.warn(`enter_worktree: createUserWorktree failed: ${reason}`);
@@ -57,6 +57,9 @@ describe('ExitWorktreeTool — WorktreeSession sidecar cleanup', () => {
getTargetDir: () => repoRoot,
getSessionId: () => sessionId,
getSessionService: () => sessionService,
// Phase D-2: EnterWorktreeTool (used here for setup) reads this
// setting; return empty so the symlink loop is a no-op.
getWorktreeSymlinkDirectories: () => [],
} as unknown as Config;
}
@@ -27,6 +27,10 @@ function makeMockConfig(targetDir = process.cwd()): Config {
return {
getTargetDir: vi.fn(() => targetDir),
getSessionId: vi.fn(() => 'mock-session-id'),
// Phase D-2: EnterWorktreeTool (used here for setup) reads this
// setting when creating a worktree. Return empty so the symlink
// loop is a no-op in tests.
getWorktreeSymlinkDirectories: vi.fn(() => []),
} as unknown as Config;
}
@@ -181,6 +185,7 @@ describe('ExitWorktreeTool', () => {
const enterCfg = {
getTargetDir: () => repoRoot,
getSessionId: () => 'session-creator',
getWorktreeSymlinkDirectories: () => [],
} as unknown as Config;
const enter = new EnterWorktreeTool(enterCfg);
const inv = enter.build({ name: slug });
@@ -2261,6 +2261,19 @@
}
}
},
"worktree": {
"description": "Configuration for general-purpose git worktrees created by the CLI (the `enter_worktree` tool, the `agent isolation: \"worktree\"` parameter, and the startup `--worktree` flag). Does NOT affect Agent Arena worktrees — see `agents.arena.worktreeBaseDir` for those.",
"type": "object",
"properties": {
"symlinkDirectories": {
"description": "Directories under the main repository to symlink into every general-purpose worktree on creation. Useful for sharing large opt-in dirs like `node_modules` so the model can run tests / builds inside the worktree without a fresh install. Paths must be relative to the repo root; absolute paths, anything containing `..`, and any path inside `.git` or `.qwen` (the CLI-managed metadata tree, which contains the worktrees directory itself) are rejected. Missing source dirs and existing destination paths are silently skipped (no overwrite, no failure).",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"$version": {
"type": "number",
"description": "Settings schema version for migration tracking.",