hammerkit 1.25.0 → 1.27.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/container-task-state.js +8 -6
  12. package/dist/executer/docker-service.js +24 -13
  13. package/dist/executer/docker-task.js +18 -29
  14. package/dist/executer/event-cache.js +4 -0
  15. package/dist/executer/execute-command.js +18 -5
  16. package/dist/executer/execute-docker.js +4 -1
  17. package/dist/executer/execution-steps.js +33 -7
  18. package/dist/executer/get-docker-executor.js +14 -7
  19. package/dist/executer/scheduler/enqueue-next.js +17 -3
  20. package/dist/executer/watch-loop.js +2 -2
  21. package/dist/executer/watch-state-key.js +1 -1
  22. package/dist/file/within-root.js +42 -0
  23. package/dist/harness/connect.js +99 -0
  24. package/dist/harness/format.js +73 -0
  25. package/dist/harness/mcp-command.js +59 -0
  26. package/dist/harness/mcp.js +432 -0
  27. package/dist/harness/opencode.js +492 -0
  28. package/dist/harness/pi.js +217 -0
  29. package/dist/harness/sandbox-client.js +373 -0
  30. package/dist/index.js +33 -36
  31. package/dist/kubernetes/apply.js +31 -1
  32. package/dist/kubernetes/await-running-state.js +33 -1
  33. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +44 -22
  34. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  35. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  36. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  37. package/dist/kubernetes/get-service-ip.js +15 -1
  38. package/dist/kubernetes/job-placement.js +99 -0
  39. package/dist/kubernetes/job-secrets.js +306 -0
  40. package/dist/kubernetes/kept-claims.js +191 -0
  41. package/dist/kubernetes/kubernetes-instance.js +10 -2
  42. package/dist/kubernetes/network-gate.js +71 -0
  43. package/dist/kubernetes/network-policy.js +526 -0
  44. package/dist/kubernetes/resources.js +13 -2
  45. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  46. package/dist/kubernetes/secrets.js +2 -0
  47. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  48. package/dist/kubernetes/volumes.js +100 -11
  49. package/dist/main.js +41 -0
  50. package/dist/network/egress-proxy.js +368 -0
  51. package/dist/network/port-relay.js +35 -0
  52. package/dist/optimizer/get-work-cache-stats.js +16 -0
  53. package/dist/optimizer/work-task-cache-description.js +3 -2
  54. package/dist/planner/service-definition.js +8 -2
  55. package/dist/planner/utils/append-work-environment.js +2 -0
  56. package/dist/planner/utils/append-work-service.js +4 -0
  57. package/dist/planner/utils/append-work-task.js +17 -1
  58. package/dist/planner/utils/assert-item-paths.js +50 -0
  59. package/dist/planner/utils/hosted-volumes.js +35 -0
  60. package/dist/planner/utils/task-arguments.js +155 -0
  61. package/dist/planner/work-cache-id.js +15 -1
  62. package/dist/planner/work-network.js +193 -0
  63. package/dist/planner/work-runtime-docker.js +43 -0
  64. package/dist/planner/work-runtime-kubernetes.js +336 -102
  65. package/dist/planner/work-runtime-local.js +16 -8
  66. package/dist/planner/work-service.js +7 -3
  67. package/dist/planner/work-state-record.js +30 -0
  68. package/dist/program.js +80 -7
  69. package/dist/remote/annotations.js +103 -0
  70. package/dist/remote/bundle-text.js +24 -0
  71. package/dist/remote/bundle.js +9 -1
  72. package/dist/remote/remote-commands.js +4 -1
  73. package/dist/remote/remote-work-commands.js +182 -69
  74. package/dist/remote/server-client.js +18 -5
  75. package/dist/remote/source-manifest.js +15 -2
  76. package/dist/remote/token-source.js +67 -0
  77. package/dist/schema/build-file-base-task-schema.js +3 -0
  78. package/dist/schema/build-file-container-service-schema.js +6 -0
  79. package/dist/schema/build-file-container-task-schema.js +3 -0
  80. package/dist/schema/build-file-environment-schema.js +9 -0
  81. package/dist/schema/network-schema.js +78 -0
  82. package/dist/schema/reference-parser.js +1 -0
  83. package/dist/schema/schema-parser.js +16 -3
  84. package/dist/schema/work-scope-parser.js +13 -0
  85. package/dist/secrets/command-secret-provider.js +73 -10
  86. package/dist/secrets/github-app-token-command.js +23 -0
  87. package/dist/secrets/github-app-token.js +191 -0
  88. package/dist/server/accounts-api.js +9 -2
  89. package/dist/server/definitions-api.js +69 -47
  90. package/dist/server/definitions.js +76 -51
  91. package/dist/server/executor.js +156 -41
  92. package/dist/server/git-checkout.js +72 -8
  93. package/dist/server/kubernetes-backend.js +554 -0
  94. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  95. package/dist/server/oauth.js +1 -0
  96. package/dist/server/plan.js +51 -1
  97. package/dist/server/policy.js +104 -15
  98. package/dist/server/proposals-api.js +24 -40
  99. package/dist/server/proposals.js +12 -25
  100. package/dist/server/repo-access.js +37 -6
  101. package/dist/server/run-logs.js +217 -0
  102. package/dist/server/run-plan.js +51 -2
  103. package/dist/server/run-request.js +23 -10
  104. package/dist/server/run-store.js +5 -0
  105. package/dist/server/run-worker-command.js +13 -1
  106. package/dist/server/run-worker.js +121 -24
  107. package/dist/server/runs.js +184 -10
  108. package/dist/server/sandbox-docker.js +62 -0
  109. package/dist/server/sandbox-files-api.js +269 -0
  110. package/dist/server/sandbox-files.js +1054 -0
  111. package/dist/server/sandbox-patch.js +481 -0
  112. package/dist/server/sandbox-pod-command.js +15 -0
  113. package/dist/server/sandbox-pod.js +146 -0
  114. package/dist/server/sandbox-storage.js +462 -0
  115. package/dist/server/sandboxes-api.js +210 -0
  116. package/dist/server/sandboxes.js +519 -0
  117. package/dist/server/server-config.js +412 -22
  118. package/dist/server/server.js +204 -39
  119. package/dist/utils/run-id.js +12 -0
  120. package/harness/opencode.ts +11 -0
  121. package/harness/pi.ts +7 -0
  122. package/package.json +8 -2
  123. package/dist/remote/text-diff.js +0 -89
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.HostExecutor = exports.defaultWorker = void 0;
3
+ exports.HostExecutor = exports.volumeScope = exports.cacheNamespace = exports.defaultWorker = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const crypto_1 = require("crypto");
6
6
  const fs_1 = require("fs");
