mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
* feat(worktree): Phase D — startup --worktree flag + symlinkDirectories + PR refs
Three cross-cutting capabilities on top of the Phase A-C worktree
foundation (PRs #4073, #4174).
D-1: --worktree [name] CLI flag creates a worktree (or re-attaches to
one that already exists) before any model turn runs. Supports bare,
plain-slug, `=`, and PR-reference forms; --worktree + --acp rejected
with a clear error; --worktree + --resume overrides the resumed
session's saved sidecar and emits a stderr line.
D-2: worktree.symlinkDirectories: string[] settings key opts into
symlinking main-repo directories (e.g. node_modules) into every
newly-created general-purpose worktree. Applies to all three creation
paths: --worktree flag, EnterWorktreeTool, AgentTool isolation. Path
traversal, absolute paths, and existing destinations all guarded;
missing source dirs and EEXIST silently skipped (fail-open).
D-3: --worktree=#<N> / --worktree <github-url> resolves a PR number,
runs `git fetch origin pull/<N>/head` (30s timeout, no `gh` CLI
dependency, LANG=C for stable error-taxonomy matching), and creates
the worktree off FETCH_HEAD. URL regex tolerates /files, /commits,
/checks sub-paths so users can paste any GitHub PR URL.
Phase 6 verification fixes also included:
- Re-attach to an existing worktree instead of failing with "Worktree
already exists" — the common `qwen --resume <sid> --worktree foo`
workflow now succeeds. The session ownership marker is preserved on
re-attach so cross-session exit_worktree action="remove" still fails
for non-owners.
- Normalize path-taking argv fields (mcpConfig, jsonSchema @<path>,
openaiLoggingDir, jsonFile, inputFile, telemetryOutfile,
includeDirectories) to absolute paths against the launch cwd BEFORE
the worktree chdir. Otherwise downstream fs.existsSync('./mcp.json')
resolves into the worktree, where the file doesn't exist.
Phase 7 code-review fixes:
- buildStartupWorktreeNotice differentiates "Active worktree" (fresh
create) from "Re-attached to worktree" (re-attach path).
- Notice survives sidecar persist failure: set before the try block,
refreshed inside with override addendum if persist succeeded.
- getRegisteredWorktreeBranch verifies the candidate path's git
common-dir matches the source repo's — rejects sibling `git init`
directories that happen to be on a worktree-<slug> branch.
Three-mode parity for the startup notice: TUI consumes via
AppContainer effect, headless prepends a <system-reminder> + emits a
worktree_started JSON event. ACP path is mutually exclusive with
--worktree (ACP hosts supply per-session cwd separately).
Tests (66 + 15 new):
- 15 cli/src/startup/worktreeStartup.test.ts (slug forms, PR fetch
against local fake remote, re-attach happy + wrong-branch guard)
- 8 core/src/services/gitWorktreeService.test.ts (parsePRReference:
#N, URLs, malformed, traversal, leading zeros, non-string)
- 10 core/src/services/gitWorktreeService.symlinks.integ.test.ts
(symlink loop + fetchPullRequestRef error taxonomy)
Known limitations (documented in docs/users/features/worktree.md):
- Cross-slug --resume <sid> --worktree <different-new-slug> is
unsupported by design (sessions are bound to projectHash(cwd));
future Config refactor anchoring storage at repo root would lift this.
- Mid-session enter_worktree still does NOT switch cwd/targetDir
(Phase A's simplification); only the startup --worktree flag does.
- yargs ambiguity: `qwen --worktree "say hi"` consumes the prompt as
the slug. Quick Start shows the `=` form and reordering workarounds.
Docs:
- docs/users/features/worktree.md (new): Quick Start with --worktree
flag, CLI Reference table for all four input forms + error codes,
settings table, Limitations.
- docs/design/worktree.md: Phase D section expanded into D-1/D-2/D-3
with open questions resolved; capability table updated.
- docs/e2e-tests/worktree-phase-d.md (new): full E2E plan with Phase 4
dry-run baseline + Phase 6 post-impl reproduction tables.
Refs #4056
* refactor(worktree): apply self-review feedback on Phase D
Self-review pass over the Phase D commit (2636f59273) catching one real
typecheck regression plus a batch of small quality + efficiency
improvements. No user-visible behavior change beyond fixing the build.
Build fix:
- worktreeStartup.ts imports — pre-commit prettier had reorganized
`writeWorktreeSession` and `readWorktreeSession` under an
`import type { ... }` block, erasing them at compile time
(verbatimModuleSyntax). `tsc --noEmit` was failing with TS1361.
Bundle path still worked (esbuild is lenient) so this only surfaced
when running typecheck.
Startup-path efficiency (~10-25 ms saved per --worktree invocation on
macOS; more on Windows):
- Drop redundant `isGitRepository()` probe — `getRepoTopLevel()`
returns null on non-git paths and covers both gates in one
subprocess.
- Run `getCurrentBranch()` + `getCurrentCommitHash()` in parallel via
Promise.all (independent calls).
- Combine the two `git rev-parse` probes inside
`getRegisteredWorktreeBranch` into a single multi-arg call, and run
it in parallel with the source-repo common-dir lookup. Saves one
fork+exec on the re-attach path.
Quality:
- Extract `withReminder()` local helper in nonInteractiveCli.ts so the
startup-notice and resume-restore branches share the system-reminder
wrapping.
- Log `readWorktreeSession` failures in `persistStartupWorktreeSidecar`
with the sidecar path so operators can recover the previous slug
from a backup. Silent swallow was making "where did my worktree
binding go?" undebuggable.
- Drop the dead `Config.getWorktreeSettings()` accessor (only
`getWorktreeSymlinkDirectories()` has callers); keep the underlying
`WorktreeSettings` interface for future fields.
- Document the `pendingStartupWorktreeNotice` invariant: at most one
consumer per process; ACP path is gated out earlier so only TUI XOR
headless reads it.
- Add a maintainer note in the gemini.tsx path-normalization block:
the argv path-field allowlist is hand-maintained, register new
path-bearing flags there or `--worktree` silently breaks for them.
- Drop `Phase 6 fix (G1)/(G2)` parenthetical labels from inline
comments — internal review-cycle identifiers that decay to noise
post-merge. Substantive prose retained.
Tests: cli 15/15 (unchanged) + core 66/66 (unchanged); bundle smoke
verified fresh / re-attach / invalid slug / non-git cases.
Findings deliberately left for follow-up:
- Larger refactor extracting a shared `provisionUserWorktree` helper
for the EnterWorktreeTool / startup overlap (~80% duplicate).
- Splitting the re-attach branch out of `setupStartupWorktree` into
its own function.
- `isPathWithinRoot` / `isInsideManagedWorktree` shared utils.
- `symlinkConfiguredDirectories` loop concurrency (saves 5-15 ms on a
cold path that runs only when symlinkDirectories is configured).
* docs(worktree): refresh stale docstring in worktreeStartup
Top-of-file docstring still said `{adj}-{noun}-{4hex}` (actual format
is 6 hex chars) and described the PR form as "detected and rejected
with a clear 'coming in D-3' message" — but D-3 shipped in the same
PR. Tighten to reflect what the code actually does.
* fix(worktree): address findings from dual-reviewer self-check
Two real bugs surfaced by an independent dual-reviewer pass (Claude +
Codex) on the Phase D commits. Both correctness-affecting; both
escaped the earlier internal reviews.
P0 — re-attach captured the wrong baseline for the exit dialog
(Codex):
setupStartupWorktree captured `originalHeadCommit` from the launch
cwd (main checkout) before any chdir. On the re-attach path the
WorktreeExitDialog later runs `git rev-list <originalHeadCommit>..HEAD`
inside the worktree to count "new commits this session". With the
main-checkout baseline this counted every commit ever made in the
kept worktree as new work from the current session — misleading the
keep/remove prompt. Re-capture HEAD from inside the worktree after
chdir so the count means what the dialog text says it means.
P0 — getRegisteredWorktreeBranch mis-identified plain directories as
registered worktrees (Claude):
A plain directory at `<repo>/.qwen/worktrees/<slug>/` (e.g. a stale
artifact from a previous tool) had no `.git` file of its own, so
`git rev-parse --git-common-dir` walked up to the outer repo and
returned the outer common-dir — matching the source repo's
common-dir check and impersonating a registered worktree. If the
outer repo happened to be on `worktree-<slug>`, setupStartupWorktree
would silently chdir into the plain directory and treat it as
attached; subsequent `exit_worktree action="remove"` would then
delete a directory that was never registered.
Fix: also probe `--show-toplevel` and require it to equal the
candidate path (canonicalised via `realpath` so macOS /var → /private/var
doesn't break the equality check). A plain dir under the main repo
gets the outer repo's toplevel and is correctly rejected.
Smaller polish from the same review:
- Normalize the literal string `'HEAD'` returned by `getCurrentBranch`
on detached HEAD to `undefined`, so the `baseRef` handed to
`git worktree add -b … HEAD` does not implicitly anchor against
the loose commit when the launch cwd is detached.
- `symlinkConfiguredDirectories`: blocklist `.git` (any nested
ancestor) and `.qwen/worktrees` (any nested ancestor). Linking
`.git` would silently break commits inside the worktree; linking
`.qwen/worktrees` would create a worktrees-inside-worktrees loop
that confuses the startup sweep.
- `WorktreeSettings.symlinkDirectories` typed `readonly string[]` to
match the `createUserWorktree(options.symlinkDirectories)` contract
and the immutable-config convention elsewhere. `Config.getWorktreeSymlinkDirectories()`
return type updated to match.
Docs:
- design/worktree.md precedence table rewritten. The previous
`--worktree` 赢 row was unreachable in practice (sessions are bound
to `projectHash(cwd)`, and the chdir happens before session lookup).
New table reflects what actually happens for each combination of
`--resume` × `--worktree`, including the documented
cross-projectHash limitation. The `persistStartupWorktreeSidecar`
override branch is now annotated as dead-on-the-current-architecture
but kept so a future Config refactor (anchor storage at repo root)
picks it up for free.
Tests: cli 15/15 + core 66/66 unchanged. Bundle smoke confirms both
P0 fixes end-to-end (re-attach captures worktree HEAD = run-1 tip,
plain-dir attempt errors out without clobbering existing content).
* refactor(worktree): consolidate probe + name detached-HEAD sentinel
Second /simplify pass on the dual-reviewer fixes. Three convergent
findings; net effect is one fewer subprocess on the re-attach path
and clearer intent on string handling / blocklist guards.
Efficiency + quality:
- Fold the worktree HEAD SHA into `getRegisteredWorktreeBranch`'s
combined rev-parse. The probe already requests common-dir,
toplevel, and abbrev-ref HEAD in a single subprocess; adding a
leading `HEAD` positional (which must come BEFORE `--abbrev-ref` so
the flag doesn't apply to it) returns the SHA on its own line.
Return type widened to `{ branch, headCommit } | null`. Removes
the second `GitWorktreeService` instantiation and `getCurrentCommitHash`
call that `setupStartupWorktree`'s re-attach branch used to do.
Quality:
- Hoist `'HEAD'` to a module-level `DETACHED_HEAD` constant in
`worktreeStartup.ts`. Three uses, two meanings (input filter when
normalizing `getCurrentBranch` output, fallback metadata for the
sidecar's `originalBranch` field on detached state). Naming the
sentinel makes intent self-documenting and pre-empts the "why is
the value we just stripped re-appearing as a fallback?" reader stall
flagged by the round-3 quality review.
Reuse + quality:
- `symlinkConfiguredDirectories`: replace two hand-rolled containment
checks (`startsWith(prefix + sep)` for `.qwen/worktrees`; `path.relative(...).split(sep)[0]`
for `.git`) with `isWithinRoot` from `utils/fileUtils.ts`, which is
already imported in this file. Replace the hardcoded
`path.join(repoRootAbs, '.qwen', 'worktrees')` with `this.getUserWorktreesDir()`
so the layout lives in one place (the exported `WORKTREES_DIR`
constant). Split the misleading `sourceAbs === repoRootAbs` clause
out of the `.git` branch into its own dedicated "empty / repo-root
path" rejection with a clearer warn message.
Tests: cli 15/15 + core 66/66 unchanged. Bundle smoke verified the
folded probe still captures the worktree's HEAD on re-attach (not
the launch-cwd HEAD).
Skipped from this review pass:
- Moving `'HEAD'` normalization into `GitWorktreeService.getCurrentBranch()`
itself — would ripple through `enter-worktree.ts` and `agent.ts`
callers that hand the result verbatim to `git worktree add -b ...`.
Out of scope for a polish pass; the local const is enough.
* fix(worktree): broaden symlink blocklist from .qwen/worktrees to all of .qwen
Caught by a second pr-tracker dual-reviewer pass (Codex). The previous
guard at `symlinkConfiguredDirectories` only refused paths inside
`<repoRoot>/.qwen/worktrees/` — `.qwen` itself (the parent) sailed
through because `isWithinRoot` is a strict descendant check. A user
setting `symlinkDirectories: ['.qwen']` would therefore symlink the
entire CLI metadata tree into the new worktree, recursively pulling
in `.qwen/worktrees` and recreating the loop the guard was meant to
prevent. Other `.qwen/*` subtrees (`projects`, `tmp`, …) are CLI
state with no legitimate cross-worktree sharing use case either.
Fix: broaden the guard to reject the whole `<repoRoot>/.qwen` tree.
Both `.qwen` itself and any descendant fail closed.
Also synced the user-facing settings schema description (the in-IDE
help text and the published JSON schema) so it mentions the `.git`
and `.qwen` rejection rules. The `WorktreeSettings` interface JSDoc
already mentioned them; the schema description had not been updated.
Tests: cli 15/15 + core 66/66 unchanged. Smoke confirms `--worktree foo`
with `symlinkDirectories: ['.qwen']` configured leaves the worktree
free of any `.qwen` symlink (only the legitimate per-worktree
`.qwen-session` marker file appears).
* fix(worktree): guard fetchPullRequestRef against CodeQL command-injection alert
CodeQL flagged a "Second order command injection" finding (rule 235) on
the `git fetch origin pull/<N>/head` call in `fetchPullRequestRef`. The
taint analyzer doesn't see the type-narrowing at the function entry
(`Number.isSafeInteger(prNumber) && prNumber > 0 && prNumber <= 1e9`),
so it considers `prNumber` library input that could in principle reach
a `--upload-pack=…`-shaped flag and thereby execute an arbitrary
program. In practice the entry guard already prevents that, but the
alert blocks the CodeQL CI check.
Add `--end-of-options` between `origin` and the refspec — git's
canonical "stop parsing flags" marker (git ≥ 2.24). Tells git
definitively that every subsequent argv element is a positional, not
a flag, which (a) satisfies the analyzer, (b) adds defense-in-depth
against a future regression that might relax the entry guard, and
(c) has zero behavior change for any well-formed PR number.
Verified locally: `git fetch --end-of-options origin pull/<N>/head`
against a local bare-remote with a seeded `refs/pull/42/head` still
fetches the ref correctly; the `--worktree=#42` smoke test reads back
the PR content from the materialized worktree.
Tests: cli 15/15 + core 66/66 unchanged.
* fix(worktree): lexical sanitizer for CodeQL + missing test mock entry
Two fixes from the third CI round on PR #4381:
1. CodeQL re-fires (round 2 of the same finding).
`--end-of-options` is a git-runtime defense, not a lexical sanitizer
that CodeQL's `js/second-order-command-line-injection` taint tracker
recognises. The alert re-fired against the same call after the
previous fix.
Switch to a CodeQL-recognised sanitizer: validate the numeric
component against `/^[1-9][0-9]*$/` immediately at the sink. The
regex digit-only check is one of the documented sanitizer patterns
the rule looks for, and proves at the analyzer level that the
resulting argv element cannot resemble a flag (`--foo`). The entry
guard at the top of the function still establishes the same fact
at runtime; this layer makes the proof visible to static analysis.
Keep `--end-of-options` as a runtime fallback against any future
regression that loosens the entry guard.
2. `nonInteractiveCli.test.ts` mock was missing the new
`consumePendingStartupWorktreeNotice` Config method.
Phase D-1 added the method on `Config` and `nonInteractiveCli`
calls it on every prompt to pick up the one-shot startup-worktree
notice. The test file's `mockConfig` literal was not updated, so
all 19 `runNonInteractive` tests threw
`TypeError: config.consumePendingStartupWorktreeNotice is not a
function` on Ubuntu / macOS CI.
Add a stub returning `null` so the helper short-circuits, matching
the equivalent Phase C stub for `getResumedSessionData`.
Local: cli (worktreeStartup + nonInteractiveCli) 60 passed + 1
skipped; core (gitWorktreeService + symlinks + hooks +
enter-worktree) 66 passed.
* test(worktree): mock getWorktreeSymlinkDirectories in three more test files
Round 4 of the same Phase D-2 mock-drift class. CI surfaced 9 test
failures across three files whose `Config` mocks construct
`EnterWorktreeTool` for setup but lack the new
`getWorktreeSymlinkDirectories` method `createUserWorktree` now
calls:
- enter-worktree.session.integ.test.ts (2 tests)
- exit-worktree.session.integ.test.ts (3 tests) — provisions
worktrees via EnterWorktreeTool before exercising exit paths
- exit-worktree.test.ts (4 tests) — same provisioning pattern via
`provisionWorktree()` and the `makeMockConfig` helper
Add a `getWorktreeSymlinkDirectories: () => []` stub to each so
the symlink loop is a no-op in tests.
`enter-worktree.test.ts` and `agent/agent.test.ts` intentionally
skipped — they mock `GitWorktreeService.createUserWorktree` outright,
so the method call never fires in their code paths. Adding the stub
there would be defensive speculation. If a future test exercises
the real path, it'll surface there too and we'll add it then.
Local: core tools tests now 123 passed (was 9 failed / 114 passed
on CI run 26213122427 against commit 000c9f63).
* fix(worktree): normalize repoRoot path separators + disable autocrlf in tests
Round 5 of CI: Windows-only test failures on the latest HEAD. Two
unrelated Windows-specific bugs, both in / around worktreeStartup.
1. `setupStartupWorktree` stored the raw `getRepoTopLevel()` output
in `context.repoRoot`. git always emits POSIX paths via
`--show-toplevel` (`C:/Users/...`), so on Windows the value was
forward-slash where `fs.realpath` and `path.join` produce
backslash. The sidecar's `originalCwd` field got the
inconsistent format and a downstream `expect(...).toBe(tempRepo)`
in the round-trip test compared `C:/Users/.../tmp/...` against
`C:\Users\.../tmp/...`.
Wrap the value in `path.resolve()` to normalize to the
platform-native separator before storing. Downstream consumers
(`path.join(session.originalCwd, '.qwen', 'worktrees')` in
`restoreWorktreeContext`, `new GitWorktreeService(originalCwd)`
in `AppContainer`) already handle either format, so no migration
concern for older sidecars.
2. `makeTempRepo` in worktreeStartup.test.ts didn't configure
`core.autocrlf=false`. On Windows runners the default is `true`,
so files committed and pushed to the test's fake-remote `pull/<N>/head`
ref get CRLF-converted on the worktree's checkout. The PR-content
assertion `expect(prFile).toBe('from PR 42\n')` then failed with
`'from PR 42\r\n'`.
Add `core.autocrlf=false` + `core.eol=lf` to the temp-repo setup
so test files round-trip byte-for-byte regardless of host platform.
Local mac: cli worktreeStartup 15/15 still pass. Windows verification
deferred to CI.
* fix(worktree): reject '..' segments + use junction on Windows
Two Copilot findings on symlinkConfiguredDirectories (PR #4381 round 3):
1. The settingsSchema description, docs/users/features/worktree.md, and
WorktreeSettings JSDoc all promise that entries containing `..` are
rejected — but the post-resolve isWithinRoot check accepted
`foo/../bar` (resolves to `bar`, inside the repo). Add a literal `..`
segment check before path.resolve so the code matches the contract.
2. On Windows, fs.symlink(..., 'dir') requires
SeCreateSymbolicLinkPrivilege (admin / Developer Mode) and EPERMs on
default consumer installs. Use 'junction' for directory entries on
win32 — junctions are reparse points that achieve the same semantics
without elevation. Keep 'dir' on POSIX and 'file' for non-directory
sources (no junction-equivalent for files; rare path).
Adds an integration test exercising `foo/../bar` to lock in the
syntactic guard; existing absolute-path and traversal tests already
covered the other rejection forms.
* fix(worktree): PR-worktree HEAD-SHA capture + symlink guard tests
Three findings from wenshao round 4 (PR #4381):
1. For --worktree=#42 (PR worktrees), originalHeadCommit was captured
from the parent repo's HEAD via getCurrentCommitHash() — but the
worktree branches off FETCH_HEAD (the PR tip), not main. Downstream,
WorktreeExitDialog's `rev-list <originalHeadCommit>..HEAD` would
count every commit in the fetched PR as "new work this session"
alongside the user's actual commits.
Same root cause covers the FETCH_HEAD TOCTOU window: between
`git fetch origin pull/<N>/head` and `git worktree add ... FETCH_HEAD`,
a concurrent `git fetch` from any other process sharing this repo
could overwrite .git/FETCH_HEAD, causing the worktree to branch off
an unrelated commit.
Fix: add GitWorktreeService.resolveRef(ref) that returns a 40-char
SHA (or null). In setupStartupWorktree, immediately after
fetchPullRequestRef succeeds, resolve FETCH_HEAD to an immutable
SHA; pass that SHA both as the baseRef to createUserWorktree (closes
the TOCTOU) AND as originalHeadCommit in the returned context
(closes the exit-dialog miscount). Fail-close on null resolve.
2. Orphaned JSDoc block at gitWorktreeService.ts:1035-1048 — originally
wrote validateUserWorktreeSlug's docs, stranded above parsePRReference
after that function was inserted between them. Move the block down to
sit immediately above validateUserWorktreeSlug at its current line.
3. `.git` / `.qwen` symlink rejection guards (~20 lines of security-
critical code at gitWorktreeService.ts:1640-1655) had no regression
tests — only absolute paths, `..` traversal, isWithinRoot escapes,
and missing sources were covered. Add two integ tests in
gitWorktreeService.symlinks.integ.test.ts: one asserts `.git/hooks`
is refused, one asserts `.qwen/projects` is refused.
Also extends the existing PR-worktree integration test in
worktreeStartup.test.ts to assert originalHeadCommit equals the
resolved FETCH_HEAD SHA AND does NOT equal the parent repo's main HEAD
— the assertion would fail loudly if the new SHA-capture path were
reverted.
* fix(worktree): realpath check on symlinkDirectories source + dest paths
Security fix from PR #4381 round 7 (wenshao/qwen3.7-max). The lexical
isWithinRoot + .git/.qwen blocklist checks in symlinkConfiguredDirectories
all operated on path.resolve(repoRoot, raw) — a STRING operation that
doesn't follow symlinks. A committed (or out-of-band) symlink at
<repo>/node_modules pointing into .git would pass every gate:
1. path.resolve gives `<repo>/node_modules` (lexical, passes
isWithinRoot against repo root).
2. The .git/.qwen blocklists also see the lexical path — they don't
detect that the realpath chains into .git.
3. fs.stat() follows the symlink and succeeds against .git/.
4. fs.symlink writes `<worktree>/node_modules → <repo>/node_modules`,
which OS-side resolves through to <repo>/.git. Any tool inside the
worktree that writes to node_modules/hooks/post-merge then has RCE
on the next hook-firing git operation.
Fix: after fs.stat succeeds, fs.realpath the source and RE-RUN the three
containment checks against the realpath. Refuse on any escape. Use the
realpath (not the lexical sourceAbs) as the symlink target so the new
link is one-hop canonical rather than preserving the chain.
Also closes the dest-side variant of the same root cause — flagged in
round 4 thread #5 (declined then as overthinking) but now in scope per
the skill's iteration rule (two consecutive rounds raising the same
root-cause class). path.join(worktreePath, raw) is also lexical: if
git worktree add materialized a committed worktree-level symlink (e.g.
HEAD ships tools → /etc), then fs.mkdir / fs.symlink for a nested entry
like "tools/cache" writes OUTSIDE the worktree. Realpath the dest
parent before mkdir and refuse if it escapes the worktree.
New integ test covers both source-side variants (escape-to-git via
out-of-band symlink + escape-to-outside-dir) in one block. Was RED
against the pre-fix code: <wt>/escape-to-git was created as a symlink
that chained into the source repo's .git. GREEN after the fix.
* fix(worktree): canonicalise repo root before symlinkDirectories checks
Round-7's source-side realpath fix introduced a canonical-vs-lexical
mismatch: `repoRootAbs = path.resolve(this.sourceRepoPath)` is purely
lexical, while `realSource = await fs.realpath(sourceAbs)` is canonical.
On macOS where `/tmp → /private/tmp` and `/var → /private/var` are
ubiquitous, and on any Linux/Windows setup where the user's checkout
sits behind a symlink, the prefixes diverge at the symlink boundary and
`isWithinRoot(realSource, repoRootAbs)` silently rejects every
configured entry.
Production callers (worktreeStartup.ts, EnterWorktreeTool,
agent isolation) all pass the lexical path returned by
`git rev-parse --show-toplevel`. The integ tests masked the bug because
the shared `beforeEach` did `repoRoot = await fs.realpath(dir)` upfront.
Round 8 fix:
- Hoist `repoRootAbs`, `gitDirAbs`, `qwenDirAbs`, and `realWorktreePath`
outside the for-loop — they're loop invariants and were being
recomputed once per entry.
- `await fs.realpath(this.sourceRepoPath)` for `repoRootAbs` so every
containment check below is canonical-vs-canonical. The derived
`gitDirAbs` / `qwenDirAbs` blocklist paths inherit the canonical
prefix automatically. `sourceAbs = path.resolve(repoRootAbs, raw)`
inherits it too, so the early lexical reject paths (absolute, `..`,
repo-root equality, isWithinRoot) stay self-consistent.
- Fail-close: if the repo root itself doesn't realpath (deleted /
inaccessible), bail out of the entire symlink loop rather than
continuing with comparisons we can't trust. Non-destructive — the
worktree was created earlier by `git worktree add`.
New integ test provisions the production shape: a symlink path used
as `sourceRepoPath`, distinct from its canonical realpath. RED on the
pre-fix code (assertion fired with "symlinkDirectories entry was
silently rejected — canonical vs lexical isWithinRoot mismatch"),
GREEN after.
397 lines
26 KiB
Markdown
397 lines
26 KiB
Markdown
# Worktree 通用能力设计
|
||
|
||
## 问题陈述
|
||
|
||
qwen-code 目前仅有面向 Arena 多模型对比场景的内部 worktree 实现(`GitWorktreeService`),用户无法在普通会话中使用 worktree 隔离工作,AgentTool 也不支持为 subagent 创建隔离的 worktree 环境。
|
||
|
||
目标是将 worktree 做成通用能力,支持用户会话级隔离和 Agent 级隔离,同时保证现有 Arena 功能体验完全不变。
|
||
|
||
## 现状对比
|
||
|
||
| 功能 | qwen-code | claude-code | 阶段 |
|
||
| --------------------------------- | --------------- | ----------- | ------- |
|
||
| `EnterWorktree` 工具 | ✅(Phase A) | ✅ | — |
|
||
| `ExitWorktree` 工具 | ✅(Phase A) | ✅ | — |
|
||
| AgentTool `isolation: 'worktree'` | ✅(Phase B) | ✅ | — |
|
||
| 过期 worktree 自动清理 | ✅(Phase B) | ✅ | — |
|
||
| worktree 会话状态持久化与恢复 | ❌ | ✅ | Phase C |
|
||
| Post-creation setup(hooks 配置) | ❌ | ✅ | Phase C |
|
||
| StatusLine worktree 状态展示 | ❌ | ✅ | Phase C |
|
||
| WorktreeExitDialog(退出提示) | ❌ | ✅ | Phase C |
|
||
| `--worktree` CLI 启动标志 | ✅(Phase D) | ✅ | — |
|
||
| 符号链接目录(node_modules 等) | ✅(Phase D) | ✅ | — |
|
||
| PR 引用(`--worktree=#123`) | ✅(Phase D) | ✅ | — |
|
||
| sparse checkout | ❌ | ✅ | Future |
|
||
| tmux 集成 | ❌ | ✅ | Future |
|
||
| Arena 多模型 worktree 隔离 | ✅(qwen 独有) | ❌ | — |
|
||
| 脏状态覆盖(stash + copy) | ✅ | ✅ | — |
|
||
| Baseline commit 追踪 | ✅(qwen 独有) | ❌ | — |
|
||
|
||
## 设计原则
|
||
|
||
**worktree 是通用能力,Arena 是其上层应用。**
|
||
|
||
- 通用 worktree 层:`EnterWorktree`/`ExitWorktree` 工具、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 setup(Phase C)
|
||
- UI 状态展示(Phase C)
|
||
|
||
---
|
||
|
||
### Phase B:Agent 隔离(AgentTool `isolation: 'worktree'`)+ 描述更新
|
||
|
||
**目标:** 模型可为 subagent 创建临时隔离 worktree,agent 结束后自动清理;同步更新受影响的工具描述和提示词。
|
||
|
||
**要实现的功能:**
|
||
|
||
_Agent 隔离核心:_
|
||
|
||
- `AgentTool` 新增 `isolation?: 'worktree'` 参数
|
||
- Agent 启动时创建临时 worktree(slug:`agent-{7hex}`,路径:`.qwen/worktrees/agent-{7hex}`)
|
||
- Agent 结束后:无变更则自动删除;有变更则保留,将路径和分支返回在结果中
|
||
- 过期 worktree 自动清理:扫描 `.qwen/worktrees/`,匹配 `agent-{7hex}` 模式,超过 30 天且无未推送提交则删除,fail-closed 策略
|
||
|
||
_描述与提示词更新:_
|
||
|
||
- `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` 并就地 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 的实际改动。沿用此选择。
|
||
|
||
---
|
||
|
||
### Future:高级功能(按需实现)
|
||
|
||
以下功能面向更特定的使用场景,当前阶段不纳入排期,待用户需求明确后再评估实现。
|
||
|
||
| 功能 | 说明 |
|
||
| ----------------------- | ----------------------------------------------------------------------------------------- |
|
||
| sparse checkout | `worktree.sparsePaths` 配置项,大型 monorepo 只 checkout 指定路径,缩短创建时间和磁盘占用 |
|
||
| `.worktreeinclude` 文件 | 将 gitignore 的文件(`.env`、`secrets.json` 等)自动复制进 worktree |
|
||
| tmux 集成 | `--worktree --tmux` 在新 tmux 窗口启动 worktree 会话 |
|