@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.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +51 -0
  3. package/src/agents/build-args-without-flags.ts +25 -0
  4. package/src/agents/build-claude-override-args.ts +26 -0
  5. package/src/agents/claude-adapter.ts +49 -2
  6. package/src/agents/claude-effort-levels.ts +5 -0
  7. package/src/agents/codex-adapter.ts +43 -2
  8. package/src/agents/find-flag-value.ts +27 -0
  9. package/src/agents/gateway-adapter.ts +45 -2
  10. package/src/agents/grok-adapter.ts +19 -1
  11. package/src/cli.ts +18 -0
  12. package/src/client/daemon-client.ts +14 -2
  13. package/src/client/spawn-picker.ts +27 -4
  14. package/src/daemon/build-agent-list.ts +37 -1
  15. package/src/daemon/build-config-revision.ts +27 -0
  16. package/src/daemon/build-execution-targets.ts +34 -0
  17. package/src/daemon/build-fleet-events.ts +2 -1
  18. package/src/daemon/build-payload-hash.ts +31 -0
  19. package/src/daemon/build-scoped-context.ts +232 -0
  20. package/src/daemon/build-target-access.ts +33 -0
  21. package/src/daemon/build-target-forbidden-error.ts +13 -0
  22. package/src/daemon/build-target-identity.ts +22 -0
  23. package/src/daemon/build-target-list.ts +50 -0
  24. package/src/daemon/daemon-connection.ts +331 -95
  25. package/src/daemon/daemon.ts +452 -75
  26. package/src/daemon/effect-remains-error.ts +13 -0
  27. package/src/daemon/execution-provider.ts +103 -0
  28. package/src/daemon/find-execution-refusal.ts +104 -0
  29. package/src/daemon/idempotency-ledger.ts +164 -0
  30. package/src/daemon/local-pty-provider.ts +83 -0
  31. package/src/daemon/mint-session-id.ts +5 -5
  32. package/src/daemon/parse-spawn-overrides.ts +94 -0
  33. package/src/daemon/permission-registry.ts +14 -4
  34. package/src/daemon/restore-fleet.ts +66 -19
  35. package/src/daemon/session-runtime.ts +10 -0
  36. package/src/daemon/sessions.ts +282 -65
  37. package/src/daemon/start-headless-run.ts +11 -0
  38. package/src/daemon/start-headless-turn.ts +11 -2
  39. package/src/daemon/target-access.ts +36 -0
  40. package/src/mcp/answer-mcp-request.ts +12 -4
  41. package/src/mcp/answer-rpc-request.ts +28 -1
  42. package/src/mcp/build-principal-caller.ts +15 -0
  43. package/src/mcp/build-spawn-descriptions.ts +45 -0
  44. package/src/mcp/build-tool-list.ts +32 -4
  45. package/src/mcp/mcp-tools.ts +98 -8
  46. package/src/mcp/parse-idempotency-key.ts +29 -0
  47. package/src/mcp/reconnecting-caller.ts +39 -7
  48. package/src/mcp/require-daemon-features.ts +7 -0
  49. package/src/mcp/run-tool.ts +63 -11
  50. package/src/mcp/types.ts +2 -0
  51. package/src/protocol/daemon-error.ts +5 -1
  52. package/src/protocol/daemon-features.ts +24 -0
  53. package/src/protocol/protocol.ts +44 -11
  54. package/src/protocol/request-param-schemas.ts +60 -10
  55. package/src/shared/agent-session-id.ts +1 -1
  56. package/src/shared/collect-principals.ts +51 -0
  57. package/src/shared/collect-targets.ts +144 -0
  58. package/src/shared/config.ts +139 -14
  59. package/src/shared/daemon-id.ts +8 -0
  60. package/src/shared/format-json-kind.ts +21 -0
  61. package/src/shared/sort-json-keys.ts +19 -0
  62. package/src/shared/to-daemon-id.ts +11 -0
  63. package/src/store/fleet-entry.ts +42 -9
  64. package/src/store/idempotency-record.ts +48 -0
  65. package/src/store/message-owner.ts +1 -1
  66. package/src/store/run-migrations.ts +254 -5
  67. package/src/store/state-store.ts +433 -31
  68. package/src/workspace/check-workspace-completeness.ts +90 -0
  69. package/src/workspace/create-workspace-clone.ts +239 -0
  70. package/src/workspace/normalize-git-url.ts +59 -0
  71. package/src/workspace/read-workspace-tar.ts +38 -0
  72. package/src/workspace/resolve-path-source.ts +170 -0
  73. package/src/workspace/run-git.ts +103 -0
  74. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  75. package/src/workspace/workspace-provenance.ts +11 -0
  76. 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
+ }