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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ to what you are reading. A new section here needs a reason a pointer cannot serv
- `docs/dev/performance.md` — the large-repo benchmark: the fixtures, what each
number does and does not include, the baseline it is compared against, and
the published results. Read it before touching the log walk, `status`, or
the refresh path, and re-run `pnpm bench` when you do.
the refresh path, and re-run `pnpm bench` when you do — it regenerates
`README.md`'s Performance block as well as the two under `docs/dev/`, and
`test/benchmark.test.ts` fails if any of the three is hand-edited.
- `docs/dev/releasing.md` — what a version number means and when to bump which
part, the cut-a-release runbook (changelog lands on `main` FIRST), and the
prerelease-promotion traps. Read it before tagging anything.
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Every route, per platform, with the Gatekeeper and update notes spelled out:
- **Free, all of it.** GPL-3.0, no license fee, no per-seat pricing, no "pro" tier gating rebase or conflict resolution.
- **No account, no telemetry.** Nothing to sign in to and no analytics SDK anywhere in the tree. Your repositories and history never leave your machine. Forge tokens for the optional pull-request integration are yours, supplied by you and stored by your own git credential helper.
- **Native, not a bundled browser.** A small Tauri binary with real OS windows on all three platforms.
- **Fast, and measured rather than asserted.** A reproducible benchmark over generated repositories that isolate deep history, a huge working tree and thousands of refs — plus a real clone of the Linux kernel — with `git`'s own timings beside every row, the bad ones published along with the good: [Performance](#performance).
- **Keyboard-first and dense.** A Rider-style default keymap, a command palette, type-to-jump lists, hunk navigation and staging without touching the mouse — a dev-first TortoiseGit alternative that assumes you know git.

## How it compares
Expand Down Expand Up @@ -132,6 +133,38 @@ images shown as "binary" rather than a preview — the full list is under
Mercurial, and no Finder/Explorer shell integration, which is the thing
TortoiseGit exists for.

## Performance

"Slow on big repositories" is the most consistent complaint about every
established git GUI, so "fast" is a claim this project has to back with a number
somebody else can check. `pnpm bench` builds three deterministic fixtures —
deep history, a huge working tree, thousands of refs — optionally clones
`torvalds/linux` on top, drives the real git backend through all of them, and
records `git`'s own timing on the same question beside every row.

The block below is generated from that run, and `test/benchmark.test.ts`
re-renders it from the committed record and fails the build when the two
disagree — so no figure here can be nudged by hand.

<!-- BEGIN BENCHMARK SUMMARY — generated by scripts/bench.sh, do not edit -->

Measured on Apple M4 Pro (14 cores, 48 GB, macos/aarch64) with git version 2.50.1 (Apple Git-155), on 2026-09-17 — medians over repeat calls against the real backend. `pnpm bench` reproduces the generated fixtures in about a minute; the kernel clone is opt-in.

| Repository | First screen | Status | First page of history | …vs `git` |
| --- | --- | --- | --- | --- |
| **torvalds/linux**<br>1,482,923 commits · 96,034 files · 946 tags · 13 changed | 15.84 s | 989 ms | 15.95 s | 1.7× |
| **deep**<br>50,000 commits · 16 files | 253 ms | 0.53 ms | 249 ms | 1.3× |
| **wide**<br>1 commit · 50,000 files · 55,000 changed | 5.42 s | 5.42 s | 0.25 ms | — |
| **refs**<br>2,000 commits · 32 files · 5,001 branches · 2,000 tags | 219 ms | 0.55 ms | 135 ms | 16× |

**First screen** is the eleven reads the app issues when it opens a repository, issued at once — a composite, because the failure worth catching is one slow read blocking the other ten. **Status** returns per-file added and removed counts, so its baseline is `git status --porcelain` plus both `--numstat` diffs rather than a bare `git status`. Ratios are against git's *work*, with process start-up subtracted — deliberately the comparison that flatters us least — and a dash is a baseline too small to divide by. No figure here includes the UI: the benchmark drives the git backend directly, with no webview in it.

**torvalds/linux is the bad case, and publishing it is the point.** The first screen costs 15.84 s there, and reaching ten pages into its history costs 157.67 s: a sorted libgit2 revwalk pre-walks all 1,482,923 commits before it yields one, and the next page pays for that again. The developer who opens a repository that size and waits is the one this was written for, so the number belongs here rather than in a backlog.

Every operation on every fixture, the `git` command behind each baseline, and what the numbers were read to mean: [`docs/dev/performance.md`](./docs/dev/performance.md).

