@zgeoff/atc 2.16.0 → 2.17.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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +24 -1
  3. package/src/client/collect-target-picks.ts +53 -0
  4. package/src/client/dirs.ts +1 -1
  5. package/src/client/spawn-picker.ts +132 -6
  6. package/src/client/ui.ts +6 -1
  7. package/src/daemon/daemon-connection.ts +129 -0
  8. package/src/daemon/daemon-context.ts +23 -0
  9. package/src/daemon/daemon.ts +29 -0
  10. package/src/daemon/materialize-workspace.ts +42 -18
  11. package/src/daemon/require-git-transports.ts +23 -0
  12. package/src/mcp/require-daemon-features.ts +2 -0
  13. package/src/protocol/daemon-features.ts +8 -0
  14. package/src/protocol/protocol.ts +2 -0
  15. package/src/protocol/request-param-schemas.ts +67 -15
  16. package/src/shared/collect-workspaces-config.ts +105 -0
  17. package/src/shared/config.ts +14 -0
  18. package/src/shared/default-git-transports.ts +5 -0
  19. package/src/shared/is-git-url.ts +11 -0
  20. package/src/sources/build-sources.ts +40 -0
  21. package/src/sources/collect-builtin-sources.ts +41 -0
  22. package/src/sources/dirs/build-dirs-source.ts +70 -0
  23. package/src/sources/git/build-git-source.ts +25 -0
  24. package/src/sources/github/build-github-source.ts +113 -0
  25. package/src/sources/github/collect-github-repos.ts +165 -0
  26. package/src/sources/github/find-github-alternate-url.ts +20 -0
  27. package/src/sources/github/read-git-protocol.ts +11 -0
  28. package/src/sources/github/run-gh.ts +66 -0
  29. package/src/sources/types.ts +70 -0
  30. package/src/workspace/check-git-transport.ts +61 -0
  31. package/src/workspace/check-repository-access.ts +108 -0
  32. package/src/workspace/collect-remote-refs.ts +154 -0
  33. package/src/workspace/create-git-askpass.ts +84 -0
  34. package/src/workspace/create-workspace-clone.ts +29 -76
  35. package/src/workspace/expand-git-shorthand.ts +13 -0
  36. package/src/workspace/find-remote-ref.ts +34 -0
  37. package/src/workspace/normalize-git-url.ts +2 -7
  38. package/src/workspace/resolve-git-url.ts +46 -0
  39. package/src/workspace/resolve-path-source.ts +11 -4
  40. package/src/workspace/run-git.ts +52 -4
  41. package/src/workspace/workspace-source.ts +10 -2
  42. /package/src/{client → shared}/collect-root-dirs.ts +0 -0
  43. /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
