qwen-code/docs/design/worktree.md
顾盼 5ad5301805
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.
2026-05-27 17:04:51 +08:00

397 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Worktree 通用能力设计
## 问题陈述
qwen-code 目前仅有面向 Arena 多模型对比场景的内部 worktree 实现(`GitWorktreeService`),用户无法在普通会话中使用 worktree 隔离工作AgentTool 也不支持为 subagent 创建隔离的 worktree 环境。
目标是将 worktree 做成通用能力,支持用户会话级隔离和 Agent 级隔离,同时保证现有 Arena 功能体验完全不变。
## 现状对比
| 功能 | qwen-code | claude-code | 阶段 |
| --------------------------------- | --------------- | ----------- | ------- |
| `EnterWorktree` 工具 | ✅Phase A | ✅ | — |
| `ExitWorktree` 工具 | ✅Phase A | ✅ | — |
| AgentTool `isolation: 'worktree'` | ✅Phase B | ✅ | — |
| 过期 worktree 自动清理 | ✅Phase B | ✅ | — |
| worktree 会话状态持久化与恢复 | ❌ | ✅ | Phase C |
| Post-creation setuphooks 配置) | ❌ | ✅ | Phase C |
| StatusLine worktree 状态展示 | ❌ | ✅ | Phase C |
| WorktreeExitDialog退出提示 | ❌ | ✅ | Phase C |
| `--worktree` CLI 启动标志 | ✅Phase D | ✅ | — |
| 符号链接目录node_modules 等) | ✅Phase D | ✅ | — |
| PR 引用(`--worktree=#123` | ✅Phase D | ✅ | — |
| sparse checkout | ❌ | ✅ | Future |
| tmux 集成 | ❌ | ✅ | Future |
| Arena 多模型 worktree 隔离 | ✅qwen 独有) | ❌ | — |
| 脏状态覆盖stash + copy | ✅ | ✅ | — |
| Baseline commit 追踪 | ✅qwen 独有) | ❌ | — |
## 设计原则
**worktree 是通用能力Arena 是其上层应用。**
- 通用 worktree 层:`EnterWorktree`/`ExitWorktree` 工具、AgentTool `isolation` 参数、会话状态管理、自动清理
- Arena 层:多模型并行调度、`worktreeBaseDir` 自定义路径、批量创建与 diff 对比,继续使用 `GitWorktreeService.setupWorktrees()` 的现有逻辑,不受通用层改动影响
AgentTool 的 `isolation: 'worktree'` 只走通用路径Arena 内部不经过这个参数创建 worktree两者路径独立。
## 路径与配置
### 通用 worktree 路径
`EnterWorktree` 工具或 AgentTool `isolation: 'worktree'` 创建的 worktree 固定存放在:
```
{git 仓库根}/.qwen/worktrees/{slug}
```
路径不可配置。slug 命名规则:
- 用户会话 worktree用户指定名称或自动生成格式`{形容词}-{名词}-{4位随机}`
- Agent worktree`agent-{7位随机 hex}`
### Arena worktree 路径(已有,保持不变)
Arena 的 worktree 路径由 `agents.arena.worktreeBaseDir` 控制,默认 `~/.qwen/arena``ArenaManager.ts:125`),与通用路径完全独立,不做任何改动。
### 扩展配置
| 配置项 | 类型 | 用途 | 阶段 |
| --------------------------------- | ---------- | ---------------------------------------------------------------- | ------- |
| `ui.hideBuiltinWorktreeIndicator` | `boolean` | 隐藏 Footer 中内置 `⎇ worktree-… (…)` 行,留给 custom statusline | Phase C |
| `worktree.symlinkDirectories` | `string[]` | 符号链接指定目录(如 `node_modules`)到 worktree避免磁盘浪费 | Phase D |
| `worktree.sparsePaths` | `string[]` | git sparse-checkout cone 模式,大型 monorepo 只写入指定路径 | Future |
Phase A / B 不新增任何配置项。
## 工具设计
### EnterWorktree
**触发条件:** 用户明确说 "start a worktree"、"use a worktree"、"create a worktree" 等词语。不应在用户说"修复 bug"、"开发功能"时自动触发。
**输入 schema**
```
name?: string // 可选slug 格式:字母/数字/点/下划线/破折号,最大 64 字符
```
**行为:**
1. 验证当前未在 worktree 中(防止嵌套)
2. 解析到 git 仓库根(处理已在子目录的情况)
3. 调用 `GitWorktreeService` 创建 worktree路径为 `.qwen/worktrees/{slug}`
4. 将 worktree 会话写入 `SessionService`
5. 切换工作目录到 worktree 路径
6. 清除文件缓存
**输出:** `worktreePath``worktreeBranch``message`
### ExitWorktree
**触发条件:** 用户说 "exit the worktree"、"leave the worktree"、"go back" 等。
**输入 schema**
```
action: 'keep' | 'remove'
discard_changes?: boolean // 仅 action='remove' 时有效
```
**安全守卫:**
- 仅操作本会话通过 `EnterWorktree` 创建的 worktree
- `action='remove'` 且存在未提交变更时,拒绝执行(除非 `discard_changes: true`
**行为:**
- `keep`:清空会话中的 worktree 状态,保留 worktree 目录和分支,恢复原始工作目录
- `remove`:删除 worktree 目录,删除对应 git 分支,清空会话状态,恢复原始工作目录
**输出:** `action``originalCwd``worktreePath``worktreeBranch`
## 用户触发方式
| 方式 | 示例 | 实现阶段 |
| -------------- | -------------------------------------------------------- | -------- |
| 会话中明确请求 | 用户说 "在 worktree 中开始工作" → 模型调用 EnterWorktree | Phase A |
| Agent 隔离 | 模型为 subagent 设置 `isolation: 'worktree'` | Phase B |
| CLI 启动标志 | `qwen --worktree my-feature` | Phase D |
无斜杠命令。会话中 worktree 的触发依赖用户明确提及,`isolation: 'worktree'` 才是模型自主决策的场景。
## 分阶段实现计划
### Phase A核心工具用户会话级 worktree
**目标:** 用户能在会话中进入 / 退出 worktree。
**要实现的功能:**
- `EnterWorktree` 工具:创建 worktree切换工作目录记录会话状态
- `ExitWorktree` 工具keep / remove 两种退出方式,安全守卫
- `GitWorktreeService` 扩展:新增面向单用户会话的 `createUserWorktree()` / `removeUserWorktree()` 方法,复用现有 git 操作逻辑,不改动 Arena 使用的批量接口
- `SessionService` 扩展:新增 `WorktreeSession` 字段,记录 `{ slug, worktreePath, worktreeBranch, originalCwd, originalBranch }``--resume` 时恢复 worktree 工作目录
- 工具 prompt为每个工具编写使用说明明确何时调用、何时不调用
**影响文件:**
| 文件 | 变更类型 |
| -------------------------------------------------- | --------------------------------------------- |
| `packages/core/src/tools/tool-names.ts` | 新增 `ENTER_WORKTREE``EXIT_WORKTREE` 常量 |
| `packages/core/src/tools/EnterWorktreeTool/` | 新建目录:`EnterWorktreeTool.ts``prompt.ts` |
| `packages/core/src/tools/ExitWorktreeTool/` | 新建目录:`ExitWorktreeTool.ts``prompt.ts` |
| `packages/core/src/services/gitWorktreeService.ts` | 新增用户会话级接口(不改动 Arena 接口) |
| `packages/core/src/services/sessionService.ts` | 新增 `WorktreeSession` 字段及读写方法 |
| `packages/core/src/tools/` 注册入口 | 注册新工具 |
**不在 Phase A 范围内:**
- Agent 隔离Phase B
- hooks 配置等 post-creation setupPhase C
- UI 状态展示Phase C
---
### Phase BAgent 隔离AgentTool `isolation: 'worktree'`+ 描述更新
**目标:** 模型可为 subagent 创建临时隔离 worktreeagent 结束后自动清理;同步更新受影响的工具描述和提示词。
**要实现的功能:**
_Agent 隔离核心_
- `AgentTool` 新增 `isolation?: 'worktree'` 参数
- Agent 启动时创建临时 worktreeslug`agent-{7hex}`,路径:`.qwen/worktrees/agent-{7hex}`
- Agent 结束后:无变更则自动删除;有变更则保留,将路径和分支返回在结果中
- 过期 worktree 自动清理:扫描 `.qwen/worktrees/`,匹配 `agent-{7hex}` 模式,超过 30 天且无未推送提交则删除fail-closed 策略
_描述与提示词更新_
- `AgentTool` description 补充 `isolation: 'worktree'` 参数说明(参考 claude-code `AgentTool/prompt.ts:272`
- 新增 `buildWorktreeNotice()`:当 fork subagent 在 worktree 中运行时,向其注入上下文提示,说明其处于隔离 worktree、路径继承自父 agent、编辑前需重新读取文件参考 claude-code `forkSubagent.ts:buildWorktreeNotice`
_无需改动_
- review skill`SKILL.md`review 使用独立机制(路径 `.qwen/tmp/review-pr-<n>`,通过 `qwen review fetch-pr` 命令创建),与通用 worktree 路径和机制完全不同,不存在混淆
**Arena 兼容保证:** Arena 内部不经过 `isolation` 参数创建 worktree此改动不触碰 Arena 代码路径。
**影响文件:**
| 文件 | 变更类型 |
| -------------------------------------------------- | ------------------------------------------------------ |
| `packages/core/src/tools/agent/agent.ts` | 新增 `isolation` 参数及 worktree 创建/清理逻辑 |
| `packages/core/src/tools/agent/fork-subagent.ts` | 新增 `buildWorktreeNotice()` 并在 worktree 模式下注入 |
| `packages/core/src/services/gitWorktreeService.ts` | 新增 `createAgentWorktree()` / `removeAgentWorktree()` |
| `packages/core/src/services/worktreeCleanup.ts` | 新建:过期 worktree 自动清理逻辑 |
---
### Phase C会话完整性SessionService 持久化 + UI 安全网)
**目标:** worktree 状态在会话中断后可恢复,用户在界面上始终知道自己在哪个 worktree 里,退出会话时有安全提示。
**要实现的功能:**
_SessionService worktree 状态持久化 + `--resume` 恢复_
- `SessionService` 扩展 `WorktreeSession` 字段,记录 `{ slug, worktreePath, worktreeBranch, originalCwd, originalBranch }`
- `EnterWorktreeTool` 调用 `sessionService.setWorktreeSession()` 写入状态
- `ExitWorktreeTool` 调用 `sessionService.clearWorktreeSession()` 清除状态
- `--resume` 启动路径读取该字段,恢复 `targetDir` 并向模型注入上下文提示
_Post-creation setup_
- 创建 worktree 后自动执行 `git config core.hooksPath <mainRepo>/.git/hooks`,确保 worktree 内的提交与主仓库 hooks 行为一致
_StatusLine worktree 展示_
- `UIStateContext` 新增 `activeWorktree` 字段(从 session 状态读取),在会话进入 / 退出 worktree 时更新
- `StatusLineCommandInput` payload 新增 `worktree?: { slug: string; branch: string }` 字段,供用户 statusline 脚本使用
- `Footer``activeWorktree` 非空时内置展示一行 `⎇ <branch> (<slug>)`,无需用户配置 statusline 脚本即可获得基本可见性
_WorktreeExitDialog_
- 新增 `WorktreeExitDialog.tsx` 组件,参考现有 Dialog 写法
- 修改退出键Ctrl+C / Ctrl+D处理逻辑检测到 `activeWorktree` 非空时,拦截第二次确认,展示 Dialog 提示用户选择 keep 或 remove
- keep / remove 操作复用 `ExitWorktreeTool` 的现有路径
**影响文件:**
| 文件 | 变更类型 |
| ------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `packages/core/src/services/sessionService.ts` | 新增 `WorktreeSession` 字段及读写方法 |
| `packages/core/src/tools/enter-worktree.ts` | 调用 `sessionService.setWorktreeSession()` |
| `packages/core/src/tools/exit-worktree.ts` | 调用 `sessionService.clearWorktreeSession()` |
| `packages/core/src/services/gitWorktreeService.ts` | `createUserWorktree()` / `createAgentWorktree()` 后追加 `core.hooksPath` 配置 |
| `packages/cli/src/ui/contexts/UIStateContext.tsx` | 新增 `activeWorktree` 字段及 set/clear action |
| `packages/cli/src/ui/hooks/useStatusLine.ts` | `StatusLineCommandInput` 新增 `worktree` 字段 |
| `packages/cli/src/ui/components/Footer.tsx` | 内置 worktree 行展示 |
| `packages/cli/src/ui/components/WorktreeExitDialog.tsx` | 新建 |
| `packages/cli/src/ui/components/DialogManager.tsx` | 注册 `WorktreeExitDialog` |
| `packages/cli/src/ui/components/ExitWarning.tsx` 或退出键处理 | 检测 `activeWorktree` 并拦截退出 |
---
### Phase D启动时配置`--worktree` CLI 标志 + 目录符号链接 + PR 引用)
**目标:** 支持在启动时直接进入 worktree、通过目录符号链接减少大型项目的磁盘开销以及通过 PR 引用快速基于一个 pull request 创建 worktree。
**范围:** 三个功能在一个阶段一起落地,因为它们都挂在同一个启动入口上,且 symlink / PR fetch 两者都需要在 worktree 创建之后立即执行 — 单独拆分会重复改 bootstrap 序列。
#### D-1`--worktree [name]` CLI 启动标志
**参数形态:** yargs 选项接受三种形式:
| 形式 | 行为 |
| ------------------------- | ---------------------------------------------------- |
| `qwen --worktree` | bare flag自动生成 slug`{形容词}-{名词}-{6hex}` |
| `qwen --worktree my-name` | 显式 slug沿用 `EnterWorktreeTool` 的 slug 校验规则 |
| `qwen --worktree=my-name` | 等价于上一种 |
不提供短别名 `-w`qwen-code 短别名只保留给最高频参数,避免命名冲突)。
**启动序列:** worktree 在以下位置创建:
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` 并就地 chdirPhase 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** 到已有 worktreePhase 6 修复) |
| 有 | 无 | 恢复旧 worktreePhase C 行为sidecar 命中则注入 reminder |
| 有sid 出自同一 worktree | 有(同一 slugre-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 flagPhase 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-3PR 引用解析(`--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 就无法创建 worktreeslug 校验失败 **中止** 启动(与 `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 的实际改动。沿用此选择。
---
### Future高级功能按需实现
以下功能面向更特定的使用场景,当前阶段不纳入排期,待用户需求明确后再评估实现。
| 功能 | 说明 |
| ----------------------- | ----------------------------------------------------------------------------------------- |
| sparse checkout | `worktree.sparsePaths` 配置项,大型 monorepo 只 checkout 指定路径,缩短创建时间和磁盘占用 |
| `.worktreeinclude` 文件 | 将 gitignore 的文件(`.env``secrets.json` 等)自动复制进 worktree |
| tmux 集成 | `--worktree --tmux` 在新 tmux 窗口启动 worktree 会话 |