<!-- END BENCHMARK SUMMARY -->

## Features

- **Start anywhere** — open a repository, clone one (submodules included, with progress), or init a new one, and keep the ones you use in the recent list; reveal any file in Finder or Explorer, or open the repository in your terminal, straight from the context menu.
Expand Down
51 changes: 37 additions & 14 deletions docs/dev/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,27 +122,50 @@ disputed with evidence rather than in the abstract.
| `$PGBENCH_HOME/results/<fixture>.json` | no | raw, every sample — what makes a result checkable |
| `docs/dev/benchmark.json` | yes | the published record, summary statistics only |
| the table block below | yes | the same numbers, as a document |

Both committed artifacts are **generated and not hand-editable**.
`test/benchmark.test.ts` re-renders the markdown from the JSON and fails when
they disagree. That guard is the point of the whole exercise: the way a measured
number turns back into an adjective is somebody nudging it in a hurry.
| `README.md`'s Performance section | yes | the same run, one row per fixture, for a reader who will never open this file |

All three committed artifacts are **generated and not hand-editable**.
`test/benchmark.test.ts` re-renders both markdown blocks from the JSON and fails
when any of them disagree. That guard is the point of the whole exercise: the
way a measured number turns back into an adjective is somebody nudging it in a
hurry.

The README block is the same `renderReadme` output the guard re-renders, so
`pnpm bench` rewrites three files and they are committed together. It prints
three operations rather than twelve, and those three are `REQUIRED_OPS` minus
`open` — the ops every fixture is already forced to publish. Widening it to an
op a fixture may legitimately lack is how the front page starts printing a blank
cell for the thing that regressed; `test/benchmark.test.ts` asserts every timing
cell there carries a digit.

The record sits beside this file rather than under `site/`, and both are covered
by the `docs/dev/` entry already in the `js` path filter in
`.github/workflows/tests.yml`. Without that coverage the guard would be
skippable by exactly the change it polices — the failure mode #210 already
shipped once.

### The marketing site does not print these yet

#257 asks for a measured figure on the site in place of an adjective, and the
block to do it is written. It is **deliberately not shipped yet**: the honest
headline today is that opening `torvalds/linux` takes 15.8 seconds, and the
right response to that is to fix it rather than to publish it as a selling
point. It ships once the log-walk work in the findings below lands — at which
point the record moves to `site/src/data/` beside `comparison.json`, which is
where this repository keeps published records the site reads.
### The README publishes them; the marketing site still does not

The two are not the same audience and the numbers read differently to each.

The **README** prints them, and leads with the bad case. That file already
carries a "Where we are behind" paragraph and a "Status" section of known gaps,
so a table whose worst row is `torvalds/linux` at 15.8 seconds is in keeping
with it rather than at odds with it — and the sentence under the table says so
in as many words. It is also where the word "fast" appears in the first line,
which makes it the single most valuable place for the adjective to be replaced
by something a reader can check. The block is generated and guarded exactly like
the one below; the prose around it is hand-written, and
`test/benchmark.test.ts` fails if a figure is copied into it, because a
hand-typed number stops moving on the next run.

The **marketing site** is still waiting. #257 asks for a measured figure there
in place of an adjective and the block to do it is written, but a landing page
sells, and 15.8 seconds as a selling point is a different claim from 15.8
seconds as a disclosed limitation. It ships once the log-walk work in the
findings below lands — at which point the record moves to `site/src/data/`
beside `comparison.json`, which is where this repository keeps published records
the site reads.

Until then nothing under `site/**` is touched by a re-measurement, which also
means `pnpm bench` cannot redeploy the website by accident.
Expand Down
167 changes: 156 additions & 11 deletions scripts/bench-report.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// Render benchmark runs into the two things that get committed (issue 257).
// Render benchmark runs into the three things that get committed (issue 257).
//
// `src-tauri/benches/repo_bench.rs` writes one JSON document per fixture, full
// of raw samples. This turns the set of them into:
Expand All @@ -8,12 +8,16 @@
// read by `test/benchmark.test.ts`;
// * the table block inside `docs/dev/performance.md`, between its generated
// markers — the developer-facing record, with every operation, the `git`
// baseline beside it and the ratio between them.
// baseline beside it and the ratio between them;
// * the summary block inside `README.md`, between markers of its own — the
// same run, cut down to one row per fixture, because the README's first
// line calls this app fast and that is the sentence the numbers are for.
//
// **Both are generated, and neither is hand-editable.** That is the whole point
// of the exercise: the site is supposed to print a number somebody measured,
// and the way a measured number turns back into an adjective is somebody
// nudging it in a hurry.
// **All three are generated, and none of them is hand-editable.** That is the
// whole point of the exercise: what gets published is supposed to be a number
// somebody measured, and the way a measured number turns back into an adjective
// is somebody nudging it in a hurry. `pnpm bench` writes all three; they are
// committed together, and `test/benchmark.test.ts` fails on a partial commit.
//
// The markdown is rendered from the PUBLISHED record rather than from the raw
// runs, and that indirection is the guard test's whole leverage: the test
Expand Down Expand Up @@ -128,6 +132,13 @@ export const BEGIN =
"<!-- BEGIN BENCHMARK RESULTS — generated by scripts/bench.sh, do not edit -->";
export const END = "<!-- END BENCHMARK RESULTS -->";

