Skip to content

feat(web-shell): manage git remotes from the workspace branch picker - #11163

Merged
wenshao merged 49 commits into
mainfrom
feat/git-manage-remotes
Sep 17, 2026
Merged

wenshao merged 49 commits into
mainfrom
feat/git-manage-remotes

Conversation

@wenshao

@wenshao wenshao commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

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 of git remote -v or per-name get-url lookups. 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:

  • The listing reads config, scoped exactly. One git config --list --show-scope -z read yields the full section (urls, push urls, refspecs, promisor/partial-clone) immune to insteadOf rewriting and rendered-output annotations, and the scope filter lists the repository's own scope (local + worktree, include-sourced) — the sections git remote add/remove act on — while inherited global/system sections stay out. Add pre-flights the name across ALL scopes and refuses a same-name inherited section with 409 remote_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 orphaned refs/remotes/<name>/* a refspec-less removal leaves, and refuses 409 remote_still_configured rather than certifying a state git would still resolve.
  • Display is sanitized, requests are raw. A .git/config the 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:

  1. Click the workspace's git pill → the popover opens with the usual branch actions; a new Manage Remotes… row sits after Checkout Tag or Revision… (it is also matched by the popover's search box).
  2. Click it → the panel replaces the branch list: each configured remote shows its name and fetch URL (hover for the push URL when it differs), with an add form (name + URL) at the bottom and a back arrow at the top.
  3. Add a remote → a success notice appears in the footer and the row shows up immediately. Adding the same name again surfaces git's own remote … already exists error in the footer, list unchanged.
  4. Remove → the first click on the row's trash icon arms a red Confirm; the second click removes it. Navigate back: the branch list's remote group for that remote is gone, and if the current branch tracked it, the chip's upstream/ahead-behind state refreshed too.
  5. Race behavior: delete a remote in a terminal while the panel is open, then confirm its removal in the panel → git's No such remote lands in the footer and the panel re-reads the list instead of keeping a row that can never be removed.
  6. Search while in the panel filters remotes by name/URL; a query matching nothing says No remotes match the search (distinct from No remotes configured). Entering or leaving the panel clears the query, so typing "remotes" to find the action row does not open a panel filtered to nothing.
  7. Invisible-character hardening: with a hand-edited config (e.g. a remote name or URL containing U+202E/U+200F), the row renders with those characters stripped, searching the displayed name still finds it, and removal still succeeds (the request carries the raw configured name).
  8. Partial clone: in a git clone --filter=blob:none repo with a push-URL override, the panel shows the true fetch URL and the override in the tooltip.
  9. Guard parity with the sibling branch routes: untrusted workspace → 403, closed workspace generation → 503 workspace_runtime_unavailable, ?cwd= escaping the workspace → 400, non-repo → 404 not_a_git_repository. Error bodies never contain absolute paths.

Executable evidence: packages/web-shell/client/e2e/web-shell.git-remotes.spec.ts drives 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):

1. New entry row in the branch picker 2. Remotes panel: list + add form
3. Add form filled in 4. Add success in the footer
5. Two-click remove confirm armed (row 3)