@@ -0,0 +1,154 @@
1
+ import { createGitAskpass } from './create-git-askpass';
2
+ import type { GitCredential } from './create-git-askpass';
3
+ import { runGit } from './run-git';
4
+
5
+ export interface RemoteRef {
6
+ // The short name: `main`, `feat/x`, `v1.0`.
7
+ readonly name: string;
8
+ readonly kind: 'branch' | 'tag';
9
+
10
+ // The commit the ref points at; an annotated tag's is the commit it
11
+ // peels to.
12
+ readonly sha: string;
13
+ }
14
+
15
+ interface RemoteRefListing {
16
+ readonly ok: true;
17
+
18
+ // The branch the upstream's HEAD points at, or null when it reports none.
19
+ readonly head: string | null;
20
+ readonly refs: readonly RemoteRef[];
21
+
22
+ // Every listed ref by full name, each annotated tag's peeled `^{}` entry
23
+ // beside it, for resolving one ref the way a clone does.
24
+ readonly byName: ReadonlyMap<string, string>;
25
+ }
26
+
27
+ interface RemoteRefRefusal {
28
+ readonly ok: false;
29
+ readonly code: 'clone_failed' | 'credential_missing';
30
+ readonly message: string;
31
+ }
32
+
33
+ // How long a listing may take: a host that never answers holds a client
34
+ // waiting on it no longer than this.
35
+ const REMOTE_TIMEOUT_MS = 20_000;
36
+
37
+ /**
38
+ * Lists an upstream's branches and tags and the branch its HEAD points at,
39
+ * through one `git ls-remote` that authenticates exactly as a clone of the
40
+ * same URL does: through the host's git config, or through a private
41
+ * askpass helper for an env credential. git never prompts. A listing git
42
+ * cannot read is refused as `clone_failed` with git's own message, and so
43
+ * is one that takes longer than the time limit, 20 s unless given. git
44
+ * fetches only over `transports`.
45
+ */
46
+ export async function collectRemoteRefs(
47
+ url: string,
48
+ credential: GitCredential | undefined,
49
+ transports: readonly string[],
50
+ timeoutMs: number = REMOTE_TIMEOUT_MS,
51
+ ): Promise<RemoteRefListing | RemoteRefRefusal> {
52
+ const askpass = await createGitAskpass(credential);
53
+
54
+ if (!askpass.ok) {
55
+ return askpass;
56
+ }
57
+
58
+ let listed: Awaited<ReturnType<typeof runGit>>;
59
+
60
+ try {
61
+ const sshEnv = await buildSSHTimeoutEnv(timeoutMs);
62
+
63
+ listed = await runGit(
64
+ [
65
+ ...askpass.args,
66
+ ...buildHTTPTimeoutArgs(timeoutMs),
67
+ 'ls-remote',
68
+ '--symref',
69
+ '--',
70
+ url,
71
+ 'HEAD',
72
+ 'refs/heads/*',
73
+ 'refs/tags/*',
74
+ ],
75
+ { env: { ...askpass.env, ...sshEnv }, timeoutMs, transports },
76
+ );
77
+ } finally {
78
+ await askpass[Symbol.asyncDispose]();
79
+ }
80
+
81
+ if (listed.timedOut) {
82
+ return {
83
+ ok: false,
84
+ code: 'clone_failed',
85
+ message: `git ls-remote did not answer within ${timeoutMs / 1000} s`,
86
+ };
87
+ }
88
+
89
+ if (listed.exitCode !== 0) {
90
+ return { ok: false, code: 'clone_failed', message: listed.stderr.trim() };
91
+ }
92
+
93
+ return parseListing(listed.stdout);
94
+ }
95
+
96
+ // An ssh connection that takes longer than the limit is given up by ssh.
97
+ // A host that chose its own ssh command keeps it.
98
+ async function buildSSHTimeoutEnv(timeoutMs: number): Promise<Readonly<Record<string, string>>> {
99
+ if (process.env['GIT_SSH_COMMAND'] !== undefined || process.env['GIT_SSH'] !== undefined) {
100
+ return {};
101
+ }
102
+
103
+ const configured = await runGit(['config', '--get', 'core.sshCommand']);
104
+
105
+ if (configured.exitCode === 0 && configured.stdout.trim() !== '') {
106
+ return {};
107
+ }
108
+
109
+ const seconds = String(Math.max(1, Math.ceil(timeoutMs / 1000)));
110
+
111
+ return { GIT_SSH_COMMAND: `ssh -o ConnectTimeout=${seconds}` };
112
+ }
113
+
114
+ // An HTTP transfer that moves under a byte a second for the whole limit is
115
+ // given up by git itself.
116
+ function buildHTTPTimeoutArgs(timeoutMs: number): string[] {
117
+ const seconds = String(Math.max(1, Math.ceil(timeoutMs / 1000)));
118
+
119
+ return ['-c', 'http.lowSpeedLimit=1', '-c', `http.lowSpeedTime=${seconds}`];
120
+ }
121
+
122
+ const SYMREF_PREFIX = 'ref: refs/heads/';
123
+
124
+ function parseListing(stdout: string): RemoteRefListing {
125
+ const byName = new Map<string, string>();
126
+
127
+ let head: string | null = null;
128
+
129
+ for (const line of stdout.split('\n')) {
130
+ const [left = '', name = ''] = line.split('\t');
131
+
132
+ if (name === 'HEAD' && left.startsWith(SYMREF_PREFIX)) {
133
+ head = left.slice(SYMREF_PREFIX.length);
134
+ } else if (name.startsWith('refs/heads/') || name.startsWith('refs/tags/')) {
135
+ byName.set(name, left);
136
+ }
137
+ }
138
+
139
+ const refs: RemoteRef[] = [];
140
+
141
+ for (const [name, sha] of byName) {
142
+ if (name.startsWith('refs/heads/')) {
143
+ refs.push({ name: name.slice('refs/heads/'.length), kind: 'branch', sha });
144
+ } else if (!name.endsWith('^{}')) {
145
+ refs.push({
146
+ name: name.slice('refs/tags/'.length),
147
+ kind: 'tag',
148
+ sha: byName.get(`${name}^{}`) ?? sha,
149
+ });
150
+ }
151
+ }
152
+
153
+ return { ok: true, head, refs, byName };
154
+ }
@@ -0,0 +1,84 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ export interface GitCredential {
6
+ readonly kind: 'env';
7
+ readonly name: string;
8
+ }
9
+
10
+ interface GitAskpass {
11
+ readonly ok: true;
12
+
13
+ // What each network git command takes: the variables that point it at
14
+ // the helper, and the arguments that switch the host's credential
15
+ // helpers off.
16
+ readonly env: Readonly<Record<string, string>>;
17
+ readonly args: readonly string[];
18
+ [Symbol.asyncDispose]: () => Promise<void>;
19
+ }
20
+
21
+ interface MissingCredential {
22
+ readonly ok: false;
23
+ readonly code: 'credential_missing';
24
+ readonly message: string;
25
+ }
26
+
27
+ const ASKPASS_SECRET_VAR = 'ATC_GIT_ASKPASS_SECRET';
28
+
29
+ // Username prompts get a fixed name that GitHub and GitLab both accept
30
+ // alongside a token; password prompts get the token.
31
+ const ASKPASS_SCRIPT = `#!/bin/sh
32
+ case "$1" in
33
+ Username*) printf '%s\\n' x-access-token ;;
34
+ *) printf '%s\\n' "$${ASKPASS_SECRET_VAR}" ;;
35
+ esac
36
+ `;
37
+
38
+ /**
39
+ * Selects how network git commands authenticate. Without a credential they
40
+ * authenticate through the host's own git config, and the result adds
41
+ * nothing. An env credential is read from the named variable and handed to
42
+ * git only through a private askpass helper, never through the command line
43
+ * or the URL, with the host's credential helpers switched off so none of
44
+ * them stores the token. Disposing the result deletes the helper.
45
+ */
46
+ export async function createGitAskpass(
47
+ credential: GitCredential | undefined,
48
+ ): Promise<GitAskpass | MissingCredential> {
49
+ if (credential === undefined) {
50
+ return { ok: true, env: {}, args: [], [Symbol.asyncDispose]: async () => {} };
51
+ }
52
+
53
+ const secret = process.env[credential.name];
54
+
55
+ if (secret === undefined || secret === '') {
56
+ return {
57
+ ok: false,
58
+ code: 'credential_missing',
59
+ message: 'the credential environment variable is unset or empty',
60
+ };
61
+ }
62
+
63
+ const dir = await mkdtemp(join(tmpdir(), 'atc-askpass-'));
64
+
65
+ const helper = join(dir, 'askpass');
66
+
67
+ // A helper that cannot be written leaves no directory behind.
68
+ try {
69
+ await writeFile(helper, ASKPASS_SCRIPT, { mode: 0o700 });
70
+ } catch (error) {
71
+ await rm(dir, { recursive: true, force: true });
72
+
73
+ throw error;
74
+ }
75
+
76
+ return {
77
+ ok: true,
78
+ env: { GIT_ASKPASS: helper, [ASKPASS_SECRET_VAR]: secret },
79
+ args: ['-c', 'credential.helper='],
80
+ [Symbol.asyncDispose]: async () => {
81
+ await rm(dir, { recursive: true, force: true });
82
+ },
83
+ };
84
+ }
@@ -1,19 +1,18 @@
1
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
1
+ import { rm } from 'node:fs/promises';
4
2
  import { checkWorkspaceCompleteness } from './check-workspace-completeness';
