Imported from theBGuy/GitDesktop (
.claude/skills/gd-conventions/SKILL.md). Install upstream withnpx skills add theBGuy/GitDesktop --skill gd-conventions. Copyright stays with the author.
GitDesktop conventions & gotchas
You are working in the user's real repository. CLAUDE.md covers the project brief and docs-sync; this file adds what past sessions learned the hard way. Where this file and generic best practice disagree, this file wins.
Hard rules (violations have destroyed user state before)
- Git is a whitelist. Permitted:
git --no-pager diff / status / log / showandgit branch --list, each optionally prefixed with-C <path>to address a task worktree. Everything else — commit, add/stage, checkout, reset, stash, rm, clean, push, pull, fetch, merge, rebase, tag, branch create/delete, worktree, remote, config — is forbidden, even "just to test". The user commits their own work, possibly in parallel with your session; a past subagent's stray commit wiped.gitignoreand broke the app. - No stray files. Create only what your task calls for. Scratch files go
in the session scratchpad or
C:/temp, never the repo; destructive experiments happen in a throwaway repo underC:/temp. (One exception, for runs that may create files: the LF-copy gate's__cigate__<name>sibling, deleted after the check — reviewers never create it: the LF copy is a file.) - Don't edit
src/components/ui/— vendored shadcn/Base UI primitives. Fix at the feature/call-site level. That folder'sREADME.mdinventories the sanctioned local modifications (a re-vendor silently reverts them); any future sanctioned edit updates it in the same change. - Never repo-wide
cargo fmtinsrc-tauri(~35 files of collateral). New files only:rustfmt <that file>— and never on amod.rs: rustfmt follows itsmoddeclarations into every child file (measured: 6-file collateral). Verify an edit's formatting withrustfmt --check --config skip_children=trueand compare flagged hunks to your lines. - Report from evidence. Verification claims come from command output in this session, never memory. Quote failures verbatim.
Verification commands (allowlisted — run, don't ask)
pnpm build # tsc + bundle — the frontend gate
pnpm exec tsc -b --noEmit # typecheck alone — plain `tsc --noEmit` is a NO-OP here (project refs); only -b checks
pnpm exec biome check ./src/ # lint/format CHECK (no mutation)
cargo test --manifest-path src-tauri/Cargo.toml
cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
cd site && pnpm build # when the marketing site changed
pnpm run checks # guard scripts + self-tests — CI quality.yml guards job
Frontend changes run the first pair; Rust changes add the cargo pair. Not "done" until green.
⚠ pnpm lint is a rewrite, not a check — it runs biome check --write ./src/ ./site/, mutating every file under src/ AND site/ including
the user's parallel work. Implementers fix only their own files via the
targeted pnpm exec biome check --write <files in scope>; reviewers never
run any --write form.
⚠ biome check false-fails on CRLF (this Windows worktree). autocrlf checks
files out CRLF, and biome check (formatter included) flags a CR on unedited
files as an error — a red result that isn't yours. The one trustworthy local
gate is the CI form over LF-normalized copies: copy each edited file to a
__cigate__<name> sibling (CRLF→LF), run pnpm exec biome ci on the copies,
delete them after. biome lint is NOT a gate — it skips the formatter and
assist layers biome ci enforces (a measured false clean, three CI reds).
Never "fix" a red by converting line endings.
Frontend conventions
React best practices. Before writing or refactoring any React component or
hook, invoke the vercel-react-best-practices skill (Vercel's 70-rule
performance + correctness playbook) and apply it as you write — it does not
auto-load, so pull it in yourself; don't defer it to an after-the-fact review pass.
Design tokens. Mono + dark + one mint accent; semantic state tokens in
src/App.css: --success, --warning, --info, --merged; danger is
--destructive. No hardcoded green/amber/red classes in new code (a few
intentional pre-existing hardcodes exist, e.g. file-list status colors —
leave them, don't re-flag them). Never convey meaning by color alone (WCAG AA).
Keyboard-first. Every new selectable list gets arrow-key navigation in
the same change (listKeyboardNav in src/lib/list-keyboard-nav.ts) — an
invariant, not polish. A list container that co-hosts a text editor (inline
edit field, reply box) passes ignoreTextEntry so arrows keep moving the
caret; leave it off where arrows deliberately drive nav from a filter input.
Destructive paths stay behind confirmation via the
shared useConfirm/ConfirmDialogHost primitive (src/lib/stores/confirm.ts,
host in src/components/confirm-dialog-host.tsx) — never a bespoke confirm
dialog. Commit-level destructive prompts (checkout, revert, cherry-pick, undo)
share their wording through src/features/history/commit-confirms.ts: a new
route to one of those ops imports the existing prompt, never re-spells it.
Command palette. Any new tab/surface/action needs an ACTIONS entry in
src/lib/hotkeys/registry.ts + useHotkeyAction wiring in the same change
(defaultBinding: null = palette-only). Missed twice before. Labels use the
words the user reads on screen. Action-text search lives in
src/lib/hotkeys/search.ts (queryTokens + matchesActionText), shared by the
palette and Settings → Keyboard so a query can't hit in one and miss in the
other — a new surface searching ACTIONS imports it rather than re-spelling the
match. It AND-s the query's whitespace-separated tokens over label + category
with hyphens stripped from both sides, so word order, gaps, and hyphenation cost
nothing ("cancel pipeline" finds "Cancel workflow run/pipeline", "rerun" finds
"Re-run…", and #255's "ai excluded" against a tab labeled "AI excluded" would
match today). Each token still has to be a literal substring of what remains, so
a label built from different words than the surface shows stays unfindable.
Keyboard's binding arms deliberately stay literal — key text means its
separators.
Mod-key display. Shortcut hints render via isMac / formatBinding from
@/lib/hotkeys/binding — never a literal ⌘ or "Ctrl+"; only labels branch.
Key-dispatch guards are shared, never re-derived: hasModifier,
isTypeaheadTarget, isTypeaheadKey, and isEditableTarget / firesInEditable
all live in src/lib/hotkeys/binding.ts. Any path that compares
eventToBinding(e) against a user binding and then preventDefaults applies the
same three-predicate clause the global listener does — isEditableTarget,
isTypeaheadTarget, isTypeaheadKey — because bindings are rebindable down
to a single key, a dispatcher missing any of them either steals keystrokes from
a text field or typeahead list, or withholds a named key that should still fire.
Inner-clause drift between two dispatchers is the regression this prevents; the
unguarded-binding-dispatcher guard in pnpm run checks is the ratchet.
Patterns the user has ruled on:
- No hover-revealed per-row buttons — contextual actions are always-visible, or live in keyboard/context-menu/toolbar.
- Truncated user/repo content gets a
titletooltip, added only-when-actually-clipped viaclipTitle/clipTitleFromText(src/lib/clip-title.ts) — never inline: blanking withtitle=""suppresses a titled ancestor's tooltip, and theinline-clip-titleguard inpnpm run checksfails on rewrites. Base UI Select popup rows route throughSelectClipText(src/components/select-clip-text.tsx), which must be the row's SOLE child. A bare truncate child never engages there and an item-level handler is dead once the row span self-bounds (theselect-item-clip-titleguard fails on both); the closed field takesonMouseEnter={clipTitleFromText}on itsSelectValue. - File/directory paths middle-truncate through
PathText(src/components/path-text.tsx) — head context and the filename both survive. It owns its only-when-clipped tooltip (allowlisted ininline-clip-title: the outer span it titles never overflows itself). A new path display hand-rollingtruncatere-mints the inconsistency this fixed. Label composites (Saved to <PathText/>) space viagap-*on the flex row, never a trailing space in the label — flex line boxes trim it. Non-flex-item text is exempt: the absolutely-positionedsr-onlybadge spans keep their trailing announcement space. - A
SelectControl/SelectFielditemsRecord silently reorders integer-like keys to the front (JS object semantics) — any picker whose option values are user-supplied identifiers that can be all-digits (logins, slugs) must pass theorderprop with the sequence it means (exactly the keys ofitems). - Per-file
+added -deletedcounts render throughDiffStat(src/components/diff-stat.tsx) — plain numbers in, optionalisBinary(a mutedbin) andformat(Insights abbreviates viafmt); never hand-rolled, and thehand-rolled-diff-statguard inpnpm run checksfails on a new pair. - Icon-only copy buttons are
CopyIconButton(src/components/CopyIconButton.tsx), never a raw button aroundCopyIcon; it always stops click propagation, and theraw-copy-icon-buttonguard inpnpm run checksfails on a new one. - Disabled actions explain why via
DisabledReasonButton(src/components/disabled-reason-button.tsx) — reason as tooltip + AT announcement; menu/popover trigger sites take the render arm withdisabledon the BUTTON, never the Trigger — a disabled Trigger leaves the tab order, so its reason would reach hover only. The exact composition to copy is<Trigger render={<DisabledReasonButton disabled reason/>}>; the full contract sits in the primitive's doc comment. A disabled submit may instead explain via the field'swarninghint. Raw-<button>sites the vendored Button can't size (reaction chips, the discussion upvote chip) take the SAME contract from the shareduseDisabledReasonhook +ARIA_DISABLED_CLASS(src/lib/use-disabled-reason.ts) — never hand-rolled. A vendored Button the render composition can't reach because a form component wraps it (form.SubmitButton, which forwards props but isn't itself a Trigger) takes the same hook directly:focusableWhenDisabled/aria-describedby/wrapperTitlepassed straight through, plus its own sr-only reason span alongside (src/features/welcome/CloneRepoDialog.tsxis the reference). - A status badge or chip whose explanation isn't visible text is a
StatusDetailChip(src/components/status-detail-chip.tsx) — a click popover keyboard, touch, and AT all reach, and a plain Badge when there's no detail. Atitlealone is mouse-only; thetitled-badgeguard bans it on Badge. - A conversation surface's own actions sit before the submit button via
CommentComposer'sleadingActionsslot when submit is the row's last action (the issue views); a surface whose right-slot action is itself a primary (Approve / Review… on the PR views) keeps it inactions, which renders after submit — the caller owns the row layout up to that boundary, spacers included. - The AI-generate chord in a dialog goes through
useGenerateChord(src/lib/hotkeys/useGenerateChord.ts) — never a hand-rolled handler. Its invariants: effective-binding read (null = fully inert),eventToBindingmatch,preventDefaultbeforeenabledon any surface WITH a generator, run only under the visible button's gate, swallow-don't-cancel while generating, handler mounted onDialogContent(the X close is a form sibling). The hook returns thehintstring so buttons can't forget it. Recorded exception: surfaces with several per-row generators and no focused-row concept (Edit history's reword buttons) carry no chord. - A generator dialog that stays mounted across its own close rides
useFinishAndSurface(src/features/conversations/useAiStream.ts) — closing never cancels the run; a settle while closed latches skip-seed (the reopen shows the whole draft) and toasts it with a "View" reopen. Thegenerator-dialog-finish-and-surfaceguard inpnpm run checksis the ratchet; the deliberate abort-on-close surfaces (the PR edit dialogs, branch / rename / task dialogs) are exempt by hook choice or by having nouseSeedOnOpen. A generation belongs to the repo it started in: the hook takesrepoPathplus the dialog's owncancelandclose, and a switch aborts the run and closes an open dialog —<RepositoryView>is a single instance across repo switches, so dialog state outlives the repo. A settled-unseen latch is STAMPED with its run's repo and survives navigation; the first open releases it, this repo's showing the draft and a foreign one destroying latch and toast with the form its seed resets, while the toast's View re-checks the live repo and names the origin on mismatch. Two contracts the types can't enforce: the seed guard issurface.shouldSkipSeed(generating), never a caller-ownedgenerating ||arm (a cancelled run keeps that flag true through its context fetch;consumeSkipSeedis only for a caller discarding the waiting draft on purpose — an identity axis that moved on, or an explicit draft request that outranks it), and the repo-description store delivers only while the dialog reports General active, stashing otherwise. - Worktree actions gate on in-flight removal/promote state: menu items disable
with the parenthetical reason riding the label (a disabled menu item can't
carry a tooltip), and mutation choke points re-check at fire time —
useIsRemovingWorktree/useWorktreeRemovalsfor render,refuseWhileLeaving(WorktreesDialog.tsx) plusisWorktreePromoting(worktree-removal store; fire-time only, deliberately non-reactive) for the refusal toast. - Per-variant copy/labels/glyphs are
Recordlookups, never ternary chains: an exact union discriminant gets a totalRecord(compiler exhaustiveness); a wide wire string getsPartial<Record>+ explicit fallback; genuinely mixed-predicate chains stay ternaries (precedents:PICKER_COPY,OP_LABELS,KIND_GLYPH). - API-impossible features get an explicit "… on GitHub/GitLab" link item, not a silent gap.
- Never degrade a surface to dodge machinery: no plain
<pre>where the app highlights, no spinner where skeletons exist. - Caught errors surface via
toastError(src/lib/toast.ts), orerrorMessage(src/lib/tauri/invoke.ts) where a toast is wrong (a terminal buffer) — neverString(e):invokerejects a PLAIN AppError, stringified "[object Object]". - A caption over a GROUP of controls rides
LabeledGroup(src/components/form/labeled-group.tsx), which ties the group to it viarole="group"+aria-labelledby— a<Label>that associates with nothing names nothing for assistive tech, however it is styled; thebare-group-labelguard inpnpm run checksfails on any<Label>carrying neitherhtmlFornorid. Existing idioms that already do their own aria wiring stay as they are: the CreatePrDialog / CreateIssueDialog field-group wrappers and McpServersSection'srole="group"scope groups. - A lazy panel's
Suspensefallback isLazyPanelFallback(src/components/lazy-panel-fallback.tsx) — neverfallback={null}: a blank region has no aria-busy and announces nothing to assistive tech. - Loading placeholders for a bordered row list are
ListRowSkeletons(src/components/list-row-skeleton.tsx), withlinesmatching the real row's line count andnamenaming the content ("Loading pull requests…") — flushborder-b px-3 py-2rows, never a padded stack of fixed-height bars, so a cold load doesn't shift the list as rows arrive. It carries the group'saria-busy+ sr-only status;ListRowSkeletonis the single row it wraps, not a call-site component. - A file rail's header content (a count, a filter summary) rides
DetailRail'sheaderprop (src/components/detail-rail.tsx), which places it in the h-7 caret strip — never a first-child band insidechildren: the strip is already a bordered row, so a band under it stacks a second border and spends vertical space the list wants. Keep that content to one short line; the strip's h-7 is what pins the caret at the same height in both branches, so it must not grow. - Avatars: vendored
Avatar/AvatarImage/AvatarFallback(canonical:AuthorAvatarinsrc/features/conversations/Thread.tsx) — never hand-rolled<img>/background divs. Biome-ignore comments use/*, not/**. - Shared-ContextMenu suppression for non-target right-clicks goes through
suppressContextMenu(src/lib/context-menu.ts) —preventDefaultalone still opens Base UI's menu as an empty popup. - CI copy comes from
src/features/actions/status.tsx, never hand-spelled: the provider's noun viaciRunNoun(labels and toasts derive from it, so no surface says "run" beside another's "pipeline"), the gitlab-or-bitbucket test viaisPipelineProvider, and the re-run offers, titles, and cancel wording viarerunOffers/RERUN_TITLES/cancelLabel— shared so the runs list and the run detail view can't drift. - Header meta fields (the PR header's label/value grid: labels, assignees,
projects, reviewers) render through
MetaValueCell/MetaFieldLabel(src/components/meta-field-cells.tsx) — never a hand-rolledrole="group"value cell or a re-spelled empty-dash placeholder. A forge user inside one of those cells is aUserChipfrom the same module — never a hand-rolled avatar + truncating-label span — and any chip that shares a value cell with another but keeps its own markup wears theUSER_CHIP_CLASSbox. The pickers emit those two cells only under theircellsprop; unset, each still renders its own inline trigger+chips row. Scope is that grid alone:MrTimeTracking(GitLab-only) deliberately keeps its own full-width row below it, and form-dialog field groups (CreatePrDialog / CreateIssueDialog'sLabelaria-labelledbywrappers) are a richer separate pattern this rule doesn't govern.
Layout gotchas. DialogContent is a grid — truncating flex content needs
min-w-0 on the grid item; cap tall dialogs at max-h-[85vh]. Link-styled
clickables add cursor-pointer at the call site (vendored Button sets none).
Main-side panels (children of RepositoryView's <main>, a display:block host) root
with flex h-full min-h-0 flex-col — flex-1 is inert there, and the panel's
natural height document-scrolls the whole tab, chrome included (#261's
InsightsBoard was the outlier; every other main-side panel already complies).
Tailwind animation overrides need the ! important modifier — tw-merge doesn't
dedupe the animate group, so animate-none vs an existing animate-in is a
build-order lottery (tailwind-merge 3.6.0; in-repo: data-open:animate-none!).
State & rendering gotchas.
gd/session/*branches are filtered from every branch surface (lists, pickers, bulk actions) — the user dogfoods agent sessions on them; deleting one breaks Resume. Hard invariant.<Activity>-hidden subtrees still render and fetch — gate queryenabledon the active tab; gate agent-surface notifications on the tab being watched.- Open-TRANSITION resets ride
useSeedOnOpen(src/lib/use-seed-on-open.ts) — a bareuseEffect(() => { if (open) seed(); }, [open])re-fires when a hidden<Activity>tab re-mounts its effects on show, wiping the user's draft. The carve-out: data-arrival seeds ([open, thatQuery.data]) andonOpenChangeseeds stay bare, and each must be idempotent — never stomping a user's pick. Theseed-effect-on-openguard allowlists the recorded ones. - Zustand + view transitions:
openRepo/closeRepo/openSettingsissue deferred sets that clobber a plainset()right after — navigate in ONE atomic action. - React Compiler already memoizes call results — don't add
useMemofor perf reflexively (~40% false-positive rate); render reads of mutable module state go stale under it. - Never read the clock in render — live times ride the shared 30s ticker:
<RelativeTime>/<ElapsedTime>,useRelativeNow()for composed strings,formatDurationBetweenfor finished spans (src/components/relative-time.tsx,src/components/elapsed-time.tsx,src/lib/time.ts). - Feed/timeline children mixing entity types prefix React keys per slot
(
comment-${id},event-${id}) — bare cross-type ids collide and React keeps the earlier duplicate's DOM alive. - Queries with identity axes beyond the repo (entity id, lens, state) keep
previous data via
keepPreviousDataForKeyAxes(src/lib/git/queries/core.ts), and callers gate derived UI on!isPlaceholderData— a disabled query still renders its placeholder. - Virtualized lists: a variable-height first row races
measureElement— mount the virtualizer in a child gated on data (docs/list-virtualization.md). - Multi-toggle settings batch behind a Save/Discard bar (draft + dirty), not per-toggle auto-save; a single discrete select may apply-on-change.
- A stored note/verdict that DESCRIBES form-draft values carries the draft
signature it was produced under — never clear-at-every-writer: the footer's
Discard
form.reset()s from outside the section, which no in-file clear can reach. An advisory NOTICE is retired — state cleared — on the first mismatch, so a draft returning to that signature can't resurrect a dismissed one (notein KeyboardSection,warnin AllowedHostsField); a VERDICT stays render-gated (testConfigin AiProviderSection), being valid again on return. Imperative clears only for validity axes the signature can't see (typed input). - Repo-content config features (FUNDING.yml, CODEOWNERS, …) scaffold the local file for the user to commit — never write repo content via an API.
- A mutation whose host can unmount mid-flight (a dialog closable by Esc / ✕ /
backdrop, a keyed remount) rides
await mutateAsynccontinuations, never.mutate(vars, { onSuccess, onError })— react-query drops per-call callbacks when the observer unmounts, so the toast, navigation, or cleanup that lived in them silently never runs. The house idiom isform.ts's awaited-submit convention (SquashDialoginsrc/features/history/RewriteDialogs.tsxis the reference). - An awaited continuation that writes GLOBAL selection/navigation state
(
selectIssue/selectTag/selectDiscussion,setRepoTab,setCommitDraft) guards on the liverepoPath— and the live entity, for deselects — read viauseUiStore.getState()after the await: mutateAsync continuations outlive Activity hides AND unmounts, so an unguarded write retargets whatever repo/entity the user switched to (reference:deselectIfStillHereinsrc/features/issues/RemoteIssueView.tsx). Toasts stay unconditional — the operation happened regardless. - A follow-up that needs the DOM from a state flip (focus a just-revealed
input, re-pin a grown scroll region) never rides a bare
requestAnimationFramefrom the event handler: when the state lives in react-query, the notify-batched re-render can land AFTER that rAF, so the callback hits a still-hidden node and silently no-ops. Arm a pending ref and consume it in auseLayoutEffectkeyed on the flipped state's commit (reference:CommentComposer's collapse/expand pending flags); an rAF belongs inside that effect only to outlast Base UI's close-time focus-return. - A mode swap that unmounts the control holding focus (a toolbar cluster
swapping its controls) drops focus to
<body>on the PALETTE route — the palette closes-then-dispatches, so its focus-restore lands on the about-to-unmount control. Rescue viauseFocusOnControlsSwap(src/features/diff/use-hidden-trigger-focus.ts): change-only by design so a mount never steals focus; its siblinguseHiddenTriggerFocusowns the container-query-hidden-trigger close arm. Never a hand-rolled focus effect.
Seam idioms (the spec template's "Idioms at this seam" field reads this list)
Wiring a NEW call site onto one of these surfaces means the governing idiom applies — cite it in the spec or record "none applies"; the canonical site is one grep away on the named symbol. Grows via Conventions-sync.
- Repo-identity keying — per-repo state keys on the repo IDENTITY
(
useRepoIdentity/ the shared--git-common-dirresolver), never a raw checkout path; a raw-path key diverges across worktrees of one repo. - View lens — a PR/issue number or blob is valid only in the lens that
produced it (origin vs upstream vs local match); navigation and dock entry
points thread the lens (
beforeSelect), never pass a bare number across. - Force/retry threading — a re-run or retry carries its force/trigger marker end-to-end; downstream code distinguishing "original vs re-run" reads the marker, never infers from timing.
- asText rendering — user-and-forge-supplied strings render through the file's asText discipline, never raw interpolation.
- networkMode / offline parking — react-query calls on possibly-offline
surfaces set the documented
networkMode; the default PARKS offline and the query never resolves. - Retry focus rescue — a Retry control whose press can unmount it (an
error arm swapping to a skeleton or notice) rides
useRetryFocusRescue(ConversationListPanel) with a stabletabIndex={-1}host element; focus must never fall to<body>on the shown→hidden edge. - Empty-picker copy — a picker's line while it draws no options rides
emptyPickerCopy(remote-section-state); a read parked with nothing loaded, or over a failure, says offline; only a successful read may claim none. - Degraded-list copy — a list that may be partial rides
unknownListNotice(remote-section-state), keyed on the read's WIRE flags, never row count: truncated says "may be incomplete" with no Retry; a failure offers Retry; a park never does. Several sub-lists of ONE read share one line and one Retry throughunknownListsNotice, never stacked per-list notices. - Settings list retention — a repo-settings list read renders through
RemoteListSection(repo-settings/parts), neverAsyncListBodyfed a raw query error: a failed or parked refresh keeps loaded rows under a notice. Settled by omission (nofetching), so Retry survives its own press. - Settings form retention — a repo-settings FORM over one read renders
through
RemoteFormSection(repo-settings/parts): gated on absent data, neverisError, so a failed or parked refresh keeps the form and its draft under a notice above it. A draft that reseeds after a save holds only touched fields and retires them throughreconcileTouched+stampSent(repo-settings/touched-draft, pinned by its test; a save that canonicalizes client-side, like Funding's, remaps its draft to the canonical form and stamps that) — never a clear on save (a failed post-save refetch shows the pre-save values) or adataUpdatedAtkey (every focus refetch wipes the draft); a whole-object draft would send another client's newer values back stale. - Offline remote writes — a repo-settings control that fires a remote
write (add, save, delete, role, toggle) gates on
useOnline()(src/lib/use-online.ts) with the sharedOFFLINE_WRITE_REASON(repo-settings/parts), disabled with reason, never hidden: a mutation pressed offline parks silently and fires on reconnect. Confirms takeInlineConfirm's /DangerDialog'sheldReason, pickersHeldRoleSelect's, switchesHeldSwitch's (readOnly, neverdisabled: one node that keeps its tab stop and gives keyboard and AT its reason; inside a wrapping<label>passinLabeland title the label). Openers that only reveal a form or confirm stay enabled, as do local writes (FUNDING.yml, dependabot.yml, removing the upstream remote). - Held row pickers — a row's select (role, ruleset enforcement) held while
saving or offline rides
HeldRoleSelect(repo-settings/parts): readOnly + gated open, never Base UI'sdisabled, which drops the trigger and its reason from the tab order. - Confirm-swap focus — a control that swaps with an inline confirm hands
focus across the swap via
useConfirmSwapFocus(repo-settings/parts): the same ref on the trigger and onInlineConfirm'sswapFocusRef, keyed per row in lists; without it the dialog's focus fallback sends focus to the popup when the focused node unmounts. A confirm that removes its own row or container lands on a successor: mark the rowdata-confirm-row, the section rootdata-confirm-section, and its first input or add controldata-confirm-fallback. The act button holds by reason while pending, so it keeps focus through the write. - Invalidation keys — cache invalidation goes through the shared key
builders in
src/lib/git/queries/; a hand-built key or raw-path key silently fails to co-invalidate siblings. - Queries package boundary — import the git query layer through the
@/lib/git/queriesbarrel;queries/internal.tsis package-private. Modules inside the package may import it but never re-export it, sinceexport *chains would republish it through the barrel. Guards:queries-internal-import(callers outside),queries-internal-reexport(re-exports anywhere inside),queries-barrel-internal-reference(the barrel may not name it at all). - Mutation identity pinning — a
useRepoMutationwhose mutationFn or callbacks close over repo/lens AND whose host survives a repo switch passesidentity: ["<op>", repo, lens]: react-query re-pushes hook options onto an in-flight mutation on every render, so without the key a switch retargets the create and its callbacks to the NEW repo; a changed key hash detaches the mutation with its options frozen instead. Callers must consume the promise (mutateAsync), since the observer'sisPending/datago idle at the switch. Exemplar:useCreateIssue; a plainuseMutationspells the same pinmutationKey:. Guard:mutation-identity-pinningratchets the cache-seeding sites and the create-family ones it can see by NAME, following delegation one level inside a module. It scans the git-queries package,lib/jira/queries.tsand the local PR/issue query modules, where a wrapper keyed through an optional parameter also obliges its delegating call to pass one — a repo-scoped create written anywhere else is unratcheted, and the wider class stays a review concern. - Plugin-store open/reload — an app-data store opens via
memoizedStoreLoaderand re-reads viareloadToleratingEmptyStore(src/lib/plugin-store.ts), never a hand-rolled??= load(storeName(…))or a barestore.reload(): the former memoizes a REJECTED load (store dead until restart), the latter swallows an unreadable file and saves the cache over it. - Per-record repo scope — a record offered per-repo-or-globally carries a
scopestring: the"global"sentinel (also the absent-value default) or an identity key. Reads match BOTH key forms (raw + identity, most-preferred last); writes fold raw→identity; an UNKNOWN scope fails closed (never widens to global); the store registers a relocate rewrite in repo-data-migration.ts. Two instances:settings/mcp.ts(serverScope) andscripts/scope.ts(taskScope) — deliberate mirrors of each other, not a shared import. - Repo-identity observers — every observer of
["repo-identity", repoPath]spreads the ONE factoryrepoIdentityQueryOptions(src/lib/git/repo-identity-query.ts), never an inline query: its strict queryFn REJECTS on IPC failure, so a transient error retries instead of pinning the raw-path fallback for the session. Three steady states, not two — resolved, pending, and a MOUNTED observer sitting onisErrorwith no data until a remount: a consumer gating ondata !== undefinedmust treat the error as settled (fall back to the raw path, the key the swallowingrepoIdentityand the disk loaders use) or its gate never opens. A store's WRITER takesrepoIdentityStrictunless its reads heal through anidentityKeyForfold: swallowing in a fold-less writer mints a record no healed read consults (the exemplar isconversation-filters/store.ts); loaders may still swallow, since a defaults read costs one session, never a strand.pnpm run checksfails theinline-repo-identity-queryguard on the key spelled anywhere but the factory. - Hydrate gating — a
hydratethat swallows its read must NEVER set the ready flag, and a snapshot write gates on a RESOLVEDhydrate(), not on the flag:memoizedStoreLoaderretries after a transient failure, so a store marked ready on an empty read will write that empty snapshot over data it never read (re-attempting on the write path is what makes it self-heal). The flag alone suffices only where the state provably cannot change before the read lands —agentNumber, whoseensurewon't mint while it's false.
Rust / Tauri conventions
- Large ints over IPC: snowflake/
u64ids lose precision as JS numbers — serialize as strings end-to-end. - Advisory probes fail SAFE toward inaction: a probe whose verdict can
unlock a destructive offer (
BranchRewriteStatusis the model) keeps its VERDICT "unknown" on any failed sub-probe, and never ships a defaulted count PRESENTED AS MEASURED — the pre-verdict shape zeroes the counts, and the null verdict is what makes them unreadable (every consumer gates on the verdict first). Callers render exactly what they render without the data. The unlock condition must rest on measured evidence (e.g. rewrite = reflog miss AND patch-twins present — strong evidence, not proof; the failure direction stays inaction), and the destructive action targets the measured sha, never a re-resolved ref. - GraphQL nullability: fields without
!deserialize intoOption<T>; neverunwrap_or_default()afrom_value; confirm a field exists before querying it. - Sync Tauri commands run on the main thread — take the value under the
lock, drop the guard, then block; prefer
try_wait-style non-blocking. - Command futures stay small: the invoke handler CONSTRUCTS a
#[tauri::command]'s future on the WebView2 UI-thread stack before the runtime polls it on a worker — a large command future overflows that stack in release builds (dev never reproduced it: the debug-profile future measured 123,000 B against a ~721 KB release handler frame, and debugBox::pins command futures at the IPC boundary; on Windows dev IPC uses the same custom protocol). The trigger is per-future stack footprint, not join arity: sub-futures holding capture buffers or nested async chains get spawned (tauri::async_runtime::spawn) orBox::pinned before ajoin!. A 7-way inline join of process-spawning probes shipped a stack-overflow crash (v0.12.1 About page). (guard:system_health_future_stays_small, src-tauri/src/health.rs — per-command by choice; a new command joining capture-buffer futures adds its own.) - Untrusted JSON (CLI output, forge APIs): TS derivers
typeof/shape-guard each field withtry/catchper item; Rust uses tolerant serde (Option<T>, null-tolerant defaults) over strict shapes. Grammar-validate command/URL values either side. Third-party timestamps validate before formatting —parseableDatefor ISO strings,validEpochMsfor epoch numbers (src/lib/time.ts); never rawnew Date(x)on forge/CLI data. - Windows spawning: never pass multi-line argv to
.cmdshims (BatBadBut rejection) — feed multi-line input via stdin. - User input → git refspecs/argv routes through the existing chokepoints:
validate_ref_name(git/branches.rs),validate_tag_name(git/ops.rs), pushes viabuild_push_args(git/remote.rs) — never construct an inline refspec or re-derive the validation. Refs reaching a compare-endpoint basehead route throughforge::validate_compare_branch(guard: check-rust-invariants check E). - Rust tests never read the real settings store — use the
TEST_STORE_DIRseam inapp_store.rs(arm 0 ofstore_path). The other app-data modules carry their own seams with the opposite arm order (oplog.rsGD_OPLOG_DIR,review_notes.rsGD_REVIEW_NOTES_DIR: env override outranks thecfg!(test)temp arm; both ship in release) — a new store module mirrors one of these, never resolves app-data bare. Concurrency: the MCP server is a second writing process, so both stores' read→modify→writes take the cross-process file lock instore_lock.rs(acreate_newlock file beside the store, stale-evicted, fail-open) on top of their own in-process guards —oplog.rsitsOPLOG_LOCKmutex,review_notes.rsitsnotes_lock()mutex — with the atomic whole-file replace underneath both (torn-file safety, not lost-update safety). The GUI's review-notes writes route through the lockedreview_notes_set_branch/review_notes_delete_branchTauri commands rather than the plugin store (cold-start test mode is the one exception: it aliases the store file and has no second writer). A NEW store module with more than one process writing adoptsstore_lock— don't assume last-writer-wins is acceptable. - Forge gating: per-action
Implementedflags. Shared-with-GitHub controls gate oncanWrite || forgeFeatureReady(GitHub must be zero-diff); provider-only controls gate onforgeFeatureReadyalone with the flagfalsefor GitHub; shared controls with different per-provider ids guard/dispatch on the common key and carry both id pairs. Write-access axes: availability decides what RENDERS; permission decides what's ENABLED (disable-with-reason, never hide); triage is its own lower tier — seesrc/features/pulls/usePrCapabilities.ts. - A server-constrained field in a shared PATCH rejects the whole request when
ineligible — model as
Option+ eligibility check; hide/omit when ineligible. - A Tauri package's two halves move together: the crate
(
src-tauri/Cargo.toml→Cargo.lock) and its npm half (package.json→pnpm-lock.yaml) must agree on major.minor ortauri buildrefuses the pair. An npm bump already reddens appimage-check (its paths filter listspackage.json); the blind spots are a crate-only bump and a lockfile-only drift inside a caret range, both gated by check-tauri-plugin-parity.mjs on every PR. A Dependabot group can't span ecosystems, so the two PRs combine. - CodeMirror/Lezer core packages are lockfile SINGLETONS, gated by
scripts/check-lockfile-dedupe.mjs(itsSINGLETONSlist): two installed copies of@codemirror/statethrow at editor open, and a split@lezer/highlightdrops theme coloring — neither shows in build or tests. Remedy ispnpm dedupe, then anoverrides:pin inpnpm-workspace.yamlfor a split that survives it.
Code comments
Constraint-statements only: the decision + one sentence of why (≤3 lines typical, ~6 for genuinely multi-constraint blocks). KEEP-class content: invariants, ordering/locking rules, deliberate non-obvious choices, cross-module and IPC contracts, empirically-learned external-API/platform behavior, public-API doc contracts. NEVER: what the code used to do or replaced, PR/issue/review references, how a bug was caught, worked numeric examples where the principle sentence suffices, narrating the next line, arguing the change is correct (the commit message and PR own the story). Trim any comment you touch to this standard. Carve-out: measured figures a later reader would otherwise have to re-measure (payload sizes, timed runs) may stay and cite their source (a PR or run reference is fine there).
⚠ Rust doc-comment rewrites are a clippy surface — a /// line starting with a
Markdown bullet char (+/-/*) mid-sentence turns the following lines into
doc_lazy_continuation lints. Run the Verification block's clippy line after any
doc-comment edit.
Docs-sync (same change, unprompted)
CLAUDE.md defines the full rule; short form for a user-facing feature: README
Highlights/Features bullet → site capabilities (+ FeatureRow when it
warrants; non-AI features in both site views) → in-app guide
src/features/help/content.ts → a changelog.d/<added|changed|fixed>-<slug>.md
fragment (its body is the finished Keep-a-Changelog bullet). Never hand-edit
## [Unreleased] in CHANGELOG.md — it's generated from the fragments at
release time; one file per change keeps parallel branches conflict-free. The
fragment check is required on master and keys on paths — any src/ or
src-tauri/ change needs a fragment, the no-changelog label, or
skip-changelog in the PR title.
Skills ship in TWO trees — edit both. .claude/skills/<name> (the Claude
store) and .agents/skills/<name> (the vendor-neutral store the codex and
opencode lanes read) are separate committed copies, and nothing propagates an
edit between them, so a lane resolving the copy you did not edit gets stale
text. scripts/check-skill-mirrors.mjs gates every skill present in both trees
and is a required guards step; the skips it declares (EXEMPT for per-harness
rewrites, SINGLE_TREE for one-tree skills) each carry their reason. Line
endings, the two trees' .claude/-vs-.agents/ path references, the
/cmd-vs-$cmd sigil, and harness-only frontmatter keys are normalized away —
everything else must match. The path rewrite is symmetric, so byte-identical
copies always compare equal; the script header records what that costs.
In a delegated package the spec's Docs-sync: field is authoritative:
apply exactly what it lists (those files are thereby in scope); "orchestrator
handles" → skip docs; silent spec + user-facing change → flag the gap in your
report, don't exceed scope.
Help-content specifics: shortcuts are {{kbd:action-id}} / {{key:…}}
tokens, never literal keys — but a defaultBinding: null (palette-only)
action gets no token at all (it renders the literal word "palette" —
HelpScreen's PALETTE_ONLY set); mention it as plain prose. AI-only content
gated with ai: true + {{ai}}…{{/ai}}. Verify every claim against code;
sweep stale "coming soon" mentions when a feature ships.
Prevention standing rules (owner-adopted 2026-08-15)
- Tripwire: a fix that closes a CLASS of defect ships its mechanical guard
in the same change — a biome
noRestrictedImportsentry, ascripts/check-*pattern or allowlist rule, or a pinning test. A swept class without a tripwire has re-opened before; the sweep alone is not the fix. - Class-grep at accept time: when a review or session finding is accepted, grep its class immediately — fix every sibling in the same batch, or record the count with a named home. Never leave the Nth instance for a later PR.
- Conventions-sync: a change that builds a reusable primitive adds its line
to THIS file in the same change (the docs-sync rule, applied to idioms). A
change to a hard rule also updates every file that states that rule, wherever
it is restated — the always-loaded excerpt
.claude/rules/git-safety.md, the repoAGENTS.md, the agent definitions, and any carrier added later;scripts/check-rule-mirrors.mjsholds the gated list for the git whitelist and fails when a carrier drops the rule's core (the codex spec preamble restates it condensed, synced by hand). - No inline gate predicates in CI YAML: a check's logic never ships as an
inline
node -e/shell string in a workflow file — it is invisible to biome, tonode --test, and to every static review lane (four independent defects once shipped in one such predicate). Extract toscripts/check-*.mjswith hit/miss fixtures inscripts/checks.test.mjs; the YAML step only invokes the script. - Guard scripts default CLOSED on inputs they cannot read: every
scripts/check-*.mjsenumerates its unreadable-input arms — unquoted or folded YAML scalars, binary bytes, deleted/missing files, empty corpora, non-string values, duplicate keys — and FAILS on them instead of skipping. Mining minted the fail-open-checker-arm class on 12+ records across 6 PRs, concentrated in the guard scripts the tripwire rule itself ships: a scanner that exits 0 on input it could not parse approves exactly what it cannot see. Each arm gets a miss fixture inscripts/checks.test.mjs, and an empty-corpus / zero-files-matched result is a FAIL or an explicit annotated skip, never a pass.
Definition of done
Verification green (failures quoted verbatim, passes one line each); edge
cases exercised (first/last/empty, boundaries); keyboard nav + palette
registration wired for new UI; docs-sync per the spec's Docs-sync: field
(gaps flagged, never silently skipped); footprint sweep via
git --no-pager status accounting for YOUR files only (the tree may hold the
user's parallel WIP and sibling packages — don't touch or explain what isn't
yours); nothing committed, ever.
