@zgeoff/atc 2.16.0 → 2.19.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/package.json +1 -1
- package/src/cli.ts +24 -1
- package/src/client/build-workspace-destination.ts +38 -0
- package/src/client/collect-target-picks.ts +53 -0
- package/src/client/dirs.ts +1 -1
- package/src/client/pick-refusal-step.ts +29 -0
- package/src/client/resolve-workspace-root.ts +49 -0
- package/src/client/spawn-picker.ts +1683 -84
- package/src/client/ui.ts +6 -1
- package/src/daemon/daemon-connection.ts +129 -0
- package/src/daemon/daemon-context.ts +23 -0
- package/src/daemon/daemon.ts +29 -0
- package/src/daemon/materialize-workspace.ts +42 -18
- package/src/daemon/require-git-transports.ts +23 -0
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +67 -15
- package/src/shared/collect-workspaces-config.ts +120 -0
- package/src/shared/config.ts +30 -1
- package/src/shared/default-git-transports.ts +5 -0
- package/src/shared/is-git-url.ts +11 -0
- package/src/sources/build-sources.ts +40 -0
- package/src/sources/collect-builtin-sources.ts +41 -0
- package/src/sources/dirs/build-dirs-source.ts +70 -0
- package/src/sources/git/build-git-source.ts +25 -0
- package/src/sources/github/build-github-source.ts +113 -0
- package/src/sources/github/collect-github-repos.ts +165 -0
- package/src/sources/github/find-github-alternate-url.ts +20 -0
- package/src/sources/github/read-git-protocol.ts +11 -0
- package/src/sources/github/run-gh.ts +66 -0
- package/src/sources/types.ts +70 -0
- package/src/workspace/check-git-transport.ts +61 -0
- package/src/workspace/check-repository-access.ts +108 -0
- package/src/workspace/collect-remote-refs.ts +154 -0
- package/src/workspace/create-git-askpass.ts +84 -0
- package/src/workspace/create-workspace-clone.ts +29 -76
- package/src/workspace/expand-git-shorthand.ts +13 -0
- package/src/workspace/find-remote-ref.ts +34 -0
- package/src/workspace/normalize-git-url.ts +2 -7
- package/src/workspace/resolve-git-url.ts +46 -0
- package/src/workspace/resolve-path-source.ts +11 -4
- package/src/workspace/run-git.ts +52 -4
- package/src/workspace/workspace-source.ts +10 -2
- /package/src/{client → shared}/collect-root-dirs.ts +0 -0
- /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
179
|
-
? { ok: false, code: '
|
|
180
|
-
: { ok: false, code: '
|
|
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
|
-
|
|
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
|
|
238
|
-
const branchSHA = refs.get(`refs/heads/${name}`);
|
|
191
|
+
const match = findRemoteRef(refs, source.ref);
|
|
239
192
|
|
|
240
|
-
if (
|
|
241
|
-
return { ok: true, sha:
|
|
193
|
+
if (pinned !== undefined) {
|
|
194
|
+
return { ok: true, sha: pinned, branch: match?.branch ?? null };
|
|
242
195
|
}
|
|
243
196
|
|
|
244
|
-
|
|
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
|
-
*
|
|
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(
|
|
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 &&
|
package/src/workspace/run-git.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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> {
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Where a session's workspace comes from: a directory on a host, which is
|
|
3
3
|
* resolved to a pushed commit before anything leaves that host, or a git
|
|
4
|
-
* repository and ref that the control side
|
|
4
|
+
* repository and ref, optionally pinned to a commit, that the control side
|
|
5
|
+
* clones itself.
|
|
5
6
|
*/
|
|
6
7
|
export type WorkspaceSource =
|
|
7
8
|
| { readonly kind: 'path'; readonly host: string; readonly path: string }
|
|
8
|
-
| {
|
|
9
|
+
| {
|
|
10
|
+
readonly kind: 'git';
|
|
11
|
+
readonly url: string;
|
|
12
|
+
readonly ref: string;
|
|
13
|
+
|
|
14
|
+
// The commit to check out when the ref only names its branch.
|
|
15
|
+
readonly sha?: string;
|
|
16
|
+
};
|
|
File without changes
|
|
File without changes
|