3
+ import { createGitAskpass } from './create-git-askpass';
4
+ import type { GitCredential } from './create-git-askpass';
5
+ import { findRemoteRef } from './find-remote-ref';
5
6
  import { runGit } from './run-git';
6
7
  import type { WorkspaceSource } from './workspace-source';
7
8
 
8
- interface GitCredential {
9
- readonly kind: 'env';
10
- readonly name: string;
11
- }
12
-
13
9
  interface CloneRequest {
14
10
  readonly source: Extract<WorkspaceSource, { readonly kind: 'git' }>;
15
11
  readonly dir: string;
16
12
  readonly credential?: GitCredential;
13
+
14
+ // The transports git may fetch over.
15
+ readonly transports: readonly string[];
17
16
  }
18
17
 
19
18
  interface CreatedClone {
@@ -59,7 +58,7 @@ type IncompleteCheckout = Exclude<
59
58
  export async function createWorkspaceClone(
60
59
  request: CloneRequest,
61
60
  ): Promise<CloneRefusal | CreatedClone | IncompleteCheckout> {
62
- const askpass = await createAskpass(request.credential);
61
+ const askpass = await createGitAskpass(request.credential);
63
62
 
64
63
  if (!askpass.ok) {
65
64
  return askpass;
@@ -72,63 +71,12 @@ export async function createWorkspaceClone(
72
71
  }
73
72
  }
74
73
 
75
- interface Askpass {
76
- readonly ok: true;
77
- readonly env: Readonly<Record<string, string>>;
78
- readonly args: readonly string[];
79
- [Symbol.asyncDispose]: () => Promise<void>;
80
- }
81
-
82
- const ASKPASS_SECRET_VAR = 'ATC_GIT_ASKPASS_SECRET';
83
-
84
- // Username prompts get a fixed name that GitHub and GitLab both accept
85
- // alongside a token; password prompts get the token.
86
- const ASKPASS_SCRIPT = `#!/bin/sh
87
- case "$1" in
88
- Username*) printf '%s\\n' x-access-token ;;
89
- *) printf '%s\\n' "$${ASKPASS_SECRET_VAR}" ;;
90
- esac
91
- `;
92
-
93
- async function createAskpass(
94
- credential: GitCredential | undefined,
95
- ): Promise<Askpass | CloneRefusal> {
96
- if (credential === undefined) {
97
- return { ok: true, env: {}, args: [], [Symbol.asyncDispose]: async () => {} };
98
- }
99
-
100
- const secret = process.env[credential.name];
101
-
102
- if (secret === undefined || secret === '') {
103
- return {
104
- ok: false,
105
- code: 'credential_missing',
106
- message: 'the credential environment variable is unset or empty',
107
- };
108
- }
109
-
110
- const dir = await mkdtemp(join(tmpdir(), 'atc-askpass-'));
111
-
112
- const helper = join(dir, 'askpass');
113
-
114
- await writeFile(helper, ASKPASS_SCRIPT, { mode: 0o700 });
115
-
116
- return {
117
- ok: true,
118
- env: { GIT_ASKPASS: helper, [ASKPASS_SECRET_VAR]: secret },
119
- args: ['-c', 'credential.helper='],
120
- [Symbol.asyncDispose]: async () => {
121
- await rm(dir, { recursive: true, force: true });
122
- },
123
- };
124
- }
125
-
126
74
  async function createCloneAtRef(
127
75
  request: CloneRequest,
128
76
  env: Readonly<Record<string, string>>,
129
77
  args: readonly string[],
130
78
  ): Promise<CloneRefusal | CreatedClone | IncompleteCheckout> {
131
- const target = await resolveRef(request.source, env, args);
79
+ const target = await resolveRef(request, env, args);
132
80
 
133
81
  if (!target.ok) {
134
82
  return target;
@@ -147,7 +95,7 @@ async function createCloneAtRef(
147
95
  request.source.url,
148
96
  request.dir,
149
97
  ],
150
- { env },
98
+ { env, transports: request.transports },
151
99
  );
152
100
 
153
101
  if (clone.exitCode !== 0) {
@@ -175,9 +123,9 @@ async function createCloneAtRef(
175
123
 
176
124
  await rm(request.dir, { recursive: true, force: true });
177
125
 
178
- return target.branch === null && present.exitCode !== 0
179
- ? { ok: false, code: 'ref_not_found', message: `origin has no commit ${target.sha}` }
180
- : { ok: false, code: 'clone_failed', message: checkout.stderr.trim() };
126
+ return present.exitCode === 0
127
+ ? { ok: false, code: 'clone_failed', message: checkout.stderr.trim() }
128
+ : { ok: false, code: 'ref_not_found', message: `origin has no commit ${target.sha}` };
181
129
  }
182
130
 
183
131
  const complete = await checkWorkspaceCompleteness(request.dir, target.sha);
@@ -203,20 +151,26 @@ const SHA_PATTERN = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
203
151
  * Pins a ref to a commit before cloning, so the checkout is exactly the
204
152
  * commit the ref pointed at when asked, whatever lands on the branch meanwhile.
205
153
  * A branch wins over a same-named tag, and an annotated tag resolves to the
206
- * commit it points at.
154
+ * commit it points at. A source already pinned to a commit keeps it, and
155
+ * its ref only decides whether the checkout is on a branch: a ref that
156
+ * names a branch upstream checks the commit out as that branch.
207
157
  */
208
158
  async function resolveRef(
209
- source: Extract<WorkspaceSource, { readonly kind: 'git' }>,
159
+ request: CloneRequest,
210
160
  env: Readonly<Record<string, string>>,
211
161
  args: readonly string[],
212
162
  ): Promise<CloneRefusal | ResolvedRef> {
163
+ const source = request.source;
164
+
213
165
  if (SHA_PATTERN.test(source.ref)) {
214
166
  return { ok: true, sha: source.ref, branch: null };
215
167
  }
216
168
 
169
+ const pinned = source.sha;
170
+
217
171
  const listed = await runGit(
218
172
  [...args, 'ls-remote', '--', source.url, source.ref, `${source.ref}^{}`],
219
- { env },
173
+ { env, transports: request.transports },
220
174
  );
221
175
 
222
176
  if (listed.exitCode !== 0) {
@@ -234,18 +188,17 @@ async function resolveRef(
234
188
  }),
235
189
  );
236
190
 
237
- const name = source.ref.replace(/^refs\/(?:heads|tags)\//u, '');
238
- const branchSHA = refs.get(`refs/heads/${name}`);
191
+ const match = findRemoteRef(refs, source.ref);
239
192
 
240
- if (branchSHA !== undefined && !source.ref.startsWith('refs/tags/')) {
241
- return { ok: true, sha: branchSHA, branch: name };
193
+ if (pinned !== undefined) {
194
+ return { ok: true, sha: pinned, branch: match?.branch ?? null };
242
195
  }
243
196
 
244
- const tagSHA = refs.get(`refs/tags/${name}^{}`) ?? refs.get(`refs/tags/${name}`);
245
-
246
- if (tagSHA !== undefined && !source.ref.startsWith('refs/heads/')) {
247
- return { ok: true, sha: tagSHA, branch: null };
197
+ if (match !== null) {
198
+ return { ok: true, ...match };
248
199
  }
249
200
 
201
+ const name = source.ref.replace(/^refs\/(?:heads|tags)\//u, '');
202
+
250
203
  return { ok: false, code: 'ref_not_found', message: `origin has no branch or tag '${name}'` };
251
204
  }
@@ -0,0 +1,13 @@
1
+ // A bare `owner/repo`, with or without `.git`.
2
+ const SHORTHAND_PATTERN = /^(?<name>[\w-][\w.-]*\/[\w.-]+?)(?:\.git)?$/u;
3
+
4
+ /**
5
+ * Expands the `owner/repo` shorthand a `session.spawn` git workspace may
6
+ * hold to its GitHub https URL, and returns any other URL as it is. The
7
+ * shorthand is the spawn API's alone: every other path takes full URLs.
8
+ */
9
+ export function expandGitShorthand(raw: string): string {
10
+ const name = SHORTHAND_PATTERN.exec(raw.trim())?.groups?.['name'];
11
+
12
+ return name === undefined ? raw : `https://github.com/${name}.git`;
13
+ }
@@ -0,0 +1,34 @@
1
+ interface RemoteRefMatch {
2
+ readonly sha: string;
3
+
4
+ // The branch the ref names, or null for a tag.
5
+ readonly branch: string | null;
6
+ }
7
+
8
+ /**
9
+ * Finds the commit a ref points at among the refs an upstream lists, keyed
10
+ * by full ref name with each annotated tag's peeled `^{}` entry beside it.
11
+ * A bare name matches a branch before a same-named tag, a `refs/heads/` or
12
+ * `refs/tags/` name matches only that kind, and an annotated tag resolves
13
+ * to the commit it points at. Null when the upstream has no such branch or
14
+ * tag.
15
+ */
16
+ export function findRemoteRef(
17
+ refs: ReadonlyMap<string, string>,
18
+ ref: string,
19
+ ): RemoteRefMatch | null {
20
+ const name = ref.replace(/^refs\/(?:heads|tags)\//u, '');
21
+ const branchSHA = refs.get(`refs/heads/${name}`);
22
+
23
+ if (branchSHA !== undefined && !ref.startsWith('refs/tags/')) {
24
+ return { sha: branchSHA, branch: name };
25
+ }
26
+
27
+ const tagSHA = refs.get(`refs/tags/${name}^{}`) ?? refs.get(`refs/tags/${name}`);
28
+
29
+ if (tagSHA !== undefined && !ref.startsWith('refs/heads/')) {
30
+ return { sha: tagSHA, branch: null };
31
+ }
32
+
33
+ return null;
34
+ }
@@ -4,15 +4,14 @@ type NormalizedGitURL =
4
4
 
5
5
  const SCHEME_PATTERN = /^[a-z][a-z\d+.-]*:\/\//iu;
6
6
  const SCP_PATTERN = /^(?:[^@/:\s]+@)?[^@/:\s]+:[^@\s]+$/u;
7
- const SHORTHAND_PATTERN = /^[\w.-]+\/[\w.-]+$/u;
8
7
 
9
8
  /**
10
9
  * Reduces a repository URL to a form that carries no credential, so it can be
11
10
  * recorded, shown, and handed to another host. An http(s) URL loses its
12
11
  * userinfo, query, and fragment, where tokens ride. Any other scheme URL
13
12
  * loses its password but keeps its user, which is a login name such as
14
- * `git`. An scp-style `git@host:owner/repo` and a local path pass through,
15
- * and a bare `owner/repo` expands to its GitHub https URL.
13
+ * `git`. An scp-style `git@host:owner/repo` and a local path pass through.
14
+ * Anything else, a bare `owner/repo` included, is refused.
16
15
  */
17
16
  export function normalizeGitURL(raw: string): NormalizedGitURL {
18
17
  const trimmed = raw.trim();
@@ -25,10 +24,6 @@ export function normalizeGitURL(raw: string): NormalizedGitURL {
25
24
  return { ok: true, url: trimmed };
26
25
  }
27
26
 
28
- if (SHORTHAND_PATTERN.test(trimmed) && !trimmed.startsWith('.')) {
29
- return { ok: true, url: `https://github.com/${trimmed.replace(/\.git$/u, '')}.git` };
30
- }
31
-
32
27
  if (SCP_PATTERN.test(trimmed)) {
33
28
  return { ok: true, url: trimmed };
34
29
  }
@@ -0,0 +1,46 @@
1
+ import { checkGitTransport } from './check-git-transport';
2
+ import { checkURLCredentials } from './check-url-credentials';
3
+ import { normalizeGitURL } from './normalize-git-url';
4
+
5
+ type ResolvedGitURL =
6
+ | { readonly ok: true; readonly url: string }
7
+ | {
8
+ readonly ok: false;
9
+ readonly code: 'credential_in_url' | 'invalid_git_url';
10
+ readonly message: string;
11
+ };
12
+
13
+ /**
14
+ * Resolves the URL a git workspace source is fetched from: it normalizes
15
+ * to its credential-free form, and that form must use one of `transports`, both checked
16
+ * before any git runs. Then the raw URL and the normalized form must each
17
+ * reach git without a credential. `cwd` is the directory whose git config
18
+ * the `insteadOf` rewrites are read from.
19
+ */
20
+ export async function resolveGitURL(
21
+ raw: string,
22
+ cwd: string,
23
+ transports: readonly string[],
24
+ ): Promise<ResolvedGitURL> {
25
+ const normalized = normalizeGitURL(raw);
26
+
27
+ if (!normalized.ok) {
28
+ return normalized;
29
+ }
30
+
31
+ const transport = checkGitTransport(normalized.url, transports);
32
+
33
+ if (!transport.ok) {
34
+ return transport;
35
+ }
36
+
37
+ const plain = await checkURLCredentials(raw, cwd);
38
+
39
+ if (!plain.ok) {
40
+ return plain;
41
+ }
42
+
43
+ const expanded = await checkURLCredentials(normalized.url, cwd);
44
+
45
+ return expanded.ok ? normalized : expanded;
46
+ }
@@ -4,6 +4,9 @@ import { runGit } from './run-git';
4
4
 
5
5
  interface PathSourceOptions {
6
6
  readonly allowDirty?: 'refuse' | 'warn';
7
+
8
+ // The transports git may fetch over when it asks origin for its refs.
9
+ readonly transports: readonly string[];
7
10
  }
8
11
 
9
12
  interface ResolvedPathSource {
@@ -42,7 +45,7 @@ interface PathSourceRefusal {
42
45
  */
43
46
  export async function resolvePathSource(
44
47
  path: string,
45
- options: PathSourceOptions = {},
48
+ options: PathSourceOptions,
46
49
  ): Promise<PathSourceRefusal | ResolvedPathSource> {
47
50
  if (statSync(path, { throwIfNoEntry: false })?.isDirectory() !== true) {
48
51
  return { ok: false, code: 'not_a_git_repo', message: `${path} is not a directory` };
@@ -118,7 +121,7 @@ export async function resolvePathSource(
118
121
  return { ok: false, code: url.code, message: `origin of ${root}: ${url.message}` };
119
122
  }
120
123
 
121
- const pushed = await isOnOrigin(root, sha);
124
+ const pushed = await isOnOrigin(root, sha, options.transports);
122
125
 
123
126
  if (!pushed) {
124
127
  return { ok: false, code: 'unpushed_head', message: `${sha} is not on origin; push it first` };
@@ -152,7 +155,11 @@ function hasSubmodules(listing: string): boolean {
152
155
  * it without network access; failing that, origin's advertised ref tips are
153
156
  * checked, which catches a push whose tracking ref was never fetched.
154
157
  */
155
- async function isOnOrigin(root: string, sha: string): Promise<boolean> {
158
+ async function isOnOrigin(
159
+ root: string,
160
+ sha: string,
161
+ transports: readonly string[],
162
+ ): Promise<boolean> {
156
163
  const tracking = await runGit(['branch', '-r', '--contains', sha, '--list', 'origin/*'], {
157
164
  cwd: root,
158
165
  });
@@ -161,7 +168,7 @@ async function isOnOrigin(root: string, sha: string): Promise<boolean> {
161
168
  return true;
162
169
  }
163
170
 
164
- const advertised = await runGit(['ls-remote', 'origin'], { cwd: root });
171
+ const advertised = await runGit(['ls-remote', 'origin'], { cwd: root, transports });
165
172
 
166
173
  return (
167
174
  advertised.exitCode === 0 &&
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
1
2
  import { REPOSITORY_ENV_VARS } from './repository-env-vars';
2
3
 
3
4
  interface GitRunOptions {
@@ -5,19 +6,31 @@ interface GitRunOptions {
5
6
  readonly env?: Readonly<Record<string, string>>;
6
7
  readonly input?: string;
7
8
  readonly isolated?: boolean;
9
+
10
+ // How long the command may run before it is stopped and reported as
11
+ // timed out; unset waits as long as git takes.
12
+ readonly timeoutMs?: number;
13
+
14
+ // The transports git may fetch over; https and ssh when unset.
15
+ readonly transports?: readonly string[];
8
16
  }
9
17
 
10
18
  interface GitRun {
11
19
  readonly exitCode: number;
12
20
  readonly stdout: string;
13
21
  readonly stderr: string;
22
+ readonly timedOut: boolean;
14
23
  }
15
24
 
16
25
  /**
17
26
  * Runs one git command to completion, feeding it any given input, and
18
- * returns its exit code and output.
27
+ * returns its exit code and output. A command given a time limit is
28
+ * stopped once it passes it, with every process it started, and reported
29
+ * as timed out.
19
30
  * git never prompts on a terminal here, since the daemon has none to answer
20
- * with, and its messages stay in the C locale so callers can read them.
31
+ * with, its messages stay in the C locale so callers can read them, and it
32
+ * fetches only over the transports it is given, https and ssh unless told
33
+ * otherwise, whatever URL a host config rewrite or a submodule hands it.
21
34
  * Variables that pin git to some other repository, such as the `GIT_DIR` a
22
35
  * git hook exports, are dropped so the command acts on its own directory.
23
36
  *
@@ -60,19 +73,54 @@ export async function runGit(
60
73
  ...(isolated ? ISOLATED_ENV : {}),
61
74
  GIT_TERMINAL_PROMPT: '0',
62
75
  LC_ALL: 'C',
76
+ GIT_ALLOW_PROTOCOL: (options.transports ?? DEFAULT_GIT_TRANSPORTS).join(':'),
63
77
  },
64
78
  stdin: options.input === undefined ? 'ignore' : Buffer.from(options.input),
65
79
  stdout: 'pipe',
66
80
  stderr: 'pipe',
81
+
82
+ // A command with a time limit leads its own process group, so stopping
83
+ // it stops the helpers it started, such as `git remote-http`, too.
84
+ detached: options.timeoutMs !== undefined,
67
85
  });
68
86
 
69
- const [stdout, stderr, exitCode] = await Promise.all([
87
+ const finished = Promise.all([
70
88
  new Response(proc.stdout).text(),
71
89
  new Response(proc.stderr).text(),
72
90
  proc.exited,
73
91
  ]);
74
92
 
75
- return { exitCode, stdout, stderr };
93
+ if (options.timeoutMs === undefined) {
94
+ const [stdout, stderr, exitCode] = await finished;
95
+
96
+ return { exitCode, stdout, stderr, timedOut: false };
97
+ }
98
+
99
+ // A stopped git can leave a helper holding its pipes open, so a timeout
100
+ // answers without waiting for them to close.
101
+ const limit = Promise.withResolvers<null>();
102
+
103
+ const timer = setTimeout(() => {
104
+ limit.resolve(null);
105
+ }, options.timeoutMs);
106
+
107
+ const settled = await Promise.race([finished, limit.promise]);
108
+
109
+ clearTimeout(timer);
110
+
111
+ if (settled === null) {
112
+ try {
113
+ process.kill(-proc.pid, 'SIGKILL');
114
+ } catch {
115
+ // The group already exited.
116
+ }
117
+
118
+ return { exitCode: -1, stdout: '', stderr: '', timedOut: true };
119
+ }
120
+
121
+ const [stdout, stderr, exitCode] = settled;
122
+
123
+ return { exitCode, stdout, stderr, timedOut: false };
76
124
  }
77
125
 
78
126
  function collectHostEnv(): Record<string, string | undefined> {