@zgeoff/atc 2.11.0 → 2.12.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/agents/agent-adapter.ts +51 -0
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/claude-adapter.ts +49 -2
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +43 -2
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +45 -2
- package/src/agents/grok-adapter.ts +19 -1
- package/src/cli.ts +18 -0
- package/src/client/daemon-client.ts +14 -2
- package/src/client/spawn-picker.ts +27 -4
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +34 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-scoped-context.ts +232 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +331 -95
- package/src/daemon/daemon.ts +452 -75
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +103 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/local-pty-provider.ts +83 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/restore-fleet.ts +66 -19
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +282 -65
- package/src/daemon/start-headless-run.ts +11 -0
- package/src/daemon/start-headless-turn.ts +11 -2
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +32 -4
- package/src/mcp/mcp-tools.ts +98 -8
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +7 -0
- package/src/mcp/run-tool.ts +63 -11
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +24 -0
- package/src/protocol/protocol.ts +44 -11
- package/src/protocol/request-param-schemas.ts +60 -10
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/store/fleet-entry.ts +42 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +254 -5
- package/src/store/state-store.ts +433 -31
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +239 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +103 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { runGit } from './run-git';
|
|
2
|
+
|
|
3
|
+
type CompletenessFinding =
|
|
4
|
+
| { readonly ok: true }
|
|
5
|
+
| {
|
|
6
|
+
readonly ok: false;
|
|
7
|
+
readonly code: 'has_submodules' | 'unreadable_tree';
|
|
8
|
+
readonly message: string;
|
|
9
|
+
}
|
|
10
|
+
| {
|
|
11
|
+
readonly ok: false;
|
|
12
|
+
readonly code: 'lfs_unsupported';
|
|
13
|
+
readonly message: string;
|
|
14
|
+
readonly count: number;
|
|
15
|
+
readonly paths: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const LFS_PATHS_SHOWN = 5;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Checks that a checked-out commit is the whole workspace, so a checkout is
|
|
22
|
+
* never handed on with holes in it. The commit's own tree must hold no
|
|
23
|
+
* gitlink and no `.gitmodules`, since submodules are never initialized, and
|
|
24
|
+
* no tracked path may carry the `lfs` filter, since its content would be a
|
|
25
|
+
* pointer file rather than the object it points at.
|
|
26
|
+
*/
|
|
27
|
+
export async function checkWorkspaceCompleteness(
|
|
28
|
+
dir: string,
|
|
29
|
+
sha: string,
|
|
30
|
+
): Promise<CompletenessFinding> {
|
|
31
|
+
const tree = await runGit(['ls-tree', '-r', '--full-tree', sha], { cwd: dir, isolated: true });
|
|
32
|
+
|
|
33
|
+
if (tree.exitCode !== 0) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
code: 'unreadable_tree',
|
|
37
|
+
message: `cannot list the tree of ${sha}: ${tree.stderr.trim()}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const entries = tree.stdout.split('\n');
|
|
42
|
+
|
|
43
|
+
if (entries.some((line) => line.startsWith('160000 ') || line.endsWith('\t.gitmodules'))) {
|
|
44
|
+
return { ok: false, code: 'has_submodules', message: `${sha} uses submodules` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const tracked = await runGit(['ls-files', '-z'], { cwd: dir, isolated: true });
|
|
48
|
+
|
|
49
|
+
const attributes = await runGit(['check-attr', '--stdin', '-z', 'filter'], {
|
|
50
|
+
cwd: dir,
|
|
51
|
+
input: tracked.stdout,
|
|
52
|
+
isolated: true,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (tracked.exitCode !== 0 || attributes.exitCode !== 0) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
code: 'unreadable_tree',
|
|
59
|
+
message: `cannot read the attributes of ${sha}: ${tracked.stderr.trim()}${attributes.stderr.trim()}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lfsPaths = collectLFSPaths(attributes.stdout);
|
|
64
|
+
|
|
65
|
+
if (lfsPaths.length > 0) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
code: 'lfs_unsupported',
|
|
69
|
+
message: `${sha} tracks ${lfsPaths.length} path(s) through Git LFS`,
|
|
70
|
+
count: lfsPaths.length,
|
|
71
|
+
paths: lfsPaths.slice(0, LFS_PATHS_SHOWN),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { ok: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// `check-attr -z` writes one path, attribute, value triple per path.
|
|
79
|
+
function collectLFSPaths(output: string): string[] {
|
|
80
|
+
const fields = output.split('\0');
|
|
81
|
+
const paths: string[] = [];
|
|
82
|
+
|
|
83
|
+
for (let index = 0; index + 2 < fields.length; index += 3) {
|
|
84
|
+
if (fields[index + 2] === 'lfs') {
|
|
85
|
+
paths.push(fields[index] ?? '');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return paths;
|
|
90
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { checkWorkspaceCompleteness } from './check-workspace-completeness';
|
|
5
|
+
import { runGit } from './run-git';
|
|
6
|
+
import type { WorkspaceSource } from './workspace-source';
|
|
7
|
+
|
|
8
|
+
interface GitCredential {
|
|
9
|
+
readonly kind: 'env';
|
|
10
|
+
readonly name: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CloneRequest {
|
|
14
|
+
readonly source: Extract<WorkspaceSource, { readonly kind: 'git' }>;
|
|
15
|
+
readonly dir: string;
|
|
16
|
+
readonly credential?: GitCredential;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CreatedClone {
|
|
20
|
+
readonly ok: true;
|
|
21
|
+
readonly sha: string;
|
|
22
|
+
readonly branch: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CloneRefusal {
|
|
26
|
+
readonly ok: false;
|
|
27
|
+
readonly code: 'credential_missing' | 'ref_not_found' | 'clone_failed';
|
|
28
|
+
readonly message: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type IncompleteCheckout = Exclude<
|
|
32
|
+
Awaited<ReturnType<typeof checkWorkspaceCompleteness>>,
|
|
33
|
+
{ readonly ok: true }
|
|
34
|
+
>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Clones a repository into an empty directory and checks out the commit its
|
|
38
|
+
* ref points at that moment. A branch ref is checked out as that branch; a tag
|
|
39
|
+
* or a full commit id leaves HEAD detached at the commit. The clone copies
|
|
40
|
+
* objects rather than hard-linking them, takes no hook templates, and runs no
|
|
41
|
+
* hooks on checkout.
|
|
42
|
+
*
|
|
43
|
+
* The checked-out commit must be the whole workspace: one that uses
|
|
44
|
+
* submodules or tracks paths through Git LFS is refused and the directory is
|
|
45
|
+
* removed. The clone fetches without checking out, and the checkout then
|
|
46
|
+
* runs isolated from the host's system and global git config, so no filter
|
|
47
|
+
* the host configured, LFS or otherwise, runs or reaches the network.
|
|
48
|
+
*
|
|
49
|
+
* Without a credential, git authenticates through the host's own git
|
|
50
|
+
* config. An env credential is read from the named variable and handed to git
|
|
51
|
+
* only through a private askpass helper for the network commands, never
|
|
52
|
+
* through the command line or the URL, and the helper is deleted before this
|
|
53
|
+
* returns. The host's credential helpers are switched off for those commands
|
|
54
|
+
* so none of them stores the token.
|
|
55
|
+
*/
|
|
56
|
+
export async function createWorkspaceClone(
|
|
57
|
+
request: CloneRequest,
|
|
58
|
+
): Promise<CloneRefusal | CreatedClone | IncompleteCheckout> {
|
|
59
|
+
const askpass = await createAskpass(request.credential);
|
|
60
|
+
|
|
61
|
+
if (!askpass.ok) {
|
|
62
|
+
return askpass;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
return await createCloneAtRef(request, askpass.env, askpass.args);
|
|
67
|
+
} finally {
|
|
68
|
+
await askpass[Symbol.asyncDispose]();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Askpass {
|
|
73
|
+
readonly ok: true;
|
|
74
|
+
readonly env: Readonly<Record<string, string>>;
|
|
75
|
+
readonly args: readonly string[];
|
|
76
|
+
[Symbol.asyncDispose]: () => Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ASKPASS_SECRET_VAR = 'ATC_GIT_ASKPASS_SECRET';
|
|
80
|
+
|
|
81
|
+
// Username prompts get a fixed name that GitHub and GitLab both accept
|
|
82
|
+
// alongside a token; password prompts get the token.
|
|
83
|
+
const ASKPASS_SCRIPT = `#!/bin/sh
|
|
84
|
+
case "$1" in
|
|
85
|
+
Username*) printf '%s\\n' x-access-token ;;
|
|
86
|
+
*) printf '%s\\n' "$${ASKPASS_SECRET_VAR}" ;;
|
|
87
|
+
esac
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
async function createAskpass(
|
|
91
|
+
credential: GitCredential | undefined,
|
|
92
|
+
): Promise<Askpass | CloneRefusal> {
|
|
93
|
+
if (credential === undefined) {
|
|
94
|
+
return { ok: true, env: {}, args: [], [Symbol.asyncDispose]: async () => {} };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const secret = process.env[credential.name];
|
|
98
|
+
|
|
99
|
+
if (secret === undefined || secret === '') {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
code: 'credential_missing',
|
|
103
|
+
message: 'the credential environment variable is unset or empty',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const dir = await mkdtemp(join(tmpdir(), 'atc-askpass-'));
|
|
108
|
+
|
|
109
|
+
const helper = join(dir, 'askpass');
|
|
110
|
+
|
|
111
|
+
await writeFile(helper, ASKPASS_SCRIPT, { mode: 0o700 });
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
ok: true,
|
|
115
|
+
env: { GIT_ASKPASS: helper, [ASKPASS_SECRET_VAR]: secret },
|
|
116
|
+
args: ['-c', 'credential.helper='],
|
|
117
|
+
[Symbol.asyncDispose]: async () => {
|
|
118
|
+
await rm(dir, { recursive: true, force: true });
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function createCloneAtRef(
|
|
124
|
+
request: CloneRequest,
|
|
125
|
+
env: Readonly<Record<string, string>>,
|
|
126
|
+
args: readonly string[],
|
|
127
|
+
): Promise<CloneRefusal | CreatedClone | IncompleteCheckout> {
|
|
128
|
+
const target = await resolveRef(request.source, env, args);
|
|
129
|
+
|
|
130
|
+
if (!target.ok) {
|
|
131
|
+
return target;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const clone = await runGit(
|
|
135
|
+
[
|
|
136
|
+
...args,
|
|
137
|
+
'clone',
|
|
138
|
+
'--quiet',
|
|
139
|
+
'--no-local',
|
|
140
|
+
'--no-hardlinks',
|
|
141
|
+
'--no-checkout',
|
|
142
|
+
'--template=',
|
|
143
|
+
'--',
|
|
144
|
+
request.source.url,
|
|
145
|
+
request.dir,
|
|
146
|
+
],
|
|
147
|
+
{ env },
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (clone.exitCode !== 0) {
|
|
151
|
+
return { ok: false, code: 'clone_failed', message: clone.stderr.trim() };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const checkout = await runGit(
|
|
155
|
+
[
|
|
156
|
+
'-c',
|
|
157
|
+
'core.hooksPath=/dev/null',
|
|
158
|
+
'checkout',
|
|
159
|
+
'--quiet',
|
|
160
|
+
...(target.branch === null ? ['--detach', target.sha] : ['-B', target.branch, target.sha]),
|
|
161
|
+
],
|
|
162
|
+
{ cwd: request.dir, isolated: true },
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (checkout.exitCode !== 0) {
|
|
166
|
+
await rm(request.dir, { recursive: true, force: true });
|
|
167
|
+
|
|
168
|
+
return { ok: false, code: 'clone_failed', message: checkout.stderr.trim() };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const complete = await checkWorkspaceCompleteness(request.dir, target.sha);
|
|
172
|
+
|
|
173
|
+
if (!complete.ok) {
|
|
174
|
+
await rm(request.dir, { recursive: true, force: true });
|
|
175
|
+
|
|
176
|
+
return complete;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { ok: true, sha: target.sha, branch: target.branch };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface ResolvedRef {
|
|
183
|
+
readonly ok: true;
|
|
184
|
+
readonly sha: string;
|
|
185
|
+
readonly branch: string | null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const SHA_PATTERN = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Pins a ref to a commit before cloning, so the checkout is exactly the
|
|
192
|
+
* commit the ref pointed at when asked, whatever lands on the branch meanwhile.
|
|
193
|
+
* A branch wins over a same-named tag, and an annotated tag resolves to the
|
|
194
|
+
* commit it points at.
|
|
195
|
+
*/
|
|
196
|
+
async function resolveRef(
|
|
197
|
+
source: Extract<WorkspaceSource, { readonly kind: 'git' }>,
|
|
198
|
+
env: Readonly<Record<string, string>>,
|
|
199
|
+
args: readonly string[],
|
|
200
|
+
): Promise<CloneRefusal | ResolvedRef> {
|
|
201
|
+
if (SHA_PATTERN.test(source.ref)) {
|
|
202
|
+
return { ok: true, sha: source.ref, branch: null };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const listed = await runGit(
|
|
206
|
+
[...args, 'ls-remote', '--', source.url, source.ref, `${source.ref}^{}`],
|
|
207
|
+
{ env },
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
if (listed.exitCode !== 0) {
|
|
211
|
+
return { ok: false, code: 'clone_failed', message: listed.stderr.trim() };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const refs = new Map(
|
|
215
|
+
listed.stdout
|
|
216
|
+
.split('\n')
|
|
217
|
+
.filter((line) => line !== '')
|
|
218
|
+
.map((line) => {
|
|
219
|
+
const [sha = '', name = ''] = line.split('\t');
|
|
220
|
+
|
|
221
|
+
return [name, sha] as const;
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const name = source.ref.replace(/^refs\/(?:heads|tags)\//u, '');
|
|
226
|
+
const branchSHA = refs.get(`refs/heads/${name}`);
|
|
227
|
+
|
|
228
|
+
if (branchSHA !== undefined && !source.ref.startsWith('refs/tags/')) {
|
|
229
|
+
return { ok: true, sha: branchSHA, branch: name };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const tagSHA = refs.get(`refs/tags/${name}^{}`) ?? refs.get(`refs/tags/${name}`);
|
|
233
|
+
|
|
234
|
+
if (tagSHA !== undefined && !source.ref.startsWith('refs/heads/')) {
|
|
235
|
+
return { ok: true, sha: tagSHA, branch: null };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { ok: false, code: 'ref_not_found', message: `origin has no branch or tag '${name}'` };
|
|
239
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
type NormalizedGitURL =
|
|
2
|
+
| { readonly ok: true; readonly url: string }
|
|
3
|
+
| { readonly ok: false; readonly code: 'invalid_git_url'; readonly message: string };
|
|
4
|
+
|
|
5
|
+
const SCHEME_PATTERN = /^[a-z][a-z\d+.-]*:\/\//iu;
|
|
6
|
+
const SCP_PATTERN = /^(?:[^@/:\s]+@)?[^@/:\s]+:[^@\s]+$/u;
|
|
7
|
+
const SHORTHAND_PATTERN = /^[\w.-]+\/[\w.-]+$/u;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reduces a repository URL to a form that carries no credential, so it can be
|
|
11
|
+
* recorded, shown, and handed to another host. An http(s) URL loses its
|
|
12
|
+
* userinfo, query, and fragment, where tokens ride. Any other scheme URL
|
|
13
|
+
* 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.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeGitURL(raw: string): NormalizedGitURL {
|
|
18
|
+
const trimmed = raw.trim();
|
|
19
|
+
|
|
20
|
+
if (SCHEME_PATTERN.test(trimmed)) {
|
|
21
|
+
return normalizeSchemeURL(trimmed);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (trimmed.startsWith('/')) {
|
|
25
|
+
return { ok: true, url: trimmed };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (SHORTHAND_PATTERN.test(trimmed) && !trimmed.startsWith('.')) {
|
|
29
|
+
return { ok: true, url: `https://github.com/${trimmed.replace(/\.git$/u, '')}.git` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (SCP_PATTERN.test(trimmed)) {
|
|
33
|
+
return { ok: true, url: trimmed };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { ok: false, code: 'invalid_git_url', message: 'not a git repository URL' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HTTP_PROTOCOLS: ReadonlySet<string> = new Set(['http:', 'https:']);
|
|
40
|
+
|
|
41
|
+
function normalizeSchemeURL(raw: string): NormalizedGitURL {
|
|
42
|
+
let url: URL;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
url = new URL(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return { ok: false, code: 'invalid_git_url', message: 'not a parseable repository URL' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
url.password = '';
|
|
51
|
+
|
|
52
|
+
if (HTTP_PROTOCOLS.has(url.protocol)) {
|
|
53
|
+
url.username = '';
|
|
54
|
+
url.search = '';
|
|
55
|
+
url.hash = '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { ok: true, url: url.toString() };
|
|
59
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
type TarOutcome =
|
|
2
|
+
| { readonly ok: true }
|
|
3
|
+
| { readonly ok: false; readonly code: 'tar_failed'; readonly message: string };
|
|
4
|
+
|
|
5
|
+
interface WorkspaceTar {
|
|
6
|
+
readonly stream: ReadableStream<Uint8Array>;
|
|
7
|
+
readonly done: Promise<TarOutcome>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Streams an uncompressed tar of a directory, `.git` included, with entries
|
|
12
|
+
* relative to the directory. The archive is read as it is produced and never
|
|
13
|
+
* written to disk. A failure partway through still ends the stream, so a
|
|
14
|
+
* consumer drains it and then awaits the outcome before trusting what it
|
|
15
|
+
* received. Options the host passes to GNU tar through `TAR_OPTIONS` are
|
|
16
|
+
* ignored, since one could follow symlinks out of the directory or leave
|
|
17
|
+
* files out.
|
|
18
|
+
*/
|
|
19
|
+
export function readWorkspaceTar(dir: string): WorkspaceTar {
|
|
20
|
+
const { TAR_OPTIONS: _ignored, ...env } = process.env;
|
|
21
|
+
|
|
22
|
+
const proc = Bun.spawn(['tar', '-c', '-f', '-', '-C', dir, '.'], {
|
|
23
|
+
env,
|
|
24
|
+
stdin: 'ignore',
|
|
25
|
+
stdout: 'pipe',
|
|
26
|
+
stderr: 'pipe',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const done = (async (): Promise<TarOutcome> => {
|
|
30
|
+
const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
|
31
|
+
|
|
32
|
+
return exitCode === 0
|
|
33
|
+
? { ok: true }
|
|
34
|
+
: { ok: false, code: 'tar_failed', message: stderr.trim() };
|
|
35
|
+
})();
|
|
36
|
+
|
|
37
|
+
return { stream: proc.stdout, done };
|
|
38
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { normalizeGitURL } from './normalize-git-url';
|
|
3
|
+
import { runGit } from './run-git';
|
|
4
|
+
|
|
5
|
+
interface PathSourceOptions {
|
|
6
|
+
readonly allowDirty?: 'refuse' | 'warn';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ResolvedPathSource {
|
|
10
|
+
readonly ok: true;
|
|
11
|
+
readonly url: string;
|
|
12
|
+
readonly sha: string;
|
|
13
|
+
readonly branch: string | null;
|
|
14
|
+
readonly dirty: boolean;
|
|
15
|
+
readonly warnings: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type PathSourceRefusalCode =
|
|
19
|
+
| 'not_a_git_repo'
|
|
20
|
+
| 'no_commits'
|
|
21
|
+
| 'unreadable_tree'
|
|
22
|
+
| 'has_submodules'
|
|
23
|
+
| 'workspace_dirty'
|
|
24
|
+
| 'no_origin'
|
|
25
|
+
| 'invalid_git_url'
|
|
26
|
+
| 'unpushed_head';
|
|
27
|
+
|
|
28
|
+
interface PathSourceRefusal {
|
|
29
|
+
readonly ok: false;
|
|
30
|
+
readonly code: PathSourceRefusalCode;
|
|
31
|
+
readonly message: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Turns a local checkout into the repository URL and commit another host
|
|
36
|
+
* can clone, so a workspace never carries files that exist only here. The
|
|
37
|
+
* commit is HEAD, and it must already be on origin: a remote-tracking ref
|
|
38
|
+
* under origin contains it, or origin advertises it as a ref tip. A tree
|
|
39
|
+
* with uncommitted or untracked changes is refused, or with `allowDirty:
|
|
40
|
+
* 'warn'` resolves to HEAD and leaves those changes behind with a warning.
|
|
41
|
+
* Submodules are refused. The URL is origin's, with any credential stripped.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolvePathSource(
|
|
44
|
+
path: string,
|
|
45
|
+
options: PathSourceOptions = {},
|
|
46
|
+
): Promise<PathSourceRefusal | ResolvedPathSource> {
|
|
47
|
+
if (statSync(path, { throwIfNoEntry: false })?.isDirectory() !== true) {
|
|
48
|
+
return { ok: false, code: 'not_a_git_repo', message: `${path} is not a directory` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const toplevel = await runGit(['rev-parse', '--show-toplevel'], { cwd: path });
|
|
52
|
+
|
|
53
|
+
if (toplevel.exitCode !== 0) {
|
|
54
|
+
return { ok: false, code: 'not_a_git_repo', message: `${path} is not inside a git work tree` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const root = toplevel.stdout.trim();
|
|
58
|
+
|
|
59
|
+
const head = await runGit(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], { cwd: root });
|
|
60
|
+
|
|
61
|
+
if (head.exitCode !== 0) {
|
|
62
|
+
return { ok: false, code: 'no_commits', message: `${root} has no commit to check out` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sha = head.stdout.trim();
|
|
66
|
+
|
|
67
|
+
const tree = await runGit(['ls-tree', '-r', '--full-tree', sha], { cwd: root });
|
|
68
|
+
|
|
69
|
+
if (tree.exitCode !== 0) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
code: 'unreadable_tree',
|
|
73
|
+
message: `cannot list the tree of ${sha} in ${root}: ${tree.stderr.trim()}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (hasSubmodules(tree.stdout)) {
|
|
78
|
+
return { ok: false, code: 'has_submodules', message: `${root} uses submodules` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const status = await runGit(['status', '--porcelain', '--untracked-files=normal'], { cwd: root });
|
|
82
|
+
|
|
83
|
+
if (status.exitCode !== 0) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
code: 'unreadable_tree',
|
|
87
|
+
message: `cannot read the status of ${root}: ${status.stderr.trim()}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const dirty = status.stdout.trim() !== '';
|
|
92
|
+
const warnings: string[] = [];
|
|
93
|
+
|
|
94
|
+
if (dirty && options.allowDirty !== 'warn') {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
code: 'workspace_dirty',
|
|
98
|
+
message: `${root} has uncommitted or untracked changes`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (dirty) {
|
|
103
|
+
warnings.push(`uncommitted and untracked changes in ${root} stay behind; using ${sha}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const origin = await runGit(['ls-remote', '--get-url', 'origin'], { cwd: root });
|
|
107
|
+
|
|
108
|
+
const originURL = origin.stdout.trim();
|
|
109
|
+
|
|
110
|
+
// With no origin configured, git echoes the remote name back.
|
|
111
|
+
if (origin.exitCode !== 0 || originURL === '' || originURL === 'origin') {
|
|
112
|
+
return { ok: false, code: 'no_origin', message: `${root} has no origin remote` };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const url = normalizeGitURL(originURL);
|
|
116
|
+
|
|
117
|
+
if (!url.ok) {
|
|
118
|
+
return { ok: false, code: url.code, message: `origin of ${root}: ${url.message}` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const pushed = await isOnOrigin(root, sha);
|
|
122
|
+
|
|
123
|
+
if (!pushed) {
|
|
124
|
+
return { ok: false, code: 'unpushed_head', message: `${sha} is not on origin; push it first` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const branch = await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: root });
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
url: url.url,
|
|
132
|
+
sha,
|
|
133
|
+
branch: branch.exitCode === 0 ? branch.stdout.trim() : null,
|
|
134
|
+
dirty,
|
|
135
|
+
warnings,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether a recursive tree listing holds a gitlink or a `.gitmodules` file at
|
|
141
|
+
* its root. A workspace clone never initializes submodules, so either would
|
|
142
|
+
* ship an incomplete tree.
|
|
143
|
+
*/
|
|
144
|
+
function hasSubmodules(listing: string): boolean {
|
|
145
|
+
return listing
|
|
146
|
+
.split('\n')
|
|
147
|
+
.some((line) => line.startsWith('160000 ') || line.endsWith('\t.gitmodules'));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether origin already has the commit. A local remote-tracking ref proves
|
|
152
|
+
* it without network access; failing that, origin's advertised ref tips are
|
|
153
|
+
* checked, which catches a push whose tracking ref was never fetched.
|
|
154
|
+
*/
|
|
155
|
+
async function isOnOrigin(root: string, sha: string): Promise<boolean> {
|
|
156
|
+
const tracking = await runGit(['branch', '-r', '--contains', sha, '--list', 'origin/*'], {
|
|
157
|
+
cwd: root,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (tracking.exitCode === 0 && tracking.stdout.trim() !== '') {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const advertised = await runGit(['ls-remote', 'origin'], { cwd: root });
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
advertised.exitCode === 0 &&
|
|
168
|
+
advertised.stdout.split('\n').some((line) => line.split('\t')[0] === sha)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
interface GitRunOptions {
|
|
2
|
+
readonly cwd?: string;
|
|
3
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
4
|
+
readonly input?: string;
|
|
5
|
+
readonly isolated?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface GitRun {
|
|
9
|
+
readonly exitCode: number;
|
|
10
|
+
readonly stdout: string;
|
|
11
|
+
readonly stderr: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Runs one git command to completion, feeding it any given input, and
|
|
16
|
+
* returns its exit code and output.
|
|
17
|
+
* git never prompts on a terminal here, since the daemon has none to answer
|
|
18
|
+
* with, and its messages stay in the C locale so callers can read them.
|
|
19
|
+
* Variables that pin git to some other repository, such as the `GIT_DIR` a
|
|
20
|
+
* git hook exports, are dropped so the command acts on its own directory.
|
|
21
|
+
*
|
|
22
|
+
* An isolated command reads only the repository's own config and
|
|
23
|
+
* attributes: the host's system and global config and its system and global
|
|
24
|
+
* attributes files are ignored, and every LFS filter is switched off. A
|
|
25
|
+
* checkout run this way runs no filter the host configured, so it can
|
|
26
|
+
* neither execute host code nor reach the network with the host's
|
|
27
|
+
* credentials.
|
|
28
|
+
*/
|
|
29
|
+
const ISOLATED_ARGS = [
|
|
30
|
+
'-c',
|
|
31
|
+
'core.attributesFile=/dev/null',
|
|
32
|
+
'-c',
|
|
33
|
+
'filter.lfs.smudge=',
|
|
34
|
+
'-c',
|
|
35
|
+
'filter.lfs.process=',
|
|
36
|
+
'-c',
|
|
37
|
+
'filter.lfs.required=false',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const ISOLATED_ENV = {
|
|
41
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
42
|
+
GIT_ATTR_NOSYSTEM: '1',
|
|
43
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
44
|
+
GIT_LFS_SKIP_SMUDGE: '1',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export async function runGit(
|
|
48
|
+
args: readonly string[],
|
|
49
|
+
options: GitRunOptions = {},
|
|
50
|
+
): Promise<GitRun> {
|
|
51
|
+
const isolated = options.isolated === true;
|
|
52
|
+
|
|
53
|
+
const proc = Bun.spawn(['git', ...(isolated ? ISOLATED_ARGS : []), ...args], {
|
|
54
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
55
|
+
env: {
|
|
56
|
+
...collectHostEnv(),
|
|
57
|
+
...options.env,
|
|
58
|
+
...(isolated ? ISOLATED_ENV : {}),
|
|
59
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
60
|
+
LC_ALL: 'C',
|
|
61
|
+
},
|
|
62
|
+
stdin: options.input === undefined ? 'ignore' : Buffer.from(options.input),
|
|
63
|
+
stdout: 'pipe',
|
|
64
|
+
stderr: 'pipe',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
68
|
+
new Response(proc.stdout).text(),
|
|
69
|
+
new Response(proc.stderr).text(),
|
|
70
|
+
proc.exited,
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
return { exitCode, stdout, stderr };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const REPOSITORY_ENV_VARS: ReadonlySet<string> = new Set([
|
|
77
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
78
|
+
'GIT_COMMON_DIR',
|
|
79
|
+
'GIT_CONFIG',
|
|
80
|
+
'GIT_CONFIG_COUNT',
|
|
81
|
+
'GIT_CONFIG_PARAMETERS',
|
|
82
|
+
'GIT_DIR',
|
|
83
|
+
'GIT_GRAFT_FILE',
|
|
84
|
+
'GIT_IMPLICIT_WORK_TREE',
|
|
85
|
+
'GIT_INDEX_FILE',
|
|
86
|
+
'GIT_NO_REPLACE_OBJECTS',
|
|
87
|
+
'GIT_OBJECT_DIRECTORY',
|
|
88
|
+
'GIT_PREFIX',
|
|
89
|
+
'GIT_REPLACE_REF_BASE',
|
|
90
|
+
'GIT_SHALLOW_FILE',
|
|
91
|
+
'GIT_WORK_TREE',
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
function collectHostEnv(): Record<string, string | undefined> {
|
|
95
|
+
return Object.fromEntries(
|
|
96
|
+
Object.entries(process.env).filter(
|
|
97
|
+
([name]) =>
|
|
98
|
+
!REPOSITORY_ENV_VARS.has(name) &&
|
|
99
|
+
!name.startsWith('GIT_CONFIG_KEY_') &&
|
|
100
|
+
!name.startsWith('GIT_CONFIG_VALUE_'),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}
|