@@ -10,13 +10,15 @@ const readline_1 = require("readline");
10
10
  const resolve_git_source_1 = require("../includes/resolve-git-source");
11
11
  const git_checkout_1 = require("./git-checkout");
12
12
  const run_plan_1 = require("./run-plan");
13
+ const work_network_1 = require("../planner/work-network");
13
14
  // `.git` as a file system may read it: any case, and a name Windows reads without its
14
15
  // trailing dots and spaces, or by its short name
15
16
  function isGitDirectory(segment) {
16
17
  const name = segment.toLowerCase().replace(/[. ]+$/, '');
17
18
  return name === '.git' || name === 'git~1';
18
19
  }
19
- // A cancelled run gets this long to stop its tasks and remove its containers
20
+ // A cancelled run gets this long to stop its tasks and remove its containers, after that
21
+ // its process is killed
20
22
  const STOP_GRACE_MS = 15000;
21
23
  // what is kept of the error output of a process that died without saying why
22
24
  const MAX_STDERR = 2 * 1024;
@@ -30,19 +32,29 @@ function defaultWorker() {
30
32
  }
31
33
  exports.defaultWorker = defaultWorker;
32
34
  // The cache a run shares with others is the one of its source: the runs of a repository
33
- // share one, those of an upload only the uploader's. Somebody who can only upload can
34
- // not leave an entry for a run from a reviewed repository to find.
35
+ // share one, those of an upload or a sandbox only their owner's. Somebody who can only
36
+ // upload, or an agent on a sandbox, can not leave an entry for a run from a reviewed
37
+ // repository to find.
35
38
  function cacheNamespace(run) {
36
- const key = run.source.type === 'ref' ? `ref\0${run.source.repo}` : `upload\0${run.owner.iss}\0${run.owner.sub}`;
39
+ const key = run.source.type === 'ref' ? `ref\0${run.source.repo}` : `${run.source.type}\0${run.owner.iss}\0${run.owner.sub}`;
37
40
  return `${run.source.type}-${(0, crypto_1.createHash)('sha256').update(key).digest('hex').slice(0, 24)}`;
38
41
  }
42
+ exports.cacheNamespace = cacheNamespace;
43
+ // The docker volumes of a run's services are its caller's, within the cache's source trust:
44
+ // two callers of one repository share a cache, a task is keyed by what it reads, but not the
45
+ // data their services keep.
46
+ function volumeScope(run) {
47
+ const key = `${cacheNamespace(run)}\0${run.owner.iss}\0${run.owner.sub}`;
48
+ return `${run.source.type}-${(0, crypto_1.createHash)('sha256').update(key).digest('hex').slice(0, 24)}`;
49
+ }
50
+ exports.volumeScope = volumeScope;
39
51
  /**
40
52
  * Runs the tasks of a run on this machine with the engine of `hammerkit run`, in a
41
53
  * process of its own.
42
54
  *
43
- * The working directory is made for the run and removed afterwards. Its build
44
- * file is the published version the run is pinned to, written over the code of the
45
- * source, so the commands are the published ones whatever the source says. The process
55
+ * The working directory is made for the run and removed afterwards, or reported if it can
56
+ * not be. Its build file is the published version the run is pinned to, written over the
57
+ * code of the source, so the commands are the published ones whatever the source says. The process
46
58
  * starts with an environment the server builds: the variables `runs.env` lists, a home
47
59
  * directory that is the run's alone, and a cache directory per source trust. So what
48
60
  * the engine reads of its process (the docker host, the registry logins in the home
@@ -56,21 +68,25 @@ class HostExecutor {
56
68
  }
57
69
  async execute(run, context) {
58
70
  const { definitions, planning, repos } = this.options;
59
- const version = definitions.find(run.definition.name, run.definition.hash);
71
+ const version = definitions.find(run.definition);
60
72
  const bundle = version ? await definitions.files(version) : undefined;
61
73
  if (!version || !bundle) {
62
- throw new Error(`the build file ${run.definition.name} at ${run.definition.hash} is no longer on this server`);
74
+ throw new Error(`the build file ${run.definition} is no longer on this server`);
63
75
  }
64
76
  // pulled back after the run was accepted, while it waited for an approval or in the queue
65
77
  if (version.yanked) {
66
- throw new Error(`the build file ${run.definition.name} at ${run.definition.hash} was yanked${version.yanked.reason ? `: ${version.yanked.reason}` : ''}`);
78
+ throw new Error(`the build file ${run.definition} was yanked${version.yanked.reason ? `: ${version.yanked.reason}` : ''}`);
67
79
  }
68
- if (run.source.type !== 'ref') {
80
+ if (run.source.type === 'upload') {
69
81
  throw new Error('runs from an upload are not supported by this server yet, use a git ref');
70
82
  }
71
- const { repo, ref, commit: pinned } = run.source;
72
- if (!repos.readable(repo)) {
73
- throw new Error(`this server can not read ${repo}, the operator lists it in sources`);
83
+ const source = run.source;
84
+ const sandboxes = this.options.sandboxes;
85
+ if (source.type === 'sandbox' && !sandboxes) {
86
+ throw new Error('this server keeps no sandboxes');
87
+ }
88
+ if (source.type === 'ref' && !repos.readable(source.repo)) {
89
+ throw new Error(`this server can not read ${source.repo}, the operator lists it in sources`);
74
90
  }
75
91
  const worker = this.options.worker ?? defaultWorker();
76
92
  if (!worker) {
@@ -78,11 +94,16 @@ class HostExecutor {
78
94
  }
79
95
  // what was approved is what runs: the accounts are read again from the files with
80
96
  // the environment of the run, and the run may use no more than it was admitted for
81
- const planned = await (0, run_plan_1.planRun)(bundle, run, planning.environment, planning.policy);
97
+ const policy = source.type === 'sandbox' && sandboxes ? sandboxes.policy : planning.policy;
98
+ const planned = await (0, run_plan_1.planRun)(bundle, run, planning.environment, policy);
82
99
  const extra = planned.accounts.filter((account) => !run.accounts.includes(account));
83
100
  if (extra.length > 0) {
84
101
  throw new Error(`the run would use ${extra.join(', ')}, which it was not admitted for`);
85
102
  }
103
+ const refused = (0, run_plan_1.refuseArguments)(run, planned.plan);
104
+ if (refused) {
105
+ throw new Error(refused);
106
+ }
86
107
  const abortCtrl = new AbortController();
87
108
  const abort = () => abortCtrl.abort();
88
109
  if (context.signal.aborted) {
@@ -90,34 +111,119 @@ class HostExecutor {
90
111
  }
91
112
  context.signal.addEventListener('abort', abort, { once: true });
92
113
  const root = (0, path_1.join)(this.options.directory, run.id);
93
- const checkout = (0, path_1.join)(root, 'source');
114
+ let attachment;
94
115
  try {
95
- const credentials = await repos.credentials(repo);
96
- let commit;
97
- try {
98
- commit = await (0, git_checkout_1.checkoutRef)(repos.url(repo), ref, checkout, credentials.env, abortCtrl.signal, pinned);
116
+ // where the process of the run starts: the checkout of a ref, or for a sandbox the
117
+ // published files, so what the process and the secret providers it starts read of
118
+ // their directory is never what a task wrote
119
+ let cwd;
120
+ let input;
121
+ let claim;
122
+ // the files of the sandbox the tasks work on
123
+ let work;
124
+ if (source.type === 'ref') {
125
+ cwd = (0, path_1.join)(root, 'source');
126
+ const { repo, ref, commit: pinned } = source;
127
+ const credentials = await repos.credentials(repo);
128
+ let commit;
129
+ try {
130
+ commit = await (0, git_checkout_1.checkoutRef)(repos.url(repo), ref, cwd, credentials.env, abortCtrl.signal, pinned);
131
+ }
132
+ catch (e) {
133
+ throw repos.scrub(e, credentials.values);
134
+ }
135
+ context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `checked out ${repo} at ${commit}` });
136
+ await this.writeBundle(cwd, bundle.files);
137
+ // what the version was checked against at publish holds while it runs too
138
+ input = {
139
+ entry: (0, path_1.join)(cwd, version.entry),
140
+ ...(planning.policy.network ? { network: (0, work_network_1.toNetworkSchema)(planning.policy.network) } : {}),
141
+ };
99
142
  }
100
- catch (e) {
101
- throw repos.scrub(e, credentials.values);
143
+ else {
144
+ // the published files are read from a directory of the run and work on the
145
+ // sandbox, whose own build files are just files
146
+ const { registry, network } = sandboxes;
147
+ // read before the run attaches: until then the walk of the last run's measure holds
148
+ const state = await registry.state(source.id, abortCtrl.signal);
149
+ attachment = await registry.attach(source.id, (0, path_1.join)(root, 'sandbox'));
150
+ work = attachment.directory;
151
+ claim = attachment.claim;
152
+ // what was approved is what runs: the files may have changed since, by a run
153
+ // before this one on the sandbox
154
+ if (run.approvals?.length && state.tree !== source.tree) {
155
+ throw new Error(`the files of the sandbox ${source.id} changed since the run was approved, they are ${state.tree} now and were ${source.tree}, submit the run again`);
156
+ }
157
+ if (claim && !this.options.placement) {
158
+ throw new Error(`the sandbox ${source.id} is a claim on a cluster, this server runs tasks on its own docker`);
159
+ }
160
+ context.emit({
161
+ type: 'log',
162
+ task: 'hammerkit',
163
+ stream: 'stdout',
164
+ text: `sandbox ${source.id} of ${source.repo} at ${state.commit}, files ${state.tree}${state.clean ? '' : ' with changes'}`,
165
+ });
166
+ const published = (0, path_1.join)(root, 'bundle');
167
+ await (0, promises_1.mkdir)(published, { recursive: true });
168
+ await this.writeBundle(published, bundle.files);
169
+ cwd = published;
170
+ input = {
171
+ entry: (0, path_1.join)(published, version.entry),
172
+ directory: { read: published, work },
173
+ // the worker takes the lower of sandboxes.network and runs.network
174
+ ...(planning.policy.network ? { network: (0, work_network_1.toNetworkSchema)(planning.policy.network) } : {}),
175
+ sandboxNetwork: network,
176
+ };
102
177
  }
103
- context.emit({ type: 'log', task: 'hammerkit', stream: 'stdout', text: `checked out ${repo} at ${commit}` });
104
- await this.writeBundle(checkout, bundle.files);
105
178
  const home = (0, path_1.join)(root, 'home');
106
179
  await (0, promises_1.mkdir)(home, { recursive: true });
107
180
  const cache = (0, path_1.join)(this.options.cache, cacheNamespace(run));
108
181
  await (0, promises_1.mkdir)(cache, { recursive: true });
109
- const input = {
110
- entry: (0, path_1.join)(checkout, version.entry),
111
- tasks: run.tasks,
112
- options: run.options,
113
- accounts: run.accounts,
114
- catalog: planning.environment.hosted?.catalog,
115
- };
116
- await this.runInProcess(worker, input, checkout, this.processEnvironment(home, cache), context, abortCtrl.signal);
182
+ const placement = this.options.placement;
183
+ const placed = placement ? await placement.prepare(run, context, { root: work ?? cwd, claim }) : undefined;
184
+ try {
185
+ await this.runInProcess(worker, {
186
+ ...input,
187
+ tasks: run.tasks,
188
+ options: run.options,
189
+ accounts: run.accounts,
190
+ volumeScope: volumeScope(run),
191
+ catalog: planning.environment.hosted?.catalog,
192
+ ...(placed ? { kubernetes: placed.kubernetes } : {}),
193
+ }, cwd, { ...this.processEnvironment(home, cache), ...placed?.env }, context, abortCtrl.signal);
194
+ }
195
+ finally {
196
+ if (placement && placed) {
197
+ await placement.release(run, context);
198
+ }
199
+ }
117
200
  }
118
201
  finally {
119
202
  context.signal.removeEventListener('abort', abort);
120
- await (0, promises_1.rm)(root, { recursive: true, force: true });
203
+ attachment?.release?.();
204
+ await this.removeRunDirectory(run, root, context);
205
+ if (source.type === 'sandbox') {
206
+ // what the run wrote counts against the owner's limits, from their next run or create
207
+ sandboxes?.registry.measure(source.id).catch(() => undefined);
208
+ }
209
+ }
210
+ }
211
+ // A task of the run may still hold a file of its directory for a moment after the run
212
+ // ended, which Windows refuses to remove. A directory that stays is reported, to the run
213
+ // by its error code and to the operator with its path; it does not change how the run ended.
214
+ async removeRunDirectory(run, root, context) {
215
+ try {
216
+ await (0, promises_1.rm)(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
217
+ }
218
+ catch (e) {
219
+ const error = e;
220
+ context.emit({
221
+ type: 'log',
222
+ task: 'hammerkit',
223
+ stream: 'stderr',
224
+ text: `the working directory of the run could not be removed: ${error.code ?? 'unknown error'}`,
225
+ });
226
+ this.options.log?.(`run=${run.id} could not remove its directory: ${error.message}`);
121
227
  }
122
228
  }
123
229
  // What the process of a run sees: the variables the operator listed, and a home
@@ -136,9 +242,11 @@ class HostExecutor {
136
242
  env.HAMMERKIT_DIR = cache;
137
243
  return env;
138
244
  }
139
- // Starts the process, hands it the run on its standard input and relays what it reports. It
140
- // is stopped with SIGTERM when the run is cancelled, so the engine removes its containers
141
- // and stops its tasks, and killed if it does not stop in time.
245
+ // Starts the process, hands it the run on its standard input and relays what it reports.
246
+ // A cancelled run is told so on the standard input, the same on every platform, so the
247
+ // engine stops its tasks and removes its containers; a signal on Windows would end the
248
+ // process at once and leave its tasks running in the directory of the run. The process is
249
+ // killed if it has not stopped after the grace period.
142
250
  runInProcess(command, input, cwd, env, context, signal) {
143
251
  return new Promise((resolvePromise, reject) => {
144
252
  const child = (0, child_process_1.spawn)(command[0], command.slice(1), {
@@ -147,12 +255,19 @@ class HostExecutor {
147
255
  stdio: ['pipe', 'pipe', 'pipe'],
148
256
  windowsHide: true,
149
257
  });
258
+ child.stdin.on('error', () => undefined);
259
+ // the run first, so a cancel comes after it; kept open, it is how a cancel reaches the process
260
+ child.stdin.write(`${JSON.stringify(input)}\n`);
150
261
  let result;
151
262
  let stderr = '';
152
263
  let killTimer;
153
264
  const stop = () => {
154
- child.kill('SIGTERM');
155
- killTimer = setTimeout(() => child.kill('SIGKILL'), STOP_GRACE_MS);
265
+ child.stdin.write('cancel\n', (e) => {
266
+ if (e) {
267
+ child.kill();
268
+ }
269
+ });
270
+ killTimer = setTimeout(() => child.kill('SIGKILL'), this.options.stopGraceMs ?? STOP_GRACE_MS);
156
271
  killTimer.unref();
157
272
  };
158
273
  if (signal.aborted) {
@@ -169,6 +284,8 @@ class HostExecutor {
169
284
  }
170
285
  else if (message.kind === 'result') {
171
286
  result = message;
287
+ // nothing left to tell the process
288
+ child.stdin.end();
172
289
  }
173
290
  }
174
291
  catch {
@@ -178,8 +295,6 @@ class HostExecutor {
178
295
  child.stderr.on('data', (chunk) => {
179
296
  stderr = (stderr + chunk.toString('utf8')).slice(-MAX_STDERR);
180
297
  });
181
- child.stdin.on('error', () => undefined);
182
- child.stdin.end(JSON.stringify(input));
183
298
  child.on('error', (e) => {
184
299
  signal.removeEventListener('abort', stop);
185
300
  clearTimeout(killTimer);
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.resolveRef = exports.RefError = exports.checkoutRef = exports.isValidRef = void 0;
3
+ exports.git = exports.remoteBranch = exports.isValidBranch = exports.assertBranchName = exports.resolveRef = exports.GitOutputTooLarge = exports.RefError = exports.checkoutRef = exports.isValidRef = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const promises_1 = require("fs/promises");
6
6
  const util_1 = require("util");
@@ -42,13 +42,20 @@ exports.checkoutRef = checkoutRef;
42
42
  class RefError extends Error {
43
43
  }
44
44
  exports.RefError = RefError;
45
+ class GitOutputTooLarge extends Error {
46
+ }
47
+ exports.GitOutputTooLarge = GitOutputTooLarge;
45
48
  const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
46
49
  /**
47
50
  * The commit a branch or tag of a repository points to now: `refs/heads/<ref>`, else
48
51
  * `refs/tags/<ref>` (the commit of an annotated tag). A name that is both a branch and
49
- * a tag is ambiguous. A full commit hash is taken as it is.
52
+ * a tag is ambiguous. A full commit hash is taken as it is. With `branch`, only a
53
+ * branch: a sandbox starts from one.
50
54
  */
51
- async function resolveRef(repository, ref, env, signal) {
55
+ async function resolveRef(repository, ref, env, signal, only) {
56
+ if (only === 'branch') {
57
+ return resolveBranch(repository, ref, env, signal);
58
+ }
52
59
  if (!isValidRef(ref)) {
53
60
  throw new RefError(`${JSON.stringify(ref.slice(0, 80))} is not a branch, tag or commit`);
54
61
  }
@@ -84,7 +91,52 @@ async function resolveRef(repository, ref, env, signal) {
84
91
  return commit;
85
92
  }
86
93
  exports.resolveRef = resolveRef;
87
- async function git(args, baseEnv, signal, what) {
94
+ async function resolveBranch(repository, branch, env, signal) {
95
+ assertBranchName(branch);
96
+ const commit = await remoteBranch(repository, branch, env, signal);
97
+ if (!commit) {
98
+ throw new RefError(`there is no branch ${branch}`);
99
+ }
100
+ return commit;
101
+ }
102
+ // A name a sandbox can start from: a branch, never a tag or a commit, written without `refs/heads/`.
103
+ function assertBranchName(branch) {
104
+ if (!isValidRef(branch) || branch.startsWith('refs/')) {
105
+ throw new RefError(`${JSON.stringify(branch.slice(0, 80))} is not a branch`);
106
+ }
107
+ if (COMMIT.test(branch.toLowerCase())) {
108
+ throw new RefError(`a sandbox starts from a branch, ${branch} is a commit`);
109
+ }
110
+ }
111
+ exports.assertBranchName = assertBranchName;
112
+ // A branch git takes as a name of its own: a ref, and no part of it starts or ends with a
113
+ // dot or ends with `.lock` (`git check-ref-format` refuses those).
114
+ function isValidBranch(branch) {
115
+ return (isValidRef(branch) &&
116
+ branch
117
+ .split('/')
118
+ .every((part) => !part.startsWith('.') && !part.startsWith('-') && !part.endsWith('.') && !part.endsWith('.lock')));
119
+ }
120
+ exports.isValidBranch = isValidBranch;
121
+ // The commit a branch of a repository points to now, none when there is no such branch.
122
+ async function remoteBranch(repository, branch, env, signal) {
123
+ const output = await git(['ls-remote', '--heads', '--', repository, `refs/heads/${branch}`], env, signal, `the branches of ${repository}`);
124
+ for (const line of output.split('\n')) {
125
+ const [commit, name] = line.split('\t');
126
+ if (commit && name === `refs/heads/${branch}`) {
127
+ return commit;
128
+ }
129
+ }
130
+ return undefined;
131
+ }
132
+ exports.remoteBranch = remoteBranch;
133
+ // Runs git without a prompt and with the network transports only. Errors say what could
134
+ // not be done (`unable to <action> <what>`) with the last line git printed.
135
+ async function git(args, baseEnv, signal, what, action = 'fetch',
136
+ // what git reads on stdin, the exit codes that answer rather than fail (check-ignore exits
137
+ // 1 when nothing is ignored), and what git may print (a diff of a sandbox can be larger
138
+ // than the default)
139
+ options = {}) {
88
140
  const env = {};
89
141
  for (const [key, value] of Object.entries(baseEnv)) {
90
142
  if (value !== undefined) {
@@ -94,22 +146,34 @@ async function git(args, baseEnv, signal, what) {
94
146
  env.GIT_TERMINAL_PROMPT = '0';
95
147
  env.GIT_ALLOW_PROTOCOL = ALLOWED_PROTOCOLS;
96
148
  try {
97
- return (await execFileAsync('git', args, { env, signal })).stdout;
149
+ // warnings of a large tree must not fail the command
150
+ const running = execFileAsync('git', args, { env, signal, maxBuffer: options.maxBuffer ?? 64 * 1024 * 1024 });
151
+ // a git that exits before it read its input fails the command, not the server (EPIPE)
152
+ running.child.stdin?.on('error', () => undefined);
153
+ running.child.stdin?.end(options.input);
154
+ return (await running).stdout;
98
155
  }
99
156
  catch (e) {
100
157
  const error = e;
158
+ if (typeof error.code === 'number' && options.exitCodes?.includes(error.code)) {
159
+ return error.stdout ?? '';
160
+ }
101
161
  if (error.code === 'ABORT_ERR') {
102
- throw new Error(`fetching ${what} was cancelled`);
162
+ throw new Error(`${action === 'fetch' ? 'fetching' : `${action} of`} ${what} was cancelled`);
103
163
  }
104
164
  if (error.code === 'ENOENT') {
105
- throw new Error(`unable to fetch ${what}: git is not installed`);
165
+ throw new Error(`unable to ${action} ${what}: git is not installed`);
166
+ }
167
+ if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
168
+ throw new GitOutputTooLarge(`unable to ${action} ${what}: git printed more than ${options.maxBuffer ?? 64 * 1024 * 1024} bytes`);
106
169
  }
107
170
  const reason = (error.stderr ?? '')
108
171
  .split('\n')
109
172
  .map((line) => line.trim())
110
173
  .filter((line) => line.length > 0)
111
174
  .pop() ?? error.message;
112
- throw new Error(`unable to fetch ${what}: ${reason}`);
175
+ throw new Error(`unable to ${action} ${what}: ${reason}`);
113
176
  }
114
177
  }
178
+ exports.git = git;
115
179
  //# sourceMappingURL=git-checkout.js.map