git0 0.2.98 → 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.
package/src/download.ts CHANGED
@@ -29,6 +29,156 @@ export function getAvailableDirectoryName(basePath: string): string {
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * Cleans a repository-relative sub-path into the form the tarball uses:
34
+ * forward slashes, no leading or trailing separator, no `.` segments.
35
+ *
36
+ * Every sub-path eventually becomes a prefix match against archive entry names
37
+ * and then a path joined onto the extraction directory, so this is also the
38
+ * gate that keeps a crafted path from escaping that directory: a `..` segment
39
+ * is rejected outright rather than normalized away, because the only honest
40
+ * reading of `packages/../../etc` in a "download this folder" argument is that
41
+ * something is wrong with it.
42
+ *
43
+ * @param raw - Sub-path as typed or parsed out of a URL; `null`/empty means
44
+ * "the whole repository".
45
+ * @returns The normalized sub-path, or `''` for the whole repository.
46
+ * @throws When the path contains a `..` segment.
47
+ *
48
+ * @example
49
+ * normalizeSubPath('/.continue/agents/'); // → '.continue/agents'
50
+ * normalizeSubPath(null); // → ''
51
+ * normalizeSubPath('../../etc'); // throws
52
+ */
53
+ export function normalizeSubPath(raw?: string | null): string {
54
+ if (!raw) return '';
55
+
56
+ const segments = raw
57
+ .replace(/\\/g, '/')
58
+ .split('/')
59
+ .filter(segment => segment.length > 0 && segment !== '.');
60
+
61
+ if (segments.includes('..')) {
62
+ throw new Error(`Invalid path "${raw}" — ".." segments are not allowed.`);
63
+ }
64
+
65
+ return segments.join('/');
66
+ }
67
+
68
+ /**
69
+ * Builds the `tar` entry filter that keeps only a sub-path of the archive.
70
+ *
71
+ * GitHub wraps every tarball in one top-level directory named after the commit
72
+ * (`owner-repo-abc1234/`), so the first segment of each entry name is dropped
73
+ * before comparing. The filter runs in tar's *parser*, ahead of the unpacker's
74
+ * `strip`, which is what makes filtering by the repository-relative path
75
+ * possible at all — and what makes it cheap: a filtered-out entry is skipped in
76
+ * the stream and never reaches the disk, so downloading one folder of a large
77
+ * repository writes one folder.
78
+ *
79
+ * @param subPath - Normalized sub-path; `''` keeps everything.
80
+ * @returns A predicate suitable for `tar.x({ filter })`.
81
+ *
82
+ * @example
83
+ * const keep = subPathFilter('src/lib');
84
+ * keep('react-abc123/src/lib/index.ts'); // → true
85
+ * keep('react-abc123/src/library.ts'); // → false — prefix, not a path segment
86
+ */
87
+ export function subPathFilter(subPath: string): (entryPath: string) => boolean {
88
+ if (!subPath) return () => true;
89
+
90
+ const prefix = `${subPath}/`;
91
+
92
+ return (entryPath: string) => {
93
+ const relative = entryPath
94
+ .replace(/\\/g, '/')
95
+ .split('/')
96
+ .slice(1)
97
+ .join('/')
98
+ .replace(/\/+$/, '');
99
+
100
+ return relative === subPath || relative.startsWith(prefix);
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Moves an extracted sub-path up to the root of the extraction directory, so
106
+ * `git0 owner/repo/tree/main/packages/ui` leaves the contents of `ui/` in the
107
+ * target folder rather than `packages/ui/` nested inside it.
108
+ *
109
+ * Done after extraction rather than with a larger `tar` `strip` on purpose: the
110
+ * right strip depth depends on whether the sub-path names a file or a folder,
111
+ * which a `owner/repo/some/path` shorthand does not say. Hoisting reads that
112
+ * off the disk instead of guessing, and costs one rename — the files never move
113
+ * across a filesystem, because the staging directory is a sibling of the target.
114
+ *
115
+ * @param extractPath - Directory the tarball was extracted into.
116
+ * @param subPath - Normalized sub-path that was extracted.
117
+ * @throws When the sub-path is not present in the repository.
118
+ *
119
+ * @example
120
+ * // extractPath/packages/ui/{package.json,src} → extractPath/{package.json,src}
121
+ * hoistSubPath(extractPath, 'packages/ui');
122
+ */
123
+ export function hoistSubPath(extractPath: string, subPath: string): void {
124
+ if (!subPath) return;
125
+
126
+ const landed = path.join(extractPath, subPath);
127
+ if (!fs.existsSync(landed)) {
128
+ throw new Error(
129
+ `"${subPath}" was not found in this repository — check the path and the branch.`
130
+ );
131
+ }
132
+
133
+ // Park the payload outside the target before clearing the wrapper directories,
134
+ // otherwise removing `packages/` would take `packages/ui` with it.
135
+ const staging = fs.mkdtempSync(path.join(path.dirname(extractPath), '.git0-hoist-'));
136
+ const parked = path.join(staging, 'payload');
137
+
138
+ try {
139
+ fs.renameSync(landed, parked);
140
+ fs.rmSync(path.join(extractPath, subPath.split('/')[0]), { recursive: true, force: true });
141
+
142
+ if (fs.statSync(parked).isDirectory()) {
143
+ for (const child of fs.readdirSync(parked)) {
144
+ fs.renameSync(path.join(parked, child), path.join(extractPath, child));
145
+ }
146
+ } else {
147
+ fs.renameSync(parked, path.join(extractPath, path.basename(subPath)));
148
+ }
149
+ } finally {
150
+ fs.rmSync(staging, { recursive: true, force: true });
151
+ }
152
+ }
153
+
154
+ /**
155
+ * The folder name a sub-path download should default to.
156
+ *
157
+ * The repository name is the wrong default here — downloading
158
+ * `debate-ai.com/.continue/agents` into a folder called `debate-ai.com` claims
159
+ * to hold a site and holds two config files. The last segment of the path is
160
+ * what the user asked for, except when the path names a *file*, where the
161
+ * enclosing folder is the meaningful name and the file keeps its own.
162
+ *
163
+ * @param subPath - Normalized sub-path.
164
+ * @param type - `'blob'` or `'tree'` when a GitHub URL said which; omitted for
165
+ * the `owner/repo/some/path` shorthand, which is treated as a folder.
166
+ * @returns The folder name, or `undefined` to fall back to the repository name.
167
+ *
168
+ * @example
169
+ * subPathDirName('packages/ui', 'tree'); // → 'ui'
170
+ * subPathDirName('.continue/agents/x.yaml', 'blob'); // → 'agents'
171
+ * subPathDirName('readme.md', 'blob'); // → undefined (repo name)
172
+ */
173
+ export function subPathDirName(subPath: string, type?: string): string | undefined {
174
+ if (!subPath) return undefined;
175
+
176
+ const segments = subPath.split('/');
177
+ if (type === 'blob') segments.pop();
178
+
179
+ return segments.pop();
180
+ }
181
+
32
182
  /**
33
183
  * Streams a GitHub tarball response directly into a `tar` extractor.
34
184
  *
@@ -36,18 +186,27 @@ export function getAvailableDirectoryName(basePath: string): string {
36
186
  * tarball (e.g. `owner-repo-abc1234/`) so files land directly in
37
187
  * `extractPath`.
38
188
  *
189
+ * When `subPath` is set, entries outside it are dropped as they are parsed —
190
+ * they are never written, so a partial download really is partial.
191
+ *
39
192
  * @param res - The `ReadableStream` body from the GitHub API response.
40
193
  * @param extractPath - Absolute directory to extract into (must already exist).
194
+ * @param subPath - Normalized repository-relative path to limit extraction to;
195
+ * `''` extracts the whole repository.
41
196
  * @returns Promise that resolves when extraction is complete.
42
197
  *
43
198
  * @internal
44
199
  */
45
- async function streamTarball(res: ReadableStream, extractPath: string): Promise<void> {
200
+ async function streamTarball(
201
+ res: ReadableStream,
202
+ extractPath: string,
203
+ subPath = ''
204
+ ): Promise<void> {
46
205
  const { Readable } = await import('stream');
47
206
  const nodeStream = Readable.fromWeb(res);
48
207
  await new Promise<void>((resolve, reject) => {
49
208
  nodeStream
50
- .pipe(tar.x({ C: extractPath, strip: 1 }))
209
+ .pipe(tar.x({ C: extractPath, strip: 1, filter: subPathFilter(subPath) }))
51
210
  .on('finish', resolve)
52
211
  .on('error', reject);
53
212
  });
@@ -77,23 +236,44 @@ async function streamToFile(res: ReadableStream, dest: string): Promise<void> {
77
236
  * Downloads a GitHub repository tarball and extracts it into a local directory.
78
237
  *
79
238
  * The function first attempts to download the `master` branch; if that request
80
- * returns an error it retries with `main`. The extracted directory name is
81
- * derived from the repository name, with a numeric suffix appended when the
82
- * target already exists (see {@link getAvailableDirectoryName}).
239
+ * returns an error it retries with `main`. A `ref` from a deep GitHub URL
240
+ * (`/tree/<branch>/…`) is used as-is and skips that fallback. The extracted
241
+ * directory name is derived from the repository — or from the sub-path, when
242
+ * one is given — with a numeric suffix appended when the target already exists
243
+ * (see {@link getAvailableDirectoryName}).
244
+ *
245
+ * `subPath` limits the download to one folder or file of the repository. GitHub
246
+ * serves the whole tarball either way, but the entries outside the sub-path are
247
+ * discarded in the stream rather than written, and what is kept is hoisted to
248
+ * the root of the target folder (see {@link hoistSubPath}).
83
249
  *
84
250
  * @param callGithub - Pre-configured `grab` instance bound to the GitHub API.
85
251
  * @param repo - GitHub URL (`https://github.com/owner/repo`) or `owner/repo`.
86
- * @param targetDir - Optional custom folder name; defaults to the repo name.
252
+ * @param targetDir - Optional custom folder name; defaults to the repo name, or
253
+ * to the sub-path's own name when `subPath` is set.
254
+ * @param options.subPath - Repository-relative folder or file to limit the
255
+ * download to.
256
+ * @param options.subPathType - `'blob'` or `'tree'` when the URL said which.
257
+ * @param options.ref - Branch, tag or commit to download.
87
258
  * @returns Absolute path of the directory the repo was extracted into.
88
259
  *
89
260
  * @example
90
261
  * const dir = await downloadRepo(callGithub, 'https://github.com/facebook/react');
91
262
  * // → '/current/working/dir/react'
263
+ *
264
+ * @example
265
+ * // Just one folder of a large repo:
266
+ * const dir = await downloadRepo(callGithub, 'facebook/react', null, {
267
+ * subPath: 'packages/react-dom',
268
+ * subPathType: 'tree',
269
+ * });
270
+ * // → '/current/working/dir/react-dom'
92
271
  */
93
272
  export async function downloadRepo(
94
273
  callGithub: Function,
95
274
  repo: string,
96
- targetDir: string | null = null
275
+ targetDir: string | null = null,
276
+ options: { subPath?: string | null; subPathType?: string; ref?: string } = {}
97
277
  ): Promise<string> {
98
278
  const parsed = gitUrlParse(repo);
99
279
 
@@ -103,22 +283,44 @@ export async function downloadRepo(
103
283
  parsed.owner = parsed.owner.split('/').slice(-1)[0];
104
284
  }
105
285
 
106
- const defaultDir = path.resolve(process.cwd(), targetDir?.length ? targetDir : parsed.name);
286
+ const subPath = normalizeSubPath(options.subPath);
287
+ const defaultName =
288
+ (targetDir?.length ? targetDir : undefined) ??
289
+ subPathDirName(subPath, options.subPathType) ??
290
+ parsed.name;
291
+
292
+ const defaultDir = path.resolve(process.cwd(), defaultName);
107
293
  const extractPath = getAvailableDirectoryName(defaultDir);
108
294
 
109
295
  fs.mkdirSync(extractPath, { recursive: true });
110
296
 
111
- console.log(chalk.blue(`📦 Downloading ${parsed.name} into ${path.basename(extractPath)}...`));
297
+ const what = subPath ? `${parsed.name}/${subPath}` : parsed.name;
298
+ console.log(chalk.blue(`📦 Downloading ${what} into ${path.basename(extractPath)}...`));
112
299
 
113
- const defaultBranch = (parsed as any).default_branch || 'master';
300
+ const requestedRef = options.ref || (parsed as any).ref || '';
301
+ const defaultBranch = requestedRef || (parsed as any).default_branch || 'master';
114
302
  const tarballUrl = `/repos/${parsed.owner}/${parsed.name}/tarball/${defaultBranch}`;
115
- const params = { onStream: (res: ReadableStream) => streamTarball(res, extractPath) };
303
+ const params = { onStream: (res: ReadableStream) => streamTarball(res, extractPath, subPath) };
116
304
 
117
305
  let response = await callGithub(tarballUrl, params);
306
+ // Only guess at the branch when the caller did not name one.
307
+ if (response.error && !requestedRef) {
308
+ response = await callGithub(tarballUrl.replace(/\/master$/, '/main'), params);
309
+ }
310
+
311
+ // A failed request leaves an empty directory behind. Saying so beats letting
312
+ // the next step report a missing sub-path, or the install step report a
313
+ // project with no files in it.
118
314
  if (response.error) {
119
- response = await callGithub(tarballUrl.replace('/master', '/main'), params);
315
+ fs.rmSync(extractPath, { recursive: true, force: true });
316
+ throw new Error(
317
+ `Could not download ${parsed.owner}/${parsed.name}` +
318
+ `${requestedRef ? ` at ${requestedRef}` : ''}: ${response.error}`
319
+ );
120
320
  }
121
321
 
322
+ hoistSubPath(extractPath, subPath);
323
+
122
324
  return extractPath;
123
325
  }
124
326
 
package/src/github-api.ts CHANGED
@@ -6,6 +6,92 @@ import { getCurrentPlatform } from './platform.js';
6
6
  import { categorizeReleasesByPlatform, filterReleasesByPlatform } from './releases.js';
7
7
  import { downloadRepo, downloadPackage } from './download.js';
8
8
 
9
+ /**
10
+ * What the CLI was pointed at: a repository, and optionally one path inside it.
11
+ */
12
+ export interface RepoTarget {
13
+ /** Repository owner. */
14
+ owner: string;
15
+ /** Repository name. */
16
+ name: string;
17
+ /** Canonical `https://github.com/owner/name` URL. */
18
+ href: string;
19
+ /** Branch, tag or commit named by the URL, or `''`. */
20
+ ref: string;
21
+ /** Repository-relative path to limit the download to, or `''`. */
22
+ subPath: string;
23
+ /** `'blob'` when the path names a file, `'tree'` when a folder, `''` when unstated. */
24
+ subPathType: string;
25
+ }
26
+
27
+ /**
28
+ * Parses a GitHub reference — including one that points *into* a repository —
29
+ * into the pieces a download needs.
30
+ *
31
+ * The deep forms are the ones people actually have in the clipboard. A link
32
+ * copied out of the GitHub UI carries the branch and the path
33
+ * (`/tree/main/packages/ui`, `/blob/main/src/index.ts`), and that is exactly
34
+ * the scope the user is asking for, so it is read rather than thrown away and
35
+ * the whole repository downloaded instead. The `owner/repo/path` shorthand is
36
+ * handled separately from `git-url-parse`, which reads every leading segment of
37
+ * a plain path as part of the owner.
38
+ *
39
+ * @param query - URL, shorthand, or an unrelated search string.
40
+ * @returns A {@link RepoTarget}, or `false` when the input is not a GitHub
41
+ * reference (i.e. it should be treated as a search query).
42
+ *
43
+ * @example
44
+ * parseTarget('https://github.com/debate/debate-ai.com/blob/master/.continue/agents/new-config.yaml');
45
+ * // → { owner: 'debate', name: 'debate-ai.com', ref: 'master',
46
+ * // subPath: '.continue/agents/new-config.yaml', subPathType: 'blob', … }
47
+ *
48
+ * @example
49
+ * parseTarget('facebook/react/packages/react-dom');
50
+ * // → { owner: 'facebook', name: 'react', subPath: 'packages/react-dom', subPathType: '', … }
51
+ *
52
+ * parseTarget('react starter'); // → false
53
+ */
54
+ export function parseTarget(query: string): RepoTarget | false {
55
+ const looksLikeUrl =
56
+ query.includes('github.com') ||
57
+ query.startsWith('git@github.com:') ||
58
+ query.startsWith('https://') ||
59
+ query.startsWith('git://');
60
+
61
+ if (!looksLikeUrl) {
62
+ // `owner/repo`, optionally followed by a path into the repository.
63
+ const shorthand = query.match(/^([\w-]+)\/([\w.-]+)(?:\/(.+))?$/);
64
+ if (!shorthand) return false;
65
+
66
+ const [, owner, name, subPath = ''] = shorthand;
67
+ return {
68
+ owner,
69
+ name,
70
+ href: `https://github.com/${owner}/${name}`,
71
+ ref: '',
72
+ subPath,
73
+ subPathType: '',
74
+ };
75
+ }
76
+
77
+ const parsed = gitUrlParse(query) as any;
78
+ if (!parsed?.owner || !parsed?.name) return false;
79
+
80
+ // Forks arrive with the fork chain in `owner`, e.g. "upstream/fork".
81
+ const owner = parsed.owner.includes('/')
82
+ ? parsed.owner.split('/').slice(-1)[0]
83
+ : parsed.owner;
84
+
85
+ return {
86
+ owner,
87
+ name: parsed.name,
88
+ href: `https://github.com/${owner}/${parsed.name}`,
89
+ ref: parsed.ref || '',
90
+ subPath: parsed.filepath || '',
91
+ subPathType: parsed.filepathtype || '',
92
+ };
93
+ }
94
+
9
95
  /**
10
96
  * GitHub API client for searching repositories, downloading source tarballs,
11
97
  * and fetching release assets.
@@ -66,14 +152,20 @@ class GithubAPI {
66
152
  *
67
153
  * @param repo - Full GitHub URL or `owner/repo` shorthand.
68
154
  * @param targetDir - Optional extraction folder name; defaults to the repo name.
155
+ * @param options - `subPath` / `subPathType` / `ref`, as produced by
156
+ * {@link parseTarget}, to download one folder of the repository.
69
157
  * @returns Absolute path of the extracted project directory.
70
158
  *
71
159
  * @example
72
160
  * const dir = await github.downloadRepo('https://github.com/vitejs/vite');
73
161
  * // → '/current/working/dir/vite'
74
162
  */
75
- async downloadRepo(repo: string, targetDir: string | null = null): Promise<string> {
76
- return downloadRepo(this.callGithub, repo, targetDir);
163
+ async downloadRepo(
164
+ repo: string,
165
+ targetDir: string | null = null,
166
+ options: { subPath?: string | null; subPathType?: string; ref?: string } = {}
167
+ ): Promise<string> {
168
+ return downloadRepo(this.callGithub, repo, targetDir, options);
77
169
  }
78
170
 
79
171
  /**
@@ -177,6 +269,20 @@ class GithubAPI {
177
269
  *
178
270
  * github.parseURL('just a search query'); // → false
179
271
  */
272
+ /**
273
+ * Parses a GitHub reference into owner, repo, ref and sub-path.
274
+ *
275
+ * Delegates to {@link parseTarget}. Prefer this over {@link parseURL} for
276
+ * anything that downloads — it is the one that understands `/tree/` and
277
+ * `/blob/` links.
278
+ *
279
+ * @param query - URL, `owner/repo[/path]` shorthand, or a search string.
280
+ * @returns A {@link RepoTarget}, or `false` for a search string.
281
+ */
282
+ parseTarget(query: string): RepoTarget | false {
283
+ return parseTarget(query);
284
+ }
285
+
180
286
  parseURL(query: string): ReturnType<typeof gitUrlParse> | false {
181
287
  if (
182
288
  query.includes('github.com') ||
package/src/history.ts ADDED
@@ -0,0 +1,225 @@
1
+ import chalk from 'chalk';
2
+ import { execFile } from 'child_process';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { promisify } from 'util';
6
+
7
+ const run = promisify(execFile);
8
+
9
+ /**
10
+ * Runs `git` with an argument array — never a shell string, so a repository URL
11
+ * can never be read as extra arguments or as shell syntax.
12
+ *
13
+ * @param args - Arguments passed straight to `git`.
14
+ * @param cwd - Working directory for the command.
15
+ * @returns The command's stdout, trimmed.
16
+ * @throws Whatever `execFile` throws on a non-zero exit.
17
+ *
18
+ * @internal
19
+ */
20
+ async function git(args: string[], cwd?: string): Promise<string> {
21
+ const { stdout } = await run('git', args, { cwd, maxBuffer: 1024 * 1024 * 64 });
22
+ return stdout.trim();
23
+ }
24
+
25
+ /**
26
+ * Returns true when a usable `git` binary is on `PATH`.
27
+ *
28
+ * git0's whole point is working without one, so every history feature is gated
29
+ * on this rather than assuming it.
30
+ *
31
+ * @example
32
+ * if (!(await gitAvailable())) console.log('install git first');
33
+ */
34
+ export async function gitAvailable(): Promise<boolean> {
35
+ try {
36
+ await git(['--version']);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Rejects anything that is not a plain scheme-qualified remote.
45
+ *
46
+ * The URLs git0 builds are assembled from a parsed owner and repo name, so this
47
+ * is belt-and-braces — but a value that reaches `git clone` decides what gets
48
+ * executed and where it is written, and `ext::<command>`, a bare local path or
49
+ * a leading `-` are the classic ways to turn a clone into something else.
50
+ * `file://` is allowed because it is an ordinary remote and it is what the
51
+ * tests clone from; the schemes that hand git a command to run are not.
52
+ *
53
+ * @param url - Remote URL to check.
54
+ * @returns The URL, unchanged.
55
+ * @throws When the URL is not an http(s), git or file remote.
56
+ *
57
+ * @internal
58
+ */
59
+ function assertSafeRemote(url: string): string {
60
+ if (!/^(https?|git|file):\/\/[^\s]+$/.test(url)) {
61
+ throw new Error(`Refusing to clone from "${url}" — expected an https:// repository URL.`);
62
+ }
63
+ return url;
64
+ }
65
+
66
+ /**
67
+ * Attaches a repository's full Git history to an already-extracted source
68
+ * folder, turning it into a normal working clone.
69
+ *
70
+ * **Why this is not just `git clone`.** A clone is one serial operation: the
71
+ * remote counts and packs every object in the repository's history, sends the
72
+ * pack, and only then writes a working tree you can run. Nothing can start
73
+ * until all of it finishes. git0 splits that in two — the codeload tarball is a
74
+ * pre-made, already-compressed snapshot of one commit that extracts *while it
75
+ * downloads*, so files exist and `bun install` can start almost immediately;
76
+ * the history is then fetched into `.git` alongside the install, which is
77
+ * mostly waiting on the network and on npm anyway. The end state is identical
78
+ * to a clone. The difference is that the thing you were trying to do started at
79
+ * the beginning rather than at the end. See `benchmark/` for the numbers.
80
+ *
81
+ * The clone lands in a temporary directory first and is renamed into place only
82
+ * once it succeeds, so an interrupted download leaves no half-written `.git`
83
+ * for the next command to trip over.
84
+ *
85
+ * @param extractPath - Directory holding the already-extracted source.
86
+ * @param repoUrl - `https://github.com/owner/repo` to fetch history from.
87
+ * @param options.mirror - Use `--mirror` instead of `--bare`, keeping every ref
88
+ * (notes, remote-tracking refs, pull refs) rather than branches and tags.
89
+ * @param options.ref - Branch or tag the source snapshot came from, so the
90
+ * working tree is matched against the right commit.
91
+ * @returns Path to the created `.git` directory.
92
+ * @throws When `git` is missing, the clone fails, or `.git` already exists.
93
+ *
94
+ * @example
95
+ * await attachGitHistory('/work/react', 'https://github.com/facebook/react');
96
+ * // /work/react is now a normal git repo: `git log` works, `git status` is clean
97
+ */
98
+ export async function attachGitHistory(
99
+ extractPath: string,
100
+ repoUrl: string,
101
+ options: { mirror?: boolean; ref?: string } = {}
102
+ ): Promise<string> {
103
+ assertSafeRemote(repoUrl);
104
+
105
+ const gitDir = path.join(extractPath, '.git');
106
+ if (fs.existsSync(gitDir)) {
107
+ throw new Error(`${gitDir} already exists — not overwriting it.`);
108
+ }
109
+ if (!(await gitAvailable())) {
110
+ throw new Error('git is not installed, so the history cannot be downloaded.');
111
+ }
112
+
113
+ const staging = fs.mkdtempSync(path.join(path.dirname(extractPath), '.git0-history-'));
114
+ const cloned = path.join(staging, 'repo.git');
115
+
116
+ try {
117
+ await git(['clone', options.mirror ? '--mirror' : '--bare', '--quiet', repoUrl, cloned]);
118
+ fs.renameSync(cloned, gitDir);
119
+ } catch (error) {
120
+ fs.rmSync(staging, { recursive: true, force: true });
121
+ throw error;
122
+ }
123
+ fs.rmSync(staging, { recursive: true, force: true });
124
+
125
+ // A bare clone has no working tree and no remote-tracking refs. Both are
126
+ // needed for the extracted files to read as a normal checkout.
127
+ await git(['config', '--local', '--bool', 'core.bare', 'false'], extractPath);
128
+ await git(['config', '--local', '--bool', 'core.logallrefupdates', 'true'], extractPath);
129
+ await git(
130
+ ['config', '--local', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*'],
131
+ extractPath
132
+ );
133
+ await git(['fetch', '--quiet', 'origin'], extractPath).catch(() => {});
134
+
135
+ if (options.ref) {
136
+ // The tarball came from this ref, so HEAD has to agree with what is on disk.
137
+ await git(['symbolic-ref', 'HEAD', `refs/heads/${options.ref}`], extractPath).catch(() => {});
138
+ }
139
+
140
+ // Populates the index from HEAD without touching the files the tarball wrote,
141
+ // which is what makes `git status` come back clean instead of showing the
142
+ // whole repository as deleted.
143
+ await git(['reset', '--mixed', '--quiet', 'HEAD'], extractPath).catch(() => {});
144
+
145
+ return gitDir;
146
+ }
147
+
148
+ /**
149
+ * Clones only a repository's history — no working files — into `<name>.git`.
150
+ *
151
+ * This is the `git clone --mirror` shape: the directory holds what would
152
+ * normally live inside `.git`, placed at its root. `--mirror` is the default
153
+ * here rather than `--bare` because the reason to want history without files is
154
+ * almost always to keep or move it, and `--bare` quietly leaves out notes and
155
+ * remote-tracking refs.
156
+ *
157
+ * @param repoUrl - `https://github.com/owner/repo` to mirror.
158
+ * @param targetDir - Directory to create; must not already exist.
159
+ * @param options.bare - Use `--bare` (branches and tags only) instead.
160
+ * @returns The created directory's path.
161
+ * @throws When `git` is missing, the target exists, or the clone fails.
162
+ *
163
+ * @example
164
+ * await cloneHistoryOnly('https://github.com/facebook/react', '/work/react.git');
165
+ * // → '/work/react.git' — inspect it with:
166
+ * // git --git-dir=/work/react.git log --all --oneline
167
+ */
168
+ export async function cloneHistoryOnly(
169
+ repoUrl: string,
170
+ targetDir: string,
171
+ options: { bare?: boolean } = {}
172
+ ): Promise<string> {
173
+ assertSafeRemote(repoUrl);
174
+
175
+ if (fs.existsSync(targetDir)) {
176
+ throw new Error(`${targetDir} already exists — not overwriting it.`);
177
+ }
178
+ if (!(await gitAvailable())) {
179
+ throw new Error('git is not installed, so the history cannot be downloaded.');
180
+ }
181
+
182
+ await git(['clone', options.bare ? '--bare' : '--mirror', '--quiet', repoUrl, targetDir]);
183
+ return targetDir;
184
+ }
185
+
186
+ /**
187
+ * Kicks off {@link attachGitHistory} without blocking the caller, and reports
188
+ * how it went when it lands.
189
+ *
190
+ * This is the lazy half of the feature. It is started *after* extraction has
191
+ * finished — the files are on disk, so the IDE can open them and the installer
192
+ * can run — and then left to overlap with those. History is the one part of a
193
+ * clone nothing is waiting on: nobody needs `git log` to run `bun install`.
194
+ *
195
+ * Failures are reported and swallowed. A missing `git`, a private repository or
196
+ * a dropped connection should cost the user their history, not the project they
197
+ * just downloaded and are already working in.
198
+ *
199
+ * @param extractPath - Directory holding the extracted source.
200
+ * @param repoUrl - Repository to fetch history from.
201
+ * @param options - Forwarded to {@link attachGitHistory}.
202
+ * @returns A promise the caller can await at the very end, which never rejects.
203
+ *
204
+ * @example
205
+ * const history = attachGitHistoryLazily(dir, url); // returns immediately
206
+ * await installDependencies(dir); // runs alongside it
207
+ * await history; // settle before exiting
208
+ */
209
+ export function attachGitHistoryLazily(
210
+ extractPath: string,
211
+ repoUrl: string,
212
+ options: { mirror?: boolean; ref?: string } = {}
213
+ ): Promise<void> {
214
+ console.log(chalk.blue('⏳ Fetching .git history in the background...'));
215
+
216
+ return attachGitHistory(extractPath, repoUrl, options).then(
217
+ () => {
218
+ console.log(chalk.green('✅ Git history attached — `git log` is ready.'));
219
+ },
220
+ (error: any) => {
221
+ console.log(chalk.yellow(`⚠️ Could not attach git history: ${error.message}`));
222
+ console.log(chalk.gray(' The downloaded source is unaffected.'));
223
+ }
224
+ );
225
+ }