Commit Graph
684 Commits
Author SHA1 Message Date
l0ng-ai 078dbec98f merge: the commit graph
Two conflicts, both from the graph and the commit detail view landing in the
same week:

- scm/mod.rs: each had removed its own dead-code allow. Both are gone now;
  status_rank's stays, since the file tree is what will use it.
- graph.rs built a CommitDetailView by hand while detail.rs had grown a
  constructor that takes the commit the caller already holds. It now goes
  through open_commit_detail with the row's own commit as the seed, which is
  what the detail view's author asked for: a click costs one git command
  (the file list) instead of two.

The test that covered the old hand-built view asserted `loading`, which was
an artefact of building it directly. It now asserts what its name says — that
the seed arrives — and covers the unseeded path too.
2026-08-09 11:01:12 +08:00
l0ng-ai 25bfe67a7b fix(scm): budget the graph gutter's insets, not just its lanes
The share was measured against the lane strip alone, so a 260px panel asked
for six lanes and an 84px gutter out of a 78px budget. Five is what fits, and
what the width was chosen for.
2026-08-09 10:45:58 +08:00
l0ng-ai 5418d66903 feat(scm): draw the commit graph in the panel's history section
The history section now renders a real `CommitPage`: lanes, nodes, merge
rings, refs and ages, over rows that behave like every other row in the
panel. It replaces the three-lane figure from the G7·0 spike, whose shape it
keeps unchanged — one canvas over the whole list, `paint_quad` for everything,
lane centres snapped to device pixels before the quad is built.

What the section is for decided most of the rest. 260px leaves about 26
characters beside the gutter, and this repository's subjects run to a median
of 64, so reading a message here was never going to work: what a reader gets
is where the branches are, where they merged, which refs sit where, and how
recently anything moved. Two things buy back what can be bought:

- The conventional-commit prefix comes off into a chip. `feat(terminal): ` is
  12.7 characters on average, and the type is exactly the part that reads
  better as three coloured characters than as prose. The split is strict, so
  `Merge pull request`, `fix:it` and a bare URL all keep their whole line.
- The lane gutter folds to a single column on request, worth another six.

Lane colours are derived, not tabled. `Theme::lanes()` seeds from the palette
in the order blue, yellow, magenta, green, cyan, red — no two neighbours share
a hue family, red and green are never adjacent, and red is last because a
panel three or four lanes wide never reaches it — then walks each one to
`ACCENT_FLOOR` on the window, the sidebar and a popover. Across the nine
builtins the worst contrast is 3.00:1 (untreated, `catppuccin_latte` sits at
2.31 and `rose_pine_dawn` at 2.05) and the worst adjacent pair is ΔE 13.8,
against a JND of about 2.3. A hard-coded palette would have been the one
colour in this file that ignores the theme, and the contrast tests cannot see
a literal.

Some notes on the drawing:

- Segments are deduplicated by column before anything is painted, which is
  what makes the overflow bundle work: five lanes folded into the last column
  produce one line, not five stacked at five alphas. `project` is a pure
  projection and never feeds back into the layout, so dragging the panel
  narrower re-columns for free and no branch changes colour.
- Cross-lane turns are right angles, and at a 12px pitch they read
  unambiguously — the same call tig, lazygit and `git log --graph` make. The
  horizontal runs half a line width past both centres, which is exactly what
  closes the corners the vertical stubs leave open.
- No `paint_layer` per line. Zed's graph does that; each one is a full-drawable
  render pass. `BoundsTree` already orders overlapping primitives, and edges
  arrive sorted by `paint_rank`, so the node's own line lands last.
- Nodes are rounded quads rather than paths: the quad shader rounds with an
  exact SDF and analytic anti-aliasing, where `PathBuilder` fills every vertex's
  `st` with `(0, 1)` and gets 4x MSAA alone.
- Paging grows `requested` and re-runs the query. The layout is deterministic,
  so a longer run reproduces the same prefix row for row and nothing on screen
  moves; `--skip` is O(skip) and slides under you when a ref moves. It is a
  row, not a scroll trigger — a remote `git log` is an RPC, and
  scroll-to-load turns one flick into a burst of them.
- Filtering hides the gutter. Lanes drawn across a subset of history would
  connect commits that are not adjacent, so a search result is a flat list,
  which is what it actually is.

Seventeen tests. Four in `presets` run with the existing contrast batch and
assert the floor on all three surfaces, adjacent ΔE, determinism and the seed
order. The rest cover projection, snapped lane centres, the width clamp, the
prefix split (including a Chinese subject, which is where byte indexing goes
wrong), band deduplication, the filter and the scope label. One runs a real
repository through a real pane and asserts the settled section draws zero
frames while idle — a canvas that repaints every frame reads as correct code.
2026-08-09 10:44:16 +08:00
l0ng-ai cc0e4f1edf merge: the commit detail view 2026-08-09 10:21:39 +08:00
l0ng-ai 5497252aec feat(scm): read a whole commit in the panel's second-level view
The graph gives up text for shape: a 260px row has room for about 26
characters beside its lanes, and this repository's commit subjects run
to a median of 64. This is where the rest comes back — the full subject,
the body, every ref, the parents, and the files the commit touched.

The file list is two commands, not one. Git accepts `--numstat` and
`--name-status` together and then quietly drops the numstat half
(measured on 2.50.1), so they run separately and join on the path. It is
`log -1 --first-parent` rather than `diff-tree -m --first-parent`, which
does not narrow a merge: on the same git it emits one diff per parent
and concatenates them, so a two-parent merge came back with every path
listed twice. `log` is also how `DiffSource::Commit` walks the patch,
which is what makes this list and the overlay's cards agree file for
file.

`DiffSource::Commit` now carries an optional label, so the overlay's
header can say what the commit was about instead of eight hex digits.
The label deliberately takes no part in the source's identity:
`PartialEq`, `Hash` and the overlay's string probe key are all derived
from one `tag()` function, because the same commit opened with a subject
in hand and without one has to stay one patch, one in-flight probe and
one overlay. The key used to be built from `Debug`, which would have
split the probe cache the moment a label arrived.

Also lifts `local_branches` out of the panel's inline `for-each-ref`,
and keeps the `%(upstream)` that `parse_refs` had been asking for and
throwing away.
2026-08-09 10:20:34 +08:00
l0ng-ai 60b4ebacfd spike(scm): prove the graph's one-canvas rendering plan (G7·0)
A hard-coded three-lane figure, no data layer attached, standing in for the
history section. It exists to answer the four questions the whole rendering
plan rests on before any of it is built on top, because a "no" to any of them
invalidates the rest.

