Skip to content
Merged
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
79 changes: 79 additions & 0 deletions site/src/data/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,85 @@ export type ChangelogEntry = {
};

export const changelog: ChangelogEntry[] = [
{
version: '0.12.0',
date: '2026-09-18',
status: 'feature',
summary:
'"Fast on large repositories" has been an adjective on the front page since the first commit. It is four reproducible numbers now — and the two worst things those numbers found are fixed in the same release. Opening a file\'s history walked to the root of history with a tree comparison at every commit, because the limit counted matches and a file with fewer changes than the limit had nothing to stop on: 135.6 seconds and 1,482,923 tree comparisons for one click on a kernel file, all of it holding the lock every other read on that repository queues behind. It is capped at 50,000 commits now, says on screen how far it looked, offers the unbounded walk as a deliberate choice, runs on the shared read lock, and can be stopped. Beside it, the paged log threw away a topological sort it had already paid for and rebuilt it per page, so ten pages into the kernel cost ten times one page; the order is prepared once now and every later page is a slice of it, commit search included. The measurements behind all of that are published in the README and generated from the committed record, so no figure on the front page can be nudged by hand.',
sections: [
{
title: 'New features',
items: [
{
title: 'A file\'s history says how far it looked, and can be stopped',
detail:
'The limit on a file history counted matches, not work. A file with fewer changes than the limit had nothing to stop on, so the walk ran from HEAD to the root of history comparing each commit\'s tree against its parent\'s at that path — which is most files in most large repositories, not a corner case. There is a visit cap beside the match cap now, defaulting to the newest 50,000 commits, and three things follow from it being visible rather than silent. The screen says which ceiling ended the walk — "Searched the newest 50,000 commits" — and the number it prints comes off the wire with the result rather than out of a constant in the frontend, so it cannot drift away from the policy actually applied. Beside that notice is "Search all of history", which is the old unbounded walk, asked for deliberately, for the case where the answer really is older than fifty thousand commits. And the walk is cancellable, because even capped it is an 18-second wait on a repository that size and a wait you cannot stop is a worse answer than a slower one. The Cancel reaches it from both surfaces that offer one: the status bar, and a new "Stop searching history" row in the command palette. That row exists because a cancel now routes by what can actually stop the operation. "Can this be cancelled" and "what cancels it" used to be two separate answers — a set of cancellable kinds in one file, a hardcoded network cancel at each button — which is free to disagree, and the disagreement\'s shape is a Cancel that runs and stops nothing. They are one table, because this is the first cancellable operation here that is not a subprocess: a revwalk is stopped by setting a flag it polls between commits, not by signalling a process group, so without the table the palette would have offered "Cancel network operation" for a history search and called a path that reaches nothing. It carries one label rather than the network row\'s two, for a reason worth stating: a walk polls a flag, so asking twice does exactly what asking once did, and borrowing the network row\'s "Force stop" would promise an escalation that has no counterpart here. A stopped search says so — "Search stopped. Nothing below it was searched." — rather than showing an empty list that looks like an answer.',
},
],
},
{
title: 'Performance',
items: [
{
title: 'File history is capped, and no longer blocks the repository while it runs',
detail:
'Measured on a clone of `torvalds/linux`, warm, for one click on `arch/powerpc/kernel/iommu.c`: the uncapped walk compared all 1,482,923 commits and cost 135.6 seconds; capped at 50,000 visits it costs 18.3 seconds. The cap removes about 117 of those seconds, and what remains is dominated by something the cap cannot touch — 14.5 s of it is the revwalk\'s own preparation, before any tree work at all, with only 4.1 s being the 50,000 tree comparisons themselves. The second half of the fix is the lock. This was the longest read in the backend and it held the EXCLUSIVE handle for all of it, so a single click queued every other operation on that repository behind a walk that could take minutes — precisely the failure the read/write split was built to remove, surviving in the one operation least able to afford it. It moves to the shared read path, which is safe to assert rather than assume: it is a revwalk, a commit lookup, a tree path lookup and a tree-to-tree diff, it writes nothing, and — the part that has bitten this codebase before — it never reads the index, so there is no incidental index refresh for anything else to have quietly depended on.',
},
{
title: 'The paged log prepares its sort once, commit search included',
detail:
'The log rebuilt its revwalk on every page, and in libgit2 a topologically sorted walk is not incremental in any sense: setting a sort order marks the walk limited, so preparation runs the whole reachable graph and materialises the complete ordered list before the first object id comes out. Asking that walk for 500 commits and asking it for all 1,482,923 cost the same thing, so one walk per page paid the identical price per page. It showed as a per-page cost flat in depth — on the kernel, page one cost 15.95 s and page ten 157.67 s, exactly ten times one page, which is the signature of restarting a walk rather than continuing one. git pays for that sort once and then skips. The fix rests on one measurement that is the opposite of what "just cache it" usually costs: draining a walk that has already been prepared is very nearly free. On the 50,000-commit fixture, preparing and taking 500 costs 560.2 ms and draining the other 49,500 costs 1.9 ms; on the kernel, 31.8 s and 63.4 ms for the remaining 1,482,423. Two tenths of one percent more buys the entire order — so the order is computed once, kept as a list of object ids, and every later page is a slice of it. What is kept is deliberately the finished order rather than the live walk, because holding the walk would mean holding a repository handle alive between calls, outliving the lock acquisition that orders every access to it. The ref map that decorates those rows went the same way; it was enumerated and peeled per page to label 500 commits, sixteen times git\'s own work on a repository with 7,001 refs. Two counter-intuitive things fell out of fixing it. The peeling was never the expense — one enumeration costs 113 ms and the peel inside it is 10 ms of that — so revalidating per scroll spent 113 ms of a 117 ms page on a question that could not have changed. And computing a fingerprint before building the map on a cold cache made things worse, not better: it enumerates every ref twice for an answer that cannot match anything, which made a cold first screen on that fixture slower than the code it replaced. Invalidation is two different stories on purpose. A first page is keyed by the refspec and the starting commits, so any ref that moves is a different key and misses. A continuation is keyed by the frontier it was emitted with and does not consult refs at all — resuming from a cursor never did, and the set a frontier reaches is made of commits, which are immutable. The ref map is keyed by a fingerprint of every ref name and target, so a `git tag` typed in a terminal invalidates it exactly as a tag made in the app does. The whole thing is a pure accelerator: everything in it is derivable from disk, a poisoned lock degrades to a miss rather than failing a page, and closing a repository drops it. Commit search reads the same prepared order, which is where it was worst — a search that matches nothing recent walks a long way before it fills a page, and the next page threw that walk away. One limit there is deliberate and written down: a search visits far more commits than it returns, so only an order covering all of history can serve one, and a repository past the ceiling falls back to the walk-per-page it always had rather than to a short page that would read as "no more matches exist". One behaviour is now visible that was always true: when two commits share a second, which lane comes first is not something either walk promises, because libgit2 orders its topological queue through a heap with an unstable comparator and a walk resumed from a cursor inserts differently from one that ran straight through. What holds either way — every commit exactly once, and no parent before its child — is asserted against a fixture built entirely inside one second.',
},
],
},
{
title: 'Improvements',
items: [
{
title: 'The speed claim is a number somebody else can check',
detail:
'"Slow on big repositories" is the most consistent structural complaint about every established git GUI, and being fast is one of this project\'s two strongest claims — with no numbers behind it, so it was an adjective. `pnpm bench` now builds three deterministic fixtures in about a minute and drives the real git backend through them: deep (50,000 commits), wide (50,000 files all modified plus 5,000 untracked), and refs (5,001 branches and 2,000 tags). They generate from a fast-import stream with seeded content, a fixed epoch and a fixed author, so the same parameters produce the same object ids on any machine, and each isolates one dimension — breadth hurts differently from depth, and one combined fixture would give a number that cannot say which dimension moved. A real `torvalds/linux` clone is the opt-in fourth, because a synthetic repository cannot stand in for 1.5 million real commits. Three decisions carry the rest. The composite is the point: the first-screen figure issues the eleven reads the app issues when it opens a repository, simultaneously, behind a barrier — the only shape that can catch "one slow read blocks everything else on this repository", which an operation-at-a-time benchmark is structurally blind to. Baselines ask the same question rather than the cheapest one sharing a name: status returns per-file line counts, so its baseline is `git status` plus both `--numstat` diffs, and the log baselines are `--topo-order` because the graph\'s lanes depend on that ordering — the first draft used a plain `git log` and made our first page look fourteen times slower than git, when it is at parity. And repeats are time-boxed rather than counted, because ten repeats of an eight-second log page is thirteen minutes for one table row. What it found: the whole first screen costs 253 ms on a 50,000-commit repository and 219 ms on one with 7,001 refs, opening a repository is 0.11 ms, and on the wide fixture the eleven concurrent reads (5.42 s) cost exactly what the slowest one costs alone rather than the sum of all eleven — which is the read/write split doing its job and the single result most worth not regressing. A ten-minute soak ran 2,344 fan-outs with memory flat between 67 and 69 MB and the median identical in both halves, which answers the "it gets janky after a while" complaint with data rather than assurance. The bad case is published rather than buried: the kernel opens in 15.84 s, and that row leads the README block. The block itself is generated from the committed record by the same script that renders the documentation tables, and the build fails both when the rendered block disagrees with the record and when a measured figure is typed by hand into the prose around it — because a hand-typed figure on the front page is the one that stops moving on the next run.',
},
{
title: 'The website\'s figures are rendered from the real app, at 2x',
detail:
'The three figures on the site were 1x masters captured on 2026-08-18, so every Retina visitor was shown an upscale of 1x text, and 112 commits to the app had landed since — including the icon-set swap, which retired every icon in them. The manual capture path could not fix that: it needed a human clicking a real window, and its resize target disagreed with its own aspect gate by construction. They are rendered now — the real components, in headless Chrome at device scale factor 2, with the desktop surface aliased to a single shim, and the drop shadow and traffic lights composited afterwards because those are the only pixels the operating system contributes to a window whose titlebar the app draws itself. The scene fixtures are typed against the app\'s own backend types, so a shape change there fails the type-check instead of quietly rendering a picture of a product that no longer exists, which is how the previous hand-built replica died. Two bugs surfaced while wiring it up, both invisible until now. The 2x variant never reached the browser: the component probed for the `@2x` file through a URL that at build time points at the built chunk rather than the source, so it looked in the wrong directory and no figure has ever shipped a `srcset` — the probe looked fine because there was no 2x master for it to find. And the image loader both site scripts had copied looked only for the old entry point of the image library, which moved in its current major, so the screenshot script failed outright on any machine that resolved the newer version.',
},
],
},
{
title: 'Known limitations',
items: [
{
title: 'The published benchmark numbers predate the log cache',
detail:
'New in this release, and deliberate. The record behind the README block and the performance documentation was measured before the paged-log fix landed, and it was not re-measured for it. The reason is that two sessions were benchmarking this repository at the same time against one shared cache directory, and control rows — operations neither change touches — moved 25 to 30 percent between runs. That is two programs sharing a machine, not a result, and publishing it as one would have been worse than publishing a stale figure. So the numbers understate the app on exactly the rows the fix improves: the kernel\'s "ten pages into history" figure is the before. Re-measurement waits for a quiet machine and has to cover all four fixtures at once, because the renderer publishes every result it finds — which is what stops a single-fixture run from deleting the kernel from the record, and also what would let one quietly refresh a fixture nobody re-ran.',
},
{
title: 'Opening a 1.5-million-commit repository still costs about 15 seconds',
detail:
'The cap and the cache both stop at the same floor, and it is worth naming because the obvious optimisation is a dead end. A sorted libgit2 revwalk pre-walks the whole graph before it yields anything: on the kernel, getting the FIRST commit out 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. The sort order is not the reason. Time ordering and time-plus-topological ordering measure within one percent of each other, so dropping the topological sort buys nothing. Unsorted walking IS incremental and is unusable here: 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. git escapes this with a commit-graph file, which libgit2\'s revwalk does not read.',
},
{
title: 'Two windows on one repository do not share a lock',
detail:
'Unchanged from 0.7.0. Each window opens its own handles for a repository, and that is what keeps windows independent — closing a tab in one evicts nothing the other is using. The read/write gate orders one window\'s work against itself, not one window\'s against another\'s, so work you start on the same repository from two windows is still arbitrated by git\'s own `index.lock`, exactly as it is between any two git processes.',
},
{
title: 'A Store update lands hours after the release, not with it',
detail:
'Unchanged from 0.6.0. Submission is automatic; certification is not instant. Microsoft reviews each update before it reaches the Store, so a Store install trails the `.msi`, Scoop and winget by however long that takes — usually hours. Nothing is wrong when the Store still offers the previous version shortly after a release.',
},
{
title: 'Timestamps are shown in your timezone, not the author\'s',
detail:
'Unchanged from 0.5.0. Where `git log` prints the offset a commit was authored under, PlatypusGit shows that same instant on your own clock — a commit reaches the interface as unix seconds and nothing else, so matching git here is a change to what the backend sends rather than to how a date is written. The hover names the zone it used, so no stamp is ambiguous about which clock that was.',
},
],
},
],
},
{
version: '0.11.0',
date: '2026-09-17',
Expand Down
Loading