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.
- package/benchmark/README.md +104 -0
- package/benchmark/report.ts +142 -0
- package/benchmark/tarball-vs-clone.bench.ts +173 -0
- package/dist/cli.js +264 -25
- package/dist/cli.js.map +9 -7
- package/package.json +3 -2
- package/readme.md +120 -1
- package/src/args.ts +58 -0
- package/src/cli.ts +104 -20
- package/src/download.ts +214 -12
- package/src/github-api.ts +108 -2
- package/src/history.ts +225 -0
- package/src/utils.ts +29 -9
- package/test/args.test.ts +44 -0
- package/test/benchmark-report.test.ts +102 -0
- package/test/download-repo.test.ts +178 -0
- package/test/history.test.ts +157 -0
- package/test/parse-target.test.ts +73 -0
- package/test/subpath.test.ts +178 -0
- package/test/utils.test.ts +37 -0
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(
|
|
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`.
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
|
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
|
-
|
|
297
|
+
const what = subPath ? `${parsed.name}/${subPath}` : parsed.name;
|
|
298
|
+
console.log(chalk.blue(`📦 Downloading ${what} into ${path.basename(extractPath)}...`));
|
|
112
299
|
|
|
113
|
-
const
|
|
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
|
-
|
|
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(
|
|
76
|
-
|
|
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
|
+
}
|