Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion docs/dev/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,20 @@ commands/ Thin Tauri handlers, one file per area:
│ with nothing naming the commit that vanished. NOTE it passes
│ no `--` before the revision — that separator introduces
│ PATHSPECS, so `-- <oid>` selects nothing; safe because the oid
│ is a resolved 40-hex id, not user text), file_history,
│ is a resolved 40-hex id, not user text), file_history (#474 —
│ one path's commits, with TWO ceilings: `limit` bounds the
│ matches and a VISIT cap bounds how many commits are looked at,
│ because a file with fewer changes than the limit had nothing to
│ stop on and walked to the root of history — 135 s and 1.48M
│ tree comparisons on the kernel for one click. The command owns
│ the default cap (git::FILE_HISTORY_VISIT_LIMIT) and registers
│ the walk under cancel::Scope::Walk; the backend is handed a
│ predicate to poll and knows nothing about cancellation.
│ `searchAll` waives the cap for a user who read the notice.
│ The result carries `visited` + `stoppedAt`, which is what the
│ notice says out loud), cancel_walk (#474 — stops those walks on
│ one repository; cancel_network_op's sibling for work that has
│ no subprocess to signal, so it sets a flag the walk polls),
│ verify_commit (SELECTED commit
│ only, never per row), commit_notes, which is lazy for the
│ same reason (#253 — the log walk is the hot path, so notes are
Expand Down
47 changes: 42 additions & 5 deletions docs/dev/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,25 @@ Part of the `docs/dev/` set (`architecture`, `testing`, `frontend`, `backend`,
them — the credential protocol is line-based, so a newline injects keys and
could file a password against another host.

### Cancelling a stalled network op (#234, hardened by #263)

### Cancelling a stalled network op (#234, hardened by #263, widened by #474)

- **Two kinds of work, one registry.** A network SUBPROCESS is signalled; a long
in-process libgit2 WALK is asked to notice. `#474` added the second kind —
`Scope::Walk(workdir)`, today `file_history` — and it gets none of the
machinery below: no pid is ever attached, so `kill_tree` is unreachable for
those entries and the SIGTERM→SIGKILL escalation is meaningless (a second
click sets the same flag the first one did). What it gets instead is
`Registration::is_cancelled`, which the command hands to the backend as a
`&dyn Fn() -> bool` that the walk polls between commits — so the backend knows
nothing about this module, and the scope-to-path matching stays in the command
layer where `cancel_walk` resolves the same path the same way.
**`Walk` is deliberately NOT folded into `Repo`** even though both are keyed by
the workdir: cancelling a stalled fetch must not abandon a history search the
user is waiting on, and vice versa. `cancel_all` reaches both, which is what a
closing window wants. One consequence lands in the frontend: a repository-wide
cancel still in flight would reach whatever registers next, so the file-history
screen chains its next walk after the cancel it asked for rather than racing it
(`docs/dev/frontend.md`).
- **One cancel path, at the same two choke points as the credential policy.**
`cancel.rs` is a process-wide registry; `run_git_authenticated` and
`run_clone` each register for as long as they run, so a network op that uses
Expand Down Expand Up @@ -1471,15 +1488,35 @@ against 9,000 ms) rather than a direct measurement.
**Which ops are shared today:** `status`, `branches`, `tags`, `stashes`,
`remotes`, `log`/`log_page`, `repo_state`, `rebase_status`, `bisect_status`,
`head_info`, `shallow_info`, `diff_commit`/`diff_commit_over_ceiling`,
`verify_commit`, `worktrees`. That is `refreshAll`'s eleven-read fan-out plus the
ops behind the history-arrowing ladder in #400. Every other read-only op —
`log_filtered`, `diff`, `diff_commits`, `file_history`, `blame_file`,
`verify_commit`, `worktrees`, `file_history`. That is `refreshAll`'s eleven-read
fan-out, plus the ops behind the history-arrowing ladder in #400, plus the one
op that made the loudest case for itself (below). Every other read-only op —
`log_filtered`, `diff`, `diff_commits`, `blame_file`,
`read_file_content*`, `commits_since`, `ahead_behind`, `submodules`,
`lfs_status`, `commit_notes`, `read_reflog`, `list_all_files`,
`list_files_at_rev`, `commit_template`, `difftool_plan`, `conflict_sides` — is
still exclusive. Not because it must be, but because each needs its own proof it
writes nothing, and moving one is a one-word change.

**`file_history` was the one that mattered most, and it moved in #474.** It is
the longest read in the backend — 135 s on `torvalds/linux` before the visit
cap, 18 s after it — and it held the EXCLUSIVE lock for all of it, so one click
on "File history" queued every other operation on the repository behind a walk
that could take minutes. That is precisely the failure #400 set out to remove,
surviving in the op least able to afford it. The proof it writes nothing: a
revwalk over the refs, `find_commit`, `Tree::get_path`, and a tree-to-tree diff
in the pathspec case — no index read or write-back, no ref move, no object
written, no worktree touch. Note what that list does NOT include, because the
`status` regression is the cautionary tale next door: nothing here reads the
index, so there is no accidental index refresh for another op to have come to
depend on.

`tests/file_history.rs` pins the sharing by RENDEZVOUS rather than by timing —
the walk's cancellation predicate is called with the lock held, so it is a place
to stand still and check whether another read can get in. Put `with_repo` back
and that test fails on its bounded wait (verified), while a timing assertion
would only have got slower.

## What decorates a log row, and which KIND of ref it is

`collect_ref_map` (`libgit2.rs`) scans every ref once per log call and hands the
Expand Down
61 changes: 61 additions & 0 deletions docs/dev/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,52 @@ that is only partly here — which is the whole reason the notice exists.
included, resets on a closed→open transition — a `--depth 1` chosen for one
enormous repository must not quietly truncate the next.

## Saying a file's history was only partly searched (#474)

The strip's other half, on the same screen and immediately below it. A shallow
clone is history the app never had; this is history the app declined to walk.

- **Three stops, three claims, one pure sentence** — `fileHistoryNotice`
(`lib/derive.ts`, beside `truncatedDiffNotice`, which is the same idea about a
diff's lines). `Exhausted` says nothing at all: the list is the whole answer.
`MatchLimit` says the list is full. `VisitLimit` says the SEARCH stopped
before history did, which is the one the issue was about — a file with fewer
changes than the limit gave the walk nothing to stop on, and it ran to the
root of history.
- **The number comes off the wire (`visited`), never from a constant here.**
Same rule as the blob ceiling's: the cap is backend policy
(`git::FILE_HISTORY_VISIT_LIMIT`), and a copy of it on this side would go on
saying 50,000 after the policy moved.
- **`canSearchAll` only where more searching would change the screen.** The
visit cap gets "Search all of history" — the blob ceiling's "diff it anyway"
in another place, and for the same reason: a cap is a guess about intent,
usually right, and completely wrong when it is wrong. The match limit gets no
button, because walking further finds more matches and the list still holds
`limit` of them.
- **The markup lives in `screens/FileHistory.tsx`**, because that is the only
surface with a file history on it, and deliberately mirrors `ShallowNotice`'s
layout — the two can stack, and two strips that say the same KIND of thing
must not look like two different kinds of thing. A second surface lifts it
out rather than writing its own sentence.
- **The walk joins `RepoActivity` under `history`**, which is where its status
line, elapsed clock and Cancel button come from. Before #474 it was a bare
`PGSpinner` in the pane — for up to 135 s, with no way out.
- **Leaving the screen stops the walk.** The effect's cleanup cancels it: an
abandoned walk costs a blocking thread and seconds of tree comparisons for an
answer nobody will read.
- **…but the next walk waits for that cancel to land.** `cancel_walk` addresses
the REPOSITORY (see `docs/dev/backend.md` — that coarseness is what lets the
status bar's one Cancel button work with nothing to point at), so a cancel
still in flight would reach whatever is registered when it arrives — which
after a file switch is the walk for the file the user just opened. Switching
files would cancel its own replacement, at random, depending on which IPC call
won. So the screen keeps the cancel's promise in a ref and chains the next
request after it: one extra round trip, only when a walk was really running.
- **A cancellation is an answer, not a failure.** `isCancelledError` routes it to
a neutral "Search stopped" line with "Search again" beside it, never the red
error text — a red banner would report the user's own click as a fault, and an
empty list would be indistinguishable from a file with no history.

## Checking out a remote branch — `features/branches/checkoutRemote.ts`

A remote-tracking ref is not a branch you can be ON. `checkoutRef("origin/x")`
Expand Down Expand Up @@ -1844,6 +1890,21 @@ checkout read as a click that did nothing.
"Cancelling…" on one click of either one. Its case table is keyed by
`ActivityKey`, so a kind added later cannot skip it — and a third surface
lays out this hook rather than reading `activity` itself.
- **"Can it be cancelled" and "what cancels it" are ONE table** —
`cancelPath(key)` in `repoActivity.ts`, since #474. There are now two
mechanisms: `"network"` signals a git subprocess (`cancel_network_op`), and
`"walk"` sets a flag a libgit2 revwalk polls (`cancel_walk`, for
`activity.history`). A set of cancellable keys plus a branch at the button
would be the same question answered twice, free to disagree — and the
disagreement's shape is a Cancel button that runs and stops nothing.
`isCancellable` is now derived from the table rather than beside it.
- **`history` is the first entry that is neither a subprocess nor a mutation**
(#474) — `file_history` searching one file. It earns an entry for the only
reason this module exists: on a large repository it is a wait long enough to
need stopping (18 s on the kernel with the visit cap, 135 s without it). A
long op that keeps its busy state privately gets no status line, no elapsed
clock and no Cancel — which is how it sat behind a bare `PGSpinner` until
#474.
- **The strip carries its own test hooks (`activity-strip-*`).** `cancel.e2e.ts`
waits on `activity-label` and CLICKS `activity-cancel`; WebdriverIO's `$`
takes the first match in document order, and the strip renders above the
Expand Down
43 changes: 36 additions & 7 deletions docs/dev/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,42 @@ it on ref writes.

### Known characteristics that are not on the tables

* **File history is unbounded on a cold path.** `file_history` stops at `limit`
matches, so on a file with fewer than 500 commits it walks to the root of
history with a tree comparison per commit. On the kernel that is 1.5 million
of them for one click. The benchmark measures the *most frequently changed*
path precisely so it terminates; see `hottest_path` in the harness. It also
takes the exclusive lock (`with_repo`, not `with_repo_read`), so it blocks
every other read on that repository while it runs.
* **File history was unbounded on a cold path, and is capped since #474.**
`file_history` stops at `limit` matches, so on a file with fewer changes than
that it had nothing to stop on and walked to the root of history with a tree
comparison per commit. Measured on the `linux` fixture, warm, for
`arch/powerpc/kernel/iommu.c`:

| walk | cost |
| --- | --- |
| uncapped — 1,482,923 commits, every one compared | **135.6 s** |
| capped at 50,000 visits (the default since #474) | **18.3 s** |
| the revwalk's own preparation, before any tree work | 14.5 s |
| 50,000 commits of tree comparison | 4.1 s |

So the cap removes ~117 s of the 135 s, and what is left is dominated by a
fixed cost the cap cannot touch (next bullet). The benchmark measures the
*most frequently changed* path precisely so the uncapped walk terminated at
all; see `hottest_path` in the harness. #474 also moved it to
`with_repo_read`, so it no longer blocks every other read on the repository
while it runs, and made it cancellable — 18 s is still a wait worth being
able to stop.

* **A sorted libgit2 revwalk pre-walks the whole graph before it yields
anything, and the sort order is not why.** Getting the FIRST oid out of a
`push_head` walk on the kernel costs 14.2 s; walking 50,000 costs 14.2 s; and
walking all 1,482,923 costs 14.7 s — the same number three times, because the
traversal has already happened by the time the first one comes back.
`Sort::TIME` and `Sort::TIME | Sort::TOPOLOGICAL` were measured within 1% of
each other, so dropping `TOPOLOGICAL` buys nothing and the obvious
optimisation is a dead end. `Sort::NONE` IS incremental and is unusable for a
capped walk: it yields commits in the order the traversal reaches them, so the
first 50,000 are not the newest 50,000 and a capped file history could miss
last week's change while reporting one from 2011. Every walk in
`libgit2.rs` sorts, so this floor is very probably the one behind `log_page`'s
first page too — consistent with the commit-graph measurement below, which is
the fix git gets for exactly this and we do not, but measured here only for
`file_history`.
* **No fixture carries a commit-graph file, and it would not help us if it
did.** A fresh clone has none — `git clone` does not write one, and
`gc --auto` does not fire on a single packfile — so this is what a user gets
Expand Down
Loading