feat(web-shell): unblock git update on dirty working tree - #9769
feat(web-shell): unblock git update on dirty working tree#9769wenshao wants to merge 26 commits into
Conversation
The workspace "Update Project" action ran a plain git pull, so any uncommitted changes left users with a raw dirty_working_tree error and no way forward outside a terminal. The 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 Triage finished — CI landed green on ✅ Qwen Triage 已完成 —— |
|
Thanks for the PR! Template looks good ✓
Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓
进入代码审查。🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewThe 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:
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: Files changed (10)
TestingThis 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
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:
中文说明代码审查实现与我的独立设想一致:两个显式选项穿过现有 pull 管道的每一层,UI 从它本就会收到的 409 恢复。没有更简单的方案能闭环——core 要执行 git 命令、路由要校验、SDK 要透传、弹窗要给出选择。 做对的细节:stash 检测比较 测试是真正的强项:core 与路由测试跑在真实仓库上(裸远端、分叉克隆、不同 hunk 的脏树),路由测试还断言 409 响应体不泄露工作区路径。这些测试钉住了改动——在基线代码上会失败,而不是一起通过。 一个不阻塞的小提醒: 测试这是无人值守 CI 运行——此处不构建、不执行 PR 代码。以上证据是评审时通过 API 获取的、该提交自身 CI 检查结果。快照时刻无红色检查;关键任务仍在运行,表格区域会由 finalize 任务在 CI 落定后更新。Node 测试矩阵本次仅在 ubuntu 运行(macOS/Windows 跳过),tmux/verify/集成通道未触发——均不计入证据。沙箱验证可一锤定音: — Qwen Code · qwen3.8-max Reviewed at |
|
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 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 的形态完全一致—— 置信度的来源:问题真实、可在基线代码中验证;diff 中每处改动都是目标所必需(无顺手改动);失败路径以数据保全为先(pull 失败时恢复原状,恢复冲突时保留 stash 条目);测试跑在真实 git 仓库上,且没有该实现就会失败,所以这里的绿色套件是有含义的。唯一保留项是 stash pop 不带 批准被推迟而非立即给出:评审时关键任务(ubuntu 完整 Node 套件、Serve A/B、web-shell visuals)仍在运行——现在批准等于为一个尚不存在的结果背书。若该提交上全部变绿,批准将自动跟上;若有红色或 head 移动,则不会批准。 — Qwen Code · qwen3.8-max Reviewed at |
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterFull-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
🩺 serve daemon A/BBuilt the PR base vs this PR head ✅ No response changes against the PR base across 12 scenario(s). — Qwen Code · serve A/B |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
|
@qwen-code /takeover |
|
🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes). Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。移除 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
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)
| if (opts?.force) { | ||
| await runGit(cwd, ['reset', '--hard'], env); | ||
| await runGit(cwd, ['clean', '-fd'], env); | ||
| } |
There was a problem hiding this comment.
[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)
| if (opts?.force) { | ||
| await runGit(cwd, ['reset', '--hard'], env); | ||
| await runGit(cwd, ['clean', '-fd'], env); | ||
| } |
There was a problem hiding this comment.
[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)
| stash?: boolean; | ||
| force?: boolean; | ||
| }, | ||
| cwd?: string, |
There was a problem hiding this comment.
[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/workspaceJsonRequest 传 timeoutMs,web-shell 客户端构造时也没带 fetchTimeoutMs(DEFAULT_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)
| const e = popErr as { stdout?: string; stderr?: string }; | ||
| return `${e.stdout ?? ''}\n${e.stderr ?? ''}`; |
There was a problem hiding this comment.
[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)
| setBusyAction(action); | ||
| setPullBlocked(false); | ||
| setConfirmDiscard(false); |
There was a problem hiding this comment.
[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)
| await runGit(cwd, ['merge', '--abort'], env).catch(() => {}); | ||
| await runGit(cwd, ['rebase', '--abort'], env).catch(() => {}); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| await runGit(cwd, ['reset', '--hard'], env); | ||
| await runGit(cwd, ['clean', '-fd'], env); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| setStatusMsg(null); | ||
| setPullBlocked(false); | ||
| setConfirmDiscard(false); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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 占满,不再扩展测试夹具,延后到下一轮。
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| let output: string; | ||
| try { | ||
| output = await runGit(cwd, args, env); |
There was a problem hiding this comment.
[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)
| {pullBlocked ? ( | ||
| <div className={styles.pullBlocked}> |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
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.
|
🤖 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: 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 dispositionsCritical
Suggestions implemented
Deferred to the next round
Review body and issue-level comments
VerificationCommands actually run this round (results):
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 轮)提交: 四个 Critical 发现在改动任何代码之前,均已先用探针在未修改的 PR head 上复现,随后以有见证测试的方式修复。五个 Suggestion 在本轮实现;两个记录为延后处理。每个新守卫都做了变异探针验证(移除守卫 → 对应测试失败 → 恢复守卫 → 全绿)。 发现与处置Critical
已实现的 Suggestion
延后到下一轮
审查主体与 issue 级评论
验证本轮实际执行的命令(结果):
未运行: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/模型 |
|
🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下: Autofix round: no action (growth-audit round, verdict
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
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)
| stashRestoreConflict = | ||
| ( | ||
| await runGit(cwd, ['ls-files', '--unmerged'], env).catch(() => '') | ||
| ).trim().length > 0; |
There was a problem hiding this comment.
[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:
| 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 pop 以 notes.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 = true(ls-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( |
There was a problem hiding this comment.
[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 计数),并在响应体中携带结构化字段/独立错误码(如 unmerged、branch_diverged),让客户端两层都按字段分支而非匹配消息文本。权宜方案:扩展备选以覆盖已实证的消息(needs merge、CONFLICT (、Automatic merge failed、分叉拒绝文案),并为每种状态补 {stash:true}/{force:true} 的路由测试。
— qwen3.8-max via Qwen Code /review (v0.22.0)
There was a problem hiding this comment.
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 另行排期,而不是挤进本轮。
| ).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', |
There was a problem hiding this comment.
[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 push → packages/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 push → packages/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)
There was a problem hiding this comment.
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) 取哪个(如果要取)是范围决策,在此标出而非单方面决定。
There was a problem hiding this comment.
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 的数据丢失问题)。该线程保持打开,直到维护者做出选择。
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
[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 = only → fatal: 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 = only → fatal: 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 () => { |
There was a problem hiding this comment.
[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 调用继承宿主 ~/.gitconfig(git() 助手透传 process.env;gitEnv() 保留 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)
| const handleNewBranch = useCallback(async () => { | ||
| if (busyAction) return; | ||
| clearPullPanel(); |
There was a problem hiding this comment.
[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 同时置 pullBlocked 与 showStatus('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)
| // A push failing while the panel is up must surface its own status. | ||
| clickButton('Push'); | ||
| await flush(); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| - `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 |
There was a problem hiding this comment.
[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.txt — root-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:
| - `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)
| // 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(() => {}); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| setStatusMsg(null); | ||
| setPullBlocked(false); | ||
| setConfirmDiscard(false); | ||
| setPullBlockedUnmerged(false); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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(面板与状态行均清除)。两者均为纯测试,将在下一轮对着未变的重置代码落地。
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
|
🤖 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: Feedback points and dispositionsImplemented this round
Re-verified as already resolved in code (earlier commit
|
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
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 dirtypackages/web-shell/client/components/BranchPickerPopover.tsx:297 — [review] Resolution-action 409 refusals reset the panel silently; the forwarded reason is never renderedpackages/cli/src/serve/routes/workspace-git-branches.test.ts:581 — [review] Subdirectory force-discard refusal escapes classification as an untyped 500packages/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)
| 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, | ||
| ) | ||
| ) { |
There was a problem hiding this comment.
[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)
| if (isDirtyWorkingTreeError(err)) { | ||
| setPullBlocked(true); | ||
| setConfirmDiscard(false); |
There was a problem hiding this comment.
[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)
| 'stash', | ||
| 'push', | ||
| '--include-untracked', |
There was a problem hiding this comment.
[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)
| const hermeticHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-githome-')); | ||
| const savedAmbientGitEnv: Record<string, string | undefined> = {}; |
There was a problem hiding this comment.
[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.
| 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)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| process.env['GIT_CONFIG_GLOBAL'] = path.join(hermeticHome, 'gitconfig'); | ||
| process.env['GIT_CONFIG_SYSTEM'] = path.join(hermeticHome, 'gitconfig'); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| // 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 |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| 'branchPicker.pullStashConflict': | ||
| 'Updated, but restoring your stashed changes failed. Resolve any conflict markers in your files; the stash entry is kept.', |
There was a problem hiding this comment.
[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 '(如有)'.
| '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)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| as the recovery path, except in workspaces below the repository root, | ||
| where discarding is unsupported and the conflicts must be resolved from | ||
| a terminal. |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| throw new Error( | ||
| 'cannot discard changes: the workspace is a subdirectory of the git repository, and discarding is only supported at the repository root', | ||
| ); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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 拒绝。
There was a problem hiding this comment.
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 轮回复中记录的两个选项保持不变:为子目录 + 未合并形态提供独立结构化错误码(推荐),或子树范围的放弃设计。
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
There was a problem hiding this comment.
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,不在本轮处理。
| setPullBlocked(false); | ||
| setConfirmDiscard(false); | ||
| setPullBlockedUnmerged(false); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
|
🤖 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 Findings and dispositionsCritical (all reproduced on pre-round code, then fixed)
Suggestions
Escalated (maintainer decision, thread left open)
Deferred under the round-3 convergence posture (four items listed in the Files changed
VerificationCommands actually run this round (results):
中文说明第 4 轮总结 — PR #9769(脏工作区 git pull)第 3 轮发布了 1 条评审体 Critical 和 10 条行内发现。本轮先在本轮前代码上用执行的探针复现了全部 4 条 Critical,随后按评审要求的结构性方案闭合:pull 失败改由仓库状态分类(core 抛出类型化错误),不再匹配 git 渲染的错误文本。 发现与处置Critical(均在本轮前代码上复现后修复)
Suggestion
升级待维护者决定(线程保持开放)
第 3 轮收敛姿态下延后的四项(评审体所列)本轮未被要求,未改动。"Signal the reviewed fork PR: CANCELLED" 失败检查是工作流的 fork PR 信号检查,不是本分支的红 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/模型 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
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 headpackages/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 headpackages/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)
| ['diff', '--diff-filter=A', '--name-only', 'HEAD', '@{u}'], | ||
| env, | ||
| ).catch(() => '') |
There was a problem hiding this comment.
[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:
| ['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.json 且 config.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)
| const added = ( | ||
| await runGit( | ||
| cwd, | ||
| ['diff', '--diff-filter=A', '--name-only', 'HEAD', '@{u}'], |
There was a problem hiding this comment.
[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)
| const ignored = ( | ||
| await runGit(cwd, ['check-ignore', '--', ...paths], env).catch(() => '') | ||
| ).trim(); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| // 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); |
There was a problem hiding this comment.
[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)
| const after = await currentStashSha(cwd, env); | ||
| stashed = after !== '' && after !== before; | ||
| } | ||
| const args = ['pull']; |
There was a problem hiding this comment.
[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?.force 与 opts?.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 () => { |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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() 钉住仍可作为下一轮的额外保险。
There was a problem hiding this comment.
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 () => { |
There was a problem hiding this comment.
[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: true 的 dirty_working_tree,使本测试的 toBeUndefined() 断言失败(已与两个 200 翻转一起探针验证)。修复同兄弟评论:在 makeRepo() 中把 merge.autostash/rebase.autostash 钉为仓库本地 false,或用 it.runIf(!hostHasSystemGitConfig()) 门控。
— qwen3.8-max via Qwen Code /review (v0.22.0)
There was a problem hiding this comment.
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() 钉住仍可作为下一轮的额外保险。
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| const GIT_ENV_VARS_TO_CLEAR = [ | ||
| 'GIT_CONFIG_COUNT', | ||
| 'GIT_CONFIG_NOSYSTEM', | ||
| 'GIT_DIR', |
There was a problem hiding this comment.
[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
| 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_EDITOR 与 GIT_EDITOR 加入清除列表。
— qwen3.8-max via Qwen Code /review (v0.22.0)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| const GIT_ENV_VARS_TO_CLEAR = [ | ||
| 'GIT_CONFIG_COUNT', | ||
| 'GIT_CONFIG_NOSYSTEM', | ||
| 'GIT_DIR', |
There was a problem hiding this comment.
[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:
| 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_EDITOR 与 GIT_EDITOR 加入清除列表。
— qwen3.8-max via Qwen Code /review (v0.22.0)
There was a problem hiding this comment.
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、让暂存恢复提示在消息截断时保留、并发串行化测试的环境门槛)。该发现继续排队等待下一轮处理,不会被丢弃。
| workspaceGitPull.mockRejectedValue( | ||
| new DaemonHttpError( | ||
| 409, |
There was a problem hiding this comment.
[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:
| 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)
There was a problem hiding this comment.
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 发现占满,且均未涉及弹窗测试文件)。确认泄漏为真 —— 持久的 mockRejectedValue 在 vi.clearAllMocks() 后依然存在,当前仅仅因为后续每个测试恰好都自己排了拒绝而保持绿色。下一轮将按建议修复(排两次 mockRejectedValueOnce,对应两次点击)。
There was a problem hiding this comment.
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>
|
🤖 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)TriageThirteen inline Critical findings arrived this round. Per the per-round batch Every claim that current behavior is wrong was reproduced before the fix Addressed findingsR15-1 —
|
|
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 |
|
🔀 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
left a comment
There was a problem hiding this comment.
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 helperspackages/core/src/utils/git-branches.ts:1726 — [review] probe-failure errors classified twice; restored stash pull can flip to terminal divergedpackages/core/src/utils/git-branches.ts:1068 — [review] submodule pointer-only updates refused as ignored_collision with zero ignored filespackages/core/src/utils/git-branches.test.ts:853 — [review] rebase-shape conflict tests assert only toThrow(); classification unpinned vs merge twinpackages/core/src/utils/git-branches.ts:1268 — [review] pre-state failure on diverged-clean tree classified terminal diverged though a retry mergespackages/core/src/utils/git-branches.test.ts:47 — [review] hermetic env block misses GIT_CONFIG_PARAMETERS and repo/object selectors gitEnv stripspackages/web-shell/client/components/BranchPickerPopover.test.tsx:362 — [review] client drops the daemon CONFLICT detail on stashRestoreConflict; test fixtures an impossible shapepackages/core/src/utils/git-branches.test.ts:2104 — [review] no witness pins clean-success absence of STASH_RESTORE_NOTEpackages/core/src/utils/git-branches.ts:1309 — [review] MERGE_HEAD probed before rebase dirs; conflicted merge-pick rebase mislabeled merge_in_progresspackages/core/src/utils/git-branches.test.ts:1270 — [review] origin/HEAD slips past the derived-name check into an untyped 500packages/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)
| for (let level = 0; level < 5; level++) { | ||
| bulkDir = path.join(bulkDir, `${'d'.repeat(245)}${level}`); | ||
| fs.mkdirSync(bulkDir); | ||
| } |
There was a problem hiding this comment.
[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)
| if ( | ||
| (await hasForeignHead(cwd, 'CHERRY_PICK_HEAD', env)) || | ||
| (await hasForeignHead(cwd, 'REVERT_HEAD', env)) | ||
| ) { |
There was a problem hiding this comment.
[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)
| function collisionKeys(raw: Buffer, foldCase: boolean): string[] { | ||
| const byteKey = raw.toString('binary'); | ||
| const keys = [foldCase ? byteKey.toLowerCase() : byteKey]; |
There was a problem hiding this comment.
[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'}.
| 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'); |
There was a problem hiding this comment.
[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).
| 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)
| // 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 ')) |
There was a problem hiding this comment.
[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)
| async function hasUnmergedEntries( | ||
| cwd: string, | ||
| env?: Readonly<Record<string, string | undefined>>, | ||
| ): Promise<boolean> { | ||
| return ( | ||
| ( | ||
| await runGit(cwd, ['ls-files', '--unmerged'], env).catch(() => '') | ||
| ).trim() !== '' | ||
| ); | ||
| } |
There was a problem hiding this comment.
[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_tree 且 unmerged: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( |
There was a problem hiding this comment.
[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 轮)。headRef 在 rev-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)
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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 的见证测试。线程保持打开。
| async function reverifyPullIdentities( | ||
| cwd: string, | ||
| headRef: string, | ||
| env?: Readonly<Record<string, string | undefined>>, | ||
| ): Promise<void> { |
There was a problem hiding this comment.
[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-ref 与 headRef 相等)。在 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)
There was a problem hiding this comment.
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 见证。线程保持打开。
| // Keep the resolution panel mounted (with its button spinner) while | ||
| // its own action is in flight; it only closes once the pull settles. | ||
| clearPullPanel(); |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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,断言过期落定不向新工作区界面写入任何内容。线程保持打开。
|
🔀 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
left a comment
There was a problem hiding this comment.
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 treepackages/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}`); |
There was a problem hiding this comment.
[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)) || |
There was a problem hiding this comment.
[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)
| if (foldCase) { | ||
| const text = raw.toString('utf8'); | ||
| if (Buffer.byteLength(text, 'utf8') === raw.length) { | ||
| keys.push(text.normalize('NFC').toLowerCase()); |
There was a problem hiding this comment.
[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'}.
| 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 测试只覆盖 é/É——小写映射能处理的情形。
见证(本轮):collisionKeys 在 db0d9a2d 重新读取,自第 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'); |
There was a problem hiding this comment.
[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).
| 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)
| ) | ||
| // 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 ')) |
There was a problem hiding this comment.
[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( |
There was a problem hiding this comment.
[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 轮),在未变更的代码上重新确认。headRef 在 rev-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)
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
[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)
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
[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-ref 与 headRef 相等)。在 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)
There was a problem hiding this comment.
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 见证。线程保持打开。
| clearPullPanel(); | ||
| if (result.stashRestoreConflict) { |
There was a problem hiding this comment.
[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 db0d9a2d — clearPullPanel/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)
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
[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),断言 gitPull 以 ignored_collision 拒绝且被忽略文件内容存活;从探针中移除重命名目标映射时测试必须变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
There was a problem hiding this comment.
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
|
🤖 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: Why this round existsThe deterministic gate rejected Diagnosis (evidence, not guess)
FixDeflake fix #2 (pre-warm a lazy load): a
Mutation / witness evidence
VerificationCommands actually run this round:
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(同运行验证修复)提交: 本轮为何存在确定性验证门拒绝了 诊断(基于证据,而非猜测)
修复Deflake 第 2 类修复(预热惰性加载):在 describe 块中加入一个
变异 / 见证证据
验证本轮实际执行的命令:
未执行:非 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/模型 |
|
🤖 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
Failed checks
OutcomeNo 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)本轮未做任何改动:没有新的可执行反馈,也没有需要修复的红色代码检查。 反馈分诊
失败的检查
结果无代码改动,无提交。被延后的 Suggestion 级发现继续留在队列中,等待后续轮次或维护者跟进;本轮没有拒绝项,也没有升级待决项。 Deferred non-Critical feedbackCritical-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. ( 中文说明已进入仅处理 Critical 的模式:已完成 5 个产生改动的轮次。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 2 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。 🧠 Handled by Qwen Code · model/模型 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
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 greenpackages/core/src/utils/git-branches.ts:1588 — [review] force shape's pre-discard reverifyPullIdentities has no test; the HEAD-identity-comparison mutant ships greendocs/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(() => '') |
There was a problem hiding this comment.
[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')) |
There was a problem hiding this comment.
[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)
| '--commit', | ||
| '--no-squash', | ||
| fetchedTip, |
There was a problem hiding this comment.
[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)
| // 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. |
There was a problem hiding this comment.
[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 --hard 在 success: 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_MERGE(rev-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) { |
There was a problem hiding this comment.
[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); |
There was a problem hiding this comment.
[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( |
There was a problem hiding this comment.
[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-ref 与 headRef 相等)。在 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)
| 'diff', | ||
| '--no-renames', | ||
| '--diff-filter=d', | ||
| '--name-only', |
There was a problem hiding this comment.
[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)
| const collisions = additions.filter((addition) => | ||
| collides(collisionKeys(addition, foldCase), true), | ||
| ); |
There was a problem hiding this comment.
[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)
| ignoredFiles.has(key) || | ||
| ignoredDirs.has(key) || | ||
| (withTrackedGitlinks && gitlinks.has(key)) |
There was a problem hiding this comment.
[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)
|
🔀 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
left a comment
There was a problem hiding this comment.
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 testpackages/core/src/utils/git-branches.test.ts:873 — [review] tag-upstream conflicting-pull recovery pinned only for the rebase arm; merge arm has no twinpackages/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 discriminationdocs/design/git-pull-dirty-worktree.md:88 — [review] design doc says per-workspace-cwd serialization; the lock actually keys on repository identitypackages/core/src/utils/git-branches.test.ts:1205 — [review] write-tree wedge test never asserts the index stayed untouched on the rollback-impossible pathpackages/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'); |
There was a problem hiding this comment.
[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.
| 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 defined,git 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(() => '') |
There was a problem hiding this comment.
[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.
| 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=no 后 code=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)
| if (stashed || opts?.force) { | ||
| if (!foreignState && updateAttempted) { |
There was a problem hiding this comment.
[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}.
| 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)
| {(pullBlockedUnmerged | ||
| ? t('branchPicker.pullUnmerged') | ||
| : t('branchPicker.pullBlocked')) + |
There was a problem hiding this comment.
[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_tree 且 unmerged: 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'], |
There was a problem hiding this comment.
[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.ignorecase → false 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 后:.gitignore 含 notes.md、本地存在被忽略的 notes.md,上游新增 NOTES.md;探针找不到碰撞,合并写入 NOTES.md,文件系统折叠文件名,本地 notes.md 被静默截断替换——正是该探针要防止的损害类型。
见证(探针):Linux 上 git init 后 core.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( |
There was a problem hiding this comment.
[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-ref 与 headRef 相等)。在 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)
| 'diff', | ||
| '--no-renames', | ||
| '--diff-filter=d', |
There was a problem hiding this comment.
[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>(); |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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];gitPull → success:true 且被忽略文件与一个未跟踪兄弟文件都从磁盘消失。修复翻转(精确匹配分支查询 gitlinkDirs 段前缀集合):以 ignored_collision 拒绝,两个文件完好。
修复:键加入 gitlinks 时同时把各段前缀加入 gitlinkDirs 集合,并在 withTrackedGitlinks 下的精确匹配分支查询它——仅 additions 分支。修复验证:上述超级项目 fixture——断言 ignored_collision 与文件存活;移除 gitlinkDirs 检查测试变红。
— qwen3.8-max via Qwen Code /review (v0.22.2)
| clearPullPanel(); | ||
| if (result.stashRestoreConflict) { |
There was a problem hiding this comment.
[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)
|
🤖 AutoFix updated a stale base — the fix did not pass verification, but this PR was behind Round summary — PR #9769 (same-run verification repair)The previous commit ( Root cause of the rejection (reproduced before fixing)The fixture's own git calls in 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 中文说明🤖 AutoFix 更新了一个过期的 base —— 修复未通过验证,但本 PR 落后于 验证门的拒绝原因与日志证据见上方英文部分(gate-rejection 不翻译)。 Run log: https://github.com/QwenLM/qwen-code/actions/runs/33128178614 🧠 Handled by Qwen Code · model/模型 |
|
🤖 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
See the Qwen Autofix agent step logs for model/tool output. 中文说明🤖 AutoFix 已停止:当前计数窗口内已累计 3 次时间预算耗尽(含其间推送过的轮次;本轮本身可能以别的方式失败)。即 3 次完整 agent 运行没有推送任何内容。应由人工拆分或缩减该 PR(或同时提高 agent 时间预算与其步骤兜底),然后评论 Run log: https://github.com/QwenLM/qwen-code/actions/runs/33127296832 🧠 Handled by Qwen Code · model/模型 |
|
⏸️ Takeover paused: this PR reached its round cap (100/100). Comment 中文说明⏸️ 托管已暂停:本 PR 达到轮次上限(100/100)。评论 |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
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-backpackages/web-shell/client/components/BranchPickerPopover.tsx:336 — [probe] head_changed daemon retry message discarded in the generic error branchpackages/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 testpackages/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 identitypackages/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 itpackages/core/src/utils/git-branches.ts:1107 — [probe] collisionKeys UTF-8 round-trip admits truncated 4-byte sequences, manufacturing spurious ignored_collision refusalspackages/cli/src/serve/routes/workspace-git-branches.ts:54 — [probe] capGitErrorMessage's untyped note-preservation branch is pinned by no testpackages/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'); |
There was a problem hiding this comment.
[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(() => '') |
There was a problem hiding this comment.
[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; | ||
| } |
There was a problem hiding this comment.
[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), |
There was a problem hiding this comment.
[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')) |
There was a problem hiding this comment.
[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-LOCAL → INCOMING) 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.concat — fs.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-LOCAL → INCOMING),且 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) |
There was a problem hiding this comment.
[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)
| const failure = await classifyPullFailure( | ||
| cwd, | ||
| env, | ||
| err, | ||
| opts?.stash === true, | ||
| ); |
There was a problem hiding this comment.
[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); |
There was a problem hiding this comment.
[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=true → gitPull(dir, {force:true}) success=true,之后子模块内编辑被摧毁;纯 git 对照:--no-recurse-submodules 保留编辑,clean -fd 不递归(只有 reset 需要固定)。所有守卫(refuseForeignMergeOrRebase、hasUnmergedEntries、reverifyPullIdentities)都通过,因为它们从不查看子模块内部——数据丢失发生在面板建模范围之外,且同一次点击在不同主机上行为不同。请固定 ['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] |
There was a problem hiding this comment.
[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; |
There was a problem hiding this comment.
[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)
|
Superseded by #10390, which re-lands this feature as a single commit from current 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:
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. |
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,
stashandforce(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:
cd packages/core && npx vitest run src/utils/git-branches.test.ts(63 passed).dirty_working_treewith a path-redacted message; the same dirty repo updates successfully withstash: trueand withforce: true; wrong-typed or combined options return 400. Runcd packages/cli && npx vitest run src/serve/routes/workspace-git-branches.test.ts(28 passed).{ stash: true }, and the discard path requires the confirmation click before calling with{ force: true }. Runcd 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_treewith 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
Environment (optional)
Unit and integration tests against real local git repositories (
init/clone/bare remote), plusnpm run buildandnpm run typecheck.Risk & Scope
Linked Issues
N/A — reported internally.
中文说明
这个 PR 做了什么
Web Shell 中工作区的"更新项目"操作现在能处理脏工作区,而不是遇到它就卡死。当裸 pull 被未提交的修改阻塞时,分支选择器底部会从一行看不懂的错误切换为一个选项面板,提供两种处理方式:把本地修改(包括未跟踪文件)stash 起来、执行更新、再把修改恢复回来;或者放弃本地修改后更新(需要第二次点击确认)。取消会收起面板,重新打开弹窗会重置状态。
pull 接口新增两个选项
stash和force(互斥,默认都为关)。stash 模式下如果 pull 本身失败,会中止未完成的 merge/rebase 并把 stash pop 回来,使工作区回到 pull 之前的状态;如果恢复的修改与拉取下来的提交冲突,更新仍按成功返回,输出中带上 git 的冲突说明,且 stash 条目保留,不会丢数据。force 模式在 pull 前重置已跟踪文件的修改并删除未跟踪文件,ignored 文件保留。为什么需要
工作区里有未提交修改的用户完全无法使用 Web Shell 的 git 更新:pull 被拒绝,而 UI 只显示原始错误码,用户只能回到终端手动 stash 或清理。工作区列表的 git 徽标本就掌握工作区状态,把两种标准处理方式直接呈现在界面上,可以不离开 Web Shell 完成闭环。
评审测试计划
如何验证
自动化覆盖针对真实 git 仓库逐层验证,定向测试全部通过:
cd packages/core && npx vitest run src/utils/git-branches.test.ts(63 通过)。dirty_working_tree且消息中的路径已脱敏;同一脏仓库分别以stash: true和force: true更新成功;类型错误或同时传两个选项返回 400。运行cd packages/cli && npx vitest run src/serve/routes/workspace-git-branches.test.ts(28 通过)。{ 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 / 放弃 / 取消按钮,放弃路径还有警告与确认按钮。交互由上述组件测试覆盖;本次未截图。测试环境
环境(可选)
针对真实本地 git 仓库(
init/clone/裸远端)的单元测试与集成测试,另跑npm run build与npm run typecheck。风险与范围
关联 Issue
N/A —— 内部反馈。