Verified against an isolated dev instance (own config dir and daemon),
screenshots read back:

1. One `canvas` absolutely positioned over the rows, inside the section's own
   `overflow_y_scroll`, paints where it should — lines and nodes land on their
   rows and stay on them after scrolling. A canvas per row was never on the
   table: every `Paths` batch costs a full-drawable render pass.
2. `window.content_mask().bounds` gives usable culling bounds. With 33 rows and
   a 195px viewport it reported `705..900` and the paint loop covered rows
   0..10 — 24 quads instead of ~70. Scrolling moved that to rows 16..26 while
   the mask stayed put, which is exactly right: the mask is the viewport, the
   canvas bounds are what moves.
3. Clicks reach the row `div` through the canvas. A click landed inside the
   lane gutter, on top of a painted line, and selected the row underneath.
   `Canvas::id` returns `None` and it implements no interactivity, so it never
   registers a hitbox — being above in z only decides paint order. That is what
   buys the rows gpui's native hover, click and scroll-into-view for free.
4. The height divider, which is `right_panel_resize` rotated onto the other
   axis, tracks the cursor 1:1 and clamps where it is told to.

Everything is drawn with `paint_quad`: straight segments as thin rects, nodes
as rounded quads for the SDF's analytic anti-aliasing, and cross-lane turns as
right-angle elbows. The elbows read cleanly at a 12px lane pitch — tig,
lazygit and `git log --graph` all draw them square — so curves stay behind a
switch rather than in the first version. Lane centres are snapped to device
pixels before the quad is built, not after, or a column of lines changes width
as it scrolls.

The next commit replaces the fake rows with `CommitPage` and keeps this shape.
2026-08-09 10:08:57 +08:00
l0ng-ai d8d822092d chore(scm): reserve the graph and commit-detail mount points
Both land in panel.rs — the history section below the file list, the detail
body in place of it — and both are being written in parallel. Landing the two
call sites and their stub modules up front keeps them from colliding over the
same function.

The detail body replaces the working tree's rather than sitting beside it, so
the two can never be on screen each claiming to be the file list. The history
section sits outside the scroller: it pages, and sharing a scroll region would
mean scrolling back past hundreds of commits to reach the message box.
2026-08-09 09:43:15 +08:00
l0ng-ai 16d7c1b5a2 refactor(scm): retire the panel's old flat-diff state
The panel now reads WorkingTreeStatus, so RightPanelState's diff, diff_cwd
and diff_pending had no writer left — and with them went PANEL_DIFF_SOURCE,
the seed shortcut that borrowed the panel's snapshot, and the write-back that
kept it fed. The overlay always names its own source now.

Two things were still reading that state and quietly getting nothing:

- the source control tile's badge, which now counts entries from the same
  status the panel draws, so the number and the group headers cannot disagree
- the branch row's busy spinner, which was inferring "the push finished" from
  the repository's epoch moving. Anything else that moves the epoch — a .git
  event, a file save — would have dropped the spinner mid-push. It asks the
  slot counter run_git_op claims from instead, which is the operation itself.

scm_network_busy reads through try_global rather than default_global: it runs
from render, where taking the global mutably queues a global-observer effect
on every frame.
2026-08-09 02:10:53 +08:00
l0ng-ai 1e525a65b6 merge: the Source Control panel 2026-08-09 02:03:50 +08:00
l0ng-ai 293d761cba feat(scm): show the branch, its distance and one button to close it
A row of its own between the title and the message box. Not the title's
trailing slot: off macOS the tab tiles render after it, and a branch name
is the elastic element here — it would be the first thing squeezed.

A branch level with its upstream says nothing at all. The quiet state is
the common one, and a chip that is always there stops being read; ahead,
behind and "publish" are the three things worth interrupting for, plus
the sequencer operation the repository is parked in and the amend badge.
Interactive and plain rebase read the same, because git writes
`rebase-merge/interactive` for every rebase and the distinction is not
one the repository on disk can make.

The branch name opens the switcher, which lists the local branches from
`for-each-ref`, re-read whenever anything could have moved a ref. Naming
a new branch is an inline input rather than a dialog: `window.prompt`
only offers buttons, and there is no modal component to reach for.

The root every operation runs from is now resolved with its own
`rev-parse` rather than borrowed from the cheap per-tab cache. That cache
holds a repository's *home*, which is a different directory inside a
linked worktree, and it is only filled in for panes whose shell reports a
cwd — the panel would have sat on "Loading…" forever without one.

Two render-idle tests hold the panel to asking git once and then going
quiet, over a real repository and over a directory that is not one. The
hazard they cover is specific: `scm_refresh` reaches for its cache
through `default_global` from inside `render`, so a watcher that notified
on every global write would ask for a frame from inside a frame forever.
2026-08-09 02:02:43 +08:00
l0ng-ai 7695287f4a feat(git): connect the remaining invalidation sources and subscribers
The watcher landed with one subscriber declared and one invalidation source
wired, because the three others live in files it did not own. Wiring them:

- the file tree announces working-tree edits it sees, skipping anything under
  .git so the repository's own watch is not doubled into the same window
- the editor announces a save, which is the working-tree edit neither watch
  can see when the tree is not showing that directory
- a pane announces a command boundary, which is the only signal for a command
  that edits a file nowhere anyone is looking. It only moves the epoch: the
  probe rides the app's next render, which refresh_git_status is about to
  cause anyway by writing GitStatusCache

The tree and the editor also declare themselves as watchers, so decorations
and gutters keep a repository live on their own rather than only while the
panel happens to be the visible tab. Both target the active pane's
repository, which is what the panel picks too, so the three subscriptions
usually collapse onto one watch.
2026-08-09 01:36:20 +08:00
l0ng-ai c2f7574bfa feat(scm): commit from the panel
A multi-line message box over a primary button, both pinned above the
file list so they stay reachable however far down the changes go. The
box rests at 30px — the height of `panel_search`, so every input row in
the panel sits on one line — and grows to six rows.

`secondary-enter` commits, bound inside the `ScmCommit` key context the
box installs. On macOS that chord is `ToggleFullscreen` at the window
level; the two coexist because gpui resolves a keystroke by walking
outwards from the focused node, and a test asserts exactly that — the
chord is shared and scope is the only thing telling them apart.

