git0 0.2.99 → 0.3.1

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/utils.ts CHANGED
@@ -1,22 +1,29 @@
1
1
  import chalk from 'chalk';
2
2
  import { execSync } from 'child_process';
3
3
 
4
+ /** Set once the logo has been printed, so repeat calls are no-ops. */
5
+ let logoPrinted = false;
6
+
4
7
  /**
5
- * Prints the git0 ASCII-art logo in cyan to stdout.
8
+ * Prints the git0 ASCII-art logo in cyan to stdout — **at most once per
9
+ * process**.
6
10
  *
7
- * Called at the start of every major CLI action so the user always sees the
8
- * branding regardless of which code path is entered.
11
+ * Several entry points want to guarantee the branding is on screen: `main()`
12
+ * prints it before parsing arguments, and each download path prints it before
13
+ * it starts work, because a download can be reached without going through
14
+ * `main()`. Left unguarded that renders the logo twice for the most common
15
+ * invocation of all, `git0 owner/repo`, which reads as a bug. The guard lets
16
+ * every call site keep saying "show the branding here" while the user sees it
17
+ * exactly once.
9
18
  *
10
19
  * @example
11
20
  * printLogo();
12
- * // ___
13
- * // __ _(_)‾|_ / _ \
14
- * // / _ | | __| | | |
15
- * // | (_| | | |_| |_| |
16
- * // \__, |_|\__|\___/
17
- * // |___/
21
+ * printLogo(); // no-op — the banner is already on screen
18
22
  */
19
23
  export function printLogo(): void {
24
+ if (logoPrinted) return;
25
+ logoPrinted = true;
26
+
20
27
  console.log(chalk.cyan(` ___
21
28
  __ _(_)‾|_ / _ \\
22
29
  / _ | | __| | | |
@@ -25,6 +32,19 @@ export function printLogo(): void {
25
32
  |___/`));
26
33
  }
27
34
 
35
+ /**
36
+ * Forgets that the logo was printed, so the next {@link printLogo} call
37
+ * renders it again.
38
+ *
39
+ * Only useful to tests, which drive several CLI flows through one process and
40
+ * would otherwise see the banner once for the whole suite.
41
+ *
42
+ * @internal
43
+ */
44
+ export function resetLogo(): void {
45
+ logoPrinted = false;
46
+ }
47
+
28
48
  /**
29
49
  * Runs a shell command synchronously, inheriting the parent's stdio so output
30
50
  * streams directly to the terminal.
@@ -0,0 +1,44 @@
1
+ import { describe, test, expect, vi } from 'vitest';
2
+ import { parseArgs } from '../src/args.ts';
3
+
4
+ describe('parseArgs', () => {
5
+ test('treats bare arguments as positional', () => {
6
+ const { positional, flags } = parseArgs(['facebook/react', 'my-folder']);
7
+ expect(positional).toEqual(['facebook/react', 'my-folder']);
8
+ expect(flags.history).toBe(false);
9
+ });
10
+
11
+ test('keeps URLs intact, including deep ones', () => {
12
+ const url = 'https://github.com/debate/debate-ai.com/blob/master/.continue/agents/x.yaml';
13
+ expect(parseArgs([url]).positional).toEqual([url]);
14
+ });
15
+
16
+ test('reads --path and --branch values', () => {
17
+ const { flags } = parseArgs(['facebook/react', '--path=packages/react-dom', '--branch=v18']);
18
+ expect(flags.path).toBe('packages/react-dom');
19
+ expect(flags.branch).toBe('v18');
20
+ });
21
+
22
+ test('accepts --ref as an alias for --branch', () => {
23
+ expect(parseArgs(['a/b', '--ref=main']).flags.branch).toBe('main');
24
+ });
25
+
26
+ test('reads the history flags', () => {
27
+ expect(parseArgs(['a/b', '--history']).flags.history).toBe(true);
28
+ expect(parseArgs(['a/b', '--git']).flags.history).toBe(true);
29
+ expect(parseArgs(['a/b', '--history-only']).flags.historyOnly).toBe(true);
30
+ expect(parseArgs(['a/b', '--mirror']).flags.mirror).toBe(true);
31
+ });
32
+
33
+ test('keeps a path containing an equals sign in one piece', () => {
34
+ expect(parseArgs(['a/b', '--path=dir/a=b']).flags.path).toBe('dir/a=b');
35
+ });
36
+
37
+ test('warns about an unknown flag instead of treating it as a query', () => {
38
+ const warn = vi.spyOn(console, 'log').mockImplementation(() => {});
39
+ const { positional } = parseArgs(['a/b', '--nope']);
40
+ expect(positional).toEqual(['a/b']);
41
+ expect(warn).toHaveBeenCalled();
42
+ warn.mockRestore();
43
+ });
44
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import {
3
+ formatDuration,
4
+ formatBytes,
5
+ speedups,
6
+ formatTable,
7
+ type Measurement,
8
+ } from '../benchmark/report.ts';
9
+
10
+ /** A measured run, with the fields a test does not care about filled in. */
11
+ function run(partial: Partial<Measurement> & { strategy: string }): Measurement {
12
+ return { repo: 'owner/repo', timeToFiles: 0, timeToHistory: null, ...partial };
13
+ }
14
+
15
+ describe('formatDuration', () => {
16
+ test('keeps sub-second times in milliseconds', () => {
17
+ expect(formatDuration(842)).toBe('842ms');
18
+ expect(formatDuration(999.4)).toBe('999ms');
19
+ });
20
+
21
+ test('switches to seconds at a second', () => {
22
+ expect(formatDuration(1000)).toBe('1.00s');
23
+ expect(formatDuration(12400)).toBe('12.40s');
24
+ });
25
+
26
+ test('renders "never happened" rather than zero', () => {
27
+ expect(formatDuration(null)).toBe('—');
28
+ });
29
+ });
30
+
31
+ describe('formatBytes', () => {
32
+ test('scales through binary units', () => {
33
+ expect(formatBytes(512)).toBe('512 B');
34
+ expect(formatBytes(1536)).toBe('1.5 KiB');
35
+ expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MiB');
36
+ });
37
+
38
+ test('renders an unmeasured size as a dash', () => {
39
+ expect(formatBytes(undefined)).toBe('—');
40
+ });
41
+ });
42
+
43
+ describe('speedups', () => {
44
+ test('measures time-to-files against the clone baseline', () => {
45
+ const gains = speedups([
46
+ run({ strategy: 'git clone', timeToFiles: 8000, timeToHistory: 8000 }),
47
+ run({ strategy: 'git0', timeToFiles: 2000 }),
48
+ run({ strategy: 'git0 --history', timeToFiles: 2000, timeToHistory: 9000 }),
49
+ ]);
50
+
51
+ expect(gains.get('git0')).toBe(4);
52
+ expect(gains.get('git0 --history')).toBe(4);
53
+ expect(gains.has('git clone')).toBe(false);
54
+ });
55
+
56
+ test('reports nothing when the baseline itself failed', () => {
57
+ const gains = speedups([
58
+ run({ strategy: 'git clone', error: 'network down' }),
59
+ run({ strategy: 'git0', timeToFiles: 2000 }),
60
+ ]);
61
+
62
+ expect(gains.size).toBe(0);
63
+ });
64
+
65
+ test('skips runs that failed', () => {
66
+ const gains = speedups([
67
+ run({ strategy: 'git clone', timeToFiles: 8000, timeToHistory: 8000 }),
68
+ run({ strategy: 'git0', error: 'rate limited' }),
69
+ ]);
70
+
71
+ expect(gains.has('git0')).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe('formatTable', () => {
76
+ const rows = [
77
+ run({ strategy: 'git clone', timeToFiles: 8000, timeToHistory: 8000, bytesOnDisk: 40 * 1024 * 1024 }),
78
+ run({ strategy: 'git0', timeToFiles: 2000, bytesOnDisk: 4 * 1024 * 1024 }),
79
+ ];
80
+
81
+ test('emits a markdown table with one row per measurement', () => {
82
+ const lines = formatTable(rows).split('\n');
83
+ expect(lines).toHaveLength(4);
84
+ expect(lines[0]).toContain('time to files');
85
+ expect(lines[1]).toMatch(/^\| --- \|/);
86
+ });
87
+
88
+ test('shows the speedup against the baseline', () => {
89
+ expect(formatTable(rows)).toContain('4.00×');
90
+ });
91
+
92
+ test('marks a strategy that produces no history', () => {
93
+ const git0Row = formatTable(rows).split('\n')[3];
94
+ expect(git0Row).toContain('—');
95
+ });
96
+
97
+ test('says so when a run failed rather than printing zeros', () => {
98
+ const table = formatTable([run({ strategy: 'git0', error: 'rate limited' })]);
99
+ expect(table).toContain('failed: rate limited');
100
+ expect(table).not.toContain('0ms');
101
+ });
102
+ });
@@ -0,0 +1,178 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import * as tar from 'tar';
6
+ import { downloadRepo } from '../src/download.ts';
7
+
8
+ /**
9
+ * Builds a tarball shaped exactly like the ones GitHub's `/tarball` endpoint
10
+ * serves: every entry under one top-level `owner-repo-sha/` directory.
11
+ */
12
+ function githubTarball(root: string, files: Record<string, string>): string {
13
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'git0-tarball-'));
14
+ const wrapper = path.join(staging, root);
15
+
16
+ for (const [relative, contents] of Object.entries(files)) {
17
+ const full = path.join(wrapper, relative);
18
+ fs.mkdirSync(path.dirname(full), { recursive: true });
19
+ fs.writeFileSync(full, contents);
20
+ }
21
+
22
+ const archive = path.join(staging, 'repo.tar.gz');
23
+ tar.c({ gzip: true, sync: true, C: staging, file: archive }, [root]);
24
+ return archive;
25
+ }
26
+
27
+ /**
28
+ * A `callGithub` stand-in that hands the tarball to `onStream` the way `grab`
29
+ * does — including awaiting it, which is what lets extraction finish before
30
+ * `downloadRepo` moves on.
31
+ */
32
+ function fakeGithub(archive: string, options: { fail?: boolean } = {}) {
33
+ const requested: string[] = [];
34
+
35
+ const callGithub = async (url: string, params?: any) => {
36
+ requested.push(url);
37
+ if (options.fail) return { error: 'HTTP error: 404 Not Found' };
38
+
39
+ const stream = fs.createReadStream(archive);
40
+ const { Readable } = await import('stream');
41
+ await params.onStream(Readable.toWeb(stream) as ReadableStream);
42
+ return {};
43
+ };
44
+
45
+ return { callGithub, requested };
46
+ }
47
+
48
+ const FILES = {
49
+ 'package.json': '{"name":"demo"}',
50
+ 'readme.md': '# demo\n',
51
+ 'packages/ui/package.json': '{"name":"ui"}',
52
+ 'packages/ui/src/index.ts': 'export const ui = 1;\n',
53
+ 'packages/core/index.ts': 'export const core = 1;\n',
54
+ '.continue/agents/new-config.yaml': 'name: agent\n',
55
+ };
56
+
57
+ describe('downloadRepo', () => {
58
+ let cwd: string;
59
+ let tmp: string;
60
+ let archive: string;
61
+
62
+ beforeEach(() => {
63
+ cwd = process.cwd();
64
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'git0-download-'));
65
+ process.chdir(tmp);
66
+ archive = githubTarball('owner-demo-abc1234', FILES);
67
+ });
68
+
69
+ afterEach(() => {
70
+ process.chdir(cwd);
71
+ fs.rmSync(tmp, { recursive: true, force: true });
72
+ });
73
+
74
+ test('extracts the whole repository into a folder named after it', async () => {
75
+ const { callGithub } = fakeGithub(archive);
76
+ const dir = await downloadRepo(callGithub, 'https://github.com/owner/demo');
77
+
78
+ expect(path.basename(dir)).toBe('demo');
79
+ expect(fs.readdirSync(dir).sort()).toEqual(['.continue', 'package.json', 'packages', 'readme.md']);
80
+ });
81
+
82
+ test('downloads only the sub-path when one is given', async () => {
83
+ const { callGithub } = fakeGithub(archive);
84
+ const dir = await downloadRepo(callGithub, 'https://github.com/owner/demo', null, {
85
+ subPath: 'packages/ui',
86
+ subPathType: 'tree',
87
+ });
88
+
89
+ expect(fs.readdirSync(dir).sort()).toEqual(['package.json', 'src']);
90
+ expect(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).toBe('{"name":"ui"}');
91
+ });
92
+
93
+ test('never writes the parts of the repo it was not asked for', async () => {
94
+ const { callGithub } = fakeGithub(archive);
95
+ const dir = await downloadRepo(callGithub, 'https://github.com/owner/demo', null, {
96
+ subPath: 'packages/ui',
97
+ subPathType: 'tree',
98
+ });
99
+
100
+ expect(fs.existsSync(path.join(dir, 'readme.md'))).toBe(false);
101
+ expect(fs.existsSync(path.join(dir, 'core'))).toBe(false);
102
+ expect(fs.existsSync(path.join(dir, 'packages'))).toBe(false);
103
+ });
104
+
105
+ test('names the folder after the sub-path, not the repository', async () => {
106
+ const { callGithub } = fakeGithub(archive);
107
+ const dir = await downloadRepo(callGithub, 'https://github.com/owner/demo', null, {
108
+ subPath: '.continue/agents',
109
+ subPathType: 'tree',
110
+ });
111
+
112
+ expect(path.basename(dir)).toBe('agents');
113
+ expect(fs.readdirSync(dir)).toEqual(['new-config.yaml']);
114
+ });
115
+
116
+ test('downloads a single file and names the folder after its parent', async () => {
117
+ const { callGithub } = fakeGithub(archive);
118
+ const dir = await downloadRepo(callGithub, 'https://github.com/owner/demo', null, {
119
+ subPath: '.continue/agents/new-config.yaml',
120
+ subPathType: 'blob',
121
+ });
122
+
123
+ expect(path.basename(dir)).toBe('agents');
124
+ expect(fs.readdirSync(dir)).toEqual(['new-config.yaml']);
125
+ expect(fs.readFileSync(path.join(dir, 'new-config.yaml'), 'utf8')).toBe('name: agent\n');
126
+ });
127
+
128
+ test('an explicit folder name still wins over the sub-path', async () => {
129
+ const { callGithub } = fakeGithub(archive);
130
+ const dir = await downloadRepo(callGithub, 'https://github.com/owner/demo', 'my-ui', {
131
+ subPath: 'packages/ui',
132
+ subPathType: 'tree',
133
+ });
134
+
135
+ expect(path.basename(dir)).toBe('my-ui');
136
+ });
137
+
138
+ test('requests the named ref and does not guess at a branch', async () => {
139
+ const { callGithub, requested } = fakeGithub(archive);
140
+ await downloadRepo(callGithub, 'https://github.com/owner/demo', null, { ref: 'develop' });
141
+
142
+ expect(requested).toEqual(['/repos/owner/demo/tarball/develop']);
143
+ });
144
+
145
+ test('falls back from master to main when no ref was named', async () => {
146
+ const { callGithub, requested } = fakeGithub(archive, { fail: true });
147
+ await expect(downloadRepo(callGithub, 'https://github.com/owner/demo')).rejects.toThrow();
148
+
149
+ expect(requested).toEqual([
150
+ '/repos/owner/demo/tarball/master',
151
+ '/repos/owner/demo/tarball/main',
152
+ ]);
153
+ });
154
+
155
+ test('reports a failed download instead of leaving an empty folder', async () => {
156
+ const { callGithub } = fakeGithub(archive, { fail: true });
157
+
158
+ await expect(downloadRepo(callGithub, 'https://github.com/owner/demo'))
159
+ .rejects.toThrow(/Could not download owner\/demo/);
160
+ expect(fs.existsSync(path.join(tmp, 'demo'))).toBe(false);
161
+ });
162
+
163
+ test('says which path was missing rather than producing an empty folder', async () => {
164
+ const { callGithub } = fakeGithub(archive);
165
+
166
+ await expect(
167
+ downloadRepo(callGithub, 'https://github.com/owner/demo', null, { subPath: 'packages/nope' })
168
+ ).rejects.toThrow(/not found in this repository/);
169
+ });
170
+
171
+ test('refuses a sub-path that would escape the target folder', async () => {
172
+ const { callGithub } = fakeGithub(archive);
173
+
174
+ await expect(
175
+ downloadRepo(callGithub, 'https://github.com/owner/demo', null, { subPath: '../../etc' })
176
+ ).rejects.toThrow(/\.\./);
177
+ });
178
+ });
@@ -0,0 +1,157 @@
1
+ import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
2
+ import { execFileSync } from 'child_process';
3
+ import fs from 'fs';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import { attachGitHistory, attachGitHistoryLazily, cloneHistoryOnly, gitAvailable } from '../src/history.ts';
7
+
8
+ /** A real repository on disk to clone from, so the conversion is exercised for real. */
9
+ let origin: string;
10
+ let sandbox: string;
11
+
12
+ function git(args: string[], cwd: string): string {
13
+ return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
14
+ }
15
+
16
+ beforeAll(() => {
17
+ sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'git0-history-test-'));
18
+ origin = path.join(sandbox, 'origin');
19
+ fs.mkdirSync(origin);
20
+
21
+ git(['init', '--quiet', '--initial-branch=main'], origin);
22
+ git(['config', 'user.email', 'test@example.com'], origin);
23
+ git(['config', 'user.name', 'git0 test'], origin);
24
+
25
+ fs.writeFileSync(path.join(origin, 'readme.md'), '# one\n');
26
+ git(['add', '.'], origin);
27
+ git(['commit', '--quiet', '-m', 'first'], origin);
28
+
29
+ fs.writeFileSync(path.join(origin, 'readme.md'), '# two\n');
30
+ git(['commit', '--quiet', '-am', 'second'], origin);
31
+ });
32
+
33
+ afterAll(() => {
34
+ fs.rmSync(sandbox, { recursive: true, force: true });
35
+ });
36
+
37
+ describe('gitAvailable', () => {
38
+ test('finds the git binary in this environment', async () => {
39
+ await expect(gitAvailable()).resolves.toBe(true);
40
+ });
41
+ });
42
+
43
+ describe('attachGitHistory', () => {
44
+ let extractPath: string;
45
+
46
+ beforeEach(() => {
47
+ // Stands in for a tarball extraction: the files, but no .git.
48
+ extractPath = fs.mkdtempSync(path.join(sandbox, 'extract-'));
49
+ fs.writeFileSync(path.join(extractPath, 'readme.md'), '# two\n');
50
+ });
51
+
52
+ afterEach(() => {
53
+ fs.rmSync(extractPath, { recursive: true, force: true });
54
+ });
55
+
56
+ test('turns an extracted folder into a working repository', async () => {
57
+ await attachGitHistory(extractPath, `file://${origin}`);
58
+
59
+ expect(fs.existsSync(path.join(extractPath, '.git'))).toBe(true);
60
+ expect(git(['rev-parse', '--is-bare-repository'], extractPath)).toBe('false');
61
+ });
62
+
63
+ test('brings the full history, not one commit', async () => {
64
+ await attachGitHistory(extractPath, `file://${origin}`);
65
+
66
+ expect(git(['rev-list', '--count', 'HEAD'], extractPath)).toBe('2');
67
+ });
68
+
69
+ test('leaves the working tree clean against the extracted files', async () => {
70
+ await attachGitHistory(extractPath, `file://${origin}`);
71
+
72
+ expect(git(['status', '--porcelain'], extractPath)).toBe('');
73
+ });
74
+
75
+ test('keeps the extracted files rather than re-checking them out', async () => {
76
+ fs.writeFileSync(path.join(extractPath, 'untracked.txt'), 'mine');
77
+
78
+ await attachGitHistory(extractPath, `file://${origin}`);
79
+
80
+ expect(fs.readFileSync(path.join(extractPath, 'untracked.txt'), 'utf8')).toBe('mine');
81
+ });
82
+
83
+ test('sets up origin so the repo can fetch and push', async () => {
84
+ await attachGitHistory(extractPath, `file://${origin}`);
85
+
86
+ expect(git(['config', '--get', 'remote.origin.fetch'], extractPath))
87
+ .toBe('+refs/heads/*:refs/remotes/origin/*');
88
+ });
89
+
90
+ test('refuses to overwrite an existing .git', async () => {
91
+ fs.mkdirSync(path.join(extractPath, '.git'));
92
+
93
+ await expect(attachGitHistory(extractPath, `file://${origin}`))
94
+ .rejects.toThrow(/already exists/);
95
+ });
96
+
97
+ test('refuses a remote that is not a URL', async () => {
98
+ await expect(attachGitHistory(extractPath, 'ext::sh -c whoami'))
99
+ .rejects.toThrow(/Refusing to clone/);
100
+ await expect(attachGitHistory(extractPath, '--upload-pack=touch'))
101
+ .rejects.toThrow(/Refusing to clone/);
102
+ });
103
+
104
+ test('leaves no .git behind when the clone fails', async () => {
105
+ await expect(attachGitHistory(extractPath, `file://${path.join(sandbox, 'nope')}`))
106
+ .rejects.toThrow();
107
+
108
+ expect(fs.existsSync(path.join(extractPath, '.git'))).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe('attachGitHistoryLazily', () => {
113
+ test('resolves rather than rejecting when the history cannot be fetched', async () => {
114
+ const extractPath = fs.mkdtempSync(path.join(sandbox, 'lazy-'));
115
+
116
+ // A download that fails must not take the project down with it.
117
+ await expect(attachGitHistoryLazily(extractPath, 'not-a-url')).resolves.toBeUndefined();
118
+ expect(fs.existsSync(path.join(extractPath, '.git'))).toBe(false);
119
+
120
+ fs.rmSync(extractPath, { recursive: true, force: true });
121
+ });
122
+ });
123
+
124
+ describe('cloneHistoryOnly', () => {
125
+ test('produces a history-only repository with no working files', async () => {
126
+ const target = path.join(sandbox, 'mirror.git');
127
+ await cloneHistoryOnly(`file://${origin}`, target);
128
+
129
+ expect(fs.existsSync(path.join(target, 'objects'))).toBe(true);
130
+ expect(fs.existsSync(path.join(target, 'readme.md'))).toBe(false);
131
+ expect(git(['--git-dir', target, 'rev-list', '--count', '--all'], sandbox)).toBe('2');
132
+
133
+ fs.rmSync(target, { recursive: true, force: true });
134
+ });
135
+
136
+ test('restores a normal working copy when cloned from', async () => {
137
+ const target = path.join(sandbox, 'restore.git');
138
+ await cloneHistoryOnly(`file://${origin}`, target);
139
+
140
+ const restored = path.join(sandbox, 'restored');
141
+ execFileSync('git', ['clone', '--quiet', target, restored]);
142
+
143
+ expect(fs.readFileSync(path.join(restored, 'readme.md'), 'utf8')).toBe('# two\n');
144
+
145
+ fs.rmSync(target, { recursive: true, force: true });
146
+ fs.rmSync(restored, { recursive: true, force: true });
147
+ });
148
+
149
+ test('refuses to write over an existing directory', async () => {
150
+ const target = path.join(sandbox, 'occupied.git');
151
+ fs.mkdirSync(target);
152
+
153
+ await expect(cloneHistoryOnly(`file://${origin}`, target)).rejects.toThrow(/already exists/);
154
+
155
+ fs.rmSync(target, { recursive: true, force: true });
156
+ });
157
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { parseTarget } from '../src/github-api.ts';
3
+
4
+ describe('parseTarget', () => {
5
+ test('parses a plain repository URL', () => {
6
+ const target = parseTarget('https://github.com/facebook/react');
7
+ expect(target).toMatchObject({
8
+ owner: 'facebook',
9
+ name: 'react',
10
+ href: 'https://github.com/facebook/react',
11
+ subPath: '',
12
+ subPathType: '',
13
+ });
14
+ });
15
+
16
+ test('parses owner/repo shorthand', () => {
17
+ expect(parseTarget('vitejs/vite')).toMatchObject({
18
+ owner: 'vitejs',
19
+ name: 'vite',
20
+ subPath: '',
21
+ });
22
+ });
23
+
24
+ test('reads branch and folder out of a /tree/ URL', () => {
25
+ expect(parseTarget('https://github.com/facebook/react/tree/main/packages/react-dom'))
26
+ .toMatchObject({
27
+ owner: 'facebook',
28
+ name: 'react',
29
+ ref: 'main',
30
+ subPath: 'packages/react-dom',
31
+ subPathType: 'tree',
32
+ });
33
+ });
34
+
35
+ test('reads branch and file out of a /blob/ URL', () => {
36
+ expect(
37
+ parseTarget(
38
+ 'https://github.com/debate/debate-ai.com/blob/master/.continue/agents/new-config.yaml'
39
+ )
40
+ ).toMatchObject({
41
+ owner: 'debate',
42
+ name: 'debate-ai.com',
43
+ ref: 'master',
44
+ subPath: '.continue/agents/new-config.yaml',
45
+ subPathType: 'blob',
46
+ });
47
+ });
48
+
49
+ test('takes a path off the owner/repo shorthand too', () => {
50
+ expect(parseTarget('facebook/react/packages/react-dom')).toMatchObject({
51
+ owner: 'facebook',
52
+ name: 'react',
53
+ subPath: 'packages/react-dom',
54
+ subPathType: '',
55
+ });
56
+ });
57
+
58
+ test('canonicalizes href to the repository root, not the deep link', () => {
59
+ const target = parseTarget('https://github.com/facebook/react/tree/main/packages/react-dom');
60
+ expect(target && target.href).toBe('https://github.com/facebook/react');
61
+ });
62
+
63
+ test('unwraps the fork chain GitHub puts in the owner', () => {
64
+ expect(parseTarget('https://github.com/upstream/fork/repo')).toMatchObject({
65
+ owner: 'fork',
66
+ });
67
+ });
68
+
69
+ test('returns false for a search query', () => {
70
+ expect(parseTarget('react starter template')).toBe(false);
71
+ expect(parseTarget('react')).toBe(false);
72
+ });
73
+ });