Skip to content

release: promote next to main (stable v0.4.0) - #30

Merged
ashproto merged 52 commits into
mainfrom
next
Aug 10, 2026
Merged

release: promote next to main (stable v0.4.0)#30
ashproto merged 52 commits into
mainfrom
next

Conversation

@ashproto

@ashproto ashproto commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Promotes next to main. Merging this cuts stable v0.4.0compute-version.mjs resolves v0.3.0 + a feat: commit to a minor bump:

version=0.4.0  tag=v0.4.0  channel=stable  previous_tag=v0.3.0  bump=minor

52 commits, 25 files, +4937/−329, from eight merged PRs (#25 #26 #27 #28 #29 #31 #32 #33). No website/ changes, so this does not redeploy the site — it publishes a signed + notarized stable build and advances the stable updater channel.

Headline — diff hunk/block affordance and discard (#26)

Nested hover rings over hunks and change-blocks, a floating Stage/Unstage/Discard toolbar, contiguous range selection with keyboard parity, and section-aware working-copy selection so a partially-staged (MM) file behaves correctly on both halves. Discard is new and unstaged-only; it reverse-applies against the index, so discarding unstaged lines reverts them to the staged version and staged work survives.

Correctness fixes worth calling out

Several of these are silent-corruption or wrong-target bugs found during review rather than cosmetic polish:

  • -U0 hunk placement. git apply offset-searches for the preimage, so a wrong @@ coordinate is invisible at context ≥ 1 — but at -U0 a pure insertion (forward) or pure deletion (reverse) has an empty preimage and lands exactly where the header says. Staging a hunk silently appended the line at end-of-file and returned Ok. Fixed by re-anchoring extracted hunks and passing --unidiff-zero only at -U0.
  • Stale-diff guard on discard. The confirmation dialog has no timeout and the fswatch refresh never invalidated the captured hunk index, so an external edit could make a discard reverse-apply a hunk the user never saw. The displayed diff now travels with the request and is compared before any hunk is selected.
  • Mode changes no longer ride along. chmod +x plus an edit put old mode/new mode in the header, so "Discard 1 line" also removed the executable bit.
  • Diff prefixes pinned. diff() now pins --src-prefix/--dst-prefix. Under diff.noprefix a reconstructed patch loses a path component to git apply's -p1 and lands on the wrong file — this affected stage/unstage too, not just the new discard paths.
  • No-newline-EOF partial selections (fix: stop a partial selection at a no-newline EOF from merging two lines #28) merged two lines into one; now repaired where expressible and refused where it is not.
  • Sidebar acting on the wrong repository (fix: reject an existing bare repository as an init destination #29). A repo switch deliberately keeps the previous repo's refs on screen to avoid flicker, while appState.repo already points at the new one — so "Delete branch main" could delete the wrong repository's branch. Ref actions are now inert while repoLoading. The detached-HEAD "Create branch here" was gated too: two clones of a project share commit ids, so a stale sha can silently succeed in the wrong repo rather than erroring.
  • Repository creation guards (fix: reject an existing bare repository as an init destination #29). A bare repo, a .git directory, or anything below either is now refused as a destination or parent — previously git init would nest a repository inside another's metadata.
  • Native context menus restored (fix: let the native context menu through where text is selectable #27) on editable and selectable content; previously a window-level handler suppressed every one, so commit-message boxes and diffs had no Copy/Paste.
  • Confirmed actions ran against the wrong repository (fix: bind a confirmed action to the repository it was confirmed against #33). Every confirmation is awaited with no timeout, and the work that follows read appState.repo when it ran. Switching repositories mid-prompt landed the confirmed action on the new one — force-deleting a branch, discarding changes, or pushing to a remote the user never chose. The discard snapshot did not cover this: expected_diff pins the diff, not the repository, so two clones with the same path and diff passed the check. All nine awaited dialogs now bind to the repository they were confirmed against, and the guard sits ahead of the first await in each flow rather than merely before the prompt — two of them had an earlier await that made the guard useless.
  • Sidebar ref dialogs had the same gap (fix: resolve an untracked selection into Unstaged once the lists are merged #32): seven sites captured nothing across the prompt. Also fixed there: an untracked selection was stranded when "Merge Untracked into Unstaged" was enabled, because presence was computed from file state on one field and rendered lists on another.
  • Selection surviving its file (fix: show the empty state when the selected file leaves the working copy #31). Committing or discarding a file's last change left the pane showing an empty diff instead of the no-selection state.
  • Flaky test fixture (fix: make the init-repo test fixture collision-proof under parallel threads #25) and a declared Node floor (^20.19.0 || >=22.12.0, Vite 8's requirement).

Verification

cargo test -p git-core -p git-it 232 + 7 · cargo clippy -p git-core -p git-it -- -D warnings clean · npm run check 530 files / 0 errors · npm test 393 passed. Every constituent PR was CI-green and carries a Codex 👍.

This promotion took four review rounds of its own, surfacing five findings — three of them P1 — on code where every constituent PR had already been reviewed and approved individually. All were cross-cutting: they needed a setting and a selection and a mode change, or a dialog and a repo switch. None lives inside any single PR's diff, which is why per-PR review missed them.

Known gaps — worth a look before or shortly after release

None are known-broken; all are unverifiable in any test environment — behind invoke, or a WKWebView rendering decision — so they shipped on evidence-by-reading rather than evidence-by-running:

  1. Escape-to-clear a locked diff selection, and keyboard navigation (roving tabindex, arrows, Enter, Shift+Arrow). Synthetic Escape never reaches WKWebView, so these were never exercised end to end.
  2. A right-click sweep in a bundled build for the context-menu change. The gate logic was verified against the live stylesheet, but what WKWebView actually renders is not reproducible headlessly. Note a debug build legitimately shows "Inspect Element" that the shipped app will not — devtools is not in the Tauri feature list.
  3. The working-copy empty state after a file's last change is committed or discarded.
  4. The repository guards across 17 call sites (fix: resolve an untracked selection into Unstaged once the lists are merged #32, fix: bind a confirmed action to the repository it was confirmed against #33). Worth singling out because of how it would fail: everything else here fails visibly, but a misplaced guard fails as a legitimate action silently doing nothing — click Delete, nothing happens. A script checks both invariants mechanically (no capture preceded by an earlier await; no awaited dialog without a guarded use), but placement was done by hand.

Follow-up worth doing separately: the repository-binding class took four rounds of patching a convention across those call sites. The structural fix is for the api layer to take the repository explicitly so binding cannot be forgotten — too large for a release PR, but it is the durable answer.

🤖 Generated with Claude Code

ashproto and others added 30 commits August 6, 2026 18:05
Design for a nested hover affordance (hunk + change-block rings with
floating stage/discard actions) in the working-copy diff, hunk- and
line-level discard, and a replacement for the line-selection visual
that is effectively invisible in the NERV theme.

Interaction model validated against a live prototype before writing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight TDD tasks: pure block/ordinal helpers, git-core discard ops,
Tauri/api/gitActions plumbing, hover rings, floating toolbar, range
selection, split view, and keyboard parity.

Also corrects the spec's ring mechanism: box-shadow does not merge
across rules but custom properties do, so the block ring is pure CSS
rather than a JS-positioned overlay. Only the toolbar needs measuring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Shift+Arrow steps by BLOCK in split view, where row-stepping was a no-op after
  selection snapping (new `splitBlockRanges`, which `splitRangeSnappedToBlocks` now reuses).
- The hunk-scope toolbar anchors to the hovered row, so hunks taller than the pane no
  longer hide it.
- The Discard confirm states the changed-line count instead of "this hunk", which
  understated Whole-file mode's single whole-file hunk.
- Roving tabindex: one tab stop per diff instead of one per row, with unshifted arrows
  moving between rows and Tab reaching the toolbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implementation disproved the spec's claim that a paired split row could
select "as a unit". A paired row is a display artifact, so selecting one
emits non-contiguous ordinals and build_partial_hunk — which emits in
hunk source order — silently reorders the file. Verified against real git.

Selection now snaps to whole change blocks, enforced in the frontend and
refused outright by git-core as a second line of defence.

Also folds in two plan corrections made during execution: hover must clear
on the outer wrapper rather than the tbody, and the min-height/min-width
discrepancy in Task 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hreads

TempFolder::new() named its directory git-it-init-test-{pid}-{nanos}. The
four initialize_repository tests run on parallel threads in one process, so
the pid is shared and two threads could read the same nanosecond; fs::create_dir
then failed with AlreadyExists and the unwrap() panicked. A different one of
the four failed on each run, which is what made it look random.

Replace the wall-clock stamp with a process-wide AtomicU32 counter, matching
the TempRepo fixtures in ops.rs and ops_worktree.rs. pid + counter cannot
collide by construction, so create_dir is kept over create_dir_all: a
collision should now be impossible, and would be a real bug worth panicking on.

Verified with five consecutive full cargo test runs, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
selectedIsStaged asked "does this file have any staged content", so a
partially-staged (MM) file always showed its STAGED diff even when the user
clicked its Unstaged row. Every action gated on that flag was therefore
unavailable on the unstaged half: not just the new hunk/line Discard, but
hunk/line Stage too — both sides of the workflow the feature exists for.

Selection now records the section the row was clicked in, the same
discriminator onRowContext already used for its menu. resolveSection() picks
the section to display, falling back to wherever the file went if it has left
the clicked one (staging all of it, say) rather than stranding an empty diff.
Row highlight follows the resolved section, so an MM file highlights only the
row being diffed instead of both.

Discarding unstaged lines of a partially-staged file reverts them to the
staged version rather than to HEAD, so the confirm dialog now says that
instead of claiming the change is permanently lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
presence.unstaged checked unstagedFiles, which excludes untracked, while the
rendered Unstaged list is unstagedDisplay, which includes them when "Merge
Untracked into Unstaged" is on. A merged untracked row therefore reported
section "unstaged" but resolved to "untracked" — a section with no rows in
that mode — so the selected row never highlighted, clicking it again could
not deselect, and the Unstaged header fell back to "Stage all".

Sections are rendered lists, not file states, so presence now describes what
is on screen. Two comments that asserted the opposite are corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hunk-scope actions anchored to hov.ri, so every context row the pointer
crossed re-anchored the toolbar and it chased the cursor down the hunk.
That anchor existed to stop the toolbar hiding on hunks taller than the
pane; clamping (d8b84c6) now covers that, so the anchor can be stable.

Hunk actions now sit on the hunk's own @@ header bar, where their scope is
written. Block and selection actions still anchor to their ring, so the
toolbar's position says which of the two you are targeting.

The header row gains data-h/data-i as the anchor, which also made it match
the arrow-key row query; that selector is narrowed to .diff-row so ArrowDown
cannot land on a non-focusable header and dead-end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit claimed pid + counter "cannot collide by construction"
and kept `create_dir` over `create_dir_all` on that basis. That reasoning
only holds among LIVE processes. The counter restarts at zero every run, so
uniqueness rests entirely on the pid never repeating — and a pid is unique
only while its process is alive. A run killed before `Drop` (Ctrl-C on
cargo test, or a panic=abort) leaves its directories behind; once the OS
recycles that pid, a fresh process counting from zero reproduces the exact
same path and `create_dir(...).unwrap()` panics with AlreadyExists.

Not hypothetical: this machine currently holds 36 orphaned `gte-*` fixture
directories spanning 18 distinct pids.

Remove any leftover first, then `create_dir_all` — the same two lines the
TempRepo fixtures in ops.rs, ops_worktree.rs, ops_merge.rs, ops_rewrite.rs
and graph.rs already use. Those fixtures were right and this one was the
outlier; the extra line is the point, not redundancy.

Reported by Codex review on #25. cargo test -p git-core: 183 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tale diff

Both from the Codex review on #26. Investigating the first turned up a worse
bug underneath it.

## Hunk placement at -U0

`git apply` positions a hunk by the coordinate of the image it produces —
new_start forward, old_start under --reverse — then offset-searches for the
preimage around it. That search is why a wrong coordinate normally goes
unnoticed, and it is exactly what hid this: at context >= 1 every case still
landed correctly.

At -U0 a pure insertion (forward) or a pure deletion (reverse) has an EMPTY
preimage. Nothing to search for, so git applies at precisely the line named
and reports success from the wrong place. Two defects met there:

- A hunk lifted out of a multi-hunk diff keeps BOTH coordinates from the full
  diff, so the side not facing the apply target is offset by the hunks left
  behind. `build_partial_hunk` made it worse by writing old_start into both
  slots. New `reanchor` keeps the trustworthy side and derives the other; it
  is now shared with the whole-hunk ops, which had the same fault by way of
  replaying git's live header.
- Without --unidiff-zero git enforces "a hunk with no trailing context must
  match at EOF", which every context-free hunk trips. Passed only when
  context == 0; at any real depth those checks stay on.

Removing either half alone reintroduces failures (13 and 5 respectively), so
both are load-bearing. Worst case found: at -U0, staging a hunk silently
appended the line at end-of-file and returned Ok. Restoring a deleted line via
discard was mis-placed the same way — on the path with no reflog.

## Stale diff under the confirmation dialog

A hunk index is just an integer; nothing in it says which hunk it meant. The
discard confirmation has no timeout, and the fswatch refresh never invalidates
the captured index, so an external edit could re-split the file and the op
would reverse-apply a hunk the user never saw.

The displayed diff now travels with the request and git-core compares it to the
live one BEFORE selecting any hunk, so a mismatch cannot touch the working
tree. Whole-file rather than per-hunk: distinguishing a harmless edit would
mean trusting the same index arithmetic that is in question.

Left alone deliberately: stage_hunk/unstage_hunk freshness (same window, but
they mutate the index, which is recoverable).

27 tests over four shapes x six ops. cargo test -p git-core 220 passed;
npm run check 530 files 0 errors; npm test 386 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When the last line of a file with no trailing newline changes, git emits a
`-`/`+` pair where BOTH sides carry `\ No newline at end of file`. The marker
claims the line before it ends the image it belongs to. `build_partial_hunk`
kept that marker whenever the preceding line had been emitted — including when
a demoted-to-context line was emitted after it, which makes the claim false.

`git apply` accepts the contradiction and resolves it by concatenating. With
base "a\nt" and worktree "a\nT2", discarding just the deletion produced
"a\ntT2" — the two lines merged onto one, Ok(()) returned, on the path with no
reflog and no backup bundle. Pre-existing: reproduces against HEAD.

The two directions are not symmetric, so they get different answers.

Reversing (discard/unstage) keeps the other half as context on the side that
still ends there, so dropping the stale marker expresses the intent exactly —
verified against real git, the file becomes "a\nt\nT2".

Going forward (stage) would need the demoted line newline-terminated on the new
side and not on the old. One context line cannot say that, so no patch
expresses it and it is now refused, in the same spirit as require_contiguous.
Whole-hunk ops replay git's own hunk verbatim and are unaffected, so staging
the whole hunk remains available.

Deciding the marker's fate needs to know what follows it, so the emitter now
tracks what the previous line was emitted AS rather than merely that it was,
and build_partial_hunk returns Result<Option<String>, String> to carry the
refusal. Callers keep their own "no lines selected" messages.

partial_hunk_no_newline_marker asserted the buggy output — its comment narrated
the mechanism ("last_emitted=true -> marker kept"). That exact patch shape was
confirmed against real git to stage "oldnew" instead of "old\nnew", so the
first half now expects the refusal.

cargo test -p git-core 224 passed; npm run check 530 files 0 errors;
npm test 386 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A window-level handler cancelled every native context menu in the app. The
handful of row types with their own menu already call preventDefault
themselves, so all it did elsewhere was take functionality away: right-clicking
a commit-message box, a review textarea, diff code, or rendered Markdown gave
nothing at all — no Copy, Paste, Look Up, or spelling. On macOS that reads as
broken.

The intent was presumably to hide WebKit's developer menu, but `devtools` is
not in the Tauri feature list, so the inspector only exists on debug builds and
never ships. Nothing needed guarding in release.

Rather than invent a second notion of "editable or selectable", read the
computed `user-select`: the stylesheet right below already curates that set,
opting a no-select body back in for form fields, diff code, rendered markdown,
commit bodies and `.sha`. It inherits, so descendants of `.md`/`.selectable`
are covered without enumerating them, and DiffView's gutter — which sets its
own `user-select: none` so gutter-drag staging never selects text — keeps
suppressing its menu for free. Both the prefixed and unprefixed properties are
read: minimumSystemVersion is 12.3, where unprefixed support is not a given.

Verified by running the handler against the live stylesheet: chrome, nested
toolbar spans and the gutter suppress; input, textarea, .md and its
descendants, .diff-cell, .sha, .body-msg and deep .selectable children get the
native menu. What that menu RENDERS is a WKWebView decision no test
environment reproduces, so it still wants a look in a bundled build.

npm run check 524 files 0 errors; npm test 330 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: make the init-repo test fixture collision-proof under parallel threads
`initialize_repository` guarded against an existing repository two ways, and a
bare one slipped between them: it has no `.git` child, and `rev-parse
--is-inside-work-tree` answers `false` inside it. The destination is non-empty,
so the user got the "folder is not empty" prompt rather than "already a Git
repository" — and on confirming, `git init` ran with the bare repo as its
working directory.

Verified against real git: it prints "Initialized empty Git repository in
.../bare.git/.git/" and creates a nested repository inside the bare one. The
bare repo's own HEAD, refs and core.bare survive untouched — `diff -rq` against
a backup shows only the added `.git` — so this is a wrong state rather than
data loss, but it is exactly what the sibling guard exists to prevent.

Probe the destination itself with `rev-parse --resolve-git-dir`. It answers for
the path GIVEN and does not walk up to a parent, so a plain folder that merely
sits inside a repository is still a valid destination — that case belongs to
is_inside_worktree, and a test now pins it. The `.git` check is kept alongside
rather than replaced, so nothing that was refused before is accepted now.

Reported by Codex review on #26; the code is not part of that PR — it came from
15e51c3 (2026-07-17) and git_ops.rs is untouched there — so it is fixed here.

cargo test full workspace green (git-core 185); npm run check 524 files
0 errors; npm test 330 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`chmod +x` plus an edited line is one diff with one file header, and git puts
`old mode`/`new mode` in that header. Discard reverse-applies the header to the
working tree, and `git apply` honours a mode pair — so "Discard 1 line" also
took the executable bit off. Not in the confirmation, not in the line count,
and not undoable.

Verified against real git: reverse-applying the header with the mode pair took
f.sh from 755 to 644 while reverting the text; dropping the two mode lines left
755 and reverted the text just the same.

Codex flagged `discard_lines`. `discard_hunk` reverse-applies the same header
and loses the bit identically, so both destructive ops now build a content-only
header. Stage and unstage keep the mode deliberately: there it belongs to the
same index entry the caller is moving, and the result is recoverable either way.

cargo test -p git-core: 222 passed (the one failure in a full run is the
`git_ops.rs` init-repo flake, fixed on `next` by #25 and merged in next commit).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ashproto and others added 14 commits August 8, 2026 19:37
A discard carries the patch the user was judging so git-core can refuse one
picked against a diff that has since moved. That guard worked, but the retry
did not: `applyWorkingChanges` compares WorkingFile metadata and skips the
update when it matches, so an external edit to an ALREADY-modified file leaves
the status list identical, never bumps `workingChangesRev`, and the shown patch
stays stale. `runWorktree` does not refresh on failure, so every retry sent the
same stale patch and got the same refusal — until the user happened to reselect
the file or change the context depth.

Add `invalidateWorkingDiff()`, which bumps the revision the diff effect keys
on, and call it when a discard fails. Refusal becomes something the next click
can get past instead of a dead end. Deliberately on any discard failure rather
than only the stale one: after a failed destructive op the shown state is
unverified either way, and matching on message text would be brittle.

Success still refreshes exactly once through `runWorktree` — untouched, so the
stage/unstage row animation is unaffected.

npm run check 530 files 0 errors; npm test 386 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picks up #25's init-repo fixture fix so this branch's CI stops rolling the
`git_ops::tests::initialize_repository_*` dice.
… prefixes

`git add -N` makes a path tracked-and-unstaged, so the UI offers line-level
discard on it. Its diff header says `new file mode` / `--- /dev/null`, but a
partial selection keeps the unselected additions as CONTEXT — an old side the
header denies — and git refused the whole apply with "new file X depends on old
contents". Whole-hunk discard worked, so it looked arbitrary.

The deleted-file mirror needs no repair: reverse-apply DROPS unselected `-`
lines rather than demoting them, so the new side stays empty and `+++ /dev/null`
stays true. The repair is therefore conditioned on the emitted hunk having an
old side, which also leaves a selection covering every addition alone so it
still deletes the file the way a whole-hunk discard does.

Adversarial review caught the first attempt being far worse than the bug. It
rewrote the `---` side by replacing the first `b/` ANYWHERE in the `+++` path.
`diff()` did not pin the prefixes, so under `diff.mnemonicPrefix` (prefixes
become `i/`/`w/`) `w/lib/util.js` became `w/lia/util.js` — git read that as a
rename and applied it: the file the user asked to discard one line from was
emptied, a committed file they never touched was rewritten, and it returned
Ok(()). Reproduced against real git.

Two changes, either of which would have prevented it, because this path has no
reflog:

- `diff()` pins `--src-prefix=a/ --dst-prefix=b/`. The output is not merely
  displayed, it is fed back to `git apply`; the user's config governs what they
  read in a terminal, not what this reconstructs. Also closes a pre-existing
  hazard: under `diff.noprefix` a patch loses a component to apply's `-p1` and
  lands on the wrong file, which affects stage/unstage too.
- The rewrite only ever swaps a LEADING `b/`, and when there is no such prefix
  the header is left completely untouched — the shape that shipped before this
  repair, which git refuses. Half-repairing was never verified, so it is not a
  state worth entering.

`discard_lines_is_unaffected_by_diff_prefix_config` covers both configs and
asserts the neighbour file is untouched; confirmed to fail without the pin.

cargo test -p git-core 225 passed; npm run check 530 files 0 errors;
npm test 386 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`clippy::if_same_then_else` fired on the two arms that both drop a header line:
the mode pair, and `new file mode` when the new-file header is being repaired.
Name the two conditions and test them together. No behaviour change.

Missed locally because the documented gate is `npm run check` + `npm test` +
`cargo test`, while CI also runs `cargo clippy -p git-core -p git-it -D warnings`.

cargo test -p git-core -p git-it green; clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#26 gained four commits while this sat stacked on it — the mode-change and
intent-to-add discard fixes, the diff-prefix pin, and a merge of `next`. Two
conflicts, both in ops_worktree.rs:

- `discard_lines`: this branch made `build_partial_hunk` return a Result (`?`),
  #26 routed the header through `discard_header`. Kept both.
- The tests block: both sides appended tests at the same point and shared one
  trailing brace. Kept every test from both, closing the first block explicitly.

cargo test -p git-core 229 passed — all four no-newline-EOF tests and all five
of #26's discard tests green together; clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat: add diff hunk/block hover affordance and hunk/line discard
…tion

fix: stop a partial selection at a no-newline EOF from merging two lines
…logs

`tryPresentPendingAutomaticUpdate` gated on `dialogs.state` alone, so a check
completing while Settings, Manage Repository, amend/rebase, branch-colour or a
GitHub action was open still passed the guard — and the update dialog, which
sits at a higher z-index, mounted straight over the workflow in progress.

`overlays.ts` already exists as the one place that knows every overlay
singleton, precisely so callers stop chasing individual stores and drifting as
new ones are added. Ask it instead. The pending update is not dropped: the
existing retry timer re-checks until nothing is covering the screen.

`dialogs` stays imported — still used by the two confirm() calls below. No
import cycle: nothing overlays.ts pulls in imports the updater.

Reported by Codex review on #27.

npm run check 524 files 0 errors; npm test 330 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from Codex review on #29.

**Git directory as parent.** The same blind spot this PR fixed for the
destination, one level up. When the destination does not exist yet the nesting
probe falls back to the PARENT, and `--is-inside-work-tree` answers false
inside a Git directory just as it does inside a bare repo. Verified: selecting
`some-repo/.git` produced `some-repo/.git/proj`, a whole repository inside
another repository's metadata, and returned initialized: true. A bare repo as
parent did the same. `is_git_dir(&parent)` now rejects both before any
filesystem mutation, with a message that names the actual problem.

**Node floor.** Vite 8 requires `^20.19.0 || >=22.12.0` (recorded in
package-lock.json) but package.json declared no `engines` and the
build-from-source instructions named no version, so `npm install` on Node 18 or
early 20 warns and then fails later as a confusing build error. Declared in
`engines` and stated in the README next to the commands it governs. CI already
builds on 24, so this documents the existing requirement rather than changing
it. Unrelated to the rest of this PR, but fixing it here keeps the review on
one branch.

cargo test 186 + 7 passed; clippy clean; npm run check 524 files 0 errors;
npm test 330 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: let the native context menu through where text is selectable
…rseded update

Two findings from Codex review on #29. Both are pre-existing on `next` and in
files this PR does not otherwise touch; fixed here to keep the review on one
branch.

**P1 — sidebar acting on the wrong repository.** A switch deliberately keeps the
previous repo's refs on screen until the reload lands, because clearing them
flashed the sidebar empty mid-switch. But `appState.repo` already points at the
new repo, so a command fired from a stale row runs against it. With a name both
repos have — `main`, `develop` — "Delete branch" deletes the wrong repo's
branch, and there is no reflog prompt in front of that.

Clearing the state would reintroduce the flicker the retention exists to
prevent, so the rows stay and the actions go inert: checkout, delete (also the
Linked Worktrees panel, which routes through the same handler), and the ref
context menu all refuse while `repoLoading`, which `reloadGraph` clears in a
`finally` guarded against a superseding switch. The context menu bails before it
is built, so its items are unreachable rather than merely guarded. Selection and
scrolling stay live — they read nothing and write nothing.

Also gated the detached-HEAD menu, which the report did not mention: "Create
branch here" would use the OLD repo's HEAD sha, and two clones of one project
share commit ids, so that can quietly succeed in the wrong repository instead of
erroring on an unknown sha.

**P2 — superseded automatic update still prompting.** A manual check replaces the
backend's pending-update slot, but the UI queue was only cleared on an exact
version match. A manual check finding nothing, or a different version, left the
old one queued; closing the overlay then presented a version the backend no
longer had — Download failed with "no pending update", or fetched something
other than what the dialog named. The queue is now dropped when a manual check
starts. Two regression tests, both watched failing first.

npm run check 530 files 0 errors; npm test 388 passed; cargo test 232 + 7;
clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r itself

`is_git_dir` resolves an exact path, so the parent guard saw `repo/.git` and a
bare repo but walked straight past `repo/.git/hooks` and `bare.git/objects` —
which a folder picker reaches just as easily. `--is-inside-work-tree` is false
down there too, so nothing else caught it either, and the flow created a
repository inside another repository's object store. Reproduced: it returned
initialized: true with a path of `host/.git/hooks/proj`.

`--is-inside-git-dir` is documented as true anywhere below the repository
directory, which is the question a candidate parent actually has to answer:

  path                 resolve-git-dir   is-inside-git-dir
  normal (worktree)    false             false
  normal/.git          true              true
  normal/.git/hooks    false             true
  bare.git             true              true
  bare.git/objects     false             true
  plain (no repo)      false             error

The working-tree row is why this stays a separate probe from the nesting one: a
normal working directory must remain a valid parent. The error row is handled
the way `is_inside_worktree` already does — a non-zero exit counts as false.

`is_git_dir` still guards the DESTINATION, where the exact-path question is the
right one. The parent test now covers both tops and both descendants.

cargo test 232 + 7; clippy clean; npm run check 530 files 0 errors;
npm test 388 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: reject an existing bare repository as an init destination

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 90f66147ee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/components/WorkingCopyView.svelte
`resolveSection` returns null once the path is in no list — its last change was
committed or discarded, so the file is no longer part of the working copy. But
`diffKey` tested only `selectedFile`, so it stayed non-empty and the effect
fetched an unstaged diff for a file with nothing to diff. No row could
highlight, since the row test already requires a matching section, while the
pane rendered an empty diff instead of "Select a file to view its diff."

Gate both on `section !== null` rather than clearing the selection: no state
written from a derived or an effect, and it keeps the "follow the file wherever
it went" behaviour the section resolution exists for — if the path comes back,
the selection resumes instead of having been silently dropped.

Reported by Codex review on the v0.4.0 promotion (#30).

Verified by reading plus `npm run check`; there is no WorkingCopyView test
harness, and the path is behind `isTauri()` so the browser preview cannot reach
it either. npm run check 530 files 0 errors; npm test 388 passed;
cargo test 232 + 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion

fix: show the empty state when the selected file leaves the working copy

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 02af5d3e74

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/components/WorkingCopyView.svelte Outdated
…merged

Section presence describes lists ON SCREEN, not file states — the rule the
SectionPresence doc already spells out. The component derived the three fields
inline and they disagreed about it: `unstaged` came from the merged display
list, `untracked` came from the raw file state.

So selecting an untracked file and then turning on "Merge Untracked into
Unstaged" reported presence in BOTH. resolveSection kept the recorded
"untracked", the pane resolved to a section with no rows in that mode, nothing
highlighted, and the Unstaged header offered "Stage all" instead of "Stage".

The rule has now been violated once per field, so it moves out of the component
into `renderedPresence` in workingSection.ts, beside the type it builds and the
resolver that consumes it, with the merged case under test.

`untracked` was also answering two different questions through one field. It
now answers only "is the Untracked SECTION showing this row", which is false
while merging. Whether the FILE is untracked — which selects `git diff
--no-index` and disables hunk staging — reads from the file list directly and
is unaffected by the mode, which is what it always needed to be. The Untracked
header that also consults it renders only when NOT merging, so the two readings
coincide there.

Reported by Codex review on the v0.4.0 promotion (#30).

npm run check 530 files 0 errors; npm test 393 passed; cargo test 232 + 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ashproto and others added 2 commits August 9, 2026 17:12
`refActionsBlocked()` covers the instant of the click. Every ref action that
asks first then waits on a prompt with no timeout, and `gitActions` targets
`appState.repo` — not whatever was on screen when the dialog opened. Switch
repositories mid-prompt and confirming runs the OLD repo's branch or tag name
against the NEW repository; where both hold that name, "Delete" force-deletes
the wrong one. The same time-of-check/time-of-use shape as the stale-diff guard
on discard, which is the precedent for fixing it the same way.

Codex flagged branch delete. Six more sites have the identical gap, three of
them destructive, so all seven are guarded rather than the one reported:

  confirmBranchDelete -> deleteBranch          (reported)
  prompt              -> renameBranch
  prompt              -> createTag
  prompt              -> createBranch (from ref)
  confirm             -> deleteRemoteBranch
  confirm             -> deleteTag
  prompt              -> createBranch (detached HEAD)

`sameRepoAfterPrompt()` captures the repo before the await and returns a
predicate checked after it, so each call site keeps its own control flow
instead of being wrapped in a callback. Refusing states plainly that nothing
was done, rather than failing silently or acting on a guess.

Reported by Codex review on #32.

npm run check 530 files 0 errors; npm test 393 passed; cargo test 232 + 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: resolve an untracked selection into Unstaged once the lists are merged

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 554c1c941d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/gitActions.ts Outdated
Every confirmation in gitActions is awaited with no timeout, and the work that
follows reads `appState.repo` when it RUNS, not when the dialog opened. Switch
repositories mid-prompt and the confirmed action lands on the new one.

The discard snapshot guard does not cover this: `expected_diff` pins the DIFF,
not the repository. Two clones or worktrees holding the same path with the same
diff pass the check, and the wrong copy is irreversibly discarded.

Codex flagged discardHunk and discardLines. The same gap is in every awaited
dialog in the file, so all nine are bound rather than the two reported:

  runDestructive           every caller (reset, rebase, amend, reword, ...)
  runDestructiveRebase     ditto
  discardHunk              reported
  discardLines             reported
  discard (files)          destructive
  clean (untracked)        destructive
  force push --force-with-lease
  create pull request
  runRemote credential retry — `fn` re-reads the repo, so a switch during the
    sign-in prompt would push to a remote the user never chose

Refusing rather than retargeting is deliberate: the user authorised an action
against what they were looking at, and silently applying it elsewhere is a
different action. This is the third appearance of the shape — guard, unbounded
await, act — after the diff snapshot and the sidebar ref actions, so the audit
covered the whole file rather than the reported sites.

Reported by Codex review on the v0.4.0 promotion (#30).

npm run check 530 files 0 errors; npm test 393 passed; cargo test 232 + 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ashproto and others added 2 commits August 9, 2026 17:59
…alog

Two P1s on the previous commit, both the same mistake: the capture has to sit
at the point the user's intent is fixed, and I put it immediately before the
prompt instead. Where an earlier await exists in the same flow, a switch during
THAT await is recorded as the "original" repo and the guard waves the action
through.

- `runRemote` awaits the first push/pull attempt before any credentials prompt.
  Captured before that attempt, so an auth failure arriving after a switch can
  no longer authorise a retry against the new repository — which would have
  pushed to a remote the user never chose, with the credentials just entered.
- `createPullRequest` awaits `branchSubjects` before opening the dialog, while
  `branch` and `bases` were read from the repo BEFORE it. Captured before that
  lookup, so the guard compares against the repo those selections came from.

The audit script had the same blind spot as the code: it checked for a capture
near the dialog rather than ahead of every await in the flow. Rewritten to walk
back to the enclosing function and reject a capture with any earlier await —
and to strip comments and string bodies first, because its own first version
flagged the word "await" inside the comments added here.

Both invariants now hold: 9 captures, none preceded by a real earlier await;
9 awaited dialogs, all with a guarded use.

npm run check 530 files 0 errors; npm test 393 passed; cargo test 232 + 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: bind a confirmed action to the repository it was confirmed against
@ashproto
ashproto merged commit 11bc49c into main Aug 10, 2026
11 of 12 checks passed
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.

1 participant