The button says what pressing it would do: "Commit All" when nothing is
staged, since a plain `git commit` would commit nothing and a silent
`-a` would be a lie. Amend is a menu item rather than a checkbox row,
because 260px does not have a row to spare.

Drafts are keyed by working tree, so switching tabs or panes keeps the
message. A commit is only cleared from the box once HEAD has actually
moved: clearing it on dispatch would lose a carefully written message to
a pre-commit hook that rejects it.
2026-08-09 01:36:01 +08:00
l0ng-ai 72776d3367 fix(git): only repaint when a status probe changed what is shown
Landing a probe called refresh_windows unconditionally, so the answer that
changes nothing — a re-read confirming what is already drawn, or the "still
not a repository" reply for an ordinary directory — cost a frame anyway.

rewriting_a_file_in_a_displayed_directory_costs_no_frames caught it: that
test asserts the file tree redraws nothing when a file it is showing is
rewritten, and the probe the tree kicks off for its own root was putting a
frame behind every such write.

Comparing the whole WorkingTreeStatus is O(entries), but it runs once per
probe rather than once per frame, which is the trade this is making.
2026-08-09 01:29:26 +08:00
l0ng-ai f63c1680f9 merge: a .git watcher, debouncing, and subscription gating 2026-08-09 01:27:10 +08:00
l0ng-ai 200d27286a feat(scm): stage, unstage and discard from the rows
Hovering a row brings up its buttons, absolutely positioned over an
opaque backing so they cover the tail of the directory instead of
pushing it aside — hovering must not move a pixel of the list under the
pointer. The header of each group carries the same verbs applied to all
of it.

Which buttons appear follows the group: Changes and Untracked get
discard and stage, Staged gets unstage, and a conflict gets "open" and
"mark resolved" — the latter being `git add`, because git has no other
verb for resolving.

Anything that can lose work goes through `window.prompt` first, keyed
off `GitOp::destructive` so the data layer stays the one place that
decides what is dangerous. That is the project's only confirmation
mechanism; no modal component is introduced.

A path that is not valid UTF-8 cannot be sent to git as a pathspec, so
its own buttons are disabled with a tooltip saying why, and group
actions leave it out — `validate` rejects the whole operation over one
of them, which would otherwise punish everyone else in the group.
2026-08-09 01:27:06 +08:00
l0ng-ai 71081236d0 feat(git): watch .git, debounce it, and stop when nobody is looking
The source control data layer had a cache and a way to invalidate it, but
nothing that noticed a change on its own. This adds the three pieces that
make the panel live: a `.git` watch, one debounce in front of every source
of invalidation, and a subscription gate so a repository nobody is looking
at costs nothing at all.

The watch covers `<git_dir>`, the common dir when a linked worktree makes
them different, the three `refs/` roots and the namespaces under
`refs/heads` — `Host::watch` does not recurse, so each one has to be named.
Never the working tree: recursively watching one over SSH is a disaster, and
edits there already reach the same bus from the file tree, the editor and
the command boundary.

Everything invalidating goes through one `scm_invalidate`, so a `git add`
and the watcher event it provokes fall in one window and cost one probe
between them. There is no self-triggering to defend against: the read path
sets `GIT_OPTIONAL_LOCKS=0`, whose only effect is to stop `git status`
writing back `.git/index`, so probes provably cannot wake the watch that
schedules them.

Also fixes four things that could not survive contact with a live panel:

- A network slot was released in `run_in`'s landing closure, which does not
  run if the view died first — one lost slot per abandoned push, forever.
  The claim is now a guard that rides in the work closure instead, which
  runs either way and needs no `App` to release.
- A probe that found no repository never wrote `read_at`, so the root stayed
  stale and every frame spawned another `rev-parse`. A pane sitting in an
  ordinary directory is normal, and "there is no repository here" is an
  answer like any other; `known_status` now reports it as one.
- Nothing marked a window dirty when a probe landed, so the panel waited for
  the next unrelated repaint.
- `ScmData::clear_host` had no caller, and neither did `GitStatusCache`'s
  equivalent: a dropped SSH link left the branch and the file list the
  machine had on the way down, with nothing to say so.
2026-08-09 01:25:39 +08:00
l0ng-ai fd5209f7fa merge: a unified diff view, and per-source overlay staleness 2026-08-09 01:20:49 +08:00
l0ng-ai 45879d1c22 feat(diff): add a unified view and name the source in the header
The overlay could only ever show one thing one way: `git diff HEAD`, side
by side. Both halves of that are now choices.

A segmented control in the header switches between side-by-side and
unified, stored in `Config::diff_view` so the choice survives the next
open. Unified is measured against the split cell rather than designed
next to it: the same 19px row, the same type, and the same 0.12 wash
behind an addition and a removal. It differs only where the shape forces
it — 34px per line-number gutter instead of 42, and a column of its own
for `+`/`−`, without which the context lines' code would start two
characters left of everything else.

The header now says which patch it is showing. A branch name for the
worktree and for HEAD as before; the same with a STAGED chip for the
index, which is otherwise indistinguishable; the commit glyph and a
short object id for a commit or a range. The subject and author of a
commit are not there yet — `DiffSource::Commit` carries only the rev,
and buying them costs another round trip that the commit detail view
will be making anyway.

Two pieces of coupling go with it. `PANEL_DIFF_SOURCE` no longer decides
whether the panel's snapshot may seed an overlay, or whether an overlay
has gone stale: the first is settled by the snapshot's own source, the
second by the overlay's. A commit and a range never go stale at all, and
the two sources the cached `--numstat HEAD` counts cannot describe now
compare `ScmData` epochs instead — read when the probe starts, so a
write landing under it is not mistaken for one the result reflects.

And the file cards drop their private A/M/D/R table for the shared
`status_glyph`/`status_color`, so a file wears the same letter here as
in the panel and the tree.
2026-08-09 01:19:50 +08:00
l0ng-ai b92d9afa03 feat(scm): group the panel by index and working tree
The Changes tab ran one `git diff HEAD`, so it could not tell a staged
change from an unstaged one and gave every row the letter `M`. The panel
now renders `WorkingTreeStatus`, which reports both halves of `XY`
separately, in the four sections git itself talks about: Merge Changes,
Staged Changes, Changes, Untracked.

A row wears the letter of the half its group is about, so a file added to
the index and then edited again reads `A` under Staged and `M` under
Changes — and clicking it opens the matching patch, `--cached` for a
staged row and the working tree for the rest.

