mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
22 files changed
+3719
-75
No files matched your search
+154
-31
@@ -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 会话 |
|
||||
@@ -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.
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in new issue
Block a user