/** The README's own pair. Spelled differently from the doc's on purpose: the
* two blocks are different renderings of one run, and a reader who greps for
* one marker should not land in the other file's block. */
export const README_BEGIN =
"<!-- BEGIN BENCHMARK SUMMARY — generated by scripts/bench.sh, do not edit -->";
export const README_END = "<!-- END BENCHMARK SUMMARY -->";

/** Milliseconds, at a precision the measurement can actually support.
*
* Three significant figures at most, and seconds once past a thousand
Expand Down Expand Up @@ -171,6 +182,25 @@ export function describeRepo(r) {
return parts.join(" · ");
}

/** "1 commit", not "1 commits". `tidyScale` fixes this where the harness hands
* us free text; here we are building the phrase ourselves, so we can just get
* it right. */
function count(n, singular, plural = `${singular}s`) {
return `${thousands(n)} ${Number(n) === 1 ? singular : plural}`;
}

/** The same shape line as `describeRepo`, for a table CELL rather than a
* caption: shorter words, and the singular handled, because this one renders
* into the README where `wide`'s single commit would otherwise read
* "1 commits" on the project's front page. */
export function describeRepoShort(r) {
const parts = [count(r.commits, "commit"), count(r.trackedFiles, "file")];
if (r.branches > 1) parts.push(count(r.branches, "branch", "branches"));
if (r.tags > 0) parts.push(count(r.tags, "tag"));
if (r.dirtyEntries > 0) parts.push(`${thousands(r.dirtyEntries)} changed`);
return parts.join(" · ");
}

/**
* What `git` spent on the WORK, with process start-up taken back out.
*
Expand Down Expand Up @@ -306,6 +336,99 @@ export function renderMarkdown(data) {
return out.join("\n").trimEnd();
}

/**
* The README's summary block: one row per fixture, three operations wide.
*
* Generated for the same reason `performance.md`'s tables are, and re-rendered
* by the same guard test. If anything it matters MORE here: the README's first
* line calls this app fast, so this is the file where a measured number most
* wants to drift back into an adjective.
*
* What is new here is the SELECTION. A README cannot carry four twelve-row
* tables, so it carries three columns — and those three are exactly the ones
* `REQUIRED_OPS` already forces every fixture to publish, minus `open`, which
* is a fresh handle and free everywhere. Picking columns on editorial grounds
* instead is how this block quietly starts printing a blank cell for the one
* operation that regressed.
*/
export function renderReadme(data) {
const m = data.machine;
const out = [];
out.push(
`Measured on ${m.cpu} (${m.cores} cores, ${m.memoryGb} GB, ${m.os}) with ` +
`${m.gitVersion}, on ${data.measuredOn} — medians over repeat calls ` +
"against the real backend. `pnpm bench` reproduces the generated " +
"fixtures in about a minute; the kernel clone is opt-in.",
);
out.push("");

const head = [
"Repository",
"First screen",
"Status",
"First page of history",
"…vs `git`",
];
const at = (fixture, op) => fixture.operations.find((o) => o.op === op);
const cell = (fixture, op) => {
const found = at(fixture, op);
return found ? fmtMs(found.repeatMedianMs) : "—";
};
const rows = data.fixtures.map((f) => [
`**${f.title}**<br>${describeRepoShort(f.repository)}`,
cell(f, "open_screen"),
cell(f, "status"),
cell(f, "log_first_page"),
fmtRatio(at(f, "log_first_page")?.ratioToGit ?? null),
]);
const line = (cells) => `| ${cells.join(" | ")} |`;
out.push(
[line(head), line(head.map(() => "---")), ...rows.map(line)].join("\n"),
);
out.push("");
out.push(
"**First screen** is the eleven reads the app issues when it opens a " +
"repository, issued at once — a composite, because the failure worth " +
"catching is one slow read blocking the other ten. **Status** returns " +
"per-file added and removed counts, so its baseline is `git status " +
"--porcelain` plus both `--numstat` diffs rather than a bare `git " +
"status`. Ratios are against git's *work*, with process start-up " +
"subtracted — deliberately the comparison that flatters us least — and a " +
"dash is a baseline too small to divide by. No figure here includes the " +
"UI: the benchmark drives the git backend directly, with no webview in " +
"it.",
);

// The honest headline, interpolated rather than written, so that fixing the
// thing it describes also unwrites the sentence. Guarded because the kernel
// clone is opt-in: a run without it publishes the fixtures it measured and
// says nothing about the one it did not.
const real = data.fixtures.find((f) => f.kind === "real");
const screen = real && at(real, "open_screen");
const tenth = real && at(real, "log_page_deep");
if (real && screen && tenth) {
out.push("");
out.push(
`**${real.title} is the bad case, and publishing it is the point.** The ` +
`first screen costs ${fmtMs(screen.repeatMedianMs)} there, and ` +
`reaching ten pages into its history costs ` +
`${fmtMs(tenth.repeatMedianMs)}: a sorted libgit2 revwalk pre-walks ` +
`all ${thousands(real.repository.commits)} commits before it yields ` +
"one, and the next page pays for that again. The developer who opens " +
"a repository that size and waits is the one this was written for, so " +
"the number belongs here rather than in a backlog.",
);
}

out.push("");
out.push(
"Every operation on every fixture, the `git` command behind each baseline, " +
"and what the numbers were read to mean: " +
"[`docs/dev/performance.md`](./docs/dev/performance.md).",
);
return out.join("\n").trimEnd();
}