The group chevron sits in a box exactly as wide as `git_badge`, so the
fold arrows and the status letters below them form one column.

Two things guard the render loop. The pane's directory is turned into the
repository root before anything is cached or run, because porcelain
pathspecs are relative to the root and a write from a subdirectory would
name the wrong files; and the `ScmData` watcher compares before it
notifies, since `scm_refresh` reaches for the global through
`default_global` from inside `render` and an unconditional notify would
ask for a frame from inside a frame forever.
2026-08-09 01:19:02 +08:00
l0ng-ai 5f57d32eee merge: git status decorations in the file tree 2026-08-09 01:17:59 +08:00
l0ng-ai f6e8569920 feat(scm): decorate the file tree with git status
The tree had no git in it at all. Now every row asks the repository's
`StatusIndex` what it is: the name takes the status colour, and a letter
lands in a 14px trailing cell drawn by the same `git_badge` the panel and
the diff cards use, so a status letter has exactly one look everywhere.

Directories roll up to two states and no letter — a folder is not "M",
but a collapsed folder still has to say whether there is work under it,
which is the whole reason the decoration earns its place.

The unsaved-buffer dot keeps its own column and its own shape. It is not
a git indicator and never was; round-and-warning next to the letter cell
is what keeps the two from being read as one.

Ignored rows are left exactly as they were. Italic and dim already says
everything, and a tree full of `!` is noise.

Cost per row is one hash probe and no allocation: the `Arc` and the repo
roots are taken once outside the loop, and the key is borrowed straight
out of the path on any platform whose separator is already `/`.
2026-08-09 01:16:19 +08:00
l0ng-ai 75c3af1047 feat(git): add the source control data pipeline
One cache of what each repository looks like, one way to change it, one way
to say that is now stale. The panel, the file tree's decorations and the
`.git` watcher all build on this, and they are being written in parallel —
landing the contract first is what keeps them from each inventing their own.

Kept apart from git_status deliberately. That cache answers a cheap question
for a tab badge on every cwd change and every command boundary, for every
pane. This one runs `status --porcelain=v2 -uall`, which is seconds on a
large repository, and only while something is looking. Folding them would put
the expensive probe on the cheap trigger.

Invalidation is by epoch, not by key: working out which entries a `git add`
touched is a losing game, and a counter per repository cannot miss one.

run_git_op also caps concurrent network operations per host at two. The far
side serves every request from one worker pool and keepalive's Ping queues
behind the rest of it, so enough concurrent pushes and the link is declared
dead — the client is the only place that can hold the number down.
2026-08-09 00:59:51 +08:00
l0ng-ai ac5983056c style(git): collapse the ahead/behind fallback into one condition 2026-08-09 00:55:39 +08:00
l0ng-ai 38899186dd merge: the Source Control panel's scaffold and wiring 2026-08-09 00:52:03 +08:00
l0ng-ai 80e8e09548 feat(scm): turn the Changes tab into Source Control and wire the surface up
Renames `RightPanelTab::Changes` to `Scm` in place. `#[serde(rename =
"changes")]` works in both directions, so what lands on disk is unchanged
and a build from before this commit reads the config back without kicking
anyone off the panel they left open — a fourth variant could not do that,
and 260px has no room for a fourth tab tile anyway. The action name
`ShowRightPanelChanges` stays put because `Config::keybindings` is keyed by
it, and every existing custom binding would otherwise be orphaned.

The panel body moves to `src/ui/scm/panel.rs` byte for byte; it still
renders the flat `git diff HEAD` list. Alongside it the module gets the
pieces the rest of the feature is built from: the shared status glyph and
colour tables (one definition instead of three that drift), the path and
timestamp helpers a 260px column needs, and the panel's state types.

Also wires the whole surface: fourteen actions, their key bindings, ten
palette commands in a `Git` group of their own, and the translations.
`ScmCommit` takes `secondary-enter`, which macOS already gives
`ToggleFullscreen`; the two coexist because the commit binding is scoped
to the commit box, and `every_default_chord_is_claimed_by_exactly_one_action`
now checks uniqueness per context instead of globally, which is the actual
invariant gpui enforces.

`Config` gains `diff_view` and `scm_graph_expanded`. Both default to what
happens today.

Two new icons. `git-sync.svg` is deliberately not `refresh.svg`: the panel
header already carries a refresh tile, and the same glyph meaning two
different things one row apart reads as a bug.

Adds `every_action_has_a_binding_arm`, which walks every action in
`default_bindings` rather than only the ones shipping a default keystroke.
The gap it closes is an action listed in Settings with no `make_binding`
arm behind it: the user assigns a key and the key silently does nothing.
2026-08-09 00:51:06 +08:00
l0ng-ai c675796ef7 merge: three diff sources, and the rows both views are laid out from 2026-08-09 00:41:20 +08:00
l0ng-ai 58e8ba16d1 feat(git): ask for three kinds of diff, and lift the row model out of the renderer
`probe` could only ever run `git diff HEAD`. A `DiffRequest` now names the
source (worktree, staged, HEAD, one commit, a range), the pathspecs, the
context width and the budget, and `DiffSource::args` is the single place any
diff argv is built — which is also the single place to test it.

Three things were verified against git 2.50.1 rather than assumed:

- `-c core.quotePath=false` is a real bug fix, not tidiness. With quoting on,
  `diff --git "a/\344\270\255\346\226\207\345\220\215.txt" …`; off, `diff --git
  a/中文名.txt b/中文名.txt`. `parse_quoted_pair` does not decode octal, so
  every non-ASCII path in the overlay was simply wrong. `ls-files` quotes the
  same way, so the untracked listing gets the flag too.

- `Commit` runs `log -p -1 --format= --first-parent`, not `diff-tree`.
  `diff-tree` does not honour `--first-parent` as a narrowing of a merge: over
  a merge of two branches that each added a file, `diff-tree -p -m
  --first-parent` emits both files (one patch per parent, concatenated) and
  dropping `-m` emits nothing at all. `log -p -1 --first-parent` gives the one
  first-parent patch for a merge, an ordinary commit and the initial commit
  alike, so there is no special case and no `--root`.

- A type change is not one patch. git emits a deletion and a creation for the
  same path, back to back, with nothing in either header saying why; the pair
  is folded back into a single `TypeChanged` entry. `old mode`/`new mode` only
  appears for permission changes, which stay `Modified`.