Tested on

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

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 0 on 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

  • Main risk or tradeoff: the git error classifier shared by ALL workspace git routes (branches, pull, checkout, commit, remotes) gained six remote-specific branches, matched ahead of every legacy keyword branch and anchored to a line start (or git's documented two-line lock chain) on the full untruncated detail — a config-chosen remote name can carry any keyword, and a config-chosen value can carry a real newline, so any deeper line is attacker-controllable. The ordering and the newline-injection shapes are pinned by a collocated classification-table suite, and the sibling branch-route suite stays green. Error responses redact not only the workspace path and git root but any gitdir outside the cwd's tree — a linked worktree's shared main .git dir, a submodule's gitdir, symlink-canonical spellings — parsed from the .git file with head-bounded reads. Second: removal is verified, never trusted — git's own remote remove deletes 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.
  • Not validated / out of scope: no set-url, rename, fetch, or prune; scoped routes only (no legacy /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.
  • Breaking changes / migration notes: none — routes, SDK methods, i18n keys, and UI are purely additive.

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 一律 409 remote_shadows_inherited;删除后在 git 能解析的全部作用域复核 section,补删 git 写不了的 worktree 一半,清扫指过去分支的 upstream 键与无 refspec 删除留下的孤儿 refs/remotes/<name>/*,宁可 409 remote_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:

  1. 点击 workspace 的 git 胶囊 → 弹层打开,是熟悉的分支操作;Checkout Tag or Revision… 之后多了一行 Manage Remotes…(弹层搜索框也能搜到它)。
  2. 点进去 → 面板替换分支列表:每个 remote 显示名字和 fetch URL(悬停可在 push URL 不同时看到),底部是添加表单(名字 + URL),顶部有返回箭头。
  3. 添加 remote → footer 出现成功提示,行立即出现。再用同名添加一次 → footer 显示 git 自己的 remote … already exists 错误,列表不变。
  4. 删除 → 第一次点行尾垃圾桶图标进入红色 Confirm 待确认态;第二次点击才真正删除。返回分支视图:该 remote 的远程分支分组消失;如果当前分支原本跟踪它,chip 的 upstream/领先落后状态也已刷新。
  5. 竞态行为:面板开着时在终端里删掉某个 remote,再在面板里确认删除 → footer 显示 git 的 No such remote,面板会重新拉取列表,而不是留着一个永远删不掉的行。
  6. 面板内搜索会按名字/URL 过滤 remotes;无命中时显示 No remotes match the search(与 No remotes configured 区分)。进入/离开面板会清空搜索词——为了找到入口而输入的 "remotes" 不会把面板过滤成空。
  7. 不可见字符加固:手改配置(如 remote 名或 URL 含 U+202E/U+200F)时,行内渲染已剥掉这些字符,按显示的名字搜索仍能找到该行,删除仍然成功(请求携带原始配置名)。
  8. Partial clone:在 git clone --filter=blob:none 且设置了 push URL 覆盖的仓库里,面板显示真实的 fetch URL,tooltip 里是覆盖后的 push URL。
  9. 与兄弟分支路由一致的守卫:不受信 workspace → 403,workspace generation 已关闭 → 503 workspace_runtime_unavailable,?cwd= 逃逸 workspace → 400,非 git 仓库 → 404 not_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):

1. 分支弹层中的新入口行 2. 远程面板:列表 + 添加表单
3. 填好的添加表单 4. footer 的添加成功提示
5. 两段式删除确认待确认态(第 3 行)

测试环境

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

运行环境(可选)

本地 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。

风险与范围

  • 主要风险/取舍:所有 workspace git 路由(branches、pull、checkout、commit、remotes)共享的错误分类器新增了六个 remote 专用分支,排在全部遗留关键字分支之前,且只在行首(或 git 文档化的两行 lock 链)上、对未截断的完整输出匹配——配置里的 remote 名可以携带任何关键字,配置值可以携带真实换行,任何更深的行都可被攻击者构造。顺序与换行注入形态都有同文件分类表套件钉住,兄弟分支路由套件全绿。错误响应除了脱敏 workspace 路径与 git 根,还会脱敏 cwd 树之外的任何 gitdir——linked worktree 共享的主仓 .git、子模块 gitdir、符号链接规范化后的拼写——从 .git 文件解析、限长读取。第二点:删除是复核过的,不是听信的——git 自己的 remote remove 按序删除跟踪 refs、upstream 键与 section,面板看不到 git 完成的每一步(worktree 作用域的 section 与 upstream 键、无 refspec 留下的孤儿 refs、同名继承残留),要么在 git 写不了的作用域补删,要么以不含名字的 409 拒绝。
  • 未验证 / 范围外:无 set-url、重命名、fetch、prune;仅 scoped 路由(无 legacy /workspace/git/remote* 形态,与 GitHub-PRs 先例一致);撰写环境未跑 live-daemon 的 curl 验证(其 shell guard 禁止 git fixture 写操作)——daemon 层改由 supertest 路由测试对真实临时仓库验证,UI 由 Playwright spec 验证。Windows/Linux 依赖 CI。
  • 破坏性变更 / 迁移说明:无——路由、SDK 方法、i18n key、UI 全部是纯增量。

关联 Issue

无。

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
@wenshao

wenshao commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

E2E test report

Environment: macOS (darwin), Node 22, local build (npm run build), git 2.50.1. UI e2e: Playwright Chromium against the mocked daemon; daemon/core layers: vitest + supertest against real temporary git repositories.

Web Shell UI e2e (Playwright)

  • web-shell.git-remotes.spec.ts — 3/3 passed: sidebar git pill → Manage Remotes panel → list renders name+URL; add flow (daemon request body {name, url} asserted, row appears, success footer); two-click remove (first click arms only — zero requests — second removes, request body {name} asserted); back button restores the branch list; duplicate add → mock 409 surfaced verbatim in the footer with the list unchanged; search filters by name and by URL substring.
  • web-shell.git-mode.spec.ts — 5/5 passed (regression: now shares the extracted git-workspace fixture).

Daemon routes (supertest + real temp repos) — 27/27

List with push-URL override (set-url --push); add → fresh list + on-disk git remote confirmation; duplicate add → 409 remote_already_exists; remove → fresh list; remove missing → 404 no_such_remote; non-repo → 404 not_a_git_repository; unknown workspace → 400 workspace_mismatch; escaping ?cwd= → 400 invalid_cwd; untrusted workspace → 403 (all three endpoints); closed generation → 503 workspace_runtime_unavailable (all three); name/URL validation tables → 400; absolute-path redaction asserted on every error body; classifier-collision cases (a remote named dirty-cache still classifies as remote_already_exists / no_such_remote, not dirty_working_tree). Sibling workspace-git-branches suite: 36/36 (shared classifier regression).

Core (real temp repos) — 55/55

Structured-accessor listing: sorted order, push-URL override, partial-clone promisor remote (the [blob:none] annotation shape that breaks git remote -v parsers), url-less config entry (git's name-as-URL fallback), 24-remote batched-lookup completeness (crosses the 8-wide concurrency bound). Predicate tables: add-name/add-URL/remove-name incl. the leniency divergence (a.lock add-rejected but removable) and invisible-character rejection (bidi overrides, LRM/RLM, soft hyphen, separators). Round-trips: add/remove, duplicate error, predicate-legal names (a@b, a+b, a#b, @) verified accepted by real git remote add (no 500-path divergence), hand-configured x.lock remote listed and removable.

SDK — 416/416 (2 new)

URL/method/body composition for workspaceGitRemotes / workspaceGitRemoteAdd / workspaceGitRemoteRemove, with and without cwd.

Web Shell unit — 228/228

BranchPickerPopover 66 (panel open/list, add success + daemon-failure footer, local dash guard, two-click remove with counted post-remove branch/status refreshes + onBranchChanged, filtered-to-empty vs configured-empty copy, load-failure rendering, bidi/RLM display stripping with raw-name removal and search-by-displayed-name, sticky stash-warning survival across the panel round trip, reopen reset + confirm disarm, action-query not carried into the panel filter) + WorkspaceSection + ChatEditor regressions.

Gates (on the merge-base tree, c4cf30aa2e)

npm run build exit 0 · npm run typecheck exit 0 · eslint --max-warnings 0 clean on every touched file · prettier --check clean.

Not run / boundaries

  • Live-daemon curl E2E was not run in the authoring environment (its agent shell guard blocks git init/git remote add fixture writes in temp dirs). The daemon layer is covered instead by the route suite above against real temp repos — same HTTP → core → git chain, minus a live socket.
  • Windows/Linux not tested locally; CI covers.
  • Review process: 14-agent finder fan-out + 5 reverse-audit rounds before submission; ~25 findings (2 Critical: a partial-clone parser break and a CI lint-gate failure; the rest Suggestions) all fixed in-tree, each with a witness test, before the branch was pushed.

wenshao and others added 4 commits September 6, 2026 18:50
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
@wenshao

wenshao commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover from 4

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

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. This 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/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。本窗口轮次计数从 4 起算(即本 PR 托管前已进行的评审轮数),因此再经过 1 个产生改动的轮次即进入 Critical-only,而非重新计满 5 轮。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-dev-bot

qwen-code-dev-bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator

⚠️ AutoFix round 8 ended without publishing a report — view run.

中文说明

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

wenshao and others added 2 commits September 8, 2026 08:33
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
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 AutoFix updated a stale base — the fix did not pass verification, but this PR was behind main, so it merged current main in via update-branch and will retry on the next scan. A stale base (a dependency or symbol main already changed) can fail the build without being the fix's fault; if it still fails once current, it hands off to a human.

What I found before stopping:
Autofix agent finished without required output file(s): address-summary.md, no-action.md.

See the Qwen Autofix agent step logs for model/tool output.

中文说明

🤖 AutoFix 更新了一个过期的 base —— 修复未通过验证,但本 PR 落后于 main,因此已通过 update-branch 合入当前 main,并将在下次扫描时重试。过期的 base(main 已改动的依赖或符号)可能让构建失败而并非修复本身的错;若 base 更新后仍然失败,将移交人工处理。

Run log: https://github.com/QwenLM/qwen-code/actions/runs/34192434018


🧠 Handled by Qwen Code · model/模型 kimi-k3 · CLI 0.23.0

wenshao and others added 4 commits September 8, 2026 15:10
- 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)
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🔀 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 将重新运行。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

⏸️ 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 @qwen-code /retry to re-arm a fresh window, or @qwen-code /takeover stop to release takeover. While paused, new feedback and base conflicts stay unhandled.

中文说明

⏸️ AutoFix 已因评审收敛信号暂停:3 轮自动评审连续发出机器可读的不收敛信号(自上次维护者响应以来观察到的信号码;若无响应则自窗口开始:root-cause-triage)——评审仍在以不降的速率发现新缺陷,而循环在继续扩大 diff,再跑一轮自动修复难以收敛本 PR。维护者在本 PR 上作出回应后循环自动恢复(评论或评审均可,并将作为后续轮次的指引);若信号再持续 3 轮会再次暂停。可选做法:把反复出问题的簇拆成独立 PR、把剩余修复攒成一批一次推送、评论 @qwen-code /retry 重开计数窗口、或评论 @qwen-code /takeover stop 释放接管。暂停期间,新反馈与 base 冲突不会被处理。

- 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)
wenshao pushed a commit to wenshao/qwen-code that referenced this pull request Sep 9, 2026
@wenshao

wenshao commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification against a live daemon and a real browser

Maintainer pass at head d1e994f. The PR's own "Not validated" list names two gaps — "a live-daemon curl pass was not run in the authoring environment" and a UI verified only against the mocked daemon. Both are now closed: I rebuilt the daemon from PR source and drove the real Web Shell in real Chromium against it, with every assertion checked back against .git/config on disk with real git.

No mock daemon, no page.route, no fixtures. Chain under test: Chromium → PR web-shell → PR SDK → real HTTP → qwen serve (PR bundle) → real git → real repository.

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

Head / base d1e994f80 / merge-base 1f890086f
Platform Linux 6.12.63, Node 22.22.2, git 2.47.3
Daemon node dist/cli.js serve --port 4188 --workspace <real repo> --no-web, built from PR source
Browser Playwright Chromium 1280×800, dev server proxying QWEN_DAEMON_URL=http://127.0.0.1:4188, token via ?token=

1. The red CI check is a pre-existing main bug, not this PR

Test (ubuntu-latest, Node 22.x) fails with ReferenceError: mockUseDaemonActivePromptBridge is not defined at packages/web-shell/client/App.test.tsx:28940 — a file this PR does not touch. The symbol was introduced by #11250, and main already fixed it in #11406, whose commit message says outright that it "failed the main-branch CI Test job at 70cf363". This branch merged main at 1f890086f, which still carried the break.

Reproduced and A/B'd locally on the two parameterized cases:

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 remote exactly, 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.url stayed 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 main tracking tracked/main, removing it through the UI cleared branch.main.remote, branch.main.merge and refs/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, while git remote -v reports 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 (409 remote_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 (lenient resolveContainedCwd, 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 会拒绝创建这种形态(409 remote_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]

@github-actions

Copy link
Copy Markdown
Contributor

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.
@wenshao

wenshao commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

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 [^.]+ spans lines) — fixed. The named-section guard now excludes the newline from the component class and stops the scan at the = (/^remote\.[^=\n]*\./m), so a host remote.pushDefault — plain, followed by any dotted key, or carrying a dot in its VALUE — no longer reads as a named [remote "<name>"] section, while a dotted section name still matches; a unit test pins all four shapes. The system-scope guard above it stays deliberately broad (/^remote\./m): an inherited remote.pushDefault in /etc/gitconfig makes the inherited-scope assertions non-hermetic too, and its message now says exactly that ("a [remote] section or pushDefault"). On this host the suite runs 37 passed / 0 skipped.

R18-2 (converge arm misses the unmask gate) — fixed, by the snapshot-scoped half rather than a full survivingUpstreamKeys mirror. The converge arm now runs sweptUpstreamResolving (re-verify: any pointed-branch or pushDefault key still resolving TO the removed name refuses) plus unmaskedPointedUpstream (the snapshot-scoped unmask half: a dangling inherited record the sweep just unshadowed on a snapshot-pointed branch, or an unmasked dangling inherited remote.pushDefault, refuses) after its sweeps and before surfacing git's 404. That closes the probe's shape exactly — witness refuses a converged retry whose sweep unshadows a dangling inherited upstream (global branch.main.remote = ghost shadowed by a local foo copy, section destroyed out of band: the removal now refuses 409 and leaves the inherited record untouched), with the doctrine pinned the other way by keeps the no-such-remote doctrine for an inherited upstream key the sweep never touched (an inherited survivor the snapshot never pointed at still answers git's 404, the answer the client's stale-row convergence keys on). A full survivingUpstreamKeys mirror would add nothing on this arm and would break that doctrine: keys whose value EQUALS the removed name are already owned here by sweptUpstreamResolving (pointed branches and pushDefault), an unshadowed key naming the removed name is necessarily snapshot-pointed (its effective value WAS the name), and a shadowed unpointed one is inert under the shadowed-survivor doctrine because its shadow survives the sweep. The certify path keeps the full gate (all-scope surviving-keys half included), which is what owns the mutated case the 404 doctrine must not swallow.

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 remote.<name> section is gone from every scope AND the name no longer resolves at all — the resolver probe (legacy $GIT_DIR/remotes/<name> file, insteadOf alias) plus the local-path probe (a same-named directory repo or bundle file, which git's transport answers with no config record while --get-url never consults the filesystem); 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 (the safe direction) rather than aborting the rollback ahead of the merge arm — the certification gates downstream still fail closed on the same read. The destroyed local survivor copy is then written back (the recreated section lands at EOF, past the include directive); on the exit-0 split-section path the restore runs a second, idempotent time after the worktree-half completion, or the gate would see a half the completion removes. Witnesses: restores a local upstream copy an include-held record naming the removed remote shadowed (the write-back), the two ref-lock witnesses and the legacy no-re-point pair (no re-point while the section stands or a legacy file keeps the name resolving), the directory-repo and bundle no-re-point pairs (no re-point over a live local-path upstream), writes back a destroyed pushDefault over an include-held residue once the name stops resolving and the pushRemote write-back (the push arms), restores the destroyed merge key when the discount gate probe cannot answer (the probe-failure carve-out), the split-section completion write-back, and the kill-suite pin that a scp-like removed name never spawns the path probe. Mutation matrix on the gate: pd-never-discount reddens only the pd write-back witness; pd-always-discount only the pd legacy witness; branch-always-discount the legacy + ref-lock no-re-point witnesses; branch-never-discount only the write-back witness; dropping the local-path leg only the directory-repo + bundle pair; dropping the probe-failure carve-out only the merge-restore witness; dropping the sectionless skip only the kill-suite pin. The merge-pairing condition reads the UNDISCOUNTED presence (remoteNowRaw.length > 0): the merge question is "does a remote record stand?", not "is the residue discountable".

R18-4 (colon-bearing local path fails the unmask gate open) — fixed. isSectionlessUpstream now applies git's own transport rule: scp-like only when the colon precedes the first slash, so /srv/mirrors/app:1 and ../old:sibling fall through to the resolver + path probes and a dangling one refuses; ., URLs, true scp-like spellings and the win32 UNC carve keep skipping the probes. Witness refuses an unmasked dangling colon-after-slash upstream value beside the Windows-spelled one; the local-path and scp-like pushDefault certifications stay green.

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 (remoteNameSkeleton(sanitizeRemoteDisplay(r.name)).toLowerCase()), so main and rn\u200bain land in one group and both rows mark; the polarity arm keeps folding the RAW name (it compares skeleton to raw name). Witness: the three-row sanitized collision case (clean twin stays marked via the invisible-char member flags; aria-label Remove origin (lookalike name)). Mutant: keying the group on the raw fold reddens it while the plain main/rnain case stays green. The class-level suggestion (one exported remoteInkKey helper shared by group key, render lookup and search) is deferred to a follow-up: the three consumers deliberately differ in post-normalization (the search arm adds NFKC + whitespace fold + case forms so a ligature row is findable by the text it inks as), and unifying them would change search behavior beyond this PR's scope.

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: remote-add-url → remote-add-name still restores when the URL input still holds focus at settle, and the back-button restore still fires when the row unmounted (active element body). Witnesses: re-lent-focus skip on both the remove and add paths (search focused mid-flight stays focused), plus the inert-chrome restore case (dialog container focus does not cancel the settle restore).

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 (Ȧ+handakuten and its NFD spelling now fold identically); a longest-decomposed-prefix table lookup covers code points the entrance NFC fused out of a table base plus a mark, and the generator mirrors both rules so the emitted table stays closed under the runtime fold. The remaining known limitation (a confusable spelling augmented by marks of EQUAL combining class can still split, since canonical ordering cannot reorder them) is documented in the module header and deferred as a follow-up; it needs a mark-order-aware fold, not a normalization change.

Local-audit addendum (rounds 11-14). Further defects found by the local audit pair and fixed in this batch: isSectionlessUpstream's drive-letter carve is now 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 — because a drive-letter url whose tail NTFS rejects is routed by git to ssh (host = the drive letter), and probing it would spawn ssh from a config string; on win32 an NTFS-valid drive-letter path is a local transport and reaches the probes, while on POSIX every drive-letter shape is scp-like ssh and the colon rule answers it. The predicate is exported and unit-tested under a stubbed platform (original descriptor restored), since the win32 legs are not executable off-win32; four mutants (trailing-dot magic, segment rules, drive-prefix class, forward-slash UNC) each redden only their unit test. Both design docs enumerate the win32 classification correctly, and the accepted-residuals list is numbered one to four in both languages. Three round-12/13 undirected findings were verified false against the current tree (the sentences they called missing are present in both languages; the line references they cited do not exist) and rejected; their real nits were applied (residual paragraph placement and numbering, comment reflows, inventory completeness).

Deferred follow-ups recorded (not dropped): the remoteInkKey helper unification (R18-5 class), the equal-combining-class skeleton limitation (R18-7), a wire-visible distinct error code for the pre-flight refusals so the client can name them without message matching, and the residuals the local audit recorded rather than fixed under this PR's late-round Critical-only discipline — numbered one to four in both design docs: (1) merge-key re-sweep by the post-certification cleanup on refusals landing after it; (2) the discount gate's section read covering only the invoking worktree's scope chain (sibling config.worktree blind spot); (3) the swallowed granted discount when git's rm leaves the local [branch <b>] section standing with an [include] directive after it; (4) the race-only pre-existing TOCTOU between the union gate and the tail surviving-keys gate — plus the ambient guard's [remote "a=b"] miss, recorded in the cli test comment; the per-pass gate memo (latency-only guard: it can change spawn count but never an outcome, and discriminating it would need a ≥2-pointed-branch spawn-counting harness for zero behavioral gain — recorded in the code comment); and core.protectNTFS = false on win32 (an explicit opt-out under which git reads reserved-name drive tails as local while the predicate calls them sectionless — fail-safe direction, same family as the UNC over-approximation); and the 1024 of 6564 emitted confusables keys that are not NFC fixed points and therefore unreachable under the entrance NFC (dead weight in the client table, behavior-preserving to drop — recorded in the generator). Round 17's last Suggestion is closed by a new kill-suite witness: the discount gate's scope leg sits outside its try so a killed scope read aborts the removal before any destructive cleanup, while a killed probe leg answers no-discount inside the gate; the mutant that moves the scope leg into the try reddens only that witness. The regenerated confusables table's six formerly-unified ink classes (e.g. ņ/n̦) are likewise recorded in the skeleton test comment and both design docs as the accepted side of the canonical-equivalence trade (21 of the 4853 key+mark combinations whose NFC and NFD spellings differ split under the old fold, zero under the new).

@wenshao

wenshao commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Round 3 · Live-daemon + real-browser verification of the R17/R18 batch

Maintainer pass at head b38a9843b. Rounds 1 (d1e994f, Linux / git 2.47) and 2 (08f5009a, macOS / git 2.55) covered the feature end to end, so this round is a delta pass: everything below targets the two fix commits landed since — R17 ad3de520f and R18 b38a9843b — plus the one observation round 2 left open.

Chain under test: Chromium → PR web-shell → PR SDK → real HTTP → qwen serve (built from PR source) → real git 2.47.3 → real repositories. No mock daemon, no page.route, no fixtures; the UI is the daemon's own packages/web-shell/dist (production path), and every assertion is read back from disk with real git.

Verdict: merge-ready once main is merged in. The single red check is a base-freshness gate, not code (§1). 56 live checks pass, 4 mutants each redden only their own witness, round 2's open observation N4 is closed, and the PR's own R18-7 numbers reproduce exactly from an independently built corpus.

Environment

Head / merge-base b38a9843b9 / d313505fdb
Platform 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, from node esbuild.config.js of the PR tree
UI the daemon's own packages/web-shell/dist, Chromium 1280×900 @2x, token via ?token=
Mutation arms the built git-remotes.js and BranchPickerPopover.tsx patched one gate at a time, rebuilt, re-driven; tree restored and re-verified clean afterwards

1. The red check is a base-freshness gate, not this PR's code

Lint & Static fails in 23 s, before any lint runs, at 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)

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 main; no code change. Every other lane at this head is green (Test (ubuntu-latest), web-shell E2E Smoke, Serve A/B, Real daemon E2E, Capture web-shell visuals, the Java matrix, OpenTUI no-flicker, TUI parity), with review-pr still running.

2. R18-3 — the headline: no config-chosen string leaves the box

The discount gate's local-path leg probes ls-remote -- <name>. A scp-like name is a network transport, so the gate must answer it on the string test alone. I measured that with a GIT_SSH_COMMAND recorder rather than a spawn-count assertion — a remote configured as [email protected]:secret.git, an include-held residue naming it, and a real removal driven through the shipped code and through a mutant with the short-circuit deleted:

The mutant does not merely spend a spawn — it connects out (git-upload-pack 'secret.git' to a host taken from .git/config) and then certifies the removal 200 on the answer that probe produced. The shipped code records an empty recorder and refuses. This is the sharpest thing I found in the batch, and it lands on the right side.

3. R18-4 — isSectionlessUpstream mirrors git's own transport routing — 20/20

Ground truth is real git, not the spec: for each value I ran git ls-remote -- <value> under the same recorder and classified the transport it actually chose (ssh spawn / another network attempt / the local filesystem).

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. 0rigin beside origin: round 2 measured neither row marked, because the group key folded case-sensitively. At this head both read (lookalike name).
  • R18-5 holds. main beside rn+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 displayed rnain returns both rows; git remote | grep rnain finds nothing.
  • A purely canonical NFC/NFD pair (café vs cafe+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 main plus 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 switch is byte-unchanged at this head and still samples activeBefore without flushing past the popover's 50 ms open-autofocus timer. Inserting one 80 ms act between 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)

@wenshao

wenshao commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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
ytahdn previously approved these changes Sep 17, 2026

@ytahdn ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.
@wenshao

wenshao commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Disposition for the carried Critical (the removed-name certification resolves fetch-side only): fixed at the head that lands with this comment.

  • The certification's union gate now reads url.*.pushInsteadOf prefixes from the same all-scope dump its section half already takes, so a name kept push-side-live by an alias racing in after the pre-flight read refuses 409 remote_still_configured rather than certifying — at zero extra spawns (the kill suite's spawn accounting is unchanged).
  • The discount gate carries the same leg: an include-held residue equal to a push-live name is not discounted into a silent re-point.
  • Reachability, verified against the current bytes: the steady-state shape (a section plus its alias) is refused by the pre-flight before destruction (pinned by the seven pushInsteadOf pre-flight witnesses), the no-section retry shape is skipped by the converge arm's alias conjunct, and the pair of new kill-suite witnesses pins the racing shape on both gates; each mutant reddens only its own witness.
  • Both design docs state the push-side leg in both languages.

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 (url_is_local_not_ssh, is_valid_win32_path, win32_has_dos_drive_prefix) and fuzzed over 68k strings with 0 unsafe divergences; the confusables measurements (21-of-4853, 1010-of-6564, six classes, line citations) reproduce exactly; and the R18 inline threads' fixes each carry discriminating witnesses with mutation evidence.

@ytahdn ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 -z dump now gates on three legs — a surviving remote.<name> section in any scope, a url.*.pushInsteadOf value that is a prefix of name, OR the fetch-side resolver — and any hit throws remote 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(...) to remoteSectionScopesFromRaw(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 still stripConfigDump + 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 ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

  • remoteStillResolves was fetch-side only (git ls-remote --get-url applies url.*.insteadOf, never url.*.pushInsteadOf). The post-removal re-verify (git-remotes.ts:710-716) now gates on a surviving section in any scope, a url.*.pushInsteadOf prefix of the name, OR the fetch resolver — throwing remote still configured after removal on 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 qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@wenshao

wenshao commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@wenshao

wenshao commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the focused pass. Disposition:

  • The carried push-side certification finding is fixed as you verified; the legs now read url.*.pushInsteadOf prefixes from the same all-scope dump the section legs take, at zero extra spawns (kill-suite spawn accounting unchanged), with a discriminating witness per gate (union refusal, no-discount in the rollback gate) — each mutant reddens only its own witness.
  • The two focused audits you name are being run against this exact revision now: (a) the shared Git error classifier's resistance to config-controlled diagnostic text (git echoes config-chosen refspec values / section names / alias URLs / remote names verbatim in its fatal lines, so every message shape the classifier matches is being enumerated and each asked whether a config value can steer it to a wrong branch), and its effect on the existing non-remotes routes; and (b) the workspace-scoped routes' runtime/trust/containment behavior across every downstream consumer (daemon registration, SDK client, web-shell caller), including a ?cwd= sibling-prefix containment probe. Their results land here; if either surfaces a defect it gets fixed per the same witness discipline, not argued about.
  • The historical blocking-finding reconciliation against this exact revision is in flight alongside them.

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.

@wenshao
wenshao added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit 6c962b3 Sep 17, 2026
131 of 151 checks passed
@wenshao

wenshao commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

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 path.relative-based check, so a sibling sharing the workspace prefix (repo vs repo-evil), a .. escape, a duplicated ?cwd=, an empty one, and a symlink inside the workspace pointing out all answer 400 invalid_cwd on add/remove and fall back to the workspace root on GET (200 with only the root's remotes listed). Gate order is fail-closed per stage (strict POST gating before the handler, trust → generation → cwd → pre-spawn name/url validation, NUL rejected pre-spawn on both add and remove). Runtime env threads through gitEnv, which scrubs GIT_DIR/GIT_WORK_TREE/GIT_CONFIG_*/GIT_CONFIG_KEY_* and strips ext/fd from GIT_ALLOW_PROTOCOL — a probe runtime env carrying GIT_DIR=<sibling>/.git plus a GIT_CONFIG_KEY_0=remote.origin.url rewrite still listed only the contained repo. Path redaction covers the named host classes (workspace cwd, git root, linked-worktree/submodule gitdirs, $HOME/.gitconfig in both spellings, XDG with the empty-value fallback, /etc/gitconfig token prefixes, include targets via the in file tail, plus the fail-closed absolute-path sweep and the 512-char cap), with tests asserting the body never contains the path. Consumers are exactly three: SDK DaemonClient (449/449 green incl. per-call timeout abort races), web-shell BranchPickerPopover (mutationMeanStaleList covers the new codes with silent re-read and requestId fencing), and the e2e mockDaemon mirroring the 404/409 shapes. Suites: routes 37/37, SDK 449/449, popover 139/139.

Classifier (resistance to config-controlled diagnostic text + effect on non-remotes routes): 2 Suggestions, both fixed; no Critical. Anchoring is ^ without /m and config echoes land after git's literal prefix or on deeper lines: a real newline-bearing refspec value still classifies remote_config_unparsable (the injected line-2 prefix is invisible to every anchored arm), and mutant runs confirm the anchoring is load-bearing (deleting the No such remote arm reclassifies a keyword-named remote; adding /m flips the newline rows). Every dump-read catch in core strips the dump, and the remote verbs' failures carry empty stdout in all 16 probed shapes, so a line-1 forgery needs config-controlled stdout on a failing spawn — not constructible. Keyword-bearing names classify correctly (No such remote: 'remote still configured after removal' → 404). The two findings, both now pinned: (1) the invalid-refspec arm's 409 remote_config_unparsable now answers on the pre-existing pull route as well (was 500) — pinned by a new pull-route witness (corrupted fetch refspec → 409), so a future remotes-only narrowing cannot silently change the pull contract; (2) the removal rollback's own --local --add write reports a config lock single-line, which matched neither lock arm and degraded to an unclassified 500 — the single-line lock arm now answers 409 git_config_write_failed, pinned in the classification table. Two pre-existing notes the audit enumerated (loose keyword arms matching config-controlled URL text on pull/push, and config-parse errors echoing config values carrying loose keywords) are byte-identical to main and stay as recorded observations — clients ignore those codes on the affected routes (footer-only), and the bounded-slice discipline bounds the exposure.

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.

qwen-code-dev-bot added a commit to water-in-stone/qwen-code that referenced this pull request Sep 18, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants