git0 0.2.99 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ # Benchmark: tarball + lazy history vs. `git clone`
2
+
3
+ What this measures, and why git0 downloads a repository the way it does.
4
+
5
+ ```bash
6
+ bun run bench # the default repo set
7
+ bun run bench facebook/react # or whichever repos you care about
8
+ ```
9
+
10
+ It hits github.com, so it is not part of `bun test` and never runs in CI — the
11
+ file is a `.bench.ts`, which Bun's test runner does not collect.
12
+
13
+ ## The number that matters
14
+
15
+ Not "how long did the download take". **How long until you can run the
16
+ project.** That is the clock the user is actually watching, and it stops when
17
+ the files are on disk — when `bun install` can start and the editor has
18
+ something to index. Git history is not on that path: nothing about installing
19
+ dependencies or opening a file needs `git log`.
20
+
21
+ So every strategy is measured twice:
22
+
23
+ | column | means |
24
+ | --- | --- |
25
+ | **time to files** | source tree on disk — install can start, IDE can open |
26
+ | **time to history** | full commit history available — `git log`, `git blame`, branches |
27
+
28
+ ## The four strategies
29
+
30
+ | strategy | what it does |
31
+ | --- | --- |
32
+ | `git clone` | the baseline — the command git0 replaces |
33
+ | `git clone --depth=1` | the usual "clone can be fast too" answer |
34
+ | `git0` | codeload tarball, extracted as it streams |
35
+ | `git0 --history` | the tarball, then `.git` attached afterwards |
36
+
37
+ ## Why the tarball wins on time-to-files
38
+
39
+ Three reasons, and only the third is about size.
40
+
41
+ **A clone is serial; a tarball overlaps.** `git clone` is a negotiation: the
42
+ client says what it has, the server walks the object graph, counts and compresses
43
+ objects into a pack, sends it, and only then does the client resolve deltas and
44
+ write a working tree. The first file appears near the *end*. A codeload tarball
45
+ is a single pre-made gzip stream of one commit, so git0 pipes the response
46
+ straight into a `tar` extractor — bytes are being written to disk while the rest
47
+ is still arriving. Download and extraction are the same wall-clock interval
48
+ instead of two consecutive ones.
49
+
50
+ **The server has already done the work.** GitHub caches tarballs. A clone makes
51
+ the remote pack objects for your particular request; a tarball is usually served
52
+ from cache, and is never a per-request computation on the remote's CPU.
53
+
54
+ **History is most of the bytes.** For anything long-lived the object database
55
+ dwarfs the checkout — every version of every file that ever existed, against one
56
+ copy of the current ones. The `on disk` column in the table is where that shows
57
+ up, and it is the size the tarball never transfers.
58
+
59
+ `--depth=1` closes most of the gap on time-to-files, which is exactly why it is
60
+ in the table. It is still a pack negotiation rather than a cached file, and it
61
+ leaves you with a repository that cannot do the thing you wanted history for —
62
+ `git log` shows one commit, and blame is useless. It buys speed by giving up the
63
+ feature.
64
+
65
+ ## Why "lazy" history is not a trick
66
+
67
+ `git0 --history` ends in the same state as `git clone`: a working tree with a
68
+ complete `.git` beside it. The difference is ordering. Extraction finishes,
69
+ and the history clone starts *there* — in parallel with the IDE launching and
70
+ dependencies installing, both of which take their own seconds and neither of
71
+ which touches `.git`.
72
+
73
+ So the honest comparison is two numbers, and the table gives both:
74
+
75
+ - **time to files** — git0 is far ahead, because it is not waiting on history.
76
+ - **time to history** — roughly `git clone`, plus the tarball it already
77
+ finished. You pay a little more for the complete result, and get to start
78
+ working well before it arrives.
79
+
80
+ The mechanics of attaching it (bare clone into a temporary directory, renamed
81
+ into place, `core.bare` flipped, index refreshed from `HEAD` so `git status` is
82
+ clean without touching the extracted files) are in
83
+ [`src/history.ts`](../src/history.ts).
84
+
85
+ ## Reading a run
86
+
87
+ ```text
88
+ ### expressjs/express
89
+
90
+ | strategy | repo | time to files | time to history | on disk | vs git clone |
91
+ | --- | --- | --- | --- | --- | --- |
92
+ | git clone | expressjs/express | … | … | … | — |
93
+ | git clone --depth=1 | expressjs/express | … | — | … | …× |
94
+ | git0 | expressjs/express | … | — | … | …× |
95
+ | git0 --history | expressjs/express | … | … | … | …× |
96
+ ```
97
+
98
+ `vs git clone` is the time-to-files ratio against the baseline — `4.00×` means
99
+ the working tree was ready in a quarter of the time. A `—` in **time to
100
+ history** means that strategy never produces a usable history, which is a result
101
+ about the strategy and not a gap in the measurement.
102
+
103
+ Run it against your own repositories before quoting any of it: the ratios depend
104
+ on how much history the project has and on the network between you and GitHub.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * One measured run of one strategy against one repository.
3
+ */
4
+ export interface Measurement {
5
+ /** Strategy label, e.g. `'git clone'` or `'git0 --history'`. */
6
+ strategy: string;
7
+ /** `owner/repo` that was fetched. */
8
+ repo: string;
9
+ /**
10
+ * Milliseconds until the source tree was on disk — the moment `bun install`
11
+ * could start and therefore the moment the clock that matters stops.
12
+ */
13
+ timeToFiles: number;
14
+ /**
15
+ * Milliseconds until the full commit history was available, or `null` for a
16
+ * strategy that never produces one (a tarball, a shallow clone).
17
+ */
18
+ timeToHistory: number | null;
19
+ /** Bytes written to disk, when the runner measured it. */
20
+ bytesOnDisk?: number;
21
+ /** Set when the run failed; the other numbers are then meaningless. */
22
+ error?: string;
23
+ }
24
+
25
+ /**
26
+ * Formats a duration the way a benchmark reader wants to read it: milliseconds
27
+ * while they are small enough to compare at a glance, seconds after that.
28
+ *
29
+ * @param ms - Duration in milliseconds, or `null` for "never happened".
30
+ * @returns A short human-readable string.
31
+ *
32
+ * @example
33
+ * formatDuration(842); // → '842ms'
34
+ * formatDuration(12400); // → '12.40s'
35
+ * formatDuration(null); // → '—'
36
+ */
37
+ export function formatDuration(ms: number | null): string {
38
+ if (ms === null) return '—';
39
+ return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`;
40
+ }
41
+
42
+ /**
43
+ * Formats a byte count in binary units.
44
+ *
45
+ * @param bytes - Size in bytes, or `undefined` when it was not measured.
46
+ * @returns A short human-readable string.
47
+ *
48
+ * @example
49
+ * formatBytes(1536); // → '1.5 KiB'
50
+ * formatBytes(undefined); // → '—'
51
+ */
52
+ export function formatBytes(bytes?: number): string {
53
+ if (bytes === undefined) return '—';
54
+
55
+ const units = ['B', 'KiB', 'MiB', 'GiB'];
56
+ let value = bytes;
57
+ let unit = 0;
58
+
59
+ while (value >= 1024 && unit < units.length - 1) {
60
+ value /= 1024;
61
+ unit += 1;
62
+ }
63
+
64
+ return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
65
+ }
66
+
67
+ /**
68
+ * How much sooner a strategy got to a usable working tree than the baseline.
69
+ *
70
+ * Expressed against `git clone` because that is the command git0 replaces, and
71
+ * as a ratio because the absolute numbers are dominated by the repository's
72
+ * size and the network between you and GitHub.
73
+ *
74
+ * @param measurements - Every run for one repository.
75
+ * @param baseline - Strategy to compare against.
76
+ * @returns Map of strategy to speedup (`2` means "half the time"), skipping the
77
+ * baseline itself and any run that failed.
78
+ *
79
+ * @example
80
+ * speedups([
81
+ * { strategy: 'git clone', repo: 'a/b', timeToFiles: 8000, timeToHistory: 8000 },
82
+ * { strategy: 'git0', repo: 'a/b', timeToFiles: 2000, timeToHistory: null },
83
+ * ]);
84
+ * // → Map { 'git0' => 4 }
85
+ */
86
+ export function speedups(
87
+ measurements: Measurement[],
88
+ baseline = 'git clone'
89
+ ): Map<string, number> {
90
+ const reference = measurements.find(m => m.strategy === baseline && !m.error);
91
+ const result = new Map<string, number>();
92
+ if (!reference || reference.timeToFiles <= 0) return result;
93
+
94
+ for (const measurement of measurements) {
95
+ if (measurement.error || measurement.strategy === baseline) continue;
96
+ if (measurement.timeToFiles <= 0) continue;
97
+ result.set(measurement.strategy, reference.timeToFiles / measurement.timeToFiles);
98
+ }
99
+
100
+ return result;
101
+ }
102
+
103
+ /**
104
+ * Renders the measurements as a GitHub-flavoured markdown table.
105
+ *
106
+ * Markdown rather than aligned columns so a run can be pasted straight into an
107
+ * issue or a PR, which is where benchmark numbers are actually argued about.
108
+ *
109
+ * @param measurements - Runs to render, in the order they should appear.
110
+ * @returns The table, without a trailing newline.
111
+ *
112
+ * @example
113
+ * console.log(formatTable(results));
114
+ */
115
+ export function formatTable(measurements: Measurement[]): string {
116
+ const gains = speedups(measurements);
117
+
118
+ const rows = measurements.map(measurement => {
119
+ if (measurement.error) {
120
+ return `| ${measurement.strategy} | ${measurement.repo} | failed: ${measurement.error} | | | |`;
121
+ }
122
+
123
+ const gain = gains.get(measurement.strategy);
124
+
125
+ return [
126
+ '',
127
+ measurement.strategy,
128
+ measurement.repo,
129
+ formatDuration(measurement.timeToFiles),
130
+ formatDuration(measurement.timeToHistory),
131
+ formatBytes(measurement.bytesOnDisk),
132
+ gain ? `${gain.toFixed(2)}×` : '—',
133
+ '',
134
+ ].join(' | ').trim();
135
+ });
136
+
137
+ return [
138
+ '| strategy | repo | time to files | time to history | on disk | vs git clone |',
139
+ '| --- | --- | --- | --- | --- | --- |',
140
+ ...rows,
141
+ ].join('\n');
142
+ }
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Measures how long each way of getting a GitHub repository onto your machine
4
+ * takes to reach the point where you can actually run the project.
5
+ *
6
+ * Run it:
7
+ *
8
+ * ```bash
9
+ * bun run bench # the default repo set
10
+ * bun run bench facebook/react # or whichever repos you care about
11
+ * ```
12
+ *
13
+ * It talks to github.com, so the numbers move with your connection — the point
14
+ * is the ratio between the rows of one run, not any single figure. Nothing here
15
+ * runs in CI: it is a `.bench.ts`, which `bun test` does not collect.
16
+ *
17
+ * @see ./README.md for what the numbers mean and why they come out this way.
18
+ */
19
+ import { execFile } from 'child_process';
20
+ import fs from 'fs';
21
+ import os from 'os';
22
+ import path from 'path';
23
+ import { promisify } from 'util';
24
+
25
+ import GithubAPI from '../src/github-api.js';
26
+ import { attachGitHistory } from '../src/history.js';
27
+ import { formatTable, type Measurement } from './report.js';
28
+
29
+ const run = promisify(execFile);
30
+
31
+ /** Repositories measured when none are named on the command line. */
32
+ const DEFAULT_REPOS = ['sindresorhus/is', 'expressjs/express', 'vitejs/vite'];
33
+
34
+ /**
35
+ * Total size of a directory tree, in bytes.
36
+ *
37
+ * Included because it is half the explanation: a shallow tarball and a full
38
+ * clone of the same repository differ by however many megabytes of history the
39
+ * project has accumulated, and that difference is what the download is not
40
+ * waiting for.
41
+ *
42
+ * @param target - Directory to measure.
43
+ * @returns Size in bytes, counting file contents only.
44
+ */
45
+ function directorySize(target: string): number {
46
+ let total = 0;
47
+
48
+ for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
49
+ const full = path.join(target, entry.name);
50
+ if (entry.isDirectory()) total += directorySize(full);
51
+ else if (entry.isFile()) total += fs.statSync(full).size;
52
+ }
53
+
54
+ return total;
55
+ }
56
+
57
+ /**
58
+ * Times one strategy, cleaning up after itself whether or not it worked.
59
+ *
60
+ * @param strategy - Label for the report.
61
+ * @param repo - `owner/repo` being measured.
62
+ * @param work - Receives a scratch directory; returns when the *files* are
63
+ * ready, and optionally a promise that settles when history is ready.
64
+ * @returns The measurement.
65
+ */
66
+ async function measure(
67
+ strategy: string,
68
+ repo: string,
69
+ work: (workdir: string) => Promise<{ dir: string; history?: Promise<unknown> }>
70
+ ): Promise<Measurement> {
71
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'git0-bench-'));
72
+ const started = performance.now();
73
+
74
+ try {
75
+ const { dir, history } = await work(workdir);
76
+ const timeToFiles = performance.now() - started;
77
+
78
+ let timeToHistory: number | null = null;
79
+ if (history) {
80
+ await history;
81
+ timeToHistory = performance.now() - started;
82
+ }
83
+
84
+ return {
85
+ strategy,
86
+ repo,
87
+ timeToFiles,
88
+ timeToHistory,
89
+ bytesOnDisk: directorySize(dir),
90
+ };
91
+ } catch (error: any) {
92
+ return { strategy, repo, timeToFiles: 0, timeToHistory: null, error: error.message };
93
+ } finally {
94
+ fs.rmSync(workdir, { recursive: true, force: true });
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Runs every strategy against one repository.
100
+ *
101
+ * The four rows are the four honest answers to "get me this repo":
102
+ *
103
+ * - **git clone** — the baseline, and the only other row that ends with history.
104
+ * - **git clone --depth=1** — the usual "but you can make clone fast" reply.
105
+ * It is fast, and it leaves you without the history you cloned for.
106
+ * - **git0** — tarball, extracted as it streams.
107
+ * - **git0 --history** — tarball first, history attached afterwards. Its
108
+ * *time to files* is the row above; its *time to history* is where it lands
109
+ * relative to `git clone`.
110
+ *
111
+ * @param repo - `owner/repo` to measure.
112
+ * @returns One measurement per strategy.
113
+ */
114
+ async function benchmarkRepo(repo: string): Promise<Measurement[]> {
115
+ const url = `https://github.com/${repo}`;
116
+ const name = repo.split('/')[1];
117
+ const github = new GithubAPI();
118
+
119
+ const results: Measurement[] = [];
120
+
121
+ results.push(
122
+ await measure('git clone', repo, async workdir => {
123
+ const dir = path.join(workdir, name);
124
+ await run('git', ['clone', '--quiet', url, dir]);
125
+ return { dir };
126
+ })
127
+ );
128
+
129
+ results.push(
130
+ await measure('git clone --depth=1', repo, async workdir => {
131
+ const dir = path.join(workdir, name);
132
+ await run('git', ['clone', '--quiet', '--depth=1', url, dir]);
133
+ return { dir };
134
+ })
135
+ );
136
+
137
+ results.push(
138
+ await measure('git0', repo, async workdir => {
139
+ process.chdir(workdir);
140
+ const dir = await github.downloadRepo(url);
141
+ return { dir };
142
+ })
143
+ );
144
+
145
+ results.push(
146
+ await measure('git0 --history', repo, async workdir => {
147
+ process.chdir(workdir);
148
+ const dir = await github.downloadRepo(url);
149
+ // Started once the files exist, exactly as the CLI does it — the caller
150
+ // stops the "time to files" clock before this is awaited.
151
+ return { dir, history: attachGitHistory(dir, url) };
152
+ })
153
+ );
154
+
155
+ return results;
156
+ }
157
+
158
+ /**
159
+ * Entry point: measures every named repository and prints one table each.
160
+ */
161
+ async function main(): Promise<void> {
162
+ const repos = process.argv.slice(2).filter(arg => !arg.startsWith('--'));
163
+ const cwd = process.cwd();
164
+
165
+ for (const repo of repos.length ? repos : DEFAULT_REPOS) {
166
+ console.log(`\n### ${repo}\n`);
167
+ const results = await benchmarkRepo(repo);
168
+ process.chdir(cwd);
169
+ console.log(formatTable(results));
170
+ }
171
+ }
172
+
173
+ main();