`FileStatus` also gains `Copied` and `Unmerged`. A conflicted path arrives as a
combined diff (`diff --cc`, `@@@`, one marker column per parent), so the body
parser reads its marker width from the hunk header instead of assuming one.

The budget is a parameter now (`DiffBudget::PANEL`, `::SINGLE_FILE`) rather
than three module constants read inside the parser. Defaults are unchanged.

On the UI side, the overlay's reuse test was `(cwd, host)` — clicking a staged
file while a worktree overlay was open took the "just move the focus" branch
and went on showing the unstaged patch under the staged file's name. The source
is part of an overlay's identity now, in that filter, in the panel-seed
shortcut and in the in-flight de-duplication key. `maybe_refresh_diff_overlay`
only compares HEAD snapshots against the cached `--numstat HEAD` counts, which
are the only counts they are comparable to.

`split_hunk` and tab expansion move to `ui::diff_rows` alongside a new
`unified_rows`, so the two renderings of a hunk are built and tested in one
place, without a window. The overlay's file list finally hangs on tty7's own
scrollbar instead of a bare `overflow_y_scroll`.
2026-08-09 00:40:21 +08:00
l0ng-ai 9285d6d94f merge: commit log parsing and graph lane layout 2026-08-09 00:40:01 +08:00
l0ng-ai b27d0d322b feat(git): lay out the commit history in lanes
Adds the three pieces the graph section needs from the data layer: the
lane assigner, the `log --pretty` parser it is fed from, and the paged
loader that puts the two together.

`LaneAlloc` is append-only. It keeps only what has to survive a page
boundary — which oid each lane is holding a place for, and the reverse
index — so a later page extends the graph instead of re-flowing the rows
already on screen. That is only possible because `GraphRow` is
row-local: a row says nothing about the rows below it, which is also why
a long-lived branch is drawn from its first row rather than staying
invisible until the page holding its parent loads.

Colour is the lane number, fixed when the lane is created. A per-branch
counter wraps at the palette size and puts branch 0 and branch N in the
same colour, which in a three-column panel is very likely two adjacent
lines; keying on the lane makes neighbours distinct by construction. The
"one branch, one colour" half falls out of the first parent inheriting
its child's lane in place. Lanes are recycled but never compacted —
compacting would move a lane out from under a row already drawn.

The log is read with RS between records and US between fields rather
than `-z`, whose record separator is NUL and so collides with the field
separator; fields are taken with `splitn` so the body absorbs any US of
its own. Paging is a larger `-n`, never `--skip`, and `HeadAndUpstream`
resolves to shas first so a push between two pages cannot shift the
window out from under page one.

The Cargo.lock line is the missing half of tty7-core picking up
smallvec, which main already carries.
2026-08-09 00:38:54 +08:00
l0ng-ai 6ab4799bc1 fix(git): restore the classify test helper dropped with network_env
Removing network_env took its neighbouring `kind_of` shorthand with it. The
shared target directory hid this: a stale test binary from another worktree
reported green twice before an isolated CARGO_TARGET_DIR showed the ten real
errors.
2026-08-09 00:37:28 +08:00
l0ng-ai b8757372b3 feat(git): give network operations the long deadline
run_op now routes fetch/pull/push through Host::git_with_deadline. The
no-prompt environment turned out not to belong here at all — LocalHost puts
it on every call, on both sides of the wire, so ops.rs's own copy of the
same four variables was a second definition waiting to drift. Removed it;
the conformance case git_terminal_prompt_is_disabled guards the behaviour
across local and remote, which the unit test on the constant could not.

Also corrects git_output_with_env's doc comment, which claimed read paths
must not inherit the no-prompt environment. They do, deliberately: a read
path never prompts, and a request arriving over the wire carries no bit
saying which kind it is.
2026-08-09 00:34:59 +08:00
l0ng-ai 5171880056 merge: a long deadline for network git, with no protocol change 2026-08-09 00:31:34 +08:00
l0ng-ai 94f9f2ba3b merge: porcelain v2 working-tree status 2026-08-09 00:31:25 +08:00
l0ng-ai df6f028685 feat(git): parse porcelain v2 into a working tree status
Adds the parser and the probe behind the source control panel: one
`git status --porcelain=v2 --branch --show-stash -uall -z` per refresh,
read through `Host::git` so the NUL stream survives a remote workspace
byte for byte (`git_lines` would reassemble it into one giant line).

- `parse_porcelain_v2` is pure and infallible: headers, `1`, `2`, `u`,
  `?` and `!` records, submodule sub-state, rename scores. A `2` record
  spends two NUL tokens, so the parser parks the half-built entry and
  takes the *next* record as the old path — a regression test asserts
  the row behind a rename is not swallowed.
- `probe_status` costs three round trips: `rev-parse`, `status`, and one
  `read_dir` of the git dir for the sequencer operation. The operation
  precedence mirrors git's own `wt_status_print_state`; only a parked
  rebase pays for a second `read_dir` to tell `am` from rebase and `-i`
  from plain.
- `StatusIndex::build` folds a status into the file tree's lookup, and
  drops the per-file map past `MAX_DECORATED_FILES`.

Two behaviours were measured on git 2.50.1 and shaped the code:

- `status.aheadBehind=false` does *not* suppress `# branch.ab` for
  porcelain v2 (the config is documented for non-porcelain formats), and
  `--no-ahead-behind` prints `+? -?` rather than dropping the line. So
  `+? -?` parses as *unknown*, never as in sync, and the `rev-list
  --left-right --count` fallback fires only when the line is missing —
  unconditionally re-asking would cost a remote RPC on every refresh for
  a number we almost always already have.
- `-c core.quotePath=false` is a no-op under `-z`, which disables
  C-quoting on its own. It is kept for consistency with the other git
  invocations, not because it fixes anything here.

`repo_home` becomes `pub(crate)` so the probe can reuse the one
`rev-parse` that already resolves root, git dir and common dir.
2026-08-09 00:30:20 +08:00
l0ng-ai 5b9c41555c feat(host): let long git operations finish over a control link
`push`, `pull` and `fetch` run for as long as the network takes. A remote
host sent them as a plain `Git` request, whose client-side deadline is the
20 seconds an interactive query gets, so anything slower came back as a
timeout while the far side was still working.

Add `Host::git_with_deadline`, a provided method that names the ceiling at
the call site. `RemoteHost` sends the same `Git` request it always did and
only widens its own patience, so the bytes on the wire are unchanged and a
server that predates this serves it as-is: no new verb, no version bump.
`LocalHost` does not override it — `Command::output()` has no timeout of its
own, so forwarding to `git` already means "wait until git is done".

