Repository navigation
feat(web-shell): manage git remotes from the workspace branch picker - #11163
Conversation
The workspace git popover could pull, commit, push and switch branches, but the remotes themselves were read-only context: a user who clones from a zip (no origin), works triangular (fork + upstream), or wants to drop a stale remote had to leave the Web Shell for a terminal. Adds a Manage Remotes panel inside the popover — list, add, remove — backed by new workspace-scoped daemon routes and the SDK methods for them. The listing reads git through its structured accessors rather than the rendered "remote -v" output, which git annotates for partial-clone remotes and which a line parser silently misreads. Mutations answer with the fresh list so the panel re-renders in one round trip, and a remove also refreshes the branch listing and the tracking state of the chip, because git deletes the remote-tracking refs and upstream config together with the remote. Names and URLs are validated before git is spawned (flag injection, refname rules for add; removal stays as lenient as git itself so a hand-edited config can always be cleaned up), and rendered through a display sanitizer that strips the invisible-character set — a git config the user did not author can carry bidi marks that spoof the displayed URL. Design doc: docs/design/git-manage-remotes.md
E2E test reportEnvironment: macOS (darwin), Node 22, local build ( Web Shell UI e2e (Playwright)
Daemon routes (supertest + real temp repos) — 27/27List with push-URL override ( Core (real temp repos) — 55/55Structured-accessor listing: sorted order, push-URL override, partial-clone promisor remote (the SDK — 416/416 (2 new)URL/method/body composition for Web Shell unit — 228/228
Gates (on the merge-base tree,
|
The config-scope read, removal verification and client refusal handling carried gaps the reverse audits surfaced: include.path/worktree scopes were under-listed yet un-removable (destructive dead ends), GIT_ALLOW_PROTOCOL was deleted outright (a fail-open swap against config-file policy), the classifier let echoed names/URLs steal config-write failures, and several guards shipped without witnesses. Fixes: - list the repository-owned scope via `config --list --show-scope -z` (local+worktree), completing worktree-scope removals git cannot edit - normalize an inherited GIT_ALLOW_PROTOCOL (strip ext/fd) instead of deleting it; keep deny-by-default force - order sendGitError's anchored config-write shapes first; add remote_config_unparsable for unparsable configured refspecs - follow git's boolean/integer grammar exactly (valueless/empty/hex/kmg) - keep the URL visible under row shrink; restore focus after in-flight mutations; disarm confirms on refusal; silent re-reads stay silent - witness every guard above in unit/e2e suites; sync the design doc
…at/git-manage-remotes
|
@qwen-code /takeover from 4 |
|
🤝 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 window's round counter starts at 4 (the rounds this PR spent in review before takeover), so the Critical-only brake engages after 1 more change-producing round(s) instead of a full fresh 5. Remove the 中文说明🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本窗口轮次计数从 4 起算(即本 PR 托管前已进行的评审轮数),因此再经过 1 个产生改动的轮次即进入 Critical-only,而非重新计满 5 轮。移除 |
Seven Critical threads from the review bot's second batch, all fixed with witnesses and a ten-round reverse audit on top: - add pre-flights all config scopes and refuses a same-name inherited section (remote_shadows_inherited), after probing repository-ness; removal verifies resolution across every scope so an inherited survivor can never be reported as removed (fail-closed on killed reads) - sendGitError shapes match git's message start only (line 1, or git's two-line lock chain): config-chosen names/URLs can carry any keyword, and config values can carry real newlines, so deeper line-initial text is attacker-controllable; classification reads the full redacted detail while the client message stays bounded - killed config reads rethrow with the all-scope stdout dump stripped (stderr diagnostics preserved) on every read path - focus restores: add submit button, remove-row button when focused, back-button fallback; restores are gated on lent focus, cleared on view exit and workspace switch; refused removal awaits the silent re-read so the restore lands on the converged list - no_such_remote remove refusals also refresh branches and status
|
🤖 AutoFix updated a stale base — the fix did not pass verification, but this PR was behind What I found before stopping: See the Qwen Autofix agent step logs for model/tool output. 中文说明🤖 AutoFix 更新了一个过期的 base —— 修复未通过验证,但本 PR 落后于 Run log: https://github.com/QwenLM/qwen-code/actions/runs/34192434018 🧠 Handled by Qwen Code · model/模型 |
- iterConfigRecords: shared NUL-framed --show-scope record walk for all four config-read consumers - promisor bool grammar: match git's strtoimax exactly (C-isspace leading class, attached sign, hex/octal/decimal, case-insensitive k/m/g, [INT_MIN, INT_MAX] bound) - removal: pre-removal pointingBranches snapshot (worktree-over-local, last value) so mixed-scope upstream keys stay attributable - removal: sweep worktree-scope branch.<b>.remote/merge/pushRemote and remote.pushDefault that git's rm cannot write (--fixed-value --unset-all for value-matched keys), re-verify and refuse remote_still_configured - removal: refuse when an upstream key still resolves to the removed remote from a file this module will not edit (include.path'd file or inherited global/system scope, incl. rm-unmasking), resolved with git's effective last-value-wins semantics - removal: converge a retry after a failed cleanup on the no-such-remote path instead of dead-ending
…ound - core: never fire the worktree completion when git died BEFORE mutating — the refusal phrase matches at a line start only, and an error:/fatal: invalid refspec at any line start vetoes it outright (a config-chosen refspec value can carry the phrase on a deeper line); the refusal keeps its remote_config_unparsable answer and the row stays - cli: redact gitdir paths git echoes outside the workspace tree — a linked worktree's shared main .git dir, a submodule's gitdir, a relocated admin dir (via the commondir file), the symlink-canonical spelling of each (git realpaths at setup), and over-long targets (head-bounded reads, truncated lines redacted as prefix tokens) - web-shell: IME-owned Enter guard on the add form (isComposing / keyCode 229, both inputs) - web-shell: whitespace-lookalike marking — names/URLs differing only by whitespace (CSS-collapsed) now flag, with escapes in the tooltip and aria-labels; the search filter collapses both sides to match the inked text - web-shell: sticky stash warning survives the remotes round trip — snapshot on entry, restore on back/Escape/dismiss, busy-gated so a settling mutation's footer wins, a newer standing warning always outranks a held snapshot, and re-entry never nulls a held one - web-shell: focus capture/restore resolves from the popover content's own root (shadow-portal embedding), and the settle restore bails when the content is unmounted mid-mutation (no cross-instance focus theft)
|
🔀 Base updated: red check(s) [web-shell E2E Smoke (ubuntu-latest, Node 22.x)] pass on current main — merged current main via update-branch; CI will re-run. 中文说明🔀 已更新 base:红色检查 [web-shell E2E Smoke (ubuntu-latest, Node 22.x)] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。 |
|
⏸️ AutoFix paused by a review convergence signal: 3 consecutive automated review round(s) signalled machine-readable non-convergence (codes observed since the last maintainer response, or the window start if none: root-cause-triage) — the reviewer keeps finding new defects at a rate that is not falling while the loop keeps widening the diff, so another automatic round is unlikely to converge this PR. The loop resumes once a maintainer responds on this PR (a review or comment counts, and steers the next rounds), and pauses again if the signal persists for 3 more round(s). Alternatives: split the recurring cluster into its own PR, batch the remaining fixes into one push, comment 中文说明⏸️ AutoFix 已因评审收敛信号暂停:3 轮自动评审连续发出机器可读的不收敛信号(自上次维护者响应以来观察到的信号码;若无响应则自窗口开始:root-cause-triage)——评审仍在以不降的速率发现新缺陷,而循环在继续扩大 diff,再跑一轮自动修复难以收敛本 PR。维护者在本 PR 上作出回应后循环自动恢复(评论或评审均可,并将作为后续轮次的指引);若信号再持续 3 轮会再次暂停。可选做法:把反复出问题的簇拆成独立 PR、把剩余修复攒成一批一次推送、评论 |
- core: sweep orphaned refs/remotes/<name>/* after a certified removal (git's rm deletes them only through a parseable fetch refspec), with --no-deref so a planted symref can never delete its target, plus the no-such-remote converge arm so a retry after a failed sweep does not dead-end; re-verified, refusing remote_still_configured on a survivor - core: extraPushUrls falls back to the url list when no pushurl exists (git pushes to every url then) so the badge no longer under-reports git's push fan-out - cli: route suite gains strict-mutation-gating (per-call + count), mid-flight generation close → 503, runtime-env threading, both lock chains, the ancestor-walk ceiling, and the host system/global config precondition guards - web-shell: armed remove confirm disarms on filter-out / Escape / add-submit; the popover mock now unmounts on close and dismisses on an un-prevented Escape (Radix DismissableLayer parity); Escape events in the witnesses are cancelable - web-shell: a successful removal no longer awaits the branch refresh (busy/focus released immediately); a successful add clears the remotes filter so the new row is visible; the removing row shows a spinner; the armed confirm's aria-label names the consequence badge - web-shell: search matches the extras badge text and sanitizes the needle; the push/fetch tooltip labels are localized (en + zh-CN); the e2e primary journey carries @smoke - sdk: workspaceGitRemoteAdd/Remove accept an optional per-call timeoutMs; the panel passes 600s (the mutation chains git spawns that each carry their own 30s budget) - docs + PR body updated to the shipped implementation (config-scope reads, WRITES-vs-RESOLVES scope split, six classifier branches)
Local verification against a live daemon and a real browserMaintainer pass at head No mock daemon, no Verdict: merge-ready. The one red check is not this PR's — see §1. 99 independent checks pass, 4 mutations confirm they discriminate, and 4 of 5 sampled still-open Critical review threads no longer reproduce at this head. Environment
1. The red CI check is a pre-existing
|
| arm | result |
|---|---|
| PR head as-is | Tests 2 failed | 794 skipped — same ReferenceError, same line |
| PR head + main's #11406 patch | Tests 2 passed | 794 skipped |
Action: update the branch onto current main; no code change needed here.
2. Core layer against real git — 47/47, every claim with a negative control
Each structural claim is paired with a control showing what plain git does, so a pass means the code closes a real gap rather than re-stating git's own behaviour.
| Claim | Plain git (control) | This PR |
|---|---|---|
| Worktree-scope remote | git remote remove fails, error: Could not remove config section, remote survives |
listed, and removal completed in the scope git cannot write |
| Refspec-less removal | orphans refs/remotes/noref/main |
sweeps it |
| Branch upstream keys at worktree scope | leaves a dangling branch.main.remote = up |
cleared |
| Inherited (global) same-name remote | git remote add succeeds and silently resolves both URLs |
refused up front, nothing written |
include.path-sourced remote |
invisible to git config --local |
listed; removal honestly refuses and the remote genuinely survives |
insteadOf rewriting |
git remote -v shows the rewritten URL |
shows the configured URL |
| Partial clone | [blob:none] annotation breaks -v parsers |
promisor/partialCloneFilter read from config |
Also covered: multi-URL push fan-out matching git remote -v exactly; names with dots (a.b beside a, x.url) parsed and removed independently; hand-configured bidi names listed and removable while the add predicate rejects creating them; ext::/7z::/fd::/-oProxyCommand= rejected while ssh://git@[::1]:22/…, scp-like, relative and space-bearing local paths are accepted by both the predicate and real git remote add.
3. Live daemon over real HTTP — 22/22 (the gap the PR listed as unvalidated)
Add wrote https://github.com/wenshao/qwen-code.git and git config --get remote.fork.url read it back; remove emptied it. Classifier collisions hold on the wire: removing remotes named dirty-cache and already exists both answer 404 no_such_remote, not the legacy keyword branches. No error body carried an absolute path. Guards: ?cwd= escaping → 400 invalid_cwd on mutations, unknown workspace → 400 workspace_mismatch, token-less mutation → 401.
Newline-injection probe (the PR's own headline risk). A hand-edited fetch value carrying a real LF makes git emit an attacker-chosen line-initial message:
fatal: invalid refspec 'bogus
error: Could not remove config section 'remote.evil''
Line 2 reads exactly like the completion signal the worktree path keys on. The route answered 409 remote_config_unparsable, the remote survived, and the list kept showing it — an honest refusal, not a false success.
4. Real browser → real daemon → real .git/config — 30/30
| Live remote list (read from the real repo) | Add succeeded — verified on disk |
|---|---|
![]() |
![]() |
| Duplicate add: real git error, verbatim | Two-click confirm armed (zero requests yet) |
|---|---|
![]() |
![]() |
- The panel's rows matched
git remoteexactly, before and after every mutation. - The first remove click issued zero requests to the live daemon and left the repo untouched; the second removed it from
.git/config. - The failed duplicate add did not mutate the repo (
remote.fork.urlstayed single-valued). - Race: deleting a remote in a terminal behind the open panel, then confirming its removal, surfaced git's real
error: No such remote: 'racer'and the panel re-read the list instead of keeping an undeletable row. - Tracked-remote removal: with
maintrackingtracked/main, removing it through the UI clearedbranch.main.remote,branch.main.mergeandrefs/remotes/tracked/*, and the branch list's remote group was gone on return.
5. Invisible-character hardening — proven by mutation, not assertion
A hand-edited .git/config with a remote named ev\u{202E}liat and a URL carrying \u{200B}:
| Shipped code | Same page, sanitizer mutated to identity |
|---|---|
![]() |
![]() |
renders evliat (hidden characters) |
renders evtail — the override reverses the tail and the zero-width URL looks clean |
Searching the displayed name still found the row, and the remove request carried the raw configured name (65 76 202e 6c 69 61 74) — verified on the wire. With the sanitizer mutated away, three of those checks flip red, including search-by-displayed-name.
Mutations run (all discriminated): scope filter → local-only (worktree remote disappears from the listing); orphan-ref sweep deleted (removal now throws instead of certifying); worktree upstream-key sweep deleted (same); display sanitizer → identity (spoofing returns). Notably the last two are caught by the module's own verification gates — those gates are load-bearing, not decorative.
6. Still-open Critical review threads, re-tested against real git
The autofix loop is paused on a non-convergence signal, so I re-tested a sample of threads still marked open and "Still stands":
| Thread | Result at this head |
|---|---|
| R1-5 — removal answers 200 while an inherited same-name section survives | Does not reproduce. Removal refuses (remote still configured after removal); the all-scope gate at git-remotes.ts:462 is present |
| R4-4 — a config value carrying the completion phrase forces a false completion | Does not reproduce. Refused; remote survives (§3) |
| R4-7 — Enter on an IME composition submits the add | Does not reproduce. Composing Enter ignored, plain Enter submits; guard at BranchPickerPopover.tsx:1941 |
R4-29 — extraPushUrls under-reports push fan-out |
Does not reproduce. extraPushUrls=1 ⇒ 2 targets, matching git remote -v |
| R3-2 — cross-scope listing accuracy | Reproduces, display-only — see N1 |
4 of 5 are stale threads describing code that has since changed. That is worth knowing before reading the backlog as a merge blocker.
7. Non-blocking observations
- N1 (confirms R3-2). When a remote name exists at both local and inherited scope, the panel shows only the local URL. Measured: panel
fetchUrl=https://local.invalid/l.git,extraFetchUrls=0, whilegit remote -vreports the global URL as the fetch URL and two push targets. Display-only — the mutation side is safe (R1-5 above), and Add refuses to create this shape (409remote_shadows_inherited), so it needs a pre-existing cross-scope collision made outside the panel. - N2. A url-less section reports
fetchUrl: '', but git's fallback resolves the name as the URL (git remote get-url urlless→urlless). Hand-edit-only; removal works either way. - N3. Test-plan item 9 says "
?cwd=escaping the workspace → 400". Measured: true for both mutations, but the GET route answers 200 with the workspace root's remotes (lenientresolveContainedCwd, matching the sibling branch routes). Fail-safe and correct — the sentence just needs to say "mutations". Flagged in triage stage-2 on 09-06 and still present.
8. Gates re-run locally at this head
| Suite | Result |
|---|---|
core git-remotes.test.ts + git-remotes-kill.test.ts |
166/166 |
core git-branches.test.ts |
102/103 — see note |
cli workspace-git-remotes + workspace-git-branches |
100/100 |
web-shell BranchPickerPopover.test.tsx |
109/109 |
sdk-typescript DaemonClient.test.ts |
418/418 |
The single git-branches failure is "types the refusal with a lock hint when the index is wedged", which came from #10390 and is already on main. It is git-version-dependent, not a PR regression: with a wedged index.lock, git 2.47.3 on this box prints error: could not write index with no "lock" substring, while the assertion requires one. CI's newer git passes it.
中文说明
针对 live daemon 与真实浏览器的本地验证
维护者在 head d1e994f 上的验证。PR 自陈的"未验证"两项——"撰写环境未跑 live-daemon 的 curl 验证"、UI 仅对 mock daemon 验证——现已全部补齐:我用 PR 源码重新构建了 daemon,在真实 Chromium 中驱动真实 Web Shell 对其操作,每条断言都用真实 git 回读磁盘上的 .git/config 复核。
无 mock daemon、无 page.route、无夹具。受测链路:Chromium → PR web-shell → PR SDK → 真实 HTTP → qwen serve(PR 构建)→ 真实 git → 真实仓库。
**结论:可以合入。**唯一的红色检查不是本 PR 引入的,见 §1。99 项独立检查全部通过,4 次变异证明这些检查具备判别力,抽样的 5 条仍未关闭的 Critical 评审线程中有 4 条在当前 head 上已不复现。
环境
| Head / base | d1e994f80 / merge-base 1f890086f |
| 平台 | Linux 6.12.63、Node 22.22.2、git 2.47.3 |
| Daemon | node dist/cli.js serve --port 4188 --workspace <真实仓库> --no-web,由 PR 源码构建 |
| 浏览器 | Playwright Chromium 1280×800,dev server 代理至 QWEN_DAEMON_URL=http://127.0.0.1:4188,token 经 ?token= 传入 |
1. 红色 CI 检查是 main 的既有 bug,与本 PR 无关
Test (ubuntu-latest, Node 22.x) 失败于 packages/web-shell/client/App.test.tsx:28940 的 ReferenceError: mockUseDaemonActivePromptBridge is not defined——这个文件本 PR 并未触及。该符号由 #11250 引入,main 已在 #11406 修复,其提交信息明确写着它*"failed the main-branch CI Test job at 70cf363"*。本分支在 1f890086f 合入 main,而该提交仍带着这个问题。
本地复现并做了两个参数化用例的 A/B:
| 臂 | 结果 |
|---|---|
| PR head 原样 | Tests 2 failed | 794 skipped——同样的 ReferenceError、同一行 |
| PR head + main 的 #11406 补丁 | Tests 2 passed | 794 skipped |
处置:把分支更新到当前 main 即可,本 PR 无需改代码。
2. Core 层对真实 git —— 47/47,每条都带反向对照
每条结构性主张都配了一个"plain git 会怎样"的对照,因此通过意味着代码确实补上了真实缺口,而不只是复述 git 自身行为。
| 主张 | plain git(对照) | 本 PR |
|---|---|---|
| worktree 作用域 remote | git remote remove 失败,error: Could not remove config section,remote 存活 |
被列出,并在 git 写不了的作用域完成删除 |
| 无 refspec 的删除 | 遗留孤儿 refs/remotes/noref/main |
清扫干净 |
| worktree 作用域的分支 upstream 键 | 留下悬空 branch.main.remote = up |
已清除 |
| 同名继承(global)remote | git remote add 成功,并静默把两个 URL 都纳入解析 |
预检拒绝,不写入任何内容 |
include.path 引入的 remote |
git config --local 看不见 |
被列出;删除诚实拒绝,remote 确实存活 |
insteadOf 改写 |
git remote -v 显示改写后的 URL |
显示配置中的 URL |
| Partial clone | [blob:none] 注解会破坏 -v 解析 |
从配置读出 promisor/partialCloneFilter |
同时覆盖:多 URL 的 push 扇出与 git remote -v 完全一致;带点的名字(a.b 与 a 并存、x.url)能被正确解析并独立删除;手改配置里的 bidi 名字可列出、可删除,而 add 谓词拒绝创建这类名字;ext::/7z::/fd::/-oProxyCommand= 被拒绝,而 ssh://git@[::1]:22/…、scp 形式、相对路径、含空格的本地路径同时被谓词与真实 git remote add 接受。
3. Live daemon 真实 HTTP —— 22/22(PR 自陈未验证的那一项)
添加操作写入了 https://github.com/wenshao/qwen-code.git,git config --get remote.fork.url 回读一致;删除后该键为空。分类器碰撞在真实线上成立:删除名为 dirty-cache 和 already exists 的 remote,均返回 404 no_such_remote,而非落入遗留关键字分支。所有错误响应体都不含绝对路径。守卫:?cwd= 逃逸 → 变更类接口 400 invalid_cwd,未知 workspace → 400 workspace_mismatch,无 token 的变更 → 401。
换行注入探针(PR 自己点名的主要风险)。手改的 fetch 值携带真实 LF,可让 git 输出攻击者选定的行首消息:
fatal: invalid refspec 'bogus
error: Could not remove config section 'remote.evil''
第 2 行读起来正是 worktree 补删路径所依赖的"完成信号"。路由返回 409 remote_config_unparsable,remote 存活,列表仍然显示它——是诚实的拒绝,而非虚假的成功。
4. 真实浏览器 → 真实 daemon → 真实 .git/config —— 30/30
| 实时 remote 列表(读自真实仓库) | 添加成功——已在磁盘核对 |
|---|---|
![]() |
![]() |
| 重复添加:git 的真实错误,原样呈现 | 两段式确认已待确认(此时零请求) |
|---|---|
![]() |
![]() |
- 每次变更前后,面板行与
git remote完全一致。 - 删除的第一次点击向 live daemon 发出零请求,仓库未被改动;第二次点击才真正从
.git/config移除。 - 失败的重复添加没有改动仓库(
remote.fork.url仍为单值)。 - 竞态:面板开着时在终端删掉某个 remote,再在面板中确认删除,footer 显示 git 真实的
error: No such remote: 'racer',且面板重新拉取列表,而不是留下一个删不掉的行。 - 删除被跟踪的 remote:
main原本跟踪tracked/main,通过 UI 删除后,branch.main.remote、branch.main.merge与refs/remotes/tracked/*全部清除,返回分支视图时该 remote 分组已消失。
5. 不可见字符加固 —— 用变异证明,而非仅凭断言
手改 .git/config,remote 名为 ev\u{202E}liat,URL 含 \u{200B}:
| 现有代码 | 同一页面,净化器变异为恒等函数 |
|---|---|
![]() |
![]() |
渲染为 evliat (hidden characters) |
渲染为 evtail —— 覆盖符把尾部反转,零宽 URL 看起来干干净净 |
按显示的名字搜索仍能命中该行,删除请求携带的是配置中的原始名字(65 76 202e 6c 69 61 74)——已在线上核实。把净化器变异掉之后,其中三项检查转红,包括"按显示名搜索"。
已执行的变异(全部被判别):作用域过滤改为仅 local(worktree remote 从列表消失);删掉孤儿 ref 清扫(删除转为抛错而非虚假确认);删掉 worktree upstream 键清扫(同上);显示净化器改为恒等(欺骗重现)。值得注意的是,后两项是被模块自身的校验门拦下的——这些校验门是承重的,不是摆设。
6. 仍未关闭的 Critical 评审线程,用真实 git 复测
autofix 循环因不收敛信号暂停,因此我抽样复测了仍标记为开放且写着*"Still stands"*的线程:
| 线程 | 当前 head 上的结果 |
|---|---|
| R1-5 —— 继承作用域同名 section 存活时删除仍返回 200 | **不复现。**删除会拒绝(remote still configured after removal);git-remotes.ts:462 的全作用域校验门在位 |
| R4-4 —— 配置值携带完成短语可诱导虚假完成 | **不复现。**已拒绝,remote 存活(§3) |
| R4-7 —— IME 组合态的 Enter 会提交添加 | **不复现。**组合态 Enter 被忽略,普通 Enter 正常提交;守卫在 BranchPickerPopover.tsx:1941 |
R4-29 —— extraPushUrls 少报 push 扇出 |
不复现。extraPushUrls=1 ⇒ 2 个目标,与 git remote -v 一致 |
| R3-2 —— 跨作用域列表准确性 | 复现,仅影响显示 —— 见 N1 |
5 条中有 4 条是描述已被后续改动覆盖的陈旧线程。在把这份 backlog 当作合并阻塞项来读之前,这一点值得知道。
7. 非阻塞观察
- **N1(确认 R3-2)。**当一个 remote 名同时存在于 local 与继承作用域时,面板只显示 local 的 URL。实测:面板
fetchUrl=https://local.invalid/l.git、extraFetchUrls=0,而git remote -v报告 global 的 URL 才是 fetch URL,并有两个 push 目标。仅影响显示——变更侧是安全的(见上面 R1-5),且 Add 会拒绝创建这种形态(409remote_shadows_inherited),所以它需要一个在面板之外制造的既有跨作用域冲突。 - N2。无 url 的 section 报告
fetchUrl: '',但 git 的回退会把名字本身解析为 URL(git remote get-url urlless→urlless)。仅限手改配置;无论如何删除都能工作。 - **N3。**测试计划第 9 条写着"
?cwd=逃逸 workspace → 400"。实测:两个变更类接口确实如此,但 GET 路由返回 200 并给出 workspace 根目录的 remotes(宽松的resolveContainedCwd,与兄弟分支路由一致)。这是 fail-safe 且正确的——只是这句话应写明"变更类接口"。triage stage-2 在 09-06 已指出,目前仍在。
8. 在当前 head 本地重跑的门禁
| 套件 | 结果 |
|---|---|
core git-remotes.test.ts + git-remotes-kill.test.ts |
166/166 |
core git-branches.test.ts |
102/103 —— 见下注 |
cli workspace-git-remotes + workspace-git-branches |
100/100 |
web-shell BranchPickerPopover.test.tsx |
109/109 |
sdk-typescript DaemonClient.test.ts |
418/418 |
git-branches 唯一的失败是*"types the refusal with a lock hint when the index is wedged"*,它来自 #10390 且已在 main 上。这是 git 版本依赖,不是本 PR 的回归:在 index.lock 被占住时,本机的 git 2.47.3 打印 error: could not write index,不含 "lock" 子串,而断言要求包含。CI 上更新的 git 能通过。
🧠 Verified with Claude Code · model claude-opus-5[1m]
|
Qwen Code review request accepted. Review is queued for an available runner; follow the workflow run for progress. A command-triggered review is not listed under the checks of this PR; the result is posted here as a review when it finishes. |
…ss rollback, converge and fold
R18-1: the ambient named-section guard matched across lines
(`[^.]+` spans newlines), so a host `remote.pushDefault` read as a
named section and skipped the whole route suite; exclude the newline,
stop the scan at the `=`, and pin the line boundary both ways
(two-component pushDefault plain / followed by a dotted key / carrying
a dot in its value must not match; dotted section names must).
R18-2: the no-such-remote converge arm ran the certify path's
destructive sweep without any unmask gate, so a removal could destroy
the local shadow of a dangling inherited upstream and still answer
404; run the snapshot-scoped half (sweptUpstreamResolving re-verify
plus unmaskedPointedUpstream) after the sweeps, keeping git's 404 for
inherited survivors the snapshot never pointed at.
R18-3: an include-held value equal to the removed name blocked the
collateral-damage restore; the new discountGate discounts it from the
presence reads when the section is gone from every scope AND the name
no longer resolves at all — resolver probe (legacy $GIT_DIR/remotes
file, insteadOf alias) plus local-path probe (same-named directory
repo or bundle file; a sectionless name short-circuits before the path
probe, which would otherwise put a scp-like spelling on the network),
and a probe that cannot answer yields no-discount inside the gate
rather than aborting the rollback ahead of the merge arm. The discount
spans the remote, pushRemote and pushDefault presence reads; the
merge pairing reads the undiscounted presence; the exit-0
split-section path restores a second time after its worktree-half
completion so the gate never sees a half the completion removes.
R18-4: isSectionlessUpstream treated any colon as a network transport,
failing the unmask gate open over colon-after-slash local paths;
apply git's colon-before-first-slash rule, keeping the dot and win32
UNC carves.
R18-5: an invisible character split the skeleton collision group
(group key folded the raw name while the search folded the sanitized
one); key the group, member flags and render lookup on the sanitized
fold, keeping the polarity arm on the raw name.
R18-6: the settle effect restored mutation-start focus without
reading settle-time focus, yanking focus out of a control the user
moved to mid-flight; record the start element and skip the restore
when in-content focus moved elsewhere.
R18-7: the skeleton fold was not invariant under canonical
equivalence (positional table walk before the closing NFC);
canonicalize at the entrance (NFC is a canonical function) and cover
fused code points with a longest-decomposed-prefix table lookup,
mirrored in the generator so the table stays closed.
Round-7 audit additions on top of the R18 fixes: the discount gate's
local-path leg and its probe-failure carve-out (a live directory-repo
or bundle upstream must not read as dangling residue; a wedged
resolver must not abort the rollback), the sectionless-name
short-circuit, the split-section second restore, the pushDefault
arm's dead conjunct, and witnesses for each: directory-repo and
bundle no-re-point pairs, probe-failure merge restore, pushRemote
write-back, split-section completion write-back, and the kill-suite
pin that a scp-like removed name never reaches the path probe.
Rounds 8-9 closed the remaining witness gaps: the pushurl-only
section scope-leg witness (the fetch-side resolver and the path probe
are both blind to a pushurl-only section), the kill-suite pin that a
killed gate probe yields no-discount while a later gate surfaces the
kill, the split-section witness now asserting certification, the
sanitized render-lookup witness (the skeleton-escape tail spells the
fold-covered code point), the pushDefault write-back comment
re-attributed to the unmask arm that actually refuses its fixture, and
the generator's vestigial NFD argument removed (old and new generator
produce byte-identical tables). Accepted residuals recorded in code
comments and both design docs: merge-key re-sweep on late refusals,
the sibling-worktree blind spot of the gate's section leg, the
swallowed discount when a surviving `[branch <b>]` section takes an
in-section insert ahead of a later `[include]` directive, the ambient
guard's `=`-in-name miss, and the six formerly-unified ink classes of
the regenerated table.
Mutation-verified: each mutant reddens only its target witness.
Rounds 10-11 closed the last witness gaps and comment drift: the
win32 drive-letter fail-open in isSectionlessUpstream (a drive-letter
prefix is a LOCAL path on win32 — the carve is win32-gated — while
every other platform reads it scp-like ssh, so the colon rule answers
it sectionless and keeps every probe off the wire; witnessed per
platform, with zero ssh spawns on POSIX), the converge-arm comment's
false claim that a legacy file never reaches the arm (git's rm exits 0
over it, so the resolver leg is what skips the sweep on a retry), the
per-arm sanitized member-flag witnesses (all-ASCII-only and
case-twin-only), the inventory rows' three omitted witnesses, the
comment counts ("Three refusal sources", "One gate decision per
pass"), the win32 drive-letter carve's missing no-separator spelling
(has_dos_drive_prefix needs only letter plus colon, so `C:x` probes
locally on win32 too), the merge-churn residual's code-comment record
at the post-certification cleanup, and the generator paragraph the
entrance-NFC rewrite orphaned (the ink-equal-value phenomenon is
systematic — 1010 of 6564 values — and its example is now a real
composition exclusion, U+FB30).
Rounds 13-14 rebuilt isSectionlessUpstream as a faithful mirror of
git's url_is_local_not_ssh: has_dos_drive_prefix (any non-NUL ASCII
character, or one whole non-ASCII code point, plus a colon), the
is_valid_win32_path conjunct (NTFS-forbidden characters, reserved
device-name segments, segments ending in space or period, and the
whole-path trailing-dot magic), and forward-slash UNC — a drive-letter
url whose tail NTFS rejects is routed by git to ssh, so probing it
would spawn ssh from a config string; the predicate is exported and
unit-tested under a stubbed platform (original descriptor restored),
because the win32 legs are not executable off-win32. Both design docs
enumerate the win32 classification correctly, and the accepted
residuals are numbered one to four.
Round 15 closed the last predicate divergences against git's
url_is_local_not_ssh / is_valid_win32_path: reserved device names match
as git's PREFIX rule (terminated by end, `.`, `:`, or a separator,
after optional spaces; LPT accepts any digit, COM 1-9), the
per-segment trailing-period rule exempts all-period segments of
length <= 2 (`C:/..` and `C:/.` are valid local paths, not ssh), and
the colon/slash legs are evaluated before the drive-prefix leg exactly
as git's expression orders them; the unit table covers the divergent
shapes and three mutants (old reserved regex, reordered legs, old
period rule) each redden only their unit tests.
Recorded deferrals from rounds 15-17: the per-pass gate memo is a
latency-only guard with no behavioral witness (spawn count, never
outcome); core.protectNTFS=false on win32 makes git read reserved-name
drive tails as local while the predicate calls them sectionless
(fail-safe direction, explicit opt-out only, same family as the UNC
over-approximation); and 1024 of the 6564 emitted confusables keys are
not NFC fixed points and therefore unreachable under the entrance NFC
(dead weight, behavior-preserving to drop). Round 17 also added the
kill-suite witness for the discount gate's scope leg sitting outside
its try (a killed scope read aborts before any destructive cleanup,
while a killed probe leg answers no-discount inside the gate).
Deferred follow-ups (recorded, not dropped): the merge key a refusal
landing after unsetUpstreamKeys can re-sweep when the local remote
key does not stand (include-held residue on a pointed branch); the
sibling-worktree blind spot of the discount gate's section leg; the
swallowed discount when git's rm leaves the local `[branch <b>]`
section standing and an `[include]` directive sits after it; the
ambient guard's `[remote "a=b"]` miss; the race-only pre-existing
TOCTOU window between the union gate and the tail surviving-keys
gate; the six formerly-unified ink
classes of the regenerated confusables table; the remoteInkKey helper
unification; the equal-combining-class skeleton limitation; the
converge-arm duplicate dump spawn; a wire-visible error code for the
pre-flight refusals.
|
R18 dispositions — all seven threads addressed in the batch that lands with this reply; every fix carries a discriminating witness and was mutation-checked (mutant reddens only its witness, siblings stay green). Gates at push time: core git-remotes suites 268 passed (incl. the kill suite's 29), cli remote-routes suite 37 passed / 0 skipped, web-shell suite 151 passed, prettier + eslint + typecheck green. R18-1 (ambient guard R18-2 (converge arm misses the unmask gate) — fixed, by the snapshot-scoped half rather than a full R18-3 (include-held residue blocks the collateral-damage restore) — fixed. A present value equal to the removed name is discounted from the restore's presence reads (branch remote, pushRemote, pushDefault) by a single shared gate: the R18-4 (colon-bearing local path fails the unmask gate open) — fixed. R18-5 (invisible char splits the skeleton collision group) — fixed. The collision-group key, the member flags and the render lookup all fold the SANITIZED display name ( R18-6 (settle yanks focus the user re-lent mid-flight) — fixed. The mutation records the element that held focus at mutation start in a separate ref; the settle effect skips the restore when the in-content focus at settle time is a different element (neither the recorded start element nor the content root itself, which mirrors Radix's FocusScope container focus). The start-time cases keep working: R18-7 (fold not invariant under canonical equivalence) — fixed at the entrance. The fold canonicalizes its input to NFC before the table walk — NFC is a canonical function, so canonically equivalent spellings enter the same representative and cannot split ( Local-audit addendum (rounds 11-14). Further defects found by the local audit pair and fixed in this batch: Deferred follow-ups recorded (not dropped): the |
Round 3 · Live-daemon + real-browser verification of the R17/R18 batchMaintainer pass at head Chain under test: Chromium → PR web-shell → PR SDK → real HTTP → Verdict: merge-ready once Environment
1. The red check is a base-freshness gate, not this PR's code
The lane checks out the branch head alone, so its green only proves the branch passes the gate as the branch defines it. Action: merge current 2. R18-3 — the headline: no config-chosen string leaves the boxThe discount gate's local-path leg probes The mutant does not merely spend a spawn — it connects out ( 3. R18-4 —
|
| value | git chose | predicate | |
|---|---|---|---|
/srv/mirrors/app:1 |
local (filesystem) | reaches the probes | ✅ |
../old:sibling |
local (filesystem) | reaches the probes | ✅ |
mirror, /a/b |
local (filesystem) | reaches the probes | ✅ |
[email protected]:path.git |
network (ssh) | sectionless — no probe | ✅ |
host.invalid:1234/x.git |
network (ssh) | sectionless — no probe | ✅ |
ssh://…, https://… |
network | sectionless — no probe | ✅ |
C:\repo on POSIX |
network (ssh, host C) |
sectionless — no probe | ✅ |
The colon-before-first-slash rule is exactly git's, including the two shapes R18-4 named. The win32 legs (not executable off-win32) were exercised under a stubbed process.platform, descriptor restored: an NTFS-valid drive path is local and reaches the probes, while a reserved device name (C:\con\x), an NTFS-forbidden character, a segment ending in a space, and UNC in all three separator spellings (\\, //, \/) stay sectionless — 9/9.
4. R18-2 / R18-3 against real git, with a diagonal mutation matrix
Each claim is paired with what plain git does to the same repository.
| # | Claim | plain git (control) | this head |
|---|---|---|---|
| B2 | an include-held record naming the removed remote must not block the rollback | local copy destroyed, merge destroyed, the branch is left tracking the dangling gone residue |
local copy survivor and its merge key written back; effective upstream survivor |
| B3a | a converge retry whose sweep unshadows a dangling inherited upstream | error: No such remote, branch left dangling |
409 remote still configured after removal, inherited record untouched |
| B3b | an inherited upstream key the snapshot never pointed at | error: No such remote |
git's 404 kept — the doctrine the client's stale-row convergence keys on |
Mutants, each rebuilt and re-driven over the same fixtures:
| mutant | B1 (egress) | B2 (rollback) | B3a (converge) | B3b (404) |
|---|---|---|---|---|
| shipped | ✅ | ✅ | ✅ | ✅ |
| M1 — sectionless short-circuit deleted | ❌ | ✅ | ✅ | ✅ |
| M2 — gate never grants a discount | ✅ | ❌ | ✅ | ✅ |
| M3 — converge arm loses the unmask gate | ✅ | ✅ | ❌ | ✅ |
Diagonal: every gate is load-bearing and no witness is redundant.
One thing worth stating because it shaped my own harness: gitEnv strips GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM/GIT_CONFIG_NOSYSTEM and the rest of the repository-shifting family, so an inherited scope can only be planted through HOME. That hardening is doing its job.
5. R18-1 — the ambient guard, A/B'd on a host that reproduces the miss
Same fixture HOME, same 37 cases: the pre-R18 spelling /^remote\.[^.]+\./m throws and skips all 37, this head runs 37 passed / 0 skipped. Both polarities hold on the same runner — a genuinely dotted section name ([remote "a.b"]) still fires the guard, and a pushDefault whose value carries a dot does not.
6. R18-7 — fold invariance under canonical equivalence, independently reproduced
Corpus built from the committed table itself: every key × every combining mark U+0300–U+036F, offered as NFC and as NFD. 4853 combinations whose two spellings differ, 21 splits under the pre-R18 fold, 0 at this head, and exactly 6 table keys whose own skeleton changed (ņ ѝ أ ئ ṃ ῶ) — the numbers the PR records, derived without reference to its tests.
7. R18-5 in the live UI — and round 2's N4 is closed
The live panel, reading a real .git/config |
Searching the DISPLAYED name |
|---|---|
![]() |
![]() |
- N4 (round 2) is closed.
0riginbesideorigin: round 2 measured neither row marked, because the group key folded case-sensitively. At this head both read(lookalike name). - R18-5 holds.
mainbesidern+U+200B+ain: the invisible character no longer splits the group — the clean twin still marks(lookalike name)while the carrier reads(hidden characters). Searching the displayedrnainreturns both rows;git remote | grep rnainfinds nothing. - A purely canonical NFC/NFD pair (
cafévscafe+U+0301) keeps the single-row polarity the module documents — confirmed as designed, not a gap. - Every row's tooltip and aria-label spell the raw configured name as code-point escapes (
rn\u{200b}ain,\u{30}rigin,cafe\u{301}), and the raw code point never reaches the inked text.
8. R18-6 — the settle no longer yanks focus the user re-lent mid-flight
Driven against the real daemon behind a transparent proxy that only delays the remove response (+1800 ms), so the in-flight window is wide enough for a human-like re-lend; focus at settle is outlined in the capture.
Mutant M4 (the settle restores unconditionally, i.e. the pre-R18 behaviour) yanks focus to the back button on the same run; the shipped build leaves it in the search box. The removal itself lands in both arms.
9. The flow against the live daemon — 7/7, plus 17/17 wire probes
Add — verified with git config --get |
Two-click confirm armed (zero requests so far) | A remote deleted behind the panel |
|---|---|---|
![]() |
![]() |
![]() |
Panel rows matched git remote exactly before and after every mutation; the first remove click issued zero HTTP requests and left the repository untouched; a duplicate add surfaced git's own remote fork already exists. with remote.fork.url still single-valued; the race (a terminal git remote remove stale behind the open panel) surfaced git's real No such remote and the panel re-read the list.
On the wire at this head: classifier collisions hold for remotes named dirty-cache, already exists, not a git repository and Could not remove config section (all 404 no_such_remote, never a legacy keyword branch); ?cwd= escaping → 400 invalid_cwd on both mutations; token-less mutation → 401; unregistered workspace → 400; ext::sh -c id, -oProxyCommand=id, fd::17/foo → 400 invalid_remote_url while ssh://git@[::1]:22/x.git, the scp-like spelling and a relative path with a space are accepted; the newline-injection shape (a fetch value forging error: Could not remove config section … on its second line) answers 409 remote_config_unparsable with the section surviving. 0 of 12 error bodies carried an absolute path.
10. Gates re-run locally at this head
| Suite | Result |
|---|---|
core git-remotes + git-remotes-kill |
275/275 (244 + 31) |
core git-branches |
106/107 — see note |
cli workspace-git-remotes + workspace-git-branches |
108/108 (37 + 71) |
cli workspace-git-remotes under an inherited remote.pushDefault |
37/37, 0 skipped (§5) |
sdk-typescript DaemonClient |
447/447 |
web-shell BranchPickerPopover + remote-name-skeleton |
151/151 (139 + 12) |
web-shell e2e web-shell.git-remotes.spec.ts (Playwright) |
5/5 |
The one git-branches failure is types the refusal with a lock hint when the index is wedged — byte-identical at the merge base, not in this PR's diff, and git-version-dependent: with a wedged index.lock, git 2.47.3 on this box prints error: could not write index, which carries no lock substring. It passed on git 2.55 in round 2.
11. What is gating the merge
- 185 review threads, 0 unresolved (GraphQL, fully paginated).
- Required lanes green at head; the only red is §1's freshness gate, cleared by merging
main. - So the remaining gate is a merge of
mainplus human approval, not code.
12. Carried non-blocking observations
- N1 (carried from round 1 N3 / round 2 N1, unchanged). Test-plan item 9 still reads "
?cwd=escaping the workspace → 400". Re-measured here: true for both mutations; the GET route is deliberately lenient and answered 200 with the workspace root's remotes. Wording only — add "mutations". - N2 (carried from round 2 N3, still reproducible).
resets the remotes view and restores no focus after a workspace switchis byte-unchanged at this head and still samplesactiveBeforewithout flushing past the popover's 50 ms open-autofocus timer. Inserting one 80 msactbetween the sample and the settle reproduces the Windows lane's message here verbatim:AssertionError: expected <input …(3)></input> to be <body><div>…(2)</div></body>. One line of test-side flush before the sample would stop the lane carrying a red that looks PR-owned. Not a product defect.
中文说明
第 3 轮 · 针对 R17/R18 批次的 live daemon 与真实浏览器验证
维护者在 head b38a9843b 上的验证。第 1 轮(d1e994f,Linux / git 2.47)与第 2 轮(08f5009a,macOS / git 2.55)已端到端覆盖了整个特性,因此本轮是增量验证:以下全部针对此后合入的两个修复提交——R17 ad3de520f 与 R18 b38a9843b——以及第 2 轮遗留的那一条观察。
受测链路:**Chromium → PR 的 web-shell → PR 的 SDK → 真实 HTTP → qwen serve(由 PR 源码构建)→ 真实 git 2.47.3 → 真实仓库。**无 mock daemon、无 page.route、无夹具;UI 使用 daemon 自带的 packages/web-shell/dist(生产路径),每条断言都用真实 git 回读磁盘。
**结论:合入当前 main 后即可合并。**唯一的红色检查是 base 新鲜度门禁,与代码无关(§1)。56 项实测检查通过,4 个变异体各自只让对应见证变红,第 2 轮遗留的 N4 已关闭,且 PR 自己记录的 R18-7 数字在我独立构建的语料上逐一复现。
环境
| Head / merge-base | b38a9843b9 / d313505fdb |
| 平台 | Linux 6.12.63、Node 22.22.2、git 2.47.3 |
| Daemon | node dist/cli.js serve --port 4193 --token … --require-auth --workspace ×3,由 PR 树 node esbuild.config.js 产出 |
| UI | daemon 自带的 packages/web-shell/dist,Chromium 1280×900 @2x,token 经 ?token= 传入 |
| 变异臂 | 对构建产物 git-remotes.js 与 BranchPickerPopover.tsx 每次只改一个守卫、重新构建、重新驱动;结束后恢复并复核工作树干净 |
1. 红色检查是 base 新鲜度门禁,不是本 PR 的代码
Lint & Static 在 23 秒内失败,发生在任何 lint 执行之前的 check-lint-gate-freshness.mjs:
The lint gate changed on 'main' after this branch last incorporated it:
- .github/workflows/ci.yml: ac505acbfc1d feat(omni): integrate multimodal media pipeline into main (#12019) (2026-09-16)
该通道只检出分支 head,因此它的绿色只能证明分支通过了分支自己定义的那份门禁。**处置:合入当前 main,无需改代码。**此 head 上其余通道全绿(Test (ubuntu-latest)、web-shell E2E Smoke、Serve A/B、Real daemon E2E、Capture web-shell visuals、Java 矩阵、OpenTUI no-flicker、TUI parity),review-pr 仍在运行。
2. R18-3 —— 最关键的一条:不让配置里的字符串出网
discount gate 的本地路径探针会执行 ls-remote -- <name>。而 scp 形式的名字是网络传输,所以这个门必须只靠字符串判断就作答。我没有用 spawn 计数断言,而是用 GIT_SSH_COMMAND 记录器实测:仓库里配置一个名为 [email protected]:secret.git 的 remote、一条 include 持有的同名残留,然后分别用出厂代码和删掉短路的变异体驱动真实删除:
变异体不只是多花了一次 spawn——它真的向外连接了(对着取自 .git/config 的主机执行 git-upload-pack 'secret.git'),随后还依据这次探测的结果把删除判为成功 200。出厂代码的记录器为空,并给出拒绝。这是本批次里我发现的最尖锐的一点,而它落在正确的一侧。
3. R18-4 —— isSectionlessUpstream 与 git 自身的传输路由一致 —— 20/20
地面真值取自真实 git 而非文档:每个取值都在同一记录器下执行 git ls-remote -- <value>,据此判定 git 实际选择的传输(spawn ssh / 其他网络尝试 / 本地文件系统)。
| 取值 | git 的选择 | 谓词 | |
|---|---|---|---|
/srv/mirrors/app:1 |
本地(文件系统) | 进入探针 | ✅ |
../old:sibling |
本地(文件系统) | 进入探针 | ✅ |
mirror、/a/b |
本地(文件系统) | 进入探针 | ✅ |
[email protected]:path.git |
网络(ssh) | sectionless,不探测 | ✅ |
host.invalid:1234/x.git |
网络(ssh) | sectionless,不探测 | ✅ |
ssh://…、https://… |
网络 | sectionless,不探测 | ✅ |
POSIX 上的 C:\repo |
网络(ssh,主机名 C) |
sectionless,不探测 | ✅ |
「冒号在第一个斜杠之前」的规则与 git 完全一致,包括 R18-4 点名的两种形态。win32 分支(在非 win32 上不可执行)通过桩化 process.platform 验证并恢复描述符:NTFS 合法的盘符路径是本地传输、会进入探针,而保留设备名(C:\con\x)、NTFS 禁用字符、以空格结尾的段,以及三种分隔符拼法的 UNC(\\、//、\/)都保持 sectionless —— 9/9。
4. R18-2 / R18-3 对真实 git,并配对角线变异矩阵
每条主张都配了一个「plain git 对同一仓库会怎样」的对照。
| # | 主张 | plain git(对照) | 本 head |
|---|---|---|---|
| B2 | include 持有的同名记录不得阻塞回滚 | 本地副本被摧毁、merge 键被摧毁,分支被留在悬空的 gone 残留上 |
本地副本 survivor 与 merge 键被写回;有效 upstream 为 survivor |
| B3a | 收敛重试的清扫解除了对一条悬空继承 upstream 的遮蔽 | error: No such remote,分支留在悬空态 |
409 remote still configured after removal,继承记录未被触碰 |
| B3b | 快照从未指向过的继承 upstream 键 | error: No such remote |
保留 git 的 404 —— 客户端过期行收敛所依赖的答案 |
变异体,均重新构建并在同一夹具上重新驱动:
| 变异体 | B1(出网) | B2(回滚) | B3a(收敛) | B3b(404) |
|---|---|---|---|---|
| 出厂 | ✅ | ✅ | ✅ | ✅ |
| M1 —— 删除 sectionless 短路 | ❌ | ✅ | ✅ | ✅ |
| M2 —— 门永不放行 discount | ✅ | ❌ | ✅ | ✅ |
| M3 —— 收敛臂失去 unmask 门 | ✅ | ✅ | ❌ | ✅ |
对角线成立:每个守卫都确实起作用,没有冗余见证。
有一点值得说明,因为它直接影响了我自己的 harness:gitEnv 会剥掉 GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM/GIT_CONFIG_NOSYSTEM 及其余可改变仓库指向的变量,所以继承作用域只能通过 HOME 植入。这条加固确实在起作用。
5. R18-1 —— 在能复现该缺陷的宿主上做守卫 A/B
同一份夹具 HOME、同样 37 个用例:pre-R18 的写法 /^remote\.[^.]+\./m 抛错并跳过全部 37 个,而本 head 是 37 passed / 0 skipped。两个方向的极性在同一 runner 上都成立——真正带点的 section 名([remote "a.b"])仍会触发守卫,而值里带点的 pushDefault 不会。
6. R18-7 —— 规范等价下的折叠不变性,独立复现
语料直接由提交的表构造:每个 key × 每个组合记号 U+0300–U+036F,分别以 NFC 与 NFD 形式喂给折叠。两种拼写不同的组合共 4853 个,pre-R18 折叠下分裂 21 个,本 head 0 个;自身 skeleton 发生变化的表 key 恰为 6 个(ņ ѝ أ ئ ṃ ῶ)——正是 PR 记录的那组数字,且推导过程未参考其测试。
7. R18-5 在真实 UI 中成立 —— 第 2 轮的 N4 已关闭
实时面板,读自真实 .git/config |
按显示的名字搜索 |
|---|---|
![]() |
![]() |
- N4(第 2 轮)已关闭。
0rigin与origin并存:第 2 轮实测两行都没有标记,原因是分组键大小写敏感;本 head 上两行都显示(lookalike name)。 - R18-5 成立。
main与rn+U+200B+ain:不可见字符不再把分组拆开——干净的那一行仍标记(lookalike name),携带者显示(hidden characters)。按显示的rnain搜索会同时命中两行,而git remote | grep rnain什么也搜不到。 - 纯规范变体的一对(
café与cafe+U+0301)保持模块中写明的单行极性——确认为设计如此,不是缺口。 - 每行的 tooltip 与 aria 标签都以码点转义写出原始配置名(
rn\u{200b}ain、\u{30}rigin、cafe\u{301}),原始码点从不进入可见文本。
8. R18-6 —— settle 不再夺走用户中途交出的焦点
在真实 daemon 前加了一个只延迟删除响应(+1800 ms)的透明代理,使得「操作进行中」的窗口足以完成一次拟人的焦点转移;截图中 settle 时刻的焦点用轮廓标出。
变异体 M4(settle 无条件恢复,即 pre-R18 行为)在同一次运行中把焦点夺到返回按钮上;出厂构建则把焦点留在搜索框。两臂的删除本身都正常落地。
9. 对 live daemon 的完整流程 —— 7/7,外加 17/17 线上探针
添加 —— 用 git config --get 核对 |
两段式确认已待确认(此时零请求) | 面板背后被删掉的 remote |
|---|---|---|
![]() |
![]() |
![]() |
每次变更前后,面板行与 git remote 完全一致;第一次点击删除发出零个 HTTP 请求且仓库未被改动;重复添加原样呈现 git 自己的 remote fork already exists.,remote.fork.url 仍是单值;竞态(面板开着时在终端执行 git remote remove stale)呈现 git 真实的 No such remote,面板随即重新拉取列表。
此 head 上的线级结果:分类器碰撞成立——名为 dirty-cache、already exists、not a git repository、Could not remove config section 的 remote 删除均返回 404 no_such_remote,从不落入遗留关键字分支;?cwd= 逃逸 → 两个变更接口均 400 invalid_cwd;无 token 的变更 → 401;未注册 workspace → 400;ext::sh -c id、-oProxyCommand=id、fd::17/foo → 400 invalid_remote_url,而 ssh://git@[::1]:22/x.git、scp 形式和含空格的相对路径被接受;换行注入形态(fetch 值在第二行伪造 error: Could not remove config section …)返回 409 remote_config_unparsable 且 section 存活。本轮采集的 12 个错误响应体中,0 个含绝对路径。
10. 在此 head 上本地重跑的门禁
| 套件 | 结果 |
|---|---|
core git-remotes + git-remotes-kill |
275/275(244 + 31) |
core git-branches |
106/107 —— 见下注 |
cli workspace-git-remotes + workspace-git-branches |
108/108(37 + 71) |
cli workspace-git-remotes(宿主带继承的 remote.pushDefault) |
37/37,0 skipped(§5) |
sdk-typescript DaemonClient |
447/447 |
web-shell BranchPickerPopover + remote-name-skeleton |
151/151(139 + 12) |
web-shell e2e web-shell.git-remotes.spec.ts(Playwright) |
5/5 |
唯一失败的 git-branches 用例是 types the refusal with a lock hint when the index is wedged——它在 merge base 上逐字节相同,不在本 PR 的 diff 中,且依赖 git 版本:在 index.lock 卡死时,本机 git 2.47.3 输出 error: could not write index,其中不含 lock 子串。第 2 轮在 git 2.55 上是通过的。
11. 真正卡住合并的是什么
- 185 条评审线程,0 条未解决(GraphQL,完整分页)。
- head 上的必需通道全绿;唯一的红色是 §1 的新鲜度门禁,合入
main即可消除。 - 因此剩下的门槛是「合一次
main+ 人工批准」,而不是代码。
12. 沿用的非阻塞观察
- **N1(自第 1 轮 N3 / 第 2 轮 N1 沿用,未变)。**测试计划第 9 条仍写着「
?cwd=逃逸 workspace → 400」。本轮复测:对两个变更接口成立;GET 路由是刻意宽松的,返回 200 并给出 workspace 根目录的 remotes。仅措辞问题——补上「变更类接口」。 - N2(自第 2 轮 N3 沿用,仍可复现)。
resets the remotes view and restores no focus after a workspace switch在此 head 上逐字节未变,仍然在采样activeBefore之前没有冲掉弹层挂载时那个 50 ms 的自动聚焦定时器。在采样与 settle 之间插入一个 80 ms 的act,即可在本机逐字复现 Windows 通道的报错:AssertionError: expected <input …(3)></input> to be <body><div>…(2)</div></body>。在采样前加一行测试侧的冲刷,就能让该通道不再背着一个看起来像本 PR 造成的红。不是产品缺陷。
🤖 Generated with Claude Code — Claude Opus 5 (1M context)
|
@qwen-code /triage |
qqqys
left a comment
There was a problem hiding this comment.
Critical-only review pass at head 78625a6c91628aa00f04b6dcc6663e30fc959794. Not approving: one Critical carried across three rounds is still present in the head source, verified by reading it. This pass supersedes the comment filed against 789b42b9; the code has moved since, and one of the two items raised there is now genuinely fixed.
Still standing: the removed-name certification resolves fetch-side only
packages/core/src/utils/git-remotes.ts:898-920 at this head:
// Whether git still RESOLVES the name after the section is gone — the
// certification the config-record checks cannot give ...
async function remoteStillResolves(cwd, name, env?): Promise<boolean> {
try {
const out = await runGit(cwd, ['ls-remote', '--get-url', '--', name], env);
return (out.endsWith('\n') ? out.slice(0, -1) : out) !== name;
} catch (err) { stripConfigDump(err); throw err; }
}The resolver has one leg. ls-remote --get-url answers the fetch side: it applies url.<base>.insteadOf and never consults url.<base>.pushInsteadOf, which is the rewrite git uses when pushing. A removal whose name a pushInsteadOf alias still resolves push-side is therefore certified here as "git no longer resolves this name", and the push twin of the alias survives a removal the panel reports as complete and verified. That contradicts the guarantee this PR states for itself - removal is verified rather than trusted, and the route answers 409 remote_still_configured rather than certifying a state git would still resolve.
The function's own comment is explicit that it exists to give "the certification the config-record checks cannot give" and to fail closed on any failure. The gap is not error handling but scope: the certification is half as wide as the claim it backs. A push-side leg - git config --get-urlmatch over url.*.pushInsteadOf for the name, or the equivalent push query - closes it, and the fail-closed posture already in place covers the case where that probe errors.
This was filed in round 16, carried in round 17 with the note that it still stands, and the function is unchanged at this head. It is body-level rather than an inline thread, which is why the thread state does not reflect it: all 185 threads across both pages are flagged resolved.
Verified fixed since the previous pass
The confusable skeleton fold is now invariant under canonical equivalence. packages/web-shell/client/utils/remote-name-skeleton.ts canonicalizes the entrance rather than only the exit: let out = name.normalize('NFC'); before the fixed-point loop, with the reasoning recorded - NFC is a canonical function so every canonical spelling enters the same representative, and the closing NFC alone cannot achieve that because a mark arriving inside a precomposed character's prototype stays glued to its base while canonical ordering moves it first in a decomposed spelling. The finding's claim was that the fold decided on arrival spelling; that is no longer true.
The same comment now documents a narrower residual: a confusable base plus an extra combining mark can still split when the folded marks land in equal combining class, which needs a combining-class-aware comparison space and is named as follow-up. That is a completeness limit on a lookalike warning rather than a wrong answer, it is disclosed in the code, and it is not treated as blocking here.
Not re-derived
The most recent round filed three further Criticals inline - focus restoration in BranchPickerPopover.tsx, the collision-group key folding the raw remote name where other consumers fold the skeleton, and a test-shaped item on workspace-git-remotes.test.ts - plus three probe items on git-remotes.ts. Their threads are flagged resolved. Flags are not evidence, and I did not re-derive them within this pass's budget; the one item from that round I did check independently, the skeleton fold, is genuinely fixed, which is corroborating but not a substitute for the rest.
CI
Every non-skipped check on this head concluded success, including the unit, lint and integration lanes that were cancelled or failing on an earlier head. Only review-pr was still running at review time, which is not treated as a gate. Nothing in CI is attributable to this change, and the green lanes do not cover the push-side certification above.
Next step
Add the push-side leg to remoteStillResolves so the certification covers both rewrite directions, with a case whose config holds only a url.<base>.pushInsteadOf alias for the removed name and asserts the removal is refused rather than certified. That closes the last carried Critical; the rest of this head reads as converged.
ytahdn
left a comment
There was a problem hiding this comment.
What this PR does / PR 主旨
Lets users manage git remotes from the Web Shell workspace branch picker: add/rename/remove remotes through a serve route, with the removal path hardened so it never destroys live upstream config it cannot certify as safe (fail-closed toward refusal), plus a Unicode-confusable lookalike marker for remote names.
Re-review verdict / 复审结论
APPROVE.
The head moved past my previous approval (7fba9ac). Two fresh commits landed since: an R17 docs/comment/test pass and b38a984, which closes the CI bot's R18 round plus the author's own audit findings. I independently re-verified each R18 Critical against the current head tree rather than trusting the thread.
R18-1 (test hermeticity): the global guard now uses ambientDefinesRemoteSection with /^remote\.[^=\n]*\./m, which excludes the newline so a host remote.pushdefault=... key no longer spans lines and false-throws; it is pinned by direct unit cases. The system-scope guard stays broad on purpose and the reason is documented: an inherited remote.pushDefault naming the removed remote is itself a certify-path refusal trigger.
R18-2 (converge arm unmask gate): the no-such-remote converge arm now builds a shared readUpstreamContext and runs unmaskedPointedUpstream after the sweeps and before throwing, matching the certify path (git-remotes.ts:625-628).
R18-3 (rollback discount): restoreLocalUpstreamBackups takes a sectionGone/discountGate callback and filters a present value equal to the removed name out of remoteNow/pushNow/pdNow only when the gate grants the discount; the merge-pairing arm reads the raw value so it still asks "does a remote record stand?". Fail-closed on any probe error (answers no-discount), so it can only over-refuse, never silently re-point a branch.
R18-4 (isSectionlessUpstream): now follows git's own transport rule — . and win32 UNC carve out, then slash < 0 || colon < slash treats only colon-before-first-slash (scp-like) as a network transport, so a local path like /srv/mirrors/app:1 falls through to the probes instead of failing the unmask gate open (verified in head tree at git-remotes.ts:2163-2190).
R18-6 (settle focus): mutationStartFocusRef records the in-content testid that held focus at mutation start, and the settle effect skips the restore when the user moved focus to a different in-content element mid-flight, so it no longer yanks the caret out of the search box.
R18-7 (canonical-equivalence fold): the skeleton now canonicalizes the entrance to NFC (name.normalize('NFC'), a canonical function), so any two canonically-equivalent spellings of a remote name enter the same representative before folding; the longest-decomposed-prefix table probe handles a mark the entrance NFC fused into a non-key code point. Invariance holds for the reported class.
R18-10 (collision-group key): the group key, allAscii/canonical/caseTwins arms, and the per-row lookup all fold the sanitized display name, matching the search memo's fold, so one invisible character can no longer split a real ink collision into singletons and disarm the marker on the clean twin.
Disclosed, non-blocking follow-ups (recorded in the diff comments, not silently dropped): the skeleton can still split a confusable base carrying an extra combining mark whose folded marks land in equal combining class; the ~15 KB of non-NFC-fixed-point table keys are unreachable and could be dropped; and the discount-gate memo is a latency-only guard without a behavioral witness. None reach the repo's Critical/Important bar.
CI at head 78625a6: no failing checks (24 pass, 26 skipped, only the self-referential review-pr lane pending).
The removal certification's resolver leg answered fetch-side only: `ls-remote --get-url` applies insteadOf and never sees url.<base>.pushInsteadOf, the rewrite git uses when pushing — a name kept resolving push-side by an alias racing in after the pre-flight read was certified as no longer resolving. The union gate and the discount gate now read the push-side alias prefixes from the same all-scope dump their section legs already take, at zero extra spawns (the kill suite's spawn accounting is unchanged): a name kept push-live refuses 409 remote_still_configured, and a residue equal to a push-live name is not discounted into a silent re-point. The pre-flight owns the steady-state shape (a section plus its alias refuses before destruction; the converge arm skips the same alias on retries); these legs are the backstops for the race window. Witnesses: the union-gate push-alias refusal and the discount-gate push-alias no-discount kill-suite cases (each mutant reddens only its witness); the docs state the push-side leg in both languages.
|
Disposition for the carried Critical (the removed-name certification resolves fetch-side only): fixed at the head that lands with this comment.
The skeleton-fold verification and the CI summary in the review are noted. The "not re-derived" items stand verified by the audit rounds behind this head: the win32 predicate was diffed against git master's C source ( |
ytahdn
left a comment
There was a problem hiding this comment.
What this PR does / PR 主旨
Manages git remotes from the Web Shell workspace branch picker; the removal path is hardened to certify (not trust) that a remote is gone before reporting success, and to refuse rather than destroy live upstream config it cannot prove safe.
Incremental pass 78625a6 -> f59596c / 增量复验
This pass covers the single new commit f59596c, "certify removals push-side as well as fetch-side", which lands the fix for the one Critical qqqys carried across rounds 16-18: remoteStillResolves runs git ls-remote --get-url, which resolves only the fetch-side url.*.insteadOf and is blind to url.*.pushInsteadOf, so a removal whose name a push alias still resolves push-side could previously be certified 200 "removed" while leaving git push dangling.
I verified the fix in the head tree and it closes the gap in the fail-closed direction:
- Post-removal re-verify (git-remotes.ts:710-716): a single all-scope
config --list --show-scope -zdump now gates on three legs — a survivingremote.<name>section in any scope, aurl.*.pushInsteadOfvalue that is a prefix ofname, OR the fetch-side resolver — and any hit throwsremote still configured after removal. discountGate(git-remotes.ts:1968-1972) grew the same push-alias leg, so an include-held residue equal to the name is no longer discounted (never silently written back as if dangling) while a push alias keeps it a live push upstream.- The fetch-side section leg was refactored from
remoteSectionScopes(...)toremoteSectionScopesFromRaw(dump, name, false), which is byte-for-byte what the old helper returned internally, so no fetch behavior changed — the dump is merely shared with the new push leg at zero extra spawns. Config-read errors stillstripConfigDump+ throw (a no-match reads as empty), preserving the module's fail-closed posture.
This is a genuine correctness improvement in the safe direction (extra refusals only), and it is covered by the new kill tests. The empty-value guard in pushInsteadOfAliasesFromRaw (a blank pushInsteadOf would prefix-match every name) is correctly preserved.
CI caveat: at this fresh head most checks are still pending (the commit landed minutes ago), so I am recording an incremental note rather than certifying a green, merge-ready state. Re-verify once CI settles.
ytahdn
left a comment
There was a problem hiding this comment.
What this PR does / PR 主旨
Manages git remotes from the Web Shell workspace branch picker; the removal path is hardened to certify (not trust) that a remote is gone before reporting success, and to refuse rather than destroy live upstream config it cannot prove safe.
Verdict / 结论
APPROVE.
Building on my incremental pass (78625a6 -> f59596c): the last remaining Critical — qqqys's push-side certification gap, carried across rounds 16-18 — is closed at head f59596c.
remoteStillResolveswas fetch-side only (git ls-remote --get-urlappliesurl.*.insteadOf, neverurl.*.pushInsteadOf). The post-removal re-verify (git-remotes.ts:710-716) now gates on a surviving section in any scope, aurl.*.pushInsteadOfprefix of the name, OR the fetch resolver — throwingremote still configured after removalon any hit.discountGate(git-remotes.ts:1968-1972) gained the same push-alias leg, so an include-held residue equal to the name is no longer treated as dangling while a push alias keeps it a live push upstream.- Both legs share one all-scope dump at no extra spawn cost; the fetch-side section leg is byte-for-byte the old helper's body, so no fetch behavior changed. A config-read error still strips-and-rethrows (only a true no-match reads as empty), keeping the module fail-closed. The empty-value guard on pushInsteadOf is preserved.
Direction is strictly extra refusals (safe for a destructive config path), backed by new kill tests.
CI note (stated honestly): at this freshly pushed head the status checks are still mostly pending when I submit, so this approval is a code-correctness verdict, not a CI-green certification — recommend the merge gate still wait on the checks landing.
qqqys
left a comment
There was a problem hiding this comment.
Partial Critical-only review of f59596c: COMMENT, not an approval. No new Critical is established by this pass.
The previously carried push-side certification finding is fixed in the reviewed source: packages/core/src/utils/git-remotes.ts:694-716 checks pushInsteadOf prefixes alongside surviving sections and fetch resolution; :1953-1976 applies the same protection to the rollback discount gate. The earlier fetch-only finding should not be carried forward unchanged.
Also verified in source: sibling cleanup checks the common Git directory before writing (:1621-1651); removal failure enters rollback before classification (:495-512); the skeleton canonicalizes its input; and the collision-group key and row lookup both use the sanitized name. These are direct source checks, not reliance on resolved flags or another approval.
Approval gates remain incomplete: I have not finished the full historical blocking-finding reconciliation or the independent full-diff audit. In particular, the shared Git error classifier's resistance to config-controlled diagnostic text and its effects on existing non-remotes routes, and the new workspace-scoped routes' runtime/trust/containment behavior across all downstream consumers, still require a focused review. I am not asserting a defect in those paths or asking for speculative code changes. The accepted residuals documented in the design are not promoted to new blockers here.
Next step: complete those two focused audits and reconcile remaining historical blockers against this exact revision before treating this as a complete approval. The checks currently show no concluded failures; some runs are cancelled/skipped and review-pr plus web-shell E2E remain in progress. CI is not the reason for this COMMENT.
|
@qwen-code /triage |
|
Thanks for the focused pass. Disposition:
CI on this head: the two "fail" rows are lanes from a superseded run cancelled by the concurrency group, not real failures; the successor run's lanes are green so far (Desktop Shell both platforms pass), and the bot review job is in progress. |
|
The two focused audits landed; both were run against the exact revision reviewed. Workspace-scoped routes (runtime/trust/containment across all downstream consumers): CLEAN. Containment probed, not just read: realpaths both sides and gates on a Classifier (resistance to config-controlled diagnostic text + effect on non-remotes routes): 2 Suggestions, both fixed; no Critical. Anchoring is The historical blocker reconciliation against this revision is what the reply above summarizes: the R18 inline set is closed with discriminating witnesses and mutation evidence; the carried Critical (push-side certification) is fixed as verified in source. |
Resolve the collision between optional branch-session worktrees and the git remotes management that landed on main (QwenLM#11163): - git-branches: keep both the transport-key-aware gitRemoteEnv and the now-exported runGit, and hold GIT_ALLOW_PROTOCOL out of the case-insensitive git_* scrub so main's helper-protocol normalization still sees the inherited value instead of reading an already-deleted key. - BranchPickerPopover: the workspace-reset effect now clears main's remotes request/focus/sticky state and re-runs on gitSessionId as well as gitCwd. - Tests: union both sides' hostile-env inputs and mount() prop surface.











What this PR does
The workspace git popover in the Web Shell — opened from the left sidebar's workspace git pill or the composer branch chip — gains a Manage Remotes panel: list the repository's configured remotes with their fetch and push URLs, add a remote, and remove one behind a two-click inline confirm. It is backed by three new workspace-scoped daemon routes (list / add / remove), matching SDK client methods, and a core git helper that reads remotes from git's CONFIG (
git config --list --show-scope -z, NUL-framed records) instead ofgit remote -vor per-nameget-urllookups. Mutations answer with the fresh remote list, so the panel re-renders in one round trip; a removal also refreshes the branch listing and the branch chip's tracking state, because git deletes the remote-tracking refs and upstream configuration together with the remote.Why it's needed
The popover could pull, commit, push, and switch branches, but the remotes themselves were read-only context — remote-tracking branches were listed, grouped by a remote name the user could not change. Anyone who clones from a zip (no
origin), works a triangular fork + upstream layout, or just wants to drop a stale remote had to leave the Web Shell and open a terminal. This closes that gap with the minimal useful set (list / add / remove); URL editing, rename, and fetch/prune stay out of scope.Two details worth calling out because they shaped the implementation:
git config --list --show-scope -zread yields the full section (urls, push urls, refspecs, promisor/partial-clone) immune toinsteadOfrewriting and rendered-output annotations, and the scope filter lists the repository's own scope (local + worktree, include-sourced) — the sectionsgit remote add/removeact on — while inherited global/system sections stay out. Add pre-flights the name across ALL scopes and refuses a same-name inherited section with 409remote_shadows_inherited; removal re-reads the section across every scope, completes the worktree-scope half git cannot edit, sweeps the pointing branches' upstream keys and orphanedrefs/remotes/<name>/*a refspec-less removal leaves, and refuses 409remote_still_configuredrather than certifying a state git would still resolve..git/configthe user did not author (downloaded zip, cloned repo) can carry bidi/zero-width characters in remote names and URLs. The panel strips the full Unicode Default_Ignorable set at every render boundary (rows, tooltips, aria labels, footer messages, and the search filter, so what you can see is what you can search for), while add/remove requests always carry the exact configured name — removal validation is deliberately as lenient as git itself, so a hand-edited config can always be cleaned up through the UI. Names differing only by whitespace (which CSS collapses out of the inked text) flag the same way, with the raw name spelled out as codepoint escapes in the tooltip and aria-labels.Reviewer Test Plan
How to verify
Run the daemon against a git workspace, open the Web Shell, and expand the workspace in the left sidebar:
remote … already existserror in the footer, list unchanged.No such remotelands in the footer and the panel re-reads the list instead of keeping a row that can never be removed.git clone --filter=blob:nonerepo with a push-URL override, the panel shows the true fetch URL and the override in the tooltip.workspace_runtime_unavailable,?cwd=escaping the workspace → 400, non-repo → 404not_a_git_repository. Error bodies never contain absolute paths.Executable evidence:
packages/web-shell/client/e2e/web-shell.git-remotes.spec.tsdrives flows 1–6 end-to-end in Chromium against the mocked daemon, and the daemon layer is covered against real temporary git repositories by the route suite (27 cases) and the core suite (55 cases, including the partial-clone and invisible-character shapes).Evidence (Before & After)
Before: the popover's action list ended at Checkout Tag or Revision…; remote names appeared only as read-only group headers in the collapsed Remote branch section, with no way to add or remove one.
After: the same popover carries a Manage Remotes… row opening the panel below. Screenshots captured from the mocked-daemon Playwright run (default theme, 1280×800):
Tested on
Environment (optional)
Local daemon build + Web Shell dev server; Playwright (Chromium) for the UI e2e and the screenshots above; vitest suites against real temp repos for core/routes. Full local gates green on the current head:
npm run build,npm run typecheck,eslint --max-warnings 0on every touched file, core 162/162, cli routes 100/100 (including the sibling branch-route suite), SDK 417/417, web-shell 109/109, e2e 5/5.Risk & Scope
.gitdir, a submodule's gitdir, symlink-canonical spellings — parsed from the.gitfile with head-bounded reads. Second: removal is verified, never trusted — git's ownremote removedeletes tracking refs, upstream keys, and the section in that order, and every step the panel cannot see git complete (worktree-scope sections and upstream keys, refspec-less orphaned refs, inherited same-name survivors) is either completed in the scope git could not write or refused with a name-free 409./workspace/git/remote*form, matching the GitHub-PRs precedent); a live-daemon curl pass was not run in the authoring environment (its shell guard blocks git fixture writes) — the daemon layer is instead verified by supertest route tests against real temp repos, and the UI by the Playwright spec. Windows/Linux rely on CI.Linked Issues
None.
中文说明
这个 PR 做了什么
Web Shell 的 workspace git 弹层(从左侧栏 workspace 的 git 胶囊或输入框的分支 chip 打开)新增 管理远程仓库(Manage Remotes) 面板:列出仓库已配置的 remotes 及其 fetch/push URL、添加 remote、以及带两段式内联确认的删除。底层由三个新的 workspace 级 daemon 路由(列表 / 添加 / 删除)、对应的 SDK 客户端方法,以及一个直接读取 git 配置(
git config --list --show-scope -z,NUL 分隔记录)的 core 辅助模块支撑——不解析git remote -v,也不按名逐个get-url。变更类接口的响应直接携带最新列表,面板一次往返即可重渲染;删除还会刷新分支列表与分支 chip 的跟踪状态,因为 git 会把 remote-tracking refs 和 upstream 配置随 remote 一起删掉。为什么需要
弹层原本可以 pull、commit、push、切分支,但 remotes 本身只是只读上下文——远程跟踪分支按 remote 名分组展示,而用户无法改动这些 remote。从 zip 解压(没有
origin)、fork + upstream 三角工作流、或想删掉过期 remote 的用户,都必须离开 Web Shell 去开终端。本 PR 用最小可用集合(列表 / 添加 / 删除)补上这个缺口;改 URL、重命名、fetch/prune 不在范围内。两个影响实现方式的细节:
git config --list --show-scope -z即得完整 section(url、pushurl、refspec、promisor/partial-clone),免疫insteadOf改写与渲染注解;作用域过滤只列仓库自有作用域(local + worktree,含 include)——也就是git remote add/remove实际作用的部分——继承的 global/system section 一律不进列表。Add 预检覆盖全部作用域,发现同名继承 section 一律 409remote_shadows_inherited;删除后在 git 能解析的全部作用域复核 section,补删 git 写不了的 worktree 一半,清扫指过去分支的 upstream 键与无 refspec 删除留下的孤儿refs/remotes/<name>/*,宁可 409remote_still_configured也不把一个 git 仍能解析的状态说成成功。.git/config(下载的 zip、克隆的仓库)可能在 remote 名或 URL 里带 bidi/零宽字符。面板在每一个渲染边界(行、tooltip、aria 标签、footer 消息、以及搜索过滤器——保证「看得见的就搜得到」)剥掉完整的 Unicode Default_Ignorable 字符集,而添加/删除请求始终携带配置中的原始名字——删除侧的校验刻意与 git 本身一样宽松,手改出来的配置也总能通过 UI 清理掉。只差空白字符的名字(CSS 会折叠掉)同样标记,tooltip 与 aria 标签里以码点转义写出原始名字。评审测试计划
如何验证
对着一个 git workspace 启动 daemon,打开 Web Shell,在左侧栏展开该 workspace:
remote … already exists错误,列表不变。No such remote,面板会重新拉取列表,而不是留着一个永远删不掉的行。git clone --filter=blob:none且设置了 push URL 覆盖的仓库里,面板显示真实的 fetch URL,tooltip 里是覆盖后的 push URL。workspace_runtime_unavailable,?cwd=逃逸 workspace → 400,非 git 仓库 → 404not_a_git_repository。错误响应体不含绝对路径。可执行证据:
packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts在 Chromium 里对着 mock daemon 端到端跑通第 1–6 条;daemon 层由路由套件(27 例)和 core 套件(55 例,含 partial-clone 与不可见字符形态)对着真实临时 git 仓库覆盖。前后对比
**之前:**弹层操作列表到 Checkout Tag or Revision… 为止;remote 名只作为折叠的 Remote 分支分组标题只读出现,无法增删。
**之后:**同一个弹层多了 Manage Remotes… 入口,打开下方面板。截图来自 mock daemon 的 Playwright 运行(默认主题,1280×800):
测试环境
运行环境(可选)
本地 daemon 构建 + Web Shell dev server;UI e2e 与上述截图用 Playwright(Chromium);core/路由用 vitest 对着真实临时仓库。当前 head 上本地门禁全绿:
npm run build、npm run typecheck、所有触及文件的eslint --max-warnings 0、core 162/162、cli 路由 100/100(含兄弟分支路由套件)、SDK 417/417、web-shell 109/109、e2e 5/5。风险与范围
.git、子模块 gitdir、符号链接规范化后的拼写——从.git文件解析、限长读取。第二点:删除是复核过的,不是听信的——git 自己的remote remove按序删除跟踪 refs、upstream 键与 section,面板看不到 git 完成的每一步(worktree 作用域的 section 与 upstream 键、无 refspec 留下的孤儿 refs、同名继承残留),要么在 git 写不了的作用域补删,要么以不含名字的 409 拒绝。/workspace/git/remote*形态,与 GitHub-PRs 先例一致);撰写环境未跑 live-daemon 的 curl 验证(其 shell guard 禁止 git fixture 写操作)——daemon 层改由 supertest 路由测试对真实临时仓库验证,UI 由 Playwright spec 验证。Windows/Linux 依赖 CI。关联 Issue
无。