feat(web-shell): unblock git update on dirty working tree - #10390
feat(web-shell): unblock git update on dirty working tree#10390wenshao wants to merge 2 commits into
Conversation
The workspace "Update Project" action ran a plain git pull, so any uncommitted changes left users with a raw dirty_working_tree error and no way forward outside a terminal. The pull endpoint accepts two opt-in resolutions, the two things a user would do in a terminal: stash the local changes (including untracked files) around the pull and restore them by identity, or discard them and fast-forward. Both are refused while a merge, rebase, cherry-pick, revert or am is parked in the worktree, the discard is validated (fetch + ancestor check) before anything is destroyed, and a failed stash pull aborts only the merge or rebase it started before restoring the entry. The branch picker offers the two resolutions inline when the plain pull is blocked, with the destructive one behind a confirming click, and renders the daemon's message for every other refusal. Ambient git configuration, ignored-file semantics and concurrent pulls are deliberately left to git; docs/design records each as a non-goal.
|
|
|
Thanks for the PR!
Moving on to code review. 🔍 中文说明感谢贡献!
进入代码审查。🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewMy independent take before reading the diff: two opt-in flags on the pull route — stash-around-pull and discard-then-pull — with the stash entry tracked by identity, the discard validated before it destroys anything, and a confirm-gated panel in the picker footer. The PR matches that baseline and goes further where it matters; I did not find a simpler path it missed. What I verified by cross-reading the base branch:
The three safety properties check out on code read, not just by description:
The tests genuinely pin the behavior: real bare remote + workspace clone + second-developer clone, hermetic Three non-blocking observations, recorded so a maintainer can decide whether they matter:
FlowsequenceDiagram
participant P1 as User
participant P2 as Web Shell picker
participant P3 as serve route
participant P4 as core gitPull
participant P5 as git
P1->>P2: Update Project
P2->>P3: pull (plain)
P3->>P5: git pull
P5-->>P3: refused (dirty tree)
P3-->>P2: 409 dirty_working_tree
P2-->>P1: resolution panel
P1->>P2: Stash Changes and Update
P2->>P3: pull stash=true
P3->>P4: gitPull(stash)
P4->>P5: stash push --include-untracked
P4->>P4: remember entry SHA
P4->>P5: git pull
alt pull fails
P4->>P5: abort the merge or rebase it started
P4->>P5: stash apply by SHA (entry kept)
P4-->>P2: 409 pull_failed (state restored)
else pull succeeds
P4->>P5: stash apply by SHA, drop by slot
P4-->>P2: 200 (+stashRestoreConflict if restore conflicts)
end
The Files changed (12)
Test evidence (this PR's CI, fetched via API — PR code is never executed here)At fetch time, the unit suite (ubuntu), Serve A/B and the web-shell visuals capture were still running; the finalize job updates the table when CI settles. The macOS/Windows unit legs, Integration Tests and the tmux/verify lanes show skipped — those lanes are comment-triggered or policy-gated for fork CI, not failures. No red checks so far. Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 The suite is green-or-running, but green only proves the tests pass — the author's real-stack browser evidence (Playwright against a bundled daemon) comes from macOS alone, per the PR's own Tested-on table. Sandboxed verification would settle the remaining claim: 中文说明代码审查:独立判断与 PR 方案一致,且未找到更简路径。交叉核对基线分支确认:UI 触发契约( — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 4/5 — a clean, closed-by-construction implementation of a real UX dead-end; the remainder is CI still landing and the usual fork-CI platform gap, not code doubt. This is the inverse of the pattern this gate usually watches for. #9769 started as this feature and accreted preflights and guards over 18 autofix rounds until nobody could review it; this PR goes the other way — it keeps the three properties that can be made true by construction (restore the stash entry by identity, validate before discarding, abort only what the pull started), proves them in code I could actually check rather than trust, and writes the rest down as non-goals instead of building them. The core lands at +301 lines where the predecessor had +1362, and nothing in the diff is doing anything the stated goal doesn't need. I looked for the simpler path before reading the diff and didn't find one — Reservations are the three non-blocking ones from the review: the conflict warning not surfacing the stash SHA, the client-side-only discard confirmation (consistent with the sibling routes, so not a new surface), and the documented no-serialization interleaving window. None of them loses work. The unit suite, Serve A/B and the web-shell visuals capture were still running when this posted — approval is deferred until CI lands green on 中文说明置信度:4/5 —— 对一个真实的 UX 死路给出了干净、构造上闭合的实现;剩余的是 CI 尚未全部落地与 fork CI 的常规平台缺口,而非代码层面的疑虑。 这个 PR 恰是本门禁通常防范的那种模式的反面。#9769 起步于同一特性,经 18 轮 autofix 不断堆积预检与护栏直到无人能审;本 PR 反其道而行——只保留三条可以构造上闭合的性质(按身份恢复 stash 条目、丢弃前校验、只中止 pull 自己发起的操作),并让这些性质可以在代码中被核对而非被信任,其余写为非目标而不去实现。核心代码 +301 行(前版 +1362),diff 中没有一处服务于既定目标之外的东西。 在读 diff 之前我先找了更简路径,没有找到——最显眼的候选 保留意见即审查中的三条非阻塞观察:恢复冲突的警告未带 stash SHA、放弃确认仅在客户端(与同类路由一致,不构成新攻击面)、以及有文档记录的不串行化交错窗口。三者都不会丢失工作。 发文时单测套件、Serve A/B 与 web-shell 视觉采集仍在运行——批准推迟到 CI 在 — Qwen Code · qwen3.8-max Reviewed at |
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterFull-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
🩺 serve daemon A/BBuilt the PR base vs this PR head ✅ No response changes against the PR base across 12 scenario(s). — Qwen Code · serve A/B |
|
@qwen-code /takeover |
|
🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — 'Integration Tests (CLI, No Sandbox)' was skipped in CI and its suite did not run locally.
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — 'Integration Tests (CLI, No Sandbox)' was skipped in CI and its suite did not run locally。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| const after = await stashRef(cwd, env); | ||
| const stashed = after !== '' && after !== before ? after : undefined; |
There was a problem hiding this comment.
[Critical] R1-1: The auto-stash entry is captured by position — the new top of refs/stash after the push — not by identity. Between the flow's own stash push and this after read there is a gap of two sequential git invocations, and a git stash push from the user's terminal landing in it is adopted as the flow's own entry. After the pull succeeds, restoreStash then applies and DROPS that foreign entry while the flow's own entry is never restored — the response is a plain success with no stashRestoreConflict, and the user's local edits are absent from the tree after a "successful" update. This contradicts the design doc's "identified by SHA, never by stack position… neither gets consumed nor prevents the restore" and "Nothing is lost in any interleaving". The restore/drop side IS identity-based (the post-merge-hook test pins it), but that does not transfer to the capture side. Probe (real gitPull({stash: true}), a PATH shim injecting a terminal git stash push right after the flow's own push): the foreign entry was applied to the tree and dropped, the flow's own entry was left unrestored, and the response was {"success": true}. Fix: verify provenance on capture — snapshot git stash list --format=%H %s before the push, and after it select the newest entry whose subject equals AUTO_STASH_MESSAGE and whose SHA was absent from the pre-push snapshot; when nothing matches, treat the pull as nothing-stashed instead of adopting the top.
Witness (probe, both sides):
PR code: {"foreignTerminalTxtInTree": true, "aTxtAfterPull": "one\n",
"stashList": ["stash@{0}: On master: qwen-code: auto-stash before pull"]}
WITH provenance check: foreign entry kept in the stash list, our entry not adopted
Fix witness: a capture-side attribution test in packages/core/src/utils/git-branches.test.ts (extract the selection as a pure function) — a post-push list whose newest entry carries a foreign subject above the auto-stash entry must still select the auto-stash entry; reverting selection to "top of refs/stash" must turn it red.
中文说明
[Critical] R1-1:自动 stash 条目是按位置捕获的——push 之后 refs/stash 的新栈顶——而不是按身份。本流程自己的 stash push 与这次 after 读取之间隔着两个串行的 git 调用,用户终端在此期间落下的 git stash push 会被当作本流程的条目。pull 成功后 restoreStash 会把那条外来条目 apply 进树并删除,而本流程自己的条目从未恢复——响应是普通成功、没有 stashRestoreConflict,用户的本地修改在"成功"更新后从树上消失。这与设计文档"按 SHA 识别、从不按栈顶位置……既不会被误消费也不会挡路""任何交错下都不丢东西"相矛盾。恢复/删除侧确实是身份式的(post-merge-hook 测试钉住了),但这不能传导到捕获侧。探针(真实 gitPull({stash: true}),PATH shim 在本流程 push 之后立即注入一次终端 git stash push):外来条目被 apply 进树并被删除,本流程条目未恢复,响应 {"success": true}。修复:捕获时校验来源——push 前快照 git stash list --format=%H %s,push 后选取 subject 等于 AUTO_STASH_MESSAGE 且 SHA 不在 push 前快照中的最新条目;没有匹配时视为无 stash,而不是采用栈顶。
修复见证:在 packages/core/src/utils/git-branches.test.ts 增加捕获侧归属测试(把选择逻辑提取为纯函数)——post-push 列表最新条目为外来 subject 且压在自动条目之上时,必须仍选中自动条目;把选择改回"取栈顶"该测试必须变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| await runGit(cwd, ['clean', '-fd'], env); | ||
| // A fast-forward is the same commit whether merged or rebased, so the | ||
| // `rebase` option has nothing to add here. | ||
| const output = await runGit(cwd, ['pull', '--ff-only'], env); |
There was a problem hiding this comment.
[Critical] R1-2: forcePull validates fast-forwardability against one fetch (the ancestor check refuses diverged while the local changes are still intact), but then runs git pull --ff-only, which fetches a SECOND time — after reset --hard + clean -fd have already destroyed the local changes. A divergent upstream push landing in that window (which spans the reset, the clean, and the pull's own network fetch) moves @{upstream} before the merge, --ff-only refuses, and the update fails — with everything already discarded; untracked files destroyed by clean -fd are unrecoverable. The design doc's "the post-discard pull can only ever fast-forward" and "nothing is discarded for an update that could not be applied" are falsified by the re-fetch. Probe (real gitPull({force: true}), shim force-pushing orphan history between the discard and the update): fatal: Not possible to fast-forward, aborting., local tracked edit destroyed, untracked file destroyed, update failed; with git merge --ff-only @{upstream} (no re-fetch) the same adversarial event fast-forwards successfully to the validated tip. Fix: apply the already-validated state without re-fetching — git merge --ff-only @{upstream}; the remote-tracking ref can only move via a fetch, so the validated commit is the one merged.
| const output = await runGit(cwd, ['pull', '--ff-only'], env); | |
| const output = await runGit(cwd, ['merge', '--ff-only', '@{upstream}'], env); |
Fix witness: a force-pull test that injects a divergent push after the ancestor check and asserts the refusal leaves local files intact; mutating this line back to pull --ff-only must turn it red.
中文说明
[Critical] R1-2:forcePull 用一次 fetch 校验快进性(ancestor 检查在本地修改还在时拒绝 diverged),但随后执行 git pull --ff-only——它会在 reset --hard + clean -fd 已经销毁本地修改之后第二次 fetch。分叉推送落在这个窗口(横跨 reset、clean 和 pull 自身的网络 fetch)内时,@{upstream} 在合并前移走,--ff-only 拒绝,更新失败——而一切已经被丢弃;clean -fd 删掉的未跟踪文件不可恢复。设计文档"丢弃后的 pull 只可能快进""不能应用的更新不会丢弃任何东西"被这次重新 fetch 击穿。探针(真实 gitPull({force: true}),shim 在丢弃与更新之间 force-push 孤儿历史):fatal: Not possible to fast-forward, aborting.,本地已跟踪修改被销毁、未跟踪文件被销毁、更新失败;改用 git merge --ff-only @{upstream}(不再 fetch)后同一对抗事件成功快进到已校验的上游 tip。修复:不再重新 fetch,直接应用已校验的状态——git merge --ff-only @{upstream};远端跟踪引用只能被 fetch 移动,已校验的提交就是被合并的那个。
修复见证:新增 force-pull 测试,在 ancestor 检查之后注入分叉推送并断言拒绝时本地文件仍在;把该行突变回 pull --ff-only 必须变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| // Abort only what this pull started: the guard above proved no merge or | ||
| // rebase was in progress before it ran, so any state present now is the | ||
| // pull's own. |
There was a problem hiding this comment.
[Critical] R1-3: The operation-state guard is a one-shot check at the start of the flow and is never re-verified before the commands that destroy or absorb state — three interleaving windows, all probed against real git. Demonstrated entrance (this comment's anchor): the catch path aborts ANY merge/rebase state found after a failed pull, but a merge the user started from a terminal between the guard and the pull's failure is silently --aborted — conflict resolutions destroyed — and misreported as the pull's own state; probe on git 2.43 shows MERGE_HEAD present with a staged resolution before the abort and gone afterwards, tree reset, while the response claims only "your local changes were restored" (rebase arm likewise aborted). Sibling symptom (force flow): a merge parked between the guard and reset --hard passes the ancestor check (parking moves neither HEAD nor @{upstream}), is destroyed by the reset along with any staged resolution, and the response reports success: true. Sibling symptom (stash flow): git stash push ABSORBS a merge state that appears between the guard and the push — probed exit 0 with MERGE_HEAD removed and the tree reset to HEAD — so an in-progress merge is silently converted into plain unstaged edits with its lineage gone, under a success response. The design doc's "refused while an operation is parked", "abort only what the pull itself started" and "Nothing is lost in any interleaving" do not hold in these windows. Fix: re-probe operationInProgress immediately before each destructive/absorbing command (before stash push, before reset --hard, and in the catch — abort only state that was ABSENT in a pre-pull snapshot); this narrows rather than closes the race — per-workspace serialization of the mutating flows is what closes it. If the current design stands, the doc must describe these as start-of-request checks, not unconditional guarantees.
Witness (three probes, git 2.43):
catch-path arm: user merge parked (conflict, resolution staged) -> pull fails
"You have not concluded your merge" -> `git merge --abort` exit=0
-> AFTER: MERGE_HEAD=ABSENT, a.txt reset to main side, status empty
force arm: merge parked after guard -> merge-base exit=0 -> reset clears
MERGE_HEAD and wipes the staged resolution -> {"success":true}
stash-push arm: MERGE_HEAD present + staged resolution ->
`git stash push --include-untracked` exit=0, MERGE_HEAD=GONE, tree reset;
end-to-end stashPull -> {"success":true}, nothing reported
Fix witness: a core test that plants MERGE_HEAD between the guard and the destructive command (via a runGit seam) and asserts the stash arm leaves it intact and the force arm refuses with files surviving; removing the re-checks must turn it red.
中文说明
[Critical] R1-3:operation-state 守卫是流程起点的一次性检查,在任何销毁/吸收状态的命令之前都不复核——三个交错窗口全部经过真实 git 探针证实。已证实入口(本评论锚点):catch 路径对失败 pull 之后发现的任何 merge/rebase 状态无条件 --abort,但用户在守卫之后、pull 失败之前从终端启动的 merge 会被静默中止——已暂存的冲突解决方案被销毁——并被谎报为 pull 自己的状态;git 2.43 探针显示 abort 前 MERGE_HEAD 存在且带已暂存解决方案,abort 后消失、树被重置,而响应只声称"本地修改已恢复"(rebase 臂同样被中止)。同胞症状(force 流程):守卫与 reset --hard 之间停驻的 merge 能通过 ancestor 检查(停驻不移动 HEAD 与 @{upstream}),被 reset 连同已暂存解决方案一起销毁,响应照常 success: true。同胞症状(stash 流程):git stash push 会吸收守卫之后出现的 merge 状态——实测 exit 0 且 MERGE_HEAD 被删除、树被重置为 HEAD——进行中的 merge 被无声转换成普通未暂存修改、谱系丢失,响应成功。设计文档的"有操作停驻时拒绝""只中止 pull 自己发起的操作""任何交错下都不丢东西"在这些窗口里不成立。修复:在每条破坏性/吸收性命令之前即时复核 operationInProgress(stash push 前、reset --hard 前、以及 catch 中——只中止 pull 前快照中不存在的状态);这只能收窄而非闭合竞态——按工作区对变更性流程串行化才能闭合。若维持现设计,文档必须把这些表述改为"请求开始时检查"而非无条件保证。
修复见证:新增 core 测试,通过 runGit 接缝在守卫与破坏命令之间植入 MERGE_HEAD,断言 stash 臂保持其完整、force 臂拒绝且文件存活;移除复核后必须变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| .trim(); | ||
| if (slot) { | ||
| try { | ||
| await runGit(cwd, ['stash', 'drop', '--quiet', slot], env); |
There was a problem hiding this comment.
[Critical] R1-4: restoreStash applies the entry by SHA identity but drops it by a POSITIONAL slot (stash@{N}) read in an earlier, separate git stash list call, with no identity re-check between the two. A concurrent git stash push/pop/drop from the user's terminal in that gap shifts the indices, and the drop silently removes a DIFFERENT entry while the route reports success — reintroducing the exact position-based hazard the function's own "Never stash pop" comment guards against. This is distinct from the capture-side finding: here the SHA is correctly identified and correctly applied; only the removal loses identity. Verified on git 2.43 that git stash drop <sha> is rejected ("not a stash reference"), so list-then-drop is structural, not a choice. Probe: list [U stash@{0}, A stash@{1}], apply A ok, a concurrent push shifts to [W, U, A], then drop stash@{1} silently deletes the user's entry U under a success response; re-resolving the slot from the SHA immediately before the drop flips the outcome (A dropped, U survives). Fix: re-resolve the slot immediately before dropping and verify it still maps to the SHA; on mismatch, skip the drop and report the entry as kept, degrading from silent loss of someone else's stash to keep-and-warn.
Fix witness: N/A for a deterministic pin (the guard sits inside a two-command race that real repos cannot interleave without a git-runner seam); if the re-resolve is extracted as a pure function, unit-test it against a shifted list.
中文说明
[Critical] R1-4:restoreStash 按 SHA 身份 apply 条目,却按位置槽位(stash@{N})删除——槽位来自更早的一次独立 git stash list 调用,两者之间没有身份复核。用户终端在这个间隙里的 git stash push/pop/drop 会移动索引,drop 会无声删除另一条条目而路由报告成功——重新引入了函数自己"绝不 stash pop"注释所防范的位置式风险。这与捕获侧的发现不同:此处 SHA 识别正确、apply 也正确,只有删除一步丢失身份。已在 git 2.43 验证 git stash drop <sha> 会被拒绝("not a stash reference"),因此 list-then-drop 是结构性的而非实现选择。探针:列表 [U stash@{0}, A stash@{1}],apply A 成功,并发 push 后变为 [W, U, A],随后 drop stash@{1} 在成功响应下无声删除用户条目 U;在 drop 前即时按 SHA 重解析槽位则结果翻转(A 被删、U 存活)。修复:删除前即时重解析槽位并复核其仍映射到该 SHA;不匹配则放弃删除、按"条目保留"报告,把失败模式从无声丢别人的条目降级为保留+告警。
修复见证:无法用真实仓库确定性钉住(守卫位于两条命令的竞态中间);若把重解析提取为纯函数,可用移动后的列表做单测。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| // Validate before destroying anything: fetch, then refuse unless the | ||
| // update is a fast-forward. A diverged branch would need a merge that | ||
| // could stop on conflicts after the local changes were already gone. | ||
| await runGit(cwd, ['fetch'], env); |
There was a problem hiding this comment.
[Critical] R1-5: The validation fetch does not pass --prune (contrast the fetchOnly path's ['fetch', '--all', '--prune'] in this same file), so a stale remote-tracking ref of a branch that was DELETED on the remote survives it. requireUpstream resolves the stale ref, the ancestor check passes vacuously against it, reset --hard + clean -fd destroy the local changes, and only then does git pull --ff-only fail on a ref the remote no longer has — everything discarded, update not applied, and not even a typed 409. This violates the PR's own invariant "nothing is discarded for an update that could not be applied", and needs NO concurrent actor: the routine merge-then-delete-branch workflow produces the stale ref whenever fetch.prune is unset. Probe (git 2.43, branch deleted from a second clone): stale ref survives the plain fetch → merge-base --is-ancestor exit 0 (vacuous) → after discard both the tracked edit and the untracked file are gone → pull fails "no such ref was fetched". The --prune A/B flips it: the ref is deleted, merge-base exits 128, and the refusal happens BEFORE the discard with local files intact. Fix: fetch with --prune, then re-confirm @{upstream} still resolves before destroying anything (throw a typed GitPullFailure if not). Note the post-prune failure exits 128, which escapes the diverged (=1) mapping — an upstream-gone arm needs explicit handling.
| await runGit(cwd, ['fetch'], env); | |
| await runGit(cwd, ['fetch', '--prune'], env); |
Fix witness: a force-pull test in the existing two-clone harness — delete the branch from the second clone, dirty the workspace, assert gitPull(dir, { force: true }) rejects and the local edit survives; removing --prune/the re-check must turn it red.
中文说明
[Critical] R1-5:校验 fetch 没有带 --prune(对比同文件 fetchOnly 路径的 ['fetch', '--all', '--prune']),因此远端已删除分支的陈旧远端跟踪引用会存活下来。requireUpstream 解析该陈旧引用,ancestor 检查对其空转通过,reset --hard + clean -fd 销毁本地修改,之后 git pull --ff-only 才在远端已不存在的引用上失败——一切已被丢弃、更新未应用,甚至不是类型化的 409。这违反 PR 自己的不变量"不能应用的更新不会丢弃任何东西",且无需任何并发参与者:"合并后删分支"的日常工作流在 fetch.prune 未设置时必然产生陈旧引用。探针(git 2.43,第二 clone 删除分支):陈旧引用在普通 fetch 后存活 → merge-base --is-ancestor exit 0(空转)→ 丢弃后已跟踪修改与未跟踪文件均消失 → pull 报 "no such ref was fetched"。--prune A/B 翻转:引用被删除,merge-base exit 128,拒绝发生在丢弃之前、本地文件完好。修复:fetch 带 --prune,并在销毁任何内容之前复核 @{upstream} 仍可解析(否则抛类型化 GitPullFailure)。注意 prune 后失败退出码是 128,会绕过 diverged(=1)映射——上游消失需要显式分支处理。
修复见证:用现有双 clone 夹具新增 force-pull 测试——从第二 clone 删除分支、弄脏工作区,断言 gitPull(dir, { force: true }) 拒绝且本地修改仍在;移除 --prune/复核必须变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| // its own action is in flight; it only closes once the pull settles. | ||
| clearPullPanel(); | ||
| if (result.stashRestoreConflict) { | ||
| showStatus(t('branchPicker.pullStashConflict'), 'warning'); |
There was a problem hiding this comment.
[Suggestion] R1-13: The stash-restore-conflict warning — the ONLY signal that the pull succeeded but the user's local changes were left in a stash entry instead of the working tree — lives solely in popover-local statusMsg. Closing the popover unmounts PopoverContent (Radix Presence present: forceMount || context.open; no forceMount here), so a settlement that arrives while closed writes into state nobody renders, and the reopen effect then wipes it unconditionally. Closing mid-flight is trivially reachable: the 'View Changes' action button carries no disabled={!!busyAction} (measured disabled: false mid-pull) and closes the popover, as do Radix outside-click/Escape. Probe (real component, jsdom, Radix open-gating modelled; control arm keeps the popover open): control arm shows the warning ("Updated, but restoring your stashed changes failed…"); test arm — closed mid-flight, settled while closed, reopened — has contentMounted: false at settlement and, after reopen, no warning and no stash mention in the footer, while the user's changes sit in refs/stash (possibly with conflict markers in the tree) and nothing ever says so. Fix: make the warning survive the popover lifecycle — e.g. skip the status reset in the open effect while statusType === 'warning', or surface stashRestoreConflict through a channel outside the popover (toast/notification).
Fix witness: a new case in BranchPickerPopover.test.tsx — dirty 409, click Stash against a pending promise, render open=false, settle with { success: true, stashRestoreConflict: true }, reopen, assert the footer contains 'restoring your stashed changes failed'; removing the fix makes it red.
中文说明
[Suggestion] R1-13:stash 恢复冲突警告——"pull 成功但本地修改仍留在 stash 条目里"的唯一信号——只存在于 popover 本地的 statusMsg。关闭 popover 会卸载 PopoverContent(Radix Presence present: forceMount || context.open;此处无 forceMount),因此关闭期间落地的结算写进无人渲染的状态,重新打开时 open effect 又无条件清空它。飞行中关闭是平凡可达的:'View Changes' 按钮不带 disabled={!!busyAction}(实测飞行中 disabled: false)且会关闭 popover,Radix 的外部点击/Escape 同样。探针(真实组件、jsdom、按 Radix 开闭语义建模;控制臂保持打开):控制臂显示警告("Updated, but restoring your stashed changes failed…");测试臂——飞行中关闭、关闭期间结算、重新打开——结算时 contentMounted: false,重开后页脚既无警告也不提 stash,而用户的修改就在 refs/stash 里(树上可能还带冲突标记)且没有任何界面说明。修复:让警告活过 popover 生命周期——例如 open effect 在 statusType === 'warning' 时跳过状态重置,或把 stashRestoreConflict 通过 popover 之外的通道(toast/通知)呈现。
修复见证:BranchPickerPopover.test.tsx 新增用例——脏树 409、点 Stash(promise 挂起)、渲染 open=false、以 { success: true, stashRestoreConflict: true } 落地、重新打开、断言页脚含 'restoring your stashed changes failed';移除修复该测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| 'branchPicker.pullStashConflict': | ||
| 'Updated, but restoring your stashed changes failed. They are kept in the stash entry — resolve any conflict markers, then restore the stash manually.', |
There was a problem hiding this comment.
[Suggestion] R1-14: This warning tells the user their changes are kept in "the stash entry" but never says WHICH one, even though the daemon's output already carries the exact SHA: stashPull's conflict return puts "they are kept in stash entry " into output, but the component's conflict arm substitutes this static translated string and discards result.output; DaemonGitPullResult carries only stashRestoreConflict?: boolean, no identity field. Whenever git stash list holds more than one candidate — a concurrent terminal stash (the actor this PR's own design doc and foreign-stash test treat as realistic), or a second identically-labelled qwen-code: auto-stash before pull entry stacked by a second failed restore — the user must guess which entry holds their work; restoring a wrong guess applies unrelated changes on top of the freshly pulled tree, and dropping a guess destroys the entry with the work. Fix: thread the identity through — add stashSha?: string to GitPullResult (core) and DaemonGitPullResult (SDK), set it in the conflict return, and make this message a (v) => interpolation of { sha } in both EN and ZH (the message table already supports it, e.g. branchPicker.createdBranch). Cheaper fallback: append result.output to the translated warning in handlePull.
Fix witness: extend 'shows a warning instead of success when the stash restore conflicts' with a distinct SHA in the mocked result and assert footerText() contains it; removing the interpolation fails the assertion.
中文说明
[Suggestion] R1-14:这条警告告诉用户修改保留在"Stash 条目中",却不说哪一个——尽管 daemon 的 output 里已经有确切 SHA:stashPull 的冲突返回把 "they are kept in stash entry " 放进 output,但组件冲突臂用这条静态翻译串替换并丢弃 result.output;DaemonGitPullResult 只有 stashRestoreConflict?: boolean,没有身份字段。只要 git stash list 里有多于一个候选——并发终端 stash(本 PR 自己的设计文档与外来 stash 测试都把该参与者当真实场景),或第二次恢复失败又堆进一条同名的 qwen-code: auto-stash before pull——用户只能猜哪个条目装着自己的修改;恢复错误的猜测会把无关修改叠到刚 pull 的树上,drop 一个猜测会销毁真正装着工作的那条。修复:把身份贯穿出来——core GitPullResult 与 SDK DaemonGitPullResult 增加 stashSha?: string,在冲突返回中设置它,并把这条消息改为 (v) => 形式在 EN 与 ZH 两处内插 { sha }(消息表已支持,如 branchPicker.createdBranch)。廉价替代:在 handlePull 里把 result.output 附加到翻译警告之后。
修复见证:扩展 'shows a warning instead of success when the stash restore conflicts',给 mock 结果附加一个可区分的 SHA 并断言 footerText() 含该 SHA;移除内插该断言失败。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| if ( | ||
| (await fs.promises.realpath(cwd)) !== (await fs.promises.realpath(toplevel)) | ||
| ) { |
There was a problem hiding this comment.
[Suggestion] R1-15: The force_unsupported refusal — the only guard preventing a force pull from a sub-directory workspace from running reset --hard + clean -fd over the whole repository — has no ROUTE-level test: a core-level test exists (git-branches.test.ts 'force pull from a repository subdirectory refuses without discarding' pins the code and file survival), but force_unsupported never appears in workspace-git-branches.test.ts, whose real-repo pull tests all bind the workspace at the repository root. The residual gap is the 409 wire contract ({error: 'force_unsupported', message}) that the BranchPickerPopover and SDK clients branch on — a future refactor of the route mapping (renamed field, dropped GitPullFailure arm, wrapped error) would pass CI silently and only surface for sub-repo workspaces in production. Add a route test: create the repo in a parent directory, bind the workspace to a sub-directory, dirty a file outside it, POST { force: true }, and assert 409 { error: 'force_unsupported' } plus that the outside file's modification survives.
Fix witness: the new test — break the route's force_unsupported mapping and it goes red.
中文说明
[Suggestion] R1-15:force_unsupported 拒绝——阻止子目录工作区的 force pull 对整个仓库执行 reset --hard + clean -fd 的唯一守卫——没有路由级测试:core 级测试存在(git-branches.test.ts 'force pull from a repository subdirectory refuses without discarding' 钉住拒绝码与文件存活),但 force_unsupported 在 workspace-git-branches.test.ts 中从未出现,其真实仓库 pull 测试全部把工作区绑在仓库根。残留缺口是 BranchPickerPopover 与 SDK 客户端分支依赖的 409 线上契约({error: 'force_unsupported', message})——未来重构路由映射(改字段名、漏掉 GitPullFailure 分支、包装错误)会在 CI 无声通过,只在生产环境的子目录工作区暴露。新增路由测试:在父目录建仓库、把工作区绑到子目录、在子目录外弄脏一个文件、POST { force: true },断言 409 { error: 'force_unsupported' } 且子目录外的修改存活。
修复见证:新测试本身——破坏路由的 force_unsupported 映射后变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| await runGit( | ||
| cwd, | ||
| ['stash', 'push', '--include-untracked', '-m', AUTO_STASH_MESSAGE], | ||
| env, | ||
| ); |
There was a problem hiding this comment.
[Suggestion] R1-16: A tree containing an intent-to-add entry (git add -N — e.g. an IDE staging a new file) makes git stash push --include-untracked fail atomically: probed on git 2.43, exit 1 with "Entry '' not uptodate. Cannot merge. / Cannot save the current worktree state", tree intact, no entry created. This call has no try/catch, so the raw exec error is not a GitPullFailure; none of the route's regexes match that text, so the client gets an unclassified 500 (route probe: status 500, untyped body, a bridge error log line for a non-error user condition), and the component's non-dirty catch clears the panel that existed precisely to resolve this refusal — the user is never returned to the choice, even though Discard would have worked (reset --hard clears intent-to-add markers). This contradicts the design doc's own principle "failures are classified by what the core did, not by matching git's text". Fix: wrap the push — when it fails, nothing was stashed or pulled, so rethrow a typed failure, e.g. throw new GitPullFailure('pull_failed', \cannot stash local changes, so the update was not attempted:\n${gitDetail(err)}`)(a dedicated code such asstash_failedwould be clearer still). Probe flip: with the wrap, the route answers409 pull_failed` carrying git's own notice.
Fix witness: a core test — seed git add -N new.txt plus a modified tracked file, call gitPull(cwd, { stash: true }), assert rejection as a GitPullFailure and the tree unchanged; without the guard it rejects with a raw exec error and the test is red.
中文说明
[Suggestion] R1-16:含 intent-to-add 条目(git add -N——例如 IDE 暂存新文件)的树会使 git stash push --include-untracked 原子失败:在 git 2.43 实测,exit 1,"Entry '' not uptodate. Cannot merge. / Cannot save the current worktree state",树原样、无条目创建。此调用没有 try/catch,裸执行错误不是 GitPullFailure;路由正则均不匹配该文本,客户端收到未分类 500(路由探针:status 500、无类型 body、为一个非错误用户状态记一条 bridge error 日志),组件的非脏 catch 清掉了为解决这次拒绝而存在的面板——用户再拿不回选择,尽管 Discard 本可以工作(reset --hard 会清掉 intent-to-add 标记)。这与设计文档自己的原则"失败按 core 做了什么分类,而不是匹配 git 文本"相矛盾。修复:包住 push——它失败时既未 stash 也未 pull,重抛类型化失败,如 throw new GitPullFailure('pull_failed', \cannot stash local changes, so the update was not attempted:\n${gitDetail(err)}`)(专用码如 stash_failed更清晰)。探针翻转:加上包装后路由返回409 pull_failed` 并携带 git 自己的说明。
修复见证:core 测试——git add -N new.txt + 已修改跟踪文件,调用 gitPull(cwd, { stash: true }),断言以 GitPullFailure 拒绝且树不变;没有该守卫时以裸执行错误拒绝、测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| type="button" | ||
| className={`${styles.pullBlockedButton} ${styles.pullBlockedButtonDanger}`} | ||
| disabled={!!busyAction} | ||
| onClick={() => void handlePull({ force: true })} |
There was a problem hiding this comment.
[Suggestion] R1-17: A force pull rejected with force_unsupported (workspace is a subdirectory of its repository — the exact case the daemon guard exists for) falls into handlePull's generic else branch, whose clearPullPanel() tears down the resolution panel even though the working tree is STILL dirty and the panel's Stash option remains fully viable — stashPull has no toplevel restriction (only forcePull realpath-checks). The two-step destructive confirmation ends in a dead end that hides the one remaining one-click fix until the user re-runs Pull to regenerate the 409. Contrast: operation_in_progress, the other non-dirty code, genuinely invalidates BOTH options (core refuses stash and force during sequencer states), so clearing is right there — only still-dirty codes are mishandled. Probe (real component, mocked daemon): plain pull → dirty → panel up with Stash offered → confirm discard → {"force":true} rejected with force_unsupported → panel gone, stash option gone, footer shows only the error message; keeping the panel on force_unsupported (setPullBlocked(true), setConfirmDiscard(false)) flips both flags back to true. Fix: add an isForceUnsupportedError check next to isDirtyWorkingTreeError in the catch that keeps the panel and shows pullErrorMessage(err) — its daemon message explains why discard is unavailable, unlike the static pullBlocked string.
Fix witness: a test where the pull mock rejects with 409 { error: 'force_unsupported' } after the panel is up and asserts the panel stays visible; removing the panel-keeping branch must fail it.
中文说明
[Suggestion] R1-17:force pull 被 force_unsupported 拒绝(工作区是仓库子目录——正是 daemon 守卫存在的场景)时落进 handlePull 的通用 else 分支,其 clearPullPanel() 拆掉面板,但工作树仍然脏,面板的 Stash 选项完全可用——stashPull 没有 toplevel 限制(只有 forcePull 做 realpath 检查)。两步破坏性确认以死胡同收场,把唯一剩下的一键修复藏掉,直到用户重新跑 Pull 再生成一次 409。对照:另一个非脏码 operation_in_progress 确实使两个选项都失效(core 在序列器状态期间同时拒绝 stash 与 force),收起是对的——只有"树仍脏"的码被错误处理。探针(真实组件、模拟 daemon):裸 pull → 脏 → 面板挂出含 Stash → 确认放弃 → {"force":true} 以 force_unsupported 拒绝 → 面板消失、Stash 选项消失、页脚只剩错误消息;在 force_unsupported 时保留面板(setPullBlocked(true)、setConfirmDiscard(false))两个标志翻回 true。修复:在 catch 中 isDirtyWorkingTreeError 旁新增 isForceUnsupportedError 检查,保留面板并显示 pullErrorMessage(err)——daemon 消息解释了为何放弃不可用,优于静态 pullBlocked 文案。
修复见证:新增测试,面板挂出后使 pull mock 以 409 { error: 'force_unsupported' } 拒绝,断言面板仍可见;移除保留分支必须失败。
— qwen3.8-max via Qwen Code /review (v0.22.2)
|
🔄 AutoFix is working on this PR — round 1/100. Watch live progress; this round posts its report here when it finishes. 中文说明🔄 AutoFix 正在处理此 PR —— 第 1/100 轮。查看实时进度;本轮结束后会在此发布报告。 |
What this PR does
The workspace "Update Project" action in the Web Shell now handles a dirty working tree instead of dead-ending on it. When a plain pull is blocked by uncommitted changes, the branch picker's footer switches from an opaque one-line error to a resolution panel with two ways forward: Stash Changes and Update (stash the local changes including untracked files, pull, restore them) or Discard Changes and Update… (behind a second confirming click). Cancelling dismisses the panel; reopening the picker or starting any other action resets it.
The pull endpoint accepts two opt-in booleans,
stashandforce(mutually exclusive, both off by default). They do exactly what a user would do in a terminal, and the repository is always left in a known state:git stash push --include-untracked, the samegit pullas before, then restore. The entry is identified by SHA (refs/stashcompared before/after the push,git stash apply <sha>, drop by slot looked up at drop time), never by stack position, so a terminal pushing to the shared stash meanwhile is neither consumed nor in the way. If the pull fails, the merge or rebase it started is aborted and the entry is restored →409 pull_failedwith git's message. If the restore itself conflicts, the response is a success withstashRestoreConflict: true; git keeps the entry and the output names it.git reset --hard+git clean -fd(ignored files kept), thengit pull --ff-only. Validated before anything is destroyed: fetch first, refuse a diverged branch (409 diverged) while the local changes are still intact, so the post-discard pull can only ever fast-forward. Refused from a workspace below the repository root (409 force_unsupported), becausereset --hardwould also erase changes outside the workspace.409 operation_in_progress) while a merge, cherry-pick, revert, rebase or am is parked in the worktree, sincestash pushandreset --hardboth clear that state; the failure recovery therefore only ever aborts what the pull itself started.A plain pull with no option is byte-for-byte the previous behavior. The SDK's
workspaceGitPullgains the two options, thestashRestoreConflictresult field, and a per-call timeout so the popover can outsize the client's default fetch budget for the multi-command flows.Replaces #9769. That PR started as this same 743-line feature and grew to +8217/−228 over 18 autofix rounds, each adding a preflight or guard (ignored-file collision probe, per-repo pull lock, identity re-verification, pinned merge flags overriding the user's git policy, hermetic-config shields in tests) whose edge cases the next review round then found — 235 review threads, all unresolved, and the bot itself asking for the PR to be reduced. This PR keeps the three properties that are closed by construction (restore by identity, validate before discard, abort only what we started) and records the rest as explicit non-goals in
docs/design/git-pull-dirty-worktree.md: ambient git configuration is honored as in a terminal, ignored files are expendable as in git, and concurrent pulls fail loudly on git's own index lock rather than being serialized. Net: +1627/−77 across the same 12 files, coregit-branches.ts+301 lines instead of +1362.Why it's needed
Users who keep uncommitted work in a workspace could not use the Web Shell git update at all: the pull was refused and the UI only rendered the raw daemon error code, forcing everyone back to a terminal to stash or clean by hand. The workspace list already knows the working tree state for its git chip, so surfacing the two standard resolutions inline closes the loop without leaving the shell.
Reviewer Test Plan
How to verify
Automated coverage runs every layer against real git repositories (bare remote + workspace clone + a second clone standing in for another developer); all targeted runs pass locally:
cd packages/core && npx vitest run src/utils/git-branches.test.ts, 72 passed, 15 new): plain dirty pull still refused by git unchanged; stash round trip restores tracked edits and untracked files with an empty stash list; nothing-to-stash is a plain pull; stash + rebase replays the local commit linearly; conflicting restore keeps the entry and reports its SHA; failed merge and failed rebase both restore the exact pre-pull state (HEAD, no MERGE_HEAD/rebase dir, edits, untracked file, empty stash); a foreign stash pushed mid-pull (via a realpost-mergehook) is left untouched while ours is applied and dropped by identity; missing upstream fails before stashing; force discards tracked/untracked and keeps ignored; force refuses a diverged branch and a subdirectory cwd before discarding; stash+force throws; merge-in-progress and stopped-rebase refuse both flows and keep the state.cd packages/cli && npx vitest run src/serve/routes/workspace-git-branches.test.ts, 32 passed, 10 new): wrong-typed / combined options → 400; dirty plain pull → 409dirty_working_treepath-redacted; stash → 200 with changes restored; conflicting restore → 200 +stashRestoreConflict; recovered failure → 409pull_failedpath-redacted with MERGE_HEAD gone; force → 200; diverged force → 409divergedwith edits intact; in-progress merge → 409operation_in_progress.cd packages/web-shell && npx vitest run client/components/BranchPickerPopover.test.tsx, 10 passed, 7 new; full web-shell suite 4401 passed): panel on dirty 409, stash click →{ stash: true }with the 300 s timeout, discard needs the confirm click before{ force: true }, panel stays mounted (button disabled) while in flight,stashRestoreConflictrenders as a warning, other refusals render the daemon message without the panel, Cancel sends no request, reopen resets.cd packages/sdk-typescript && npx vitest run test/unit/DaemonClient.test.ts, 353 passed, 2 new): per-call timeout overrides the client budget on both routes and stays out of the JSON body.Real stack, no mocks:
npm run bundle→node dist/cli.js serve --workspace <fixture>with an isolatedQWEN_HOME, driven by Playwright in Chromium with a/git/pullrequest ledger, then the repository inspected with git after each scenario. Fixture: 20-line README, upstream edits line 1; workspace edits line 20 (clean-restore) or line 1 (conflict-restore), plus an untrackednotes.txtand an ignoreddist/out.txt.{}→ 409dirty_working_tree{"stash":true}→ 200notes.txtback;dist/out.txtkept;git stash listempty{"stash":true}→ 200 +stashRestoreConflictUUwith conflict markers;stash@{0}: qwen-code: auto-stash before pullstill carries+line 1 (local WIP);notes.txtback{"force":true}→ 200notes.txtgone;dist/out.txtkept?lang=zh-CNEvidence (Before & After)
main, from the #9769 verification run)POST /workspaces/:workspace/git/p…, truncated, no actions.zh-CN:
Tested on
Environment (optional)
macOS, Node 24.18.1, git 2.55.0. Unit and integration tests against real local git repositories; real
qwen servedaemon fromnpm run bundle+ Playwright/Chromium for the browser evidence.npm run typecheckis clean for every package touched (the only failures in this worktree are the pre-existing ajv-version errors inintegrations/external-context-mem0, unrelated to this change); eslint clean on all changed files.Risk & Scope
git pulland to the plain pull onmaintoday), no override of the user'spull.rebase/pull.ff/autostash policy, no cross-request pull serialization. The route's pre-existing text-based classification of plain-pull errors is unchanged.stashRestoreConflictand the SDK timeout parameter are additive.Linked Issues
Replaces #9769 (same feature, reduced to a closed design; see the cross-reference comment there).
中文说明
这个 PR 做了什么
Web Shell 中工作区的「更新项目」操作现在能处理脏工作区,而不是遇到它就卡死。当裸 pull 被未提交的修改阻塞时,分支选择器底部会从一行看不懂的错误切换为一个选项面板,提供两种处理方式:Stash 修改并更新(把本地修改含未跟踪文件 stash 起来、pull、再恢复)或 放弃修改并更新…(需要第二次点击确认)。取消会收起面板;重新打开选择器或执行任何其它操作都会重置它。
pull 接口新增两个可选布尔值
stash与force(互斥,默认都关)。它们做的正是用户在终端里会做的事,并且仓库始终处于已知状态:git stash push --include-untracked,跑与之前完全相同的git pull,再恢复。stash 条目按 SHA 识别(push 前后比对refs/stash、git stash apply <sha>、drop 时再查槽位),从不按栈顶位置——终端在此期间往共享 stash 里 push 既不会被误消费也不会挡路。pull 失败时中止它自己发起的 merge/rebase 并恢复条目 →409 pull_failed附 git 的说明。恢复本身冲突时,响应仍为成功但带stashRestoreConflict: true;git 保留条目,output 里点名。git reset --hard+git clean -fd(保留 ignored 文件),再git pull --ff-only。破坏性操作前先校验:先 fetch,分叉分支在本地修改尚未丢弃时就拒绝(409 diverged),因此丢弃后的 pull 只可能快进。工作区位于仓库根目录之下时拒绝(409 force_unsupported),因为reset --hard会连工作区之外的修改一起抹掉。409 operation_in_progress),因为stash push与reset --hard都会清掉这些状态;失败恢复因此只会中止 pull 自己发起的操作。不带选项的裸 pull 与之前逐字节一致。SDK 的
workspaceGitPull新增两个选项、stashRestoreConflict结果字段,以及按调用指定的超时,让弹窗为多命令流程放大客户端默认预算。替代 #9769。 那个 PR 起点就是这个 743 行的特性,经 18 轮 autofix 膨胀到 +8217/−228,每轮加一层预检或护栏(ignored 文件碰撞探针、按仓库的 pull 锁、身份复核、覆盖用户 git 策略的固定 merge 参数、测试里的 hermetic 配置盾),而下一轮评审又找出它们的边角——235 条评审线程全部未 resolve,bot 自己也要求缩减 PR。本 PR 只保留三条构造上闭合的性质(按身份恢复、先校验再丢弃、只中止自己发起的操作),其余在
docs/design/git-pull-dirty-worktree.md里明确记为非目标:像终端一样尊重环境 git 配置;ignored 文件按 git 语义视为可牺牲;并发 pull 靠 git 自己的 index lock 大声失败而不做串行化。净变化:同样 12 个文件 +1627/−77,core 的git-branches.ts+301 行而非 +1362。为什么需要
工作区里有未提交修改的用户完全无法使用 Web Shell 的 git 更新:pull 被拒绝,而 UI 只显示原始错误码,用户只能回到终端手动 stash 或清理。工作区列表的 git 徽标本就掌握工作区状态,把两种标准处理方式直接呈现在界面上,可以不离开 Web Shell 完成闭环。
评审测试计划
如何验证
自动化覆盖逐层针对真实 git 仓库(裸远端 + 工作区 clone + 扮演另一位开发者的第二个 clone)运行,本地定向测试全部通过:
cd packages/core && npx vitest run src/utils/git-branches.test.ts,72 通过,新增 15):脏树裸 pull 仍由 git 拒绝且行为不变;stash 往返恢复已跟踪修改与未跟踪文件且 stash 列表为空;无可 stash 内容时等同裸 pull;stash + rebase 线性回放本地提交;恢复冲突时保留条目并报告其 SHA;merge 失败与 rebase 失败都恢复到精确的 pull 前状态(HEAD、无 MERGE_HEAD/rebase 目录、修改、未跟踪文件、空 stash);pull 途中被推入的外来 stash(用真实post-merge钩子制造)原样保留,而我们的条目按身份 apply+drop;缺 upstream 在 stash 前就失败;force 丢弃已跟踪/未跟踪并保留 ignored;force 在分叉分支与子目录 cwd 上丢弃前即拒绝;stash+force 抛错;进行中的 merge 与停住的 rebase 让两种流程都拒绝且状态保留。cd packages/cli && npx vitest run src/serve/routes/workspace-git-branches.test.ts,32 通过,新增 10):类型错误/组合选项 → 400;脏树裸 pull → 409dirty_working_tree且路径脱敏;stash → 200 且修改恢复;恢复冲突 → 200 +stashRestoreConflict;已恢复的失败 → 409pull_failed路径脱敏且 MERGE_HEAD 已清;force → 200;分叉 force → 409diverged且修改完好;进行中的 merge → 409operation_in_progress。cd packages/web-shell && npx vitest run client/components/BranchPickerPopover.test.tsx,10 通过,新增 7;web-shell 全套 4401 通过):脏树 409 出面板,点 stash 以{ stash: true }与 300 s 超时调用,放弃需先确认才发{ force: true },请求在途时面板保持挂载(按钮禁用),stashRestoreConflict渲染为警告,其它拒绝显示 daemon 消息且不出面板,取消不发请求,重开重置。cd packages/sdk-typescript && npx vitest run test/unit/DaemonClient.test.ts,353 通过,新增 2):按调用的超时在两条路由上都覆盖客户端预算,且不进入 JSON body。真实栈、无 mock:
npm run bundle→node dist/cli.js serve --workspace <夹具>配隔离QWEN_HOME,Playwright/Chromium 驱动并记录/git/pull请求台账,每个场景后用 git 检查仓库。夹具:20 行 README,上游改第 1 行;工作区改第 20 行(干净恢复)或第 1 行(冲突恢复),另有未跟踪notes.txt与 ignored 的dist/out.txt。{}→ 409dirty_working_tree{"stash":true}→ 200notes.txt回来;dist/out.txt保留;git stash list为空{"stash":true}→ 200 +stashRestoreConflictUU带冲突标记;stash@{0}: qwen-code: auto-stash before pull仍含+line 1 (local WIP);notes.txt回来{"force":true}→ 200notes.txt消失;dist/out.txt保留?lang=zh-CN前后对比证据
main,取自 #9769 的验证运行)POST /workspaces/:workspace/git/p…,被截断,没有任何操作。zh-CN:
测试环境
环境(可选)
macOS,Node 24.18.1,git 2.55.0。单元与集成测试针对真实本地 git 仓库;浏览器证据来自
npm run bundle起的真实qwen servedaemon + Playwright/Chromium。所有触及的包npm run typecheck干净(本 worktree 仅integrations/external-context-mem0有既存的 ajv 版本错误,与本改动无关);改动文件 eslint 干净。风险与范围
git pull及当前main的裸 pull 一致)、不覆盖用户的pull.rebase/pull.ff/autostash 策略、不做跨请求 pull 串行化。路由里既有的按文本分类裸 pull 错误的逻辑未变。stashRestoreConflict与 SDK 超时参数都是增量。关联 Issue
替代 #9769(同一特性,收敛为闭合设计;见该 PR 上的交叉引用评论)。