Waiting longer is only useful if the thing we wait for cannot stop to ask a
question. Give every git `LocalHost` spawns an environment that forbids
prompting: nulled stdin was not enough, because without
`GIT_TERMINAL_PROMPT` git opens `/dev/tty` itself. The read path carries the
same environment rather than a separate one — `status` and `diff` have
nothing to prompt about, and the wire has no bit that says "this request
talks to a network", so the remote server has to reach the same rule from
the same args to be protected at all.

Guard the byte fidelity the SCM panel now leans on: `-z` output keeps its
NULs through `Host::git` (`git_lines` would rejoin the records with
newlines), and a `:(literal)` pathspec reaches git unrewritten.
2026-08-09 00:29:33 +08:00
l0ng-ai 61d9e644ca merge: git write operations (GitOp / run_op / classify) 2026-08-09 00:28:16 +08:00
l0ng-ai 9914a939b4 chore(deps): regenerate the lock for tty7-core's smallvec
The dependency was declared but the lock was never refreshed, so every
worktree building against it produced the same one-line diff.
2026-08-09 00:27:31 +08:00
l0ng-ai bb9208cf3e feat(git): build and run the source control write operations
Adds the three pieces ops.rs was missing: GitOp::commands, the pure argv
table every write goes through; run_op, which executes those batches and
turns a failure into something the UI can act on; and classify, which
reads git's own words to decide what the failure was.

Pathspecs always carry :(literal) and always sit after a --, so a file
named a[b].txt, HEAD or -f names itself. Long lists split at 200 paths.
Non-UTF-8 paths are rejected up front rather than sent lossily.

The old command spellings throughout, never git restore, so one argv
works on a CentOS 7 dev box and here alike; the only state-dependent
branch is an unborn HEAD, and HeadState is a parameter so the table
stays pure.
2026-08-09 00:26:10 +08:00
l0ng-ai 3b3e5fa046 chore(ui): reserve the diff_rows and scm modules
Two parallel strands of the source control work each need to add one line to
ui/mod.rs. Landing both declarations up front keeps them from colliding over
it.
2026-08-09 00:14:42 +08:00
l0ng-ai c3233bb39b refactor(git): split core::git into a module tree and move the diff model into it
The source control work needs four kinds of git knowledge — how to run a
process, what the working tree looks like, what a patch looks like, and what
the history looks like — and they were about to pile into one 386-line file.

core/git.rs becomes core/git/{mod,status,diff,log,ops}.rs. Every existing
import path is unchanged: mod.rs still holds the process layer and re-exports
nothing it did not already export.

src/terminal/git_diff.rs moves wholesale into core::git::diff, leaving a
pub-use shim so diff_overlay.rs and right_panel.rs compile untouched. It never
had a gpui dependency, and the headless server should parse a patch with the
same code the GUI does.

Also lands the shared pieces the panel is built on, all inert for now:

- RecordSplitter, LineSplitter's sibling for the two git formats that are not
  newline delimited (porcelain v2 -z, and log with an ASCII record separator).
  It hands out &[u8] because a path in a -z status need not be UTF-8.
- git_output_with_env, so network operations can be told they have no terminal
  to prompt at without the read paths inheriting that.
- The status/log/ops type contracts: the XY pair, unmerged stages, HeadState,
  RepoPath (which refuses to produce a pathspec it cannot represent), the
  row-local graph edge model, and GitOp with its destructive() policy datum.

No behaviour changes. 867 core and 1036 app tests still pass.
2026-08-09 00:13:08 +08:00
l0ng-aiandl0ng-ai 817447bd48 feat(agents): recognize Oh My Pi and install its status hooks (#405)
Issue #376 asked for `omp`. Oh My Pi is a fork of Pi (can1357/oh-my-pi,
descended from badlogic/pi-mono), but the fork is where the similarity
stops for our purposes: it ships one binary of its own — `omp`, the only
`bin` in `@oh-my-pi/pi-coding-agent`, and it never installs a `pi` — and
it keeps its config under `~/.omp`. A pane running it was therefore not
detected at all, and aliasing `omp` onto `CLIAgent::Pi` would have been
worse than nothing: the status bridge would land in `~/.pi`, and Resume
Session would offer `pi --session <id>` to a binary that spells that
flag `--resume`.

So it gets its own variant, wired the whole way through:

| | |
|---|---|
| Detection | argv stem `omp`, distinct from `pi` in both directions |
| Avatar | its own mark, normalized from the project's `assets/icon.svg` |
| Resume | `omp --resume <id>`, opting out on `--no-session` |
| Fork | `omp --fork <id>` — a verified fork command, so the menu item appears |
| Hooks | Settings → Agents, at `~/.omp/agent/extensions/tty7/index.ts` |

The status bridge is the one piece the fork did not change. Oh My Pi
inherited Pi's extension contract intact — same default-exported factory,
same `session_start` / `agent_start` / `agent_end` / `session_shutdown`,
same `ctx.sessionManager.getSessionId()` — so `pi_extension_ts` now takes
the agent and substitutes two things, the package it imports the type
from and the slug it calls the emitter with. Pi's generated file is
byte-identical to before, so no installed bridge goes stale.