/** The published record. Summary statistics only — see the header note on why
* the samples stay out of the repository.
*
Expand Down Expand Up @@ -363,13 +486,19 @@ export function buildPublishedRecord(runs) {
};
}

export function splice(doc, block) {
const a = doc.indexOf(BEGIN);
const b = doc.indexOf(END);
export function splice(
doc,
block,
begin = BEGIN,
end = END,
what = "docs/dev/performance.md",
) {
const a = doc.indexOf(begin);
const b = doc.indexOf(end);
if (a === -1 || b === -1) {
throw new Error("docs/dev/performance.md is missing its generated markers");
throw new Error(`${what} is missing its generated markers`);
}
return doc.slice(0, a + BEGIN.length) + "\n\n" + block + "\n\n" + doc.slice(b);
return doc.slice(0, a + begin.length) + "\n\n" + block + "\n\n" + doc.slice(b);
}

function arg(argv, name, fallback) {
Expand Down Expand Up @@ -405,8 +534,11 @@ function main() {

const data = buildPublishedRecord(runs);
const markdown = renderMarkdown(data);
const summary = renderReadme(data);
if (print) {
console.log(markdown);
console.log(`\n${"=".repeat(72)}\nREADME summary block:\n`);
console.log(summary);
return;
}

Expand All @@ -417,6 +549,19 @@ function main() {
const docPath = join(root, "docs/dev/performance.md");
writeFileSync(docPath, splice(readFileSync(docPath, "utf8"), markdown));
console.log(` wrote ${docPath}`);

const readmePath = join(root, "README.md");
writeFileSync(
readmePath,
splice(
readFileSync(readmePath, "utf8"),
summary,
README_BEGIN,
README_END,
"README.md",
),
);
console.log(` wrote ${readmePath}`);
}

if (import.meta.url === `file://${process.argv[1]}`) {
Expand Down
6 changes: 4 additions & 2 deletions scripts/bench.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
# JSON document per fixture. It is behind `--features bench` so the Rust CI
# gate never builds it.
# 3. `scripts/bench-report.mjs` renders those documents into
# `docs/dev/performance.md` and `docs/dev/benchmark.json`, which is
# what `test/benchmark.test.ts` reads.
# `docs/dev/benchmark.json`, the table block in `docs/dev/performance.md`
# and the summary block in `README.md` — all three generated from one run,
# all three re-rendered and compared by `test/benchmark.test.ts`. Commit
# them together; a partial commit fails that guard by design.
#
# Publishing is the default because a benchmark nobody publishes is a benchmark
# nobody runs twice. `--no-publish` is for the case you are iterating on the
Expand Down
Loading
Loading