Skip to content

Commit 6c962b3

Browse files
feat(web-shell): manage git remotes from the workspace branch picker (#11163)
* feat(web-shell): manage git remotes from the workspace branch picker 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 * fix(web-shell): harden git remotes management after review audits 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 * fix(web-shell): close remotes edge cases from the second review round 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 * fix(core): harden git remote removal verification and upstream cleanup - 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 * fix(web-shell,cli): close remotes edge cases from the fourth review round - 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) * fix(core,web-shell): close remotes edge cases from the R4 review batch - 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) * fix(core,cli,web-shell): close remotes edge cases from the R5/R6 review batch - core: refuse include-held/split remote sections BEFORE spawning `git remote remove` (git rm deletes the tracking refs and upstream keys before failing on a section it cannot write — every retry destroyed again); record origins are resolved against the worktree toplevel (git chdirs) with both literal and realpath spellings, and a killed probe refuses instead of answering blind - core: the upstream-key sweep now covers local scope too and matches multi-valued keys by exact value (`--fixed-value`), since git's rm skips multi-valued keys with a warning; the re-verification pins only value-matched keys (an include-held inert merge key must not refuse a completed removal) - core: the survivor gate gains the unmask case — a pointed branch or `remote.pushDefault` whose shadowing record the removal unset must not fall back to an inherited record naming a remote with no section (the pre-removal snapshot proves causation), while git's sectionless forms (`.`, URLs, scp-like, local paths) and pre-existing dangling values never refuse - core: promisor values are resolved by the host git itself (`--type=bool --get-all`, additive like git's registration) instead of an in-module bool grammar whose integer bound tracks a moving git target; a promisor record at any scope marks the badge, and a killed badge read rethrows stripped instead of answering false - core: Apple Git's runtime-prefix defaults file reports scope `unknown`; the record walk recognizes it so the verification folds stay sound (the listing and sweep still filter to repository scopes) - cli: redaction covers the inherited config files git echoes by absolute path ($HOME/.gitconfig, the XDG config — including empty-XDG, no-HOME and trailing-slash verbatim-concatenation spellings — and the build-time system gitconfig), NUL-truncated gitdir AND commondir pointer targets, head-truncated targets keyed on the NUL-normalized form, and the bad-config-line / unable-to-access payload shapes at arbitrary include.path locations (transport URLs are not config targets and stay verbatim) - web-shell: the popover installs the house preserveImeEscape mask while open, so a composition-cancelling Escape no longer tears down the remotes view nor swallows the native IME cancel (the popover mock now delivers Escape from document-capture like Radix's DismissableLayer) - e2e: remotes spec selects on data-testid instead of hashed CSS-Modules class substrings - kill suite: the mock chains pin exact call counts (a retargeted kill read can no longer pass silently) * fix(core,web-shell): close the slash-namespace and render-identity gaps - core: isRemovableRemoteName now refuses names containing `/` — a slashed remote's tracking namespace is a subdirectory of the prefix remote's (refs/remotes/origin/staging/* lives inside refs/remotes/origin/*), so no post-removal sweep can tell the remote's own refs from the prefix remote's branch refs, and a never-configured slashed name would 404 only after the converge-arm sweep destroyed them; the panel answers 400 before anything runs and the terminal remains the tool for the shape - web-shell: the remotes row identity check gains the two structural arms the strip list cannot enumerate — a name that is not NFC (a canonical twin inks identically) and a Latin name mixing in another script's letters (the Cyrillic-о homoglyph shape) — both marking the unusual row with its distinguishing characters escaped in the tooltip and aria-label, while a legitimate non-ASCII name stays unmarked - docs: the design record's absolute render-identity claim and the remove-route leniency claim are narrowed to the shipped mechanisms * test(core): pin worktree-over-local upstream attribution on removal * fix(cli,web-shell): bound the loose classifier keywords and keep git's sentence boundary - cli: the legacy loose keyword branches (dirty / already exists / no upstream / …) match the CAPPED 512-char slice again — an unbounded scan reclassified long push/pull output whenever a path or URL past the cap carried a keyword (a `dirty-cache.git` push rejection answered 409 dirty_working_tree); the anchored remote branches keep the full detail for the two-line lock chain - web-shell: git error text in the single-line footer now collapses whitespace before stripping the invisible class — a stripped newline fused git's two-line lock chain into one unreadable sentence * test(cli): allow the redaction's process-scoped HOME/XDG_CONFIG_HOME reads * fix(core,cli,web-shell): close the R9-batch removal integrity gaps - core: the upstream-key sweep now reaches a multi-valued key's NON-effective entry naming the removed remote (residue whose later surfacing would dangle), and the merge key is swept only when the same scope's remote key holds no surviving entry — a branch that keeps its upstream keeps its merge - core: the tracking-refs sweep resolves ownership by longest-prefix against the configured remote set (a configured slashed sibling's namespace is no longer taken down with the prefix remote) and covers the exact bare ref refs/remotes/<name> - core: removal certification asks git's own resolver (`ls-remote --get-url`, documented not to contact the remote), so a legacy $GIT_DIR/remotes/<name> file or an insteadOf alias can no longer keep a "removed" name fetchable - core: the sweep now walks every linked worktree's config.worktree (the invoking worktree's reads never see siblings' files), lazily, fail-closed per worktree - core: the pre-removal snapshot carries per-branch local value lists, and a local [branch] section git rm destroyed while its shadowed copy named a SURVIVING remote is restored — a worktree-shadowed fallback upstream is no longer collateral damage - cli: the unable-to-access redaction arm accepts drive-letter absolute paths (the Windows lane runs the redaction suite) - web-shell: a remote URL carrying non-ASCII bytes fails closed — URLs are an ASCII-only surface, so the row marks and the tooltip spells the homoglyph out * fix(core,cli,web-shell): close the R10-batch removal and confusables gaps - converge arm: ask git's own transport for the bare-word path leg (bundles resolve; --resolve-git-dir called them too large), skip the sweep for empty pushInsteadOf aliases, re-verify the ref sweep, and carve win32 backslash spellings as sectionless values - unmask gate: push-side arms count pushurl records and pushInsteadOf aliases; the path leg answers through git's transport - pre-flight: inherited refusal only when a repository half exists to destroy; union gate and worktree completion keep their race backstops (kill-suite witnesses) - redaction: linear /etc/gitconfig token arm, open-family in-file arm, fail-closed absolute-path sweep with the apostrophe-tail fragment drop - confusables: table-first fold with NFC closure at generation time, symmetric all-ASCII collision marking, skeleton as a search target, U+2028/2029 escaped emission, self-map and empty-alias guards - design docs: bilingual zh-CN twin plus the round's gloss corrections * ci: run the platform lanes for the git remotes path handling The win32 path-spelling carve and the local path-transport probes behave differently per host and no POSIX-only lane can tell whether they work on Windows; classify the two git-remotes files platform-sensitive (exact paths, not a keyword) so both lanes see future changes to them. * test(core): write the slashed sibling section instead of remote-adding it git newer than 2.50 refuses `git remote add a/b` over an existing `a` ("remote name 'a/b' is a subset of existing remote 'a'"), which killed the macOS and Windows lanes in fixture setup. A config-written section is git-version independent and models the hand-edited config the lax removal predicate exists for. * Revert "ci: run the platform lanes for the git remotes path handling" main retired the platform-lane pull_request trigger and froze classify-platform-sensitivity.mjs untouched so the future restoration revert stays clean (ci.yml comment). The GIT_PATH_HANDLING entries extended a script no CI path invokes — the lanes actually ran through workflow_dispatch event gating — and broke that freeze. * fix(core,cli,web-shell,docs): close the R11 confusables split and deferred batch - web-shell: the generator closed prototypes under NFC + table only, but the runtime fold also NFKC-folds table-absent halves, so 29 emitted values split one ink-identical class across two skeletons (R11-2). Close every value under the exact consumer fold map-wide, delete self-maps inside the convergence loop so the fixed point is computed against the final map, post-verify the invariant, and regenerate (29 values, witness: U+1D52 vs U+00BA). - core: escape the NUL separator in pushKey (a raw NUL byte made grep and ripgrep treat the module as binary); rename the misnomer repository-scope describe. - cli: readHead opens workspace-controlled paths synchronously on the shared error path; a FIFO commondir wedged the daemon event loop. Guard on isFile (FIFO witness, POSIX-gated). - web-shell test: replace the vacuous cross-workspace focus assertion with an equality on the pre-settle active element. - docs (en + zh-CN): consumer-fold closure clause, ls-remote probe spelling, orphaned bullet fragment repaired, §6 witness rows. * test(core): retry the tmp-dir teardown on Windows EBUSY The windows lane lost an otherwise-passing not-a-repo test to a transient EBUSY on the afterEach rmdir (a handle held by the indexer or an exiting git child). Retry the teardown a few times instead of failing a test whose assertions already passed; non-EBUSY errors and exhausted retries still throw. * docs(web-shell): state the consumer-fold example's mechanism exactly The `º/₀` halves NFKC to `o/0`; the capital O comes from the per-half table chance (0030 -> 004F), not from NFKC. The result string was right, the stated mechanism was not — anyone re-predicting a fold whose NFKC half is itself a table key would mis-derive it. Also name the tooltip-escape consumer at the delete arm and print the code point (not the raw char) in the fixed-point diagnostic. * fix(core,web-shell,docs): close the R13 Criticals and their audit fallout - R4-3: widen EXECUTING_HELPER_URL to admit git's empty-name helper form (`::payload` execs a PATH-resolved `git-remote-`); rationale corrected — the empty name is not on git's deny-by-default list, and a protocol.allow policy that would cover it is overridable from config files. Witness rows `::sh -c id`/`::0`/`::`; mutation of the pattern turns them red while the IPv6 literals stay accepted. - R12-1: restore the local upstream backups at the TOP of the removal-failure path (above the !completed throw and the converge classification), so a refusal following an already-destructive rm cannot skip the rollback; a killed restore read rethrows instead of letting git's original answer surface un-rolled-back (kill witness pins the restore read's argv, separating it from the converge gate's scope read). - R13-2: the converge arm excludes the removed name's own namespace-less top-level bare ref from sweep AND re-verify while keeping the longest-prefix ownership resolution whole — a bare ref exactly owned by a configured slashed sibling keeps that owner (first attempt at this fix regressed exactly that; witnesses: remote-HEAD symref, flat layout, slashed sibling bare ref). Accepted residuals disclosed: a formerly-configured name's bare-ref residue is orphaned, and a flat layout's slashed branch refs stay sweepable. - R13-3: mark BOTH rows when a collision group's skeleton is non-ASCII and the variance is a table fold rather than canonical equivalence (skeletonAscii/allCanonical on the group); witness öö × ةة; the all-ASCII and pure-canonical polarities keep their pinned behavior. - Audit fallout: the TR39 fold moves to client/utils/remote-name-skeleton.ts beside its table integrity gate (no self-maps, no value containing a table key, key folds to value, value is a fold fixed point, NFC); the non-discriminating lowercase-spoof completion test is removed (the exact-prefix test carries the veto witness); design docs synced EN+ZH for the third marking polarity, the error-path restore and its fail-closed masking, the converge residuals, and the new test rows. * fix(core,cli,web-shell,docs): close the R14 Criticals and their audit fallout - R14-2: the TR39 fold iterates to a fixed point (capped) instead of running one pass — the closing NFC can compose a table key and a decomposed spelling only meets the table after composition; new remote-name-skeleton.ts carries the fold beside its integrity gate and fixed-point corner tests. - R14-3: redaction gains a quoted-path arm (git's die(_('%s' …)) family owns a quoted space-bearing payload whole) plus a slash- carrying fragment arm for apostrophe-bearing paths. - R14-4: tracking-ref ownership skips refs inside a SURVIVING remote's fetch-dest namespace (NUL-framed get-regexp read), on sweep and re-verify alike; closes the flat-dest slashed-branch residual. - R14-5: the restore's presence read is NUL-framed, so an empty value reads as present instead of absent (no more doubled pushDefault). - R14-6: isSectionlessUpstream narrows to `.`/colon-bearing values; dangling slashed/backslash upstreams now refuse like their bare-word twin; win32 UNC keeps a carve (network transport, offline-share timeout); existing paths still certify through the path probe. - R14-7: the post-refusal branch/status refresh runs in the same synchronous block as the staleness guard, ahead of the awaited re-read, so a mid-await workspace switch cannot seed the new workspace's panel with the old closure's data. - Audit fallout: stale comments corrected (sectionless shapes, flat dests, probe classes), win32 UNC carve, NUL-framed dest read without the speculative JSON.parse arm, prettier-clean test files, and EN/ZH design-doc sync for all six changes plus matrix/Files-affected attribution of the new skeleton module and its tests. * fix(core,web-shell,docs): close the R15 Criticals and their audit fallout - R15-1: foreign-namespace roots derived per wildcard position; mid- wildcard dests (refs/remotes/*/main, or*/main) fail closed. The reverse audit caught an unsatisfiable star-position conjunction that disabled the orphan sweep in any repo with another default-refspec remote; fixed and pinned by an origin + refspec-less orphan witness. - R15-2: the sibling-worktree sweep verifies the sibling shares this repository's common dir before any read or write, so a planted .git/worktrees gitdir can no longer receive its unset writes; planted-gitdir witness, real-sibling tests unchanged. - R15-3: the remotes search folds the needle in three case forms (the TR39 table is case-sensitive); Istanbul/lstanbul witness. - R15-4: the fold is invariant under canonical equivalence — two-level decomposition fallback at runtime, generator closure over NFD(key), table regenerated (6564 entries, U+021A dropped, 27 values re-classed, lunate sigma preserved); full invariance sweep pinned. - Deferred-triage fixes: hermeticity precondition regex, win32 UNC short-circuit witness, worktree-completion kill witness, dash-URL client-guard witness, NFKC-fallback pin, timeout-ceiling and mixed-scripts comment accuracy, zh predicate-bullet and UNC doc parity, pushl marker-comment accuracy. * refactor(core,web-shell,docs): land the R15 reverse-audit follow-ups - The sibling sweep skips the invoking worktree BEFORE the ownership probes, so a repo whose only worktree record is its own pays no probe spawn (just the one lazy toplevel read, reused per sibling). - The killed-sibling witness is re-aimed at the new spawn order and pins the kill to the sibling's config.worktree read; under the old queue the kill landed on an ownership probe while the test passed. - The remotes search dedupes its case-folded needles and folds each row's ink once instead of once per needle variant. - EN design doc: two stranded fragments repaired; ZH counterpart gets the missing subject in the marker sentence (parity). * test(web-shell): route the remotes e2e through the post-#11700 picker entry Main's context-overview rework removed the sidebar git pill and the welcome-view branch chip this spec opened the picker through; the merge-head smoke run failed waiting for a pill that no longer renders. The spec now enters the scenario session and opens the picker from the environment panel's branch row — the same route the branch-picker visual spec uses — keeping every assertion unchanged. * build: raise the tsc build heap ceiling to 4096 MiB A fresh `tsc --build` of packages/cli on the merged tree peaks just above the 3072 MiB ceiling: two consecutive CI Lint runs and a local clean-tsbuildinfo build all OOM at ~3.0/3.1 GiB, while each parent tree alone stays under the ceiling and incremental builds never showed it. Workspace build children inherit NODE_OPTIONS from the root build script, so raising it there is what fits the union program; the test ceilings are untouched. * test(web-shell): pin the remotes e2e to the scenario branch and armed state - openBranchPicker derived its click target from a hardcoded branch name while accepting scenario overrides; it now reads the scenario branch, pinned by the search test whose fixture switches to feat/x (RED without the derivation). - The smoke test asserts the armed Confirm label between the two remove clicks, so a single-click regression fails at the witness instead of as a second-click timeout; pinned by a temporary single-click mutation that turns the assertion RED. * fix(cli): move the git routes off the core barrel per the #11798 rule Main's new lint rule bans new cli value-imports from the core package root; the remotes and branches serve routes imported their symbols from the barrel. Point them at the defining core modules (utils/git-remotes, utils/git-branches, utils/gitDirect, utils/gitUtils) and drop the allowlist entry the branches route no longer needs, per the list's shrink-only contract. * fix(core,cli,web-shell,docs): address R16 review and convergence-audit findings - Refuse removal up front when a repository-scope section's name stays push-resolvable through a url.*.pushInsteadOf prefix: a post-destruction refusal wedges the upstream keys the certify sweep can no longer reach (the retry's 404 converge arm skips over the same alias. Six shape witnesses: local-section, strict-prefix, inherited-only 404 doctrine, worktree scope, and the included- and inherited-refusal ordering ahead of the alias refusal. - Convert the truncated-gitdir redaction from an unbounded \S* regex to a linear per-token replace with identical semantics. - Picker collision groups key on the casefolded skeleton; a per-pair case-twin arm marks case-only pairs at any script (a non-ASCII third member no longer disarms an ASCII pair); U+FFFC/U+FFFD placeholder names mark with the (lookalike name) copy; every arm and polarity carries a mutation-verified witness. - Drop the core barrel re-export of utils/git-remotes.js: its only consumer deep-imports the module per the #11798 rule. - Sync EN+ZH design docs to the four structural arms, arms 1-4 witness attribution, the six pre-flight witnesses, and the documented fetch-side wedge as a known house limitation. HEREDOC ) * fix(cli,core,docs): address R17 verification findings and convergence-audit findings - M6/M8 (four-time survivors): the bounded-slice test now pins the not-a-repo keyword family past the 512 cap (deep-cap probes for `not a git repository` and `invalid reference`), the `invalid reference` alternation half gains its positive table row, and a new table test pins the line-1 anchor of the remote-already-exists arm (a line-2 occurrence falls to the loose keyword arm). Each pin is mutation-verified: its mutant reddens exactly that test while every sibling stays green. - The inherited-scope-alias wedge (a global/system/unknown pushInsteadOf alias prefix-matching a listed repository-scope section) gains its repo-suite witness, and the pre-flight comment plus both design docs name it, including that the 409 names no alias at any scope so the UI never shows the cause (fail-closed, terminal is the only escape). - Verification-bot dispositions: the wire-visible distinct error code for the pre-flight refusal is deferred as a follow-up; the leaked HEREDOC commit-message trailer is declined (fixing it would require rewriting published history). * fix(remotes): close R18 review findings and local audit findings across 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. * fix(remotes): certify removals push-side as well as fetch-side 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. --------- Co-authored-by: qwen-code-dev-bot <[email protected]>
1 parent 3c774d1 commit 6c962b3

33 files changed

Lines changed: 23242 additions & 298 deletions

‎docs/design/git-manage-remotes.md‎

Lines changed: 1004 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/design/git-manage-remotes.zh-CN.md‎

Lines changed: 182 additions & 0 deletions
Large diffs are not rendered by default.

‎eslint.config.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ export default tseslint.config(
5959
'node_modules/*',
6060
'packages/**/dist/**',
6161
'packages/web-templates/src/generated/**',
62+
// Generated UTS #39 confusables table (6.5k-entry literal): the
63+
// type-aware rules OOM the eslint heap on it, and generated data
64+
// has no idiom to enforce. Regenerate via
65+
// packages/web-shell/scripts/generate-confusables.mjs.
66+
'packages/web-shell/client/utils/unicodeConfusables.ts',
6267
'integrations/**/dist/**',
6368
'bundle/**',
6469
'package/bundle/**',

‎eslint.legacy-core-barrel-imports.mjs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,6 @@ export const legacyCoreBarrelImports = [
152152
'packages/cli/src/serve/routes/workspace-auth.ts',
153153
'packages/cli/src/serve/routes/workspace-extensions-controller.ts',
154154
'packages/cli/src/serve/routes/workspace-extensions.ts',
155-
'packages/cli/src/serve/routes/workspace-git-branches.ts',
156155
'packages/cli/src/serve/routes/workspace-git-diff.ts',
157156
'packages/cli/src/serve/routes/workspace-git-log.ts',
158157
'packages/cli/src/serve/routes/workspace-git.ts',

‎packages/cli/src/serve/process-env-guard.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,15 @@ const allowedProcessEnvAccesses = normalizeAllowances([
289289
},
290290
},
291291
],
292+
[
293+
'packages/cli/src/serve/routes/workspace-git-branches.ts',
294+
{
295+
reason:
296+
"The git error redaction mirrors the daemon process's own HOME/" +
297+
'XDG_CONFIG_HOME to label the inherited config paths git echoes.',
298+
accesses: { 'key:HOME': 1, 'key:XDG_CONFIG_HOME': 1 },
299+
},
300+
],
292301
[
293302
'packages/cli/src/serve/server/fs-factory.ts',
294303
{

0 commit comments

Comments
 (0)