`--resume`, `-r` and `--session` are three spellings of one flag in Oh My
Pi; all three shed when a session command is rebuilt, while `--session-dir`
is a different flag and rides along. `fork_command` now honors the same
`--no-session` opt-out `resume_command` already did — Oh My Pi rejects
`--fork` outright under it, and no existing agent declares an opt-out.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 13:10:38 +08:00
l0ng-aiandl0ng-ai 741c863c2c fix(windows): make the install directory actually replaceable before updating (#403)
* fix(windows): make the install directory actually replaceable before updating

The updater stopped the daemon and started the Inno installer the moment
the daemon's endpoint disappeared — but the endpoint going away is not
the same event as the images being released. The ConPTY hosts
(OpenConsole.exe) are the daemon's children, not the shells', so the
per-pane kill never reached them, and the daemon's exit(0) skipped every
destructor that would have closed them; they kept the installed
OpenConsole.exe open for seconds after --stop-daemon returned. Silent
Setup then hit the lock, took the suppressed dialog's default (Abort),
and the updater's recovery relaunched the old build — "updated,
restarted, still the old version". A daemon that died without cleaning
up made it permanent: its orphaned hosts survive indefinitely, which is
the DeleteFile-code-5 users hit even after "closing everything".

Reproduced both shapes in isolation before fixing: with a pane open,
--stop-daemon returned ~1s in while OpenConsole.exe stayed locked for
another ~1.4s; after taskkill on the daemon, the orphaned host held the
lock forever.

The shutdown now finishes what it starts, at every layer that can be
the last one standing:

  * The daemon reaps its remaining descendants and waits for them
    before exiting, while the endpoint — the signal stop() watches —
    is still up.
  * stop() reads the pidfile before asking, and waits for that process
    to actually exit after the endpoint goes, not just stop listening.
  * The recorded-daemon reap waits for the images to be released
    instead of returning on the async TerminateProcess.
  * stop_for_update(dir) — reached via --stop-daemon
    --update-install-dir, which PrepareToInstall and the portable
    updater now pass — also terminates anything still running from the
    installation directory (the orphan case no pidfile can name) and
    only returns once the .exe/.dll images there open for writing,
    naming the holdouts in the error if they never do.
  * The updater runs that clearing itself before invoking Setup, so a
    directory that cannot be cleared fails with a cause in update.log
    and relaunches the previous build, instead of Inno's bare
    "DeleteFile failed; code 5".

The update dialog on Windows also told a macOS truth — "the background
service keeps running, so whatever is open in your panes survives".
Windows cannot replace a running daemon's image, so its install path
stops the service; the dialog now says so.

* fix(windows): tighten the install-dir clearing per review

- An image that fails to canonicalize stays in the lock check instead of
  being silently skipped; only a positive match against the caller's own
  running image is excluded.
- reap_recorded_daemon shares one deadline across the whole tree via a
  new winproc::terminate_and_wait_all, which stop_for_update and
  reap_descendants_of now use too — one implementation of "terminate,
  then wait, bounded overall" instead of three.
- [UninstallRun] passes --update-install-dir "{app}" like
  PrepareToInstall, so uninstalling after a daemon crash gets the same
  orphaned-ConPTY-host cleanup as upgrading.

* fix(update): close three gaps the update audit found

- macOS updater: wait for the parent by watching getppid() reparent to
  launchd instead of polling kill(pid, 0), which a recycled pid could
  satisfy forever. The kill loop remains only for a hand-run updater.
- Windows: a new update guard (config-dir update.lock, held by the
  updater from daemon stop to relaunch) makes ensure_running refuse to
  spawn a daemon mid-install, so a tty7 CLI call or manual launch can no
  longer relock the images the installer is replacing. Stale guards —
  dead writer or past the TTL — are shed on sight.
- Windows portable: the update backup now carries an incomplete marker
  from before the first file moves until the replacement lands. At
  launch the app reports a backup still carrying it as an interrupted
  update (the installation may mix two versions; the old files are
  preserved), and silently removes marker-less backups a finished
  update failed to delete past an antivirus hold.

* fix(update): verify the guard's writer by start time, and guard manual Setup runs

Review round three, both findings and all three minors:

- The guard no longer expires a live, verified holder: a pid is believed
  to be the writer only if the process behind it started before the
  guard was written (winproc::creation_time via GetProcessTimes), which
  is what tells a genuine holder from a recycled pid. The TTL now bounds
  only the unverifiable case, so an install slowed past ten minutes by
  an antivirus sweep keeps its protection.
- Manual Setup runs get the guard too: the --stop-daemon
  --update-install-dir helper holds it in its parent's name — the Setup
  or uninstaller that keeps replacing files after the helper returns —
  and it goes stale when that parent exits. ensure_running gained five
  seconds of patience so the post-install "Launch tty7" click, racing
  Setup's own exit, gets its daemon instead of an error.
- processes_running_from also matches images against the canonicalized
  install-dir spelling (junction, subst, 8.3 given form).
- reconcile_portable_backups reports every interrupted backup, not the
  first.
- The unix signal-and-wait loop now reuses wait_for_recorded_exit.

* style: rustfmt

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 11:04:15 +08:00
l0ng-aiandl0ng-ai c5aaf2b4f7 fix(theme): floor the tokens that bypassed the contrast machinery (#400)
* fix(theme): floor the tokens that bypassed the contrast machinery

Sidebar text, the caret and hairlines were flat blends with no floor,
sitting one line away from tokens that are bisected to hit a target
exactly. Semantic inks were floored against the window background but
painted on popovers and sidebar rows, which sit a step toward the
foreground.

- sidebar_fg is now floored at 4.5:1 on the sidebar fill it is painted
  on, not on the background. Four builtins landed it at 3.35-3.92:1.
- caret is conditioned to 3:1. The default Light theme shipped an
  orange caret on pure white at 2.07:1.
- border keeps its blend but gets a 1.5:1 floor so a divider is worth
  the same in every theme, instead of ranging 1.26-1.64:1.
- semantic inks and fills clear their floor on background, sidebar and
  popover, not just the background.

Themes that already cleared a floor are untouched.

* fix(theme): floor the hairline on every neutral fill it divides

Review catch on #400: border is handed to sidebar_border and drawn on
popover chrome, but was floored against the window background alone —
a narrower guarantee than the one this PR gives sidebar_fg and the
semantic inks.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 09:11:01 +08:00
l0ng-aiandl0ng-ai fe558a1c8e fix(terminal): localize the right-click menu and the search bar (#401)
The terminal's own context menu was the last surface still speaking
hardcoded English in a three-locale app — 14 literals, while the tab and
switcher menus route every item through t(). The search bar had three
more, and its Previous / Next / Close buttons were icon-only with no
tooltip at all.

It also spoke a fourth vocabulary for one action: "Maximize Pane" for
ToggleMaximizePane, which the menu bar, the palette and Keybindings all
call "Zoom Pane". "Close Pane" is likewise "Close Pane / Tab"
everywhere else, and it is the accurate name — the action closes the
tab when the pane is the last one.

Adds AppMenuSplitLeft / AppMenuSplitUp and SearchFind / SearchMatchCase
/ SearchUseRegex across en, zh and ja. The fork submenu now takes its
title from the locale table instead of tty7-core's fork_label(), which
is a capability probe in a crate that has no locale table.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-08 09:10:31 +08:00
l0ng-ai 1f91719da9 fix(terminal): paste images into remote panes on macOS, and into WSL by its own path (#399)
Staging a pasted screenshot to a file and handing the agent its path was
built off macOS only. A local macOS agent reads the system clipboard itself
when it sees SYN, and that carries the image at full fidelity, so the
platform kept SYN and skipped staging entirely.

That reasoning stops at the pane boundary. An agent in an SSH pane or a
remote workspace reads the clipboard of the host it runs on, which never
holds this machine's screenshot, so SYN was a no-op and pasting did nothing
at all. Remote panes now stage and upload on every platform, decided by one
predicate instead of a platform cfg over the whole feature; a local macOS
pane still forwards SYN untouched. macOS screenshots reach the pasteboard as
TIFF, which agent vision rejects the same way it rejects a Windows BMP, so
those transcode to PNG on the way out.

A WSL pane was broken for a different reason: it shares this machine's disk
but not its path syntax, so it needs no upload, only a rewrite -- and there
was none. The agent got C:\Users\...\paste-1.png and found nothing there.
The paste now carries the automount view, /mnt/c/Users/... A path with no
mapping, such as a UNC temp directory, keeps the Windows name, which at
least says where the file went.

Seven pre-existing tests for this feature were compiled out on macOS by the
same cfg and now build and pass there. Three were added: the staging
predicate, the WSL rewrite (including UNC and drive-relative refusals), and
the TIFF-to-PNG transcode, whose failure mode is otherwise a silent no-op.
2026-08-08 00:05:56 +08:00
l0ng-aiandl0ng-ai e4fff64833 fix(daemon): stop offering to carry on beside a mismatched server (#389)
Both handshakes compare their version for equality and hang up on
anything else — the pane protocol in `ensure_running`, the control
dialect in `host::server`'s hello. So a server whose number disagrees
cannot be talked round, and the "Keep Shells" button offered a state
that does not work: panes still spawn while every machine-tree call is
refused, which is how a window opens with no tabs and saves none of the
ones you make. Taking that button was indistinguishable from the bug it
sat next to.

Restart or quit, then. Quitting is the half that destroys nothing — the
server and every shell under it keep running — which makes it a real
answer for someone who would rather go install the matching build than
lose a session mid-flight. Quit goes first, the way every other
destructive prompt here is ordered: NSAlert and TaskDialog give the
first button Return, and this prompt arrives unasked at launch, the
moment a stray Return is likeliest, so the reflex key has to be the one
that destroys nothing. Restarting stays a click or an arrow away.

A prompt dismissed without an answer re-arms instead of falling through:
`take_mismatched_daemon` takes, so without this a closed window is a
back door into the state this prompt exists to prevent.

The copy is rewritten to one line of diagnosis and one line per button,
in all three locales, listed in button order. It was a paragraph that
had to be read twice to find out what the buttons did.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:41:37 +08:00
ayamir 43a22855a2 perf(graphics): move kitty frame pixels instead of copying them (#388)
A re-transmitting sender like terminal-browser sends a fresh full-window
frame per rendered frame — ~26 MiB of RGBA at Retina resolution. On the
client that buffer was copied twice for no reason on the way to the atlas:
`decode_frame` allocated a new Vec for the payload tail behind the 30-byte
header, and the uncompressed path of `to_rgba8` then cloned it again before
the in-place BGRA swap.

Thread ownership through instead:

- `Image::decode_frame_owned` consumes the frame Vec the reader already owns
  off the socket and drains the header off the front, reusing that allocation
  as the pixel buffer rather than allocating and copying a fresh one.
- `Image::take_rgba8` moves the pixel buffer out on the uncompressed fast
  path (the shm/file transport hands us pixels already in `f=32` layout), so
  `decode` swaps R<->B in place with no clone. The compressed inflate, the
  PNG guard, and the declared-dimension inflate bound are unchanged; `f=24`
  still repacks because RGB->RGBA changes the length.

Removes two ~26 MiB per-frame touches on the client hot path. On a 3216x2160
frame the decode+normalize step drops from ~1.78 ms to ~0.89 ms — ~0.9 ms
saved per frame, ~53 ms/s at 60fps. This does not touch the wire frame layout
or the daemon-side transfer; it is a pure client-side allocation cut.
2026-08-07 23:30:11 +08:00
l0ng-aiandl0ng-ai bc03e59b6f ci: keep the fork review to forks (#398)
The fork job never checked where the head branch lived. It was written
as the fork path and reads like one, but `pull_request_target` fires on
every pull request, so labelling one of ours landed there too --
silently, and with the weaker review: no plugin, no whole-repo context,
read-only tools. #389 got that instead of the review it should have had.

Also corrects the `labeled` comment in claude-code-review.yml, which
promised exactly the case that cannot work. A `pull_request` workflow is
read from the PR's merge ref, and GitHub recomputes that on a push and
not otherwise, so a PR whose last push predates the file has a merge ref
without it and no label can summon it. Pushing fixes it, and also
triggers synchronize by itself -- which is why the empty commit worked
and the label looked broken.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:26:29 +08:00
l0ng-aiandl0ng-ai 51ea055d2a ci: anchor fork review findings to lines, and fence the reads (#397)
Two changes to the same job.

Findings now go inline. The action ships an inline-comment MCP tool
that buffers rather than posts: the queue is sent after the session
ends, by the action's own code, past a classifier. That keeps the
property review.md was chosen for -- the reviewer writes the words, a
step that cannot be argued with performs the act -- while putting a
finding next to the line it is about. review.md stays for the summary,
which belongs to the change as a whole.

Reads are fenced. A comment body is posted verbatim, so any file the
reviewer can read it can publish, and the token is in this process's
environment; /proc is the short path between the two. Reads are already
confined to the working directory in the default permission mode, but
this job should not rest on a default. Deny is evaluated before allow,
so the rules hold whatever --allowedTools says.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:13:12 +08:00
l0ng-aiandl0ng-ai ea0a0f078c ci: keep reviewing a fork PR after the label goes on (#395)
The label was a one-shot trigger, so a contributor who addressed the
findings and pushed got no second look unless someone removed and
reapplied it. It now reads as a subscription: `synchronize` re-reviews
while the label is present, and taking it off stops that.

Two clauses rather than one label test, because `labeled` carries the
label that was applied and `synchronize` carries none -- testing only
the list would spend a review every time an unrelated label landed on a
subscribed PR.

This does not gate the code in the next push, and the header comment now
says so. The gate is spend and attention; the safety is the base-branch
workspace root, the absent Bash, and executing nothing.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-08-07 23:04:11 +08:00