@naxodev/apnea 0.2.0 → 0.2.2
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/README.md +18 -1
- package/SECURITY.md +36 -0
- package/briefs/orchestrator.md +4 -3
- package/dist/cli.js +8375 -15093
- package/docs/protocol/artifacts.md +18 -2
- package/docs/protocol/config.md +15 -3
- package/docs/protocol/manual-gate.md +8 -8
- package/docs/protocol/overview.md +17 -4
- package/extension/adapters/commit.ts +5 -1
- package/extension/adapters/dispatch.ts +9 -1
- package/extension/adapters/setup.ts +15 -1
- package/extension/adapters/start.ts +5 -1
- package/extension/adapters/status.ts +17 -2
- package/extension/adapters/wait.ts +6 -1
- package/extension/api.ts +7 -1
- package/extension/cli/main.ts +67 -7
- package/extension/cli/parse.ts +172 -5
- package/extension/domain/paths.ts +2 -11
- package/extension/domain/timeouts.ts +4 -0
- package/extension/domain/types.ts +65 -3
- package/extension/errors.ts +51 -16
- package/extension/operation-hooks.ts +6 -0
- package/extension/registry.ts +29 -15
- package/extension/run-tool.ts +19 -2
- package/extension/schema/config.ts +58 -16
- package/extension/schema/frontmatter.ts +57 -0
- package/extension/schema/state.ts +210 -13
- package/extension/services/app-live.ts +2 -1
- package/extension/services/config.ts +6 -4
- package/extension/services/file-system.ts +346 -75
- package/extension/services/herdr.ts +466 -260
- package/extension/services/operation-lock.ts +452 -0
- package/extension/services/process.ts +477 -0
- package/extension/services/run-store.ts +38 -16
- package/extension/services/vcs.ts +1258 -328
- package/extension/workflows/commit.ts +214 -13
- package/extension/workflows/dispatch.ts +305 -67
- package/extension/workflows/setup.ts +59 -32
- package/extension/workflows/start.ts +6 -4
- package/extension/workflows/status.ts +2 -2
- package/extension/workflows/wait.ts +62 -77
- package/package.json +2 -2
- package/schemas/config.schema.json +5 -1
- package/schemas/state.schema.json +165 -11
|
@@ -1,37 +1,81 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
constants as fsConstants,
|
|
5
|
+
fstatSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readSync,
|
|
11
|
+
readlinkSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs"
|
|
15
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
3
16
|
import { tmpdir } from "node:os"
|
|
4
17
|
import * as path from "node:path"
|
|
5
|
-
import { Context, Effect, Layer } from "effect"
|
|
18
|
+
import { Clock, Context, Effect, Layer, Result } from "effect"
|
|
6
19
|
import {
|
|
7
20
|
formatVerifyBlock,
|
|
8
21
|
normalizeVerifySource,
|
|
9
22
|
type VerifyBlock,
|
|
10
23
|
} from "../domain/verify-commands.ts"
|
|
11
24
|
import { VcsError } from "../errors.ts"
|
|
12
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
GitPendingCommit,
|
|
27
|
+
JjPendingCommit,
|
|
28
|
+
PendingCommit,
|
|
29
|
+
VcsBackend,
|
|
30
|
+
} from "../domain/types.ts"
|
|
13
31
|
import { FileSystem } from "./file-system.ts"
|
|
32
|
+
import {
|
|
33
|
+
Process,
|
|
34
|
+
ProcessExitError,
|
|
35
|
+
ProcessOutputError,
|
|
36
|
+
ProcessTimeoutError,
|
|
37
|
+
type ProcessService,
|
|
38
|
+
} from "./process.ts"
|
|
14
39
|
|
|
15
40
|
export interface VcsService {
|
|
16
41
|
readonly detect: (root: string) => Effect.Effect<VcsBackend | null>
|
|
17
|
-
readonly isDirty: (
|
|
42
|
+
readonly isDirty: (
|
|
43
|
+
root: string,
|
|
44
|
+
vcs: VcsBackend,
|
|
45
|
+
) => Effect.Effect<boolean, VcsError>
|
|
18
46
|
readonly treeFingerprint: (
|
|
19
47
|
root: string,
|
|
20
48
|
vcs: VcsBackend,
|
|
21
|
-
) => Effect.Effect<string>
|
|
49
|
+
) => Effect.Effect<string, VcsError>
|
|
22
50
|
readonly ensureGitBranch: (
|
|
23
51
|
root: string,
|
|
24
52
|
slug: string,
|
|
25
53
|
) => Effect.Effect<string, VcsError>
|
|
26
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Prepare a commit transaction without moving any ref. Git stages the tree
|
|
56
|
+
* in an isolated index; jj describes `@`. The returned anchor is everything
|
|
57
|
+
* the workflow must persist as `pending_commit` before calling
|
|
58
|
+
* `completeCommit`.
|
|
59
|
+
*/
|
|
60
|
+
readonly prepareCommit: (
|
|
27
61
|
root: string,
|
|
28
62
|
vcs: VcsBackend,
|
|
29
63
|
message: string,
|
|
64
|
+
) => Effect.Effect<PreparedCommit, VcsError>
|
|
65
|
+
/**
|
|
66
|
+
* Complete (or recognize an already-completed) prepared commit exactly
|
|
67
|
+
* once, returning the committed change/commit id. Drift between the
|
|
68
|
+
* persisted anchor and the repository is refused with a typed error.
|
|
69
|
+
*/
|
|
70
|
+
readonly completeCommit: (
|
|
71
|
+
root: string,
|
|
72
|
+
vcs: VcsBackend,
|
|
73
|
+
pending: PendingCommit,
|
|
30
74
|
) => Effect.Effect<string, VcsError>
|
|
31
75
|
readonly setBookmarkAtTerminus: (
|
|
32
76
|
root: string,
|
|
33
77
|
slug: string,
|
|
34
|
-
) => Effect.Effect<void>
|
|
78
|
+
) => Effect.Effect<void, VcsError>
|
|
35
79
|
readonly runVerify: (
|
|
36
80
|
root: string,
|
|
37
81
|
blocks: readonly VerifyBlock[],
|
|
@@ -41,24 +85,461 @@ export interface VcsService {
|
|
|
41
85
|
|
|
42
86
|
export class Vcs extends Context.Service<Vcs, VcsService>()("apnea/Vcs") {}
|
|
43
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Backend-specific result of `prepareCommit`: the common transaction fields
|
|
90
|
+
* plus the anchor fields of the corresponding `PendingCommit` member.
|
|
91
|
+
*/
|
|
92
|
+
export type PreparedCommit =
|
|
93
|
+
| Omit<GitPendingCommit, "phase_index" | "no_remaining_phases" | "verify_log">
|
|
94
|
+
| Omit<JjPendingCommit, "phase_index" | "no_remaining_phases" | "verify_log">
|
|
95
|
+
|
|
96
|
+
/** The trailer line appended to every prepared commit message body. */
|
|
97
|
+
export const TRANSACTION_TRAILER_PREFIX = "Apnea-Transaction:"
|
|
98
|
+
|
|
99
|
+
export function withTransactionTrailer(message: string, id: string): string {
|
|
100
|
+
return `${message}\n\n${TRANSACTION_TRAILER_PREFIX} ${id}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Strip the trailer for comparing a retry's `message` param. */
|
|
104
|
+
export function withoutTransactionTrailer(message: string): string {
|
|
105
|
+
const index = message.lastIndexOf(`\n\n${TRANSACTION_TRAILER_PREFIX} `)
|
|
106
|
+
return index === -1 ? message : message.slice(0, index)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isUuid(value: string): boolean {
|
|
110
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
|
111
|
+
value,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
44
115
|
function run(
|
|
45
116
|
cmd: string,
|
|
46
117
|
args: string[],
|
|
47
118
|
cwd: string,
|
|
119
|
+
env?: NodeJS.ProcessEnv,
|
|
48
120
|
): { ok: boolean; stdout: string; stderr: string; code: number } {
|
|
49
121
|
const r = spawnSync(cmd, args, {
|
|
50
122
|
cwd,
|
|
51
123
|
encoding: "utf8",
|
|
52
124
|
maxBuffer: 10 * 1024 * 1024,
|
|
125
|
+
env: env === undefined ? undefined : { ...process.env, ...env },
|
|
53
126
|
})
|
|
54
127
|
return {
|
|
55
128
|
ok: r.status === 0,
|
|
56
129
|
stdout: (r.stdout ?? "").toString(),
|
|
57
|
-
stderr: (r.stderr ?? "").toString(),
|
|
130
|
+
stderr: (r.stderr ?? r.error?.message ?? "").toString(),
|
|
58
131
|
code: r.status ?? 1,
|
|
59
132
|
}
|
|
60
133
|
}
|
|
61
134
|
|
|
135
|
+
function runRaw(
|
|
136
|
+
cmd: string,
|
|
137
|
+
args: string[],
|
|
138
|
+
cwd: string,
|
|
139
|
+
env?: NodeJS.ProcessEnv,
|
|
140
|
+
): { ok: boolean; stdout: Buffer; stderr: string; code: number } {
|
|
141
|
+
const result = spawnSync(cmd, args, {
|
|
142
|
+
cwd,
|
|
143
|
+
encoding: null,
|
|
144
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
145
|
+
env: env === undefined ? undefined : { ...process.env, ...env },
|
|
146
|
+
})
|
|
147
|
+
return {
|
|
148
|
+
ok: result.status === 0,
|
|
149
|
+
stdout: result.stdout ?? Buffer.alloc(0),
|
|
150
|
+
stderr: (result.stderr ?? result.error?.message ?? "").toString("utf8"),
|
|
151
|
+
code: result.status ?? 1,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type CommandResult = ReturnType<typeof run>
|
|
156
|
+
export type VcsCommandRunner = typeof run
|
|
157
|
+
export type VcsRawCommandRunner = typeof runRaw
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Runner for repository-mutating VCS commands. Unlike the synchronous
|
|
161
|
+
* `VcsCommandRunner` (bounded reads over spawnSync), mutations go through
|
|
162
|
+
* the #107 Process service so they carry a hard timeout, kill their process
|
|
163
|
+
* tree on cancellation, and surface typed failures.
|
|
164
|
+
*/
|
|
165
|
+
export type VcsMutationRunner = (
|
|
166
|
+
command: string,
|
|
167
|
+
args: string[],
|
|
168
|
+
cwd: string,
|
|
169
|
+
env?: NodeJS.ProcessEnv,
|
|
170
|
+
) => Effect.Effect<CommandResult, VcsError>
|
|
171
|
+
|
|
172
|
+
/** Upper bound for a single mutating VCS command (commit-tree, describe, …). */
|
|
173
|
+
export const MUTATING_VCS_TIMEOUT_MS = 120_000
|
|
174
|
+
|
|
175
|
+
export function processMutationRunner(
|
|
176
|
+
processService: ProcessService,
|
|
177
|
+
): VcsMutationRunner {
|
|
178
|
+
return (command, args, cwd, env) =>
|
|
179
|
+
processService
|
|
180
|
+
.run({
|
|
181
|
+
command,
|
|
182
|
+
args,
|
|
183
|
+
cwd,
|
|
184
|
+
env: env === undefined ? undefined : { ...process.env, ...env },
|
|
185
|
+
timeoutMs: MUTATING_VCS_TIMEOUT_MS,
|
|
186
|
+
})
|
|
187
|
+
.pipe(
|
|
188
|
+
Effect.map((result) => ({
|
|
189
|
+
ok: result.exitCode === 0,
|
|
190
|
+
stdout: result.stdout,
|
|
191
|
+
stderr: result.stderr,
|
|
192
|
+
code: result.exitCode,
|
|
193
|
+
})),
|
|
194
|
+
Effect.mapError(
|
|
195
|
+
(error): VcsError =>
|
|
196
|
+
new VcsError({
|
|
197
|
+
message: `${command} failed: ${error.message}`,
|
|
198
|
+
command: `${command} ${args[0] ?? ""}`.trim(),
|
|
199
|
+
}),
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Test seam: lift a synchronous runner into the mutation-runner shape. */
|
|
205
|
+
export function syncMutationRunner(
|
|
206
|
+
runCommand: VcsCommandRunner,
|
|
207
|
+
): VcsMutationRunner {
|
|
208
|
+
return (command, args, cwd, env) =>
|
|
209
|
+
Effect.sync(() => runCommand(command, args, cwd, env))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const APNEA_ICASE_PATHSPEC = ":(icase).apnea"
|
|
213
|
+
const APNEA_ICASE_EXCLUDES = [
|
|
214
|
+
":(exclude,icase).apnea",
|
|
215
|
+
":(exclude,icase).apnea/**",
|
|
216
|
+
]
|
|
217
|
+
const JJ_APNEA_ICASE = "root-prefix-glob-i:.apnea"
|
|
218
|
+
const JJ_NOT_APNEA_ICASE = `~${JJ_APNEA_ICASE}`
|
|
219
|
+
export const UNTRACKED_FINGERPRINT_MAX_BYTES = 256 * 1024 * 1024
|
|
220
|
+
export const UNTRACKED_FINGERPRINT_TIMEOUT_MS = 10_000
|
|
221
|
+
|
|
222
|
+
function requireCommand(
|
|
223
|
+
result: CommandResult,
|
|
224
|
+
command: string,
|
|
225
|
+
): Effect.Effect<CommandResult, VcsError> {
|
|
226
|
+
return result.ok
|
|
227
|
+
? Effect.succeed(result)
|
|
228
|
+
: Effect.fail(
|
|
229
|
+
new VcsError({
|
|
230
|
+
message: `${command} failed: ${result.stderr || result.stdout}`,
|
|
231
|
+
command,
|
|
232
|
+
}),
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function requireRawCommand(
|
|
237
|
+
result: ReturnType<VcsRawCommandRunner>,
|
|
238
|
+
command: string,
|
|
239
|
+
): Effect.Effect<ReturnType<VcsRawCommandRunner>, VcsError> {
|
|
240
|
+
return result.ok
|
|
241
|
+
? Effect.succeed(result)
|
|
242
|
+
: Effect.fail(
|
|
243
|
+
new VcsError({
|
|
244
|
+
message: `${command} failed: ${result.stderr}`,
|
|
245
|
+
command,
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function splitNullBuffers(value: Buffer): Buffer[] {
|
|
251
|
+
const parts: Buffer[] = []
|
|
252
|
+
let start = 0
|
|
253
|
+
for (let index = 0; index < value.length; index++) {
|
|
254
|
+
if (value[index] !== 0) continue
|
|
255
|
+
if (index > start) parts.push(value.subarray(start, index))
|
|
256
|
+
start = index + 1
|
|
257
|
+
}
|
|
258
|
+
if (start < value.length) parts.push(value.subarray(start))
|
|
259
|
+
return parts
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function digest(parts: readonly (string | Buffer)[]): string {
|
|
263
|
+
if (parts.every((part) => part.length === 0)) return ""
|
|
264
|
+
const hash = createHash("sha256")
|
|
265
|
+
for (const part of parts) hash.update(part)
|
|
266
|
+
return hash.digest("hex")
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function rejectCaseFoldedApneaAlias(
|
|
270
|
+
root: string,
|
|
271
|
+
): Effect.Effect<void, VcsError> {
|
|
272
|
+
return Effect.try({
|
|
273
|
+
try: () => {
|
|
274
|
+
const alias = readdirSync(root).find(
|
|
275
|
+
(name) => name.toLowerCase() === ".apnea" && name !== ".apnea",
|
|
276
|
+
)
|
|
277
|
+
if (alias !== undefined) {
|
|
278
|
+
throw new VcsError({
|
|
279
|
+
message: `refusing case-insensitive .apnea alias at repository root: ${alias}`,
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
catch: (error) =>
|
|
284
|
+
error instanceof VcsError
|
|
285
|
+
? error
|
|
286
|
+
: new VcsError({
|
|
287
|
+
message: `could not inspect repository root for .apnea aliases: ${error instanceof Error ? error.message : String(error)}`,
|
|
288
|
+
}),
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
type FingerprintLimits = {
|
|
293
|
+
readonly maxBytes: number
|
|
294
|
+
readonly timeoutMs: number
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function fingerprintUntrackedFiles(
|
|
298
|
+
root: string,
|
|
299
|
+
files: readonly (string | Buffer)[],
|
|
300
|
+
limits: FingerprintLimits = {
|
|
301
|
+
maxBytes: UNTRACKED_FINGERPRINT_MAX_BYTES,
|
|
302
|
+
timeoutMs: UNTRACKED_FINGERPRINT_TIMEOUT_MS,
|
|
303
|
+
},
|
|
304
|
+
): Effect.Effect<string, VcsError> {
|
|
305
|
+
return Effect.try({
|
|
306
|
+
try: () => {
|
|
307
|
+
if (files.length === 0) return ""
|
|
308
|
+
if (
|
|
309
|
+
!Number.isFinite(limits.maxBytes) ||
|
|
310
|
+
limits.maxBytes < 0 ||
|
|
311
|
+
!Number.isFinite(limits.timeoutMs) ||
|
|
312
|
+
limits.timeoutMs < 0
|
|
313
|
+
) {
|
|
314
|
+
throw new VcsError({ message: "invalid untracked fingerprint limits" })
|
|
315
|
+
}
|
|
316
|
+
const startedAt = Date.now()
|
|
317
|
+
const hash = createHash("sha256")
|
|
318
|
+
const buffer = Buffer.allocUnsafe(64 * 1024)
|
|
319
|
+
let totalBytes = 0
|
|
320
|
+
|
|
321
|
+
const account = (bytes: number) => {
|
|
322
|
+
totalBytes += bytes
|
|
323
|
+
if (totalBytes > limits.maxBytes) {
|
|
324
|
+
throw new VcsError({
|
|
325
|
+
message: `untracked fingerprint byte limit exceeded (${limits.maxBytes} bytes)`,
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
if (Date.now() - startedAt > limits.timeoutMs) {
|
|
329
|
+
throw new VcsError({
|
|
330
|
+
message: `untracked fingerprint timed out after ${limits.timeoutMs}ms`,
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const file of files) {
|
|
336
|
+
const rawFile = Buffer.isBuffer(file) ? file : Buffer.from(file)
|
|
337
|
+
const components = splitNullBuffers(
|
|
338
|
+
Buffer.from(rawFile.map((byte) => (byte === 0x2f ? 0 : byte))),
|
|
339
|
+
)
|
|
340
|
+
if (
|
|
341
|
+
rawFile.length === 0 ||
|
|
342
|
+
rawFile[0] === 0x2f ||
|
|
343
|
+
components.some(
|
|
344
|
+
(component) =>
|
|
345
|
+
component.length === 2 &&
|
|
346
|
+
component[0] === 0x2e &&
|
|
347
|
+
component[1] === 0x2e,
|
|
348
|
+
)
|
|
349
|
+
) {
|
|
350
|
+
throw new VcsError({
|
|
351
|
+
message: `invalid untracked path from VCS: ${rawFile.toString("hex")}`,
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
const absolute = Buffer.concat([
|
|
355
|
+
Buffer.from(`${path.resolve(root)}${path.sep}`),
|
|
356
|
+
rawFile,
|
|
357
|
+
])
|
|
358
|
+
const display = rawFile.toString("hex")
|
|
359
|
+
const before = lstatSync(absolute)
|
|
360
|
+
hash.update(rawFile)
|
|
361
|
+
hash.update("\0")
|
|
362
|
+
if (before.isSymbolicLink()) {
|
|
363
|
+
const target = readlinkSync(absolute, { encoding: "buffer" })
|
|
364
|
+
const after = lstatSync(absolute)
|
|
365
|
+
if (
|
|
366
|
+
!after.isSymbolicLink() ||
|
|
367
|
+
after.dev !== before.dev ||
|
|
368
|
+
after.ino !== before.ino ||
|
|
369
|
+
after.mtimeMs !== before.mtimeMs ||
|
|
370
|
+
after.ctimeMs !== before.ctimeMs
|
|
371
|
+
) {
|
|
372
|
+
throw new VcsError({
|
|
373
|
+
message: `untracked symlink changed while fingerprinting (hex path): ${display}`,
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
account(target.length)
|
|
377
|
+
hash.update("symlink\0")
|
|
378
|
+
hash.update(target)
|
|
379
|
+
hash.update("\0")
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
if (!before.isFile()) {
|
|
383
|
+
throw new VcsError({
|
|
384
|
+
message: `untracked fingerprints accept only regular files or symlinks (hex path): ${display}`,
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let descriptor: number | undefined
|
|
389
|
+
try {
|
|
390
|
+
descriptor = openSync(
|
|
391
|
+
absolute,
|
|
392
|
+
process.platform === "win32"
|
|
393
|
+
? "r"
|
|
394
|
+
: fsConstants.O_RDONLY |
|
|
395
|
+
fsConstants.O_NOFOLLOW |
|
|
396
|
+
fsConstants.O_NONBLOCK,
|
|
397
|
+
)
|
|
398
|
+
const opened = fstatSync(descriptor)
|
|
399
|
+
if (
|
|
400
|
+
!opened.isFile() ||
|
|
401
|
+
opened.dev !== before.dev ||
|
|
402
|
+
opened.ino !== before.ino
|
|
403
|
+
) {
|
|
404
|
+
throw new VcsError({
|
|
405
|
+
message: `untracked file changed while fingerprinting (hex path): ${display}`,
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
if (opened.size > limits.maxBytes - totalBytes) {
|
|
409
|
+
throw new VcsError({
|
|
410
|
+
message: `untracked fingerprint byte limit exceeded (${limits.maxBytes} bytes)`,
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
hash.update("file\0")
|
|
414
|
+
for (;;) {
|
|
415
|
+
const bytes = readSync(descriptor, buffer, 0, buffer.length, null)
|
|
416
|
+
if (bytes === 0) break
|
|
417
|
+
account(bytes)
|
|
418
|
+
hash.update(buffer.subarray(0, bytes))
|
|
419
|
+
}
|
|
420
|
+
const after = fstatSync(descriptor)
|
|
421
|
+
if (
|
|
422
|
+
after.size !== opened.size ||
|
|
423
|
+
after.mtimeMs !== opened.mtimeMs ||
|
|
424
|
+
after.ctimeMs !== opened.ctimeMs
|
|
425
|
+
) {
|
|
426
|
+
throw new VcsError({
|
|
427
|
+
message: `untracked file changed while fingerprinting (hex path): ${display}`,
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
hash.update("\0")
|
|
431
|
+
} finally {
|
|
432
|
+
if (descriptor !== undefined) closeSync(descriptor)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return hash.digest("hex")
|
|
436
|
+
},
|
|
437
|
+
catch: (error) =>
|
|
438
|
+
error instanceof VcsError
|
|
439
|
+
? error
|
|
440
|
+
: new VcsError({
|
|
441
|
+
message: `could not fingerprint untracked files: ${error instanceof Error ? error.message : String(error)}`,
|
|
442
|
+
}),
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function treeFingerprintWithCommand(
|
|
447
|
+
root: string,
|
|
448
|
+
vcs: VcsBackend,
|
|
449
|
+
runCommand: VcsCommandRunner,
|
|
450
|
+
runRawCommand: VcsRawCommandRunner = runRaw,
|
|
451
|
+
): Effect.Effect<string, VcsError> {
|
|
452
|
+
return Effect.gen(function* () {
|
|
453
|
+
if (vcs === "jj") {
|
|
454
|
+
const command = `jj diff --git --color=never -- ${JJ_NOT_APNEA_ICASE}`
|
|
455
|
+
const result = yield* requireCommand(
|
|
456
|
+
runCommand(
|
|
457
|
+
"jj",
|
|
458
|
+
["diff", "--git", "--color=never", "--", JJ_NOT_APNEA_ICASE],
|
|
459
|
+
root,
|
|
460
|
+
),
|
|
461
|
+
command,
|
|
462
|
+
)
|
|
463
|
+
return digest([result.stdout])
|
|
464
|
+
}
|
|
465
|
+
const pathspec = ["--", ".", ...APNEA_ICASE_EXCLUDES]
|
|
466
|
+
const staged = yield* requireCommand(
|
|
467
|
+
runCommand(
|
|
468
|
+
"git",
|
|
469
|
+
["diff", "--binary", "--no-ext-diff", "--cached", ...pathspec],
|
|
470
|
+
root,
|
|
471
|
+
),
|
|
472
|
+
"git diff --cached",
|
|
473
|
+
)
|
|
474
|
+
const unstaged = yield* requireCommand(
|
|
475
|
+
runCommand(
|
|
476
|
+
"git",
|
|
477
|
+
["diff", "--binary", "--no-ext-diff", ...pathspec],
|
|
478
|
+
root,
|
|
479
|
+
),
|
|
480
|
+
"git diff",
|
|
481
|
+
)
|
|
482
|
+
const untracked = yield* requireRawCommand(
|
|
483
|
+
runRawCommand(
|
|
484
|
+
"git",
|
|
485
|
+
["ls-files", "--others", "--exclude-standard", "-z", ...pathspec],
|
|
486
|
+
root,
|
|
487
|
+
),
|
|
488
|
+
"git ls-files --others",
|
|
489
|
+
)
|
|
490
|
+
const untrackedFingerprint = yield* fingerprintUntrackedFiles(
|
|
491
|
+
root,
|
|
492
|
+
splitNullBuffers(untracked.stdout),
|
|
493
|
+
)
|
|
494
|
+
if (
|
|
495
|
+
staged.stdout.length === 0 &&
|
|
496
|
+
unstaged.stdout.length === 0 &&
|
|
497
|
+
untrackedFingerprint.length === 0
|
|
498
|
+
) {
|
|
499
|
+
return ""
|
|
500
|
+
}
|
|
501
|
+
return digest([
|
|
502
|
+
"staged\0",
|
|
503
|
+
staged.stdout,
|
|
504
|
+
"\0unstaged\0",
|
|
505
|
+
unstaged.stdout,
|
|
506
|
+
"\0untracked\0",
|
|
507
|
+
untrackedFingerprint,
|
|
508
|
+
])
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Fingerprint the non-`.apnea` diff of a single jj revision. Used for the
|
|
514
|
+
* pending-commit content anchor: computed over `@` at preparation and
|
|
515
|
+
* recomputed over the same change at completion to detect drift.
|
|
516
|
+
*/
|
|
517
|
+
export function jjRevisionFingerprintWithCommand(
|
|
518
|
+
root: string,
|
|
519
|
+
revision: string,
|
|
520
|
+
runCommand: VcsCommandRunner = run,
|
|
521
|
+
): Effect.Effect<string, VcsError> {
|
|
522
|
+
return Effect.gen(function* () {
|
|
523
|
+
const result = yield* requireCommand(
|
|
524
|
+
runCommand(
|
|
525
|
+
"jj",
|
|
526
|
+
[
|
|
527
|
+
"diff",
|
|
528
|
+
"--git",
|
|
529
|
+
"--color=never",
|
|
530
|
+
"-r",
|
|
531
|
+
revision,
|
|
532
|
+
"--",
|
|
533
|
+
JJ_NOT_APNEA_ICASE,
|
|
534
|
+
],
|
|
535
|
+
root,
|
|
536
|
+
),
|
|
537
|
+
`jj diff --git -r ${revision}`,
|
|
538
|
+
)
|
|
539
|
+
return digest([result.stdout])
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
|
|
62
543
|
function verificationError(
|
|
63
544
|
error: unknown,
|
|
64
545
|
temporaryDirectory?: string,
|
|
@@ -74,7 +555,6 @@ function verificationError(
|
|
|
74
555
|
}
|
|
75
556
|
|
|
76
557
|
const VERIFY_LOG_LIMIT = 10 * 1024 * 1024
|
|
77
|
-
const VERIFY_KILL_CLOSE_GRACE_MS = 2_500
|
|
78
558
|
const VERIFY_RESULT_RESERVE = 2_048
|
|
79
559
|
const VERIFY_WRAPPER_SOURCE = `exec 2>&1
|
|
80
560
|
exec "$1" -e "$2"
|
|
@@ -160,96 +640,6 @@ export function verifyBlockDisplayByteLength(block: VerifyBlock): number {
|
|
|
160
640
|
)
|
|
161
641
|
}
|
|
162
642
|
|
|
163
|
-
async function taskkillTree(pid: number): Promise<boolean> {
|
|
164
|
-
return new Promise((resolve) => {
|
|
165
|
-
let settled = false
|
|
166
|
-
let killer: ChildProcess
|
|
167
|
-
const finish = (ok: boolean) => {
|
|
168
|
-
if (settled) return
|
|
169
|
-
settled = true
|
|
170
|
-
clearTimeout(timer)
|
|
171
|
-
resolve(ok)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
try {
|
|
175
|
-
killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
|
176
|
-
stdio: "ignore",
|
|
177
|
-
windowsHide: true,
|
|
178
|
-
})
|
|
179
|
-
} catch {
|
|
180
|
-
resolve(false)
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const timer = setTimeout(() => {
|
|
185
|
-
try {
|
|
186
|
-
killer.kill("SIGKILL")
|
|
187
|
-
} catch {
|
|
188
|
-
// The fallback below still targets the verification child.
|
|
189
|
-
}
|
|
190
|
-
finish(false)
|
|
191
|
-
}, 2_000)
|
|
192
|
-
killer.once("error", () => finish(false))
|
|
193
|
-
killer.once("close", (code) => finish(code === 0))
|
|
194
|
-
})
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function snapshotProcessDescendants(rootPid: number): number[] {
|
|
198
|
-
const snapshot = spawnSync("ps", ["-axo", "pid=,ppid="], {
|
|
199
|
-
encoding: "utf8",
|
|
200
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
201
|
-
})
|
|
202
|
-
if (snapshot.status !== 0 || snapshot.error) return []
|
|
203
|
-
|
|
204
|
-
const children = new Map<number, number[]>()
|
|
205
|
-
for (const line of (snapshot.stdout ?? "").split("\n")) {
|
|
206
|
-
const match = /^\s*(\d+)\s+(\d+)\s*$/.exec(line)
|
|
207
|
-
if (!match) continue
|
|
208
|
-
const pid = Number(match[1])
|
|
209
|
-
const parentPid = Number(match[2])
|
|
210
|
-
const siblings = children.get(parentPid)
|
|
211
|
-
if (siblings) siblings.push(pid)
|
|
212
|
-
else children.set(parentPid, [pid])
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const descendants: number[] = []
|
|
216
|
-
const pending = [...(children.get(rootPid) ?? [])]
|
|
217
|
-
for (let index = 0; index < pending.length; index++) {
|
|
218
|
-
const pid = pending[index]!
|
|
219
|
-
descendants.push(pid)
|
|
220
|
-
pending.push(...(children.get(pid) ?? []))
|
|
221
|
-
}
|
|
222
|
-
return descendants
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async function killProcessTree(child: ChildProcess): Promise<void> {
|
|
226
|
-
const pid = child.pid
|
|
227
|
-
if (process.platform === "win32" && pid !== undefined) {
|
|
228
|
-
if (await taskkillTree(pid)) return
|
|
229
|
-
} else if (pid !== undefined) {
|
|
230
|
-
const descendants = snapshotProcessDescendants(pid)
|
|
231
|
-
try {
|
|
232
|
-
process.kill(-pid, "SIGKILL")
|
|
233
|
-
} catch {
|
|
234
|
-
// The process may have exited between timeout and termination.
|
|
235
|
-
}
|
|
236
|
-
for (const descendantPid of descendants) {
|
|
237
|
-
try {
|
|
238
|
-
process.kill(descendantPid, "SIGKILL")
|
|
239
|
-
} catch {
|
|
240
|
-
// Process-group termination may already have killed this descendant.
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// This is also the fallback when Windows taskkill cannot kill the tree.
|
|
246
|
-
try {
|
|
247
|
-
child.kill("SIGKILL")
|
|
248
|
-
} catch {
|
|
249
|
-
// A concurrently exited child needs no further termination.
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
643
|
type VerificationProcessResult = {
|
|
254
644
|
code: number
|
|
255
645
|
output: string
|
|
@@ -257,99 +647,54 @@ type VerificationProcessResult = {
|
|
|
257
647
|
}
|
|
258
648
|
|
|
259
649
|
function runVerificationProcess(
|
|
650
|
+
processService: ProcessService,
|
|
260
651
|
wrapper: string,
|
|
261
652
|
interpreter: VerifyBlock["interpreter"],
|
|
262
653
|
script: string,
|
|
263
654
|
cwd: string,
|
|
264
655
|
timeoutMs: number,
|
|
265
656
|
outputLimit: number,
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
657
|
+
reportedTimeoutMs = timeoutMs,
|
|
658
|
+
): Effect.Effect<VerificationProcessResult> {
|
|
659
|
+
return Effect.gen(function* () {
|
|
660
|
+
const result = yield* Effect.result(
|
|
661
|
+
processService.run({
|
|
662
|
+
command: "sh",
|
|
663
|
+
args: [wrapper, interpreter, script],
|
|
271
664
|
cwd,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
code: 1,
|
|
279
|
-
output: "",
|
|
280
|
-
error: verificationError(error),
|
|
281
|
-
})
|
|
282
|
-
return
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const output: Buffer[] = []
|
|
286
|
-
let outputSize = 0
|
|
287
|
-
let outputExceeded = false
|
|
288
|
-
let processError: string | undefined
|
|
289
|
-
let termination: Promise<void> | undefined
|
|
290
|
-
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
291
|
-
let postKillCompletion: ReturnType<typeof setTimeout> | undefined
|
|
292
|
-
let settled = false
|
|
293
|
-
|
|
294
|
-
const terminate = (message: string) => {
|
|
295
|
-
if (settled) return
|
|
296
|
-
processError ??= message
|
|
297
|
-
if (termination) return
|
|
298
|
-
termination = killProcessTree(child)
|
|
299
|
-
postKillCompletion = setTimeout(() => {
|
|
300
|
-
child.stdout?.destroy()
|
|
301
|
-
finish(1)
|
|
302
|
-
}, VERIFY_KILL_CLOSE_GRACE_MS)
|
|
665
|
+
timeoutMs,
|
|
666
|
+
outputLimitBytes: Math.max(1, outputLimit),
|
|
667
|
+
}),
|
|
668
|
+
)
|
|
669
|
+
if (Result.isSuccess(result)) {
|
|
670
|
+
return { code: result.success.exitCode, output: result.success.stdout }
|
|
303
671
|
}
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
outputSize = outputLimit
|
|
310
|
-
outputExceeded = true
|
|
311
|
-
terminate(`verification output exceeded ${outputLimit} bytes`)
|
|
312
|
-
return
|
|
672
|
+
const error = result.failure
|
|
673
|
+
if (error instanceof ProcessExitError) {
|
|
674
|
+
return {
|
|
675
|
+
code: error.exitCode,
|
|
676
|
+
output: `${error.stdout}${error.stderr}`,
|
|
313
677
|
}
|
|
314
|
-
output.push(chunk)
|
|
315
|
-
outputSize += chunk.length
|
|
316
|
-
}
|
|
317
|
-
const onStdout = (chunk: Buffer) => capture(chunk)
|
|
318
|
-
const onError = (error: Error) => {
|
|
319
|
-
terminate(`verification process error: ${verificationError(error)}`)
|
|
320
678
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
child.off("error", onError)
|
|
328
|
-
child.off("close", onClose)
|
|
329
|
-
resolve({
|
|
330
|
-
code,
|
|
331
|
-
output: Buffer.concat(output).toString("utf8"),
|
|
332
|
-
...(processError === undefined ? {} : { error: processError }),
|
|
333
|
-
})
|
|
679
|
+
if (error instanceof ProcessTimeoutError) {
|
|
680
|
+
return {
|
|
681
|
+
code: 1,
|
|
682
|
+
output: `${error.stdout}${error.stderr}`,
|
|
683
|
+
error: `verification timed out after ${reportedTimeoutMs}ms`,
|
|
684
|
+
}
|
|
334
685
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
)
|
|
341
|
-
} else {
|
|
342
|
-
finish(code ?? 1)
|
|
686
|
+
if (error instanceof ProcessOutputError) {
|
|
687
|
+
return {
|
|
688
|
+
code: 1,
|
|
689
|
+
output: `${error.stdout}${error.stderr}`,
|
|
690
|
+
error: `verification output exceeded ${outputLimit} bytes`,
|
|
343
691
|
}
|
|
344
692
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
timeout = setTimeout(() => {
|
|
351
|
-
terminate(`verification timed out after ${timeoutMs}ms`)
|
|
352
|
-
}, timeoutMs)
|
|
693
|
+
return {
|
|
694
|
+
code: 1,
|
|
695
|
+
output: "stdout" in error ? `${error.stdout}${error.stderr}` : "",
|
|
696
|
+
error: `verification process error: ${verificationError(error)}`,
|
|
697
|
+
}
|
|
353
698
|
})
|
|
354
699
|
}
|
|
355
700
|
|
|
@@ -362,24 +707,699 @@ export function filterAppPaths(summary: string): string {
|
|
|
362
707
|
if (!t) return false
|
|
363
708
|
// git porcelain: XY path
|
|
364
709
|
if (/^.. /.test(line)) {
|
|
365
|
-
const p = line.slice(3).replace(/^"|"$/g, "")
|
|
710
|
+
const p = line.slice(3).replace(/^"|"$/g, "").toLowerCase()
|
|
366
711
|
return !p.startsWith(".apnea/") && !p.includes("/.apnea/")
|
|
367
712
|
}
|
|
368
713
|
// jj summary often: M path / A path
|
|
369
714
|
const m = t.match(/^[A-Z]+\s+(.+)$/)
|
|
370
715
|
if (m) {
|
|
371
|
-
const p = m[1]
|
|
716
|
+
const p = m[1]!.toLowerCase()
|
|
372
717
|
return !p.startsWith(".apnea/") && !p.includes("/.apnea/")
|
|
373
718
|
}
|
|
374
|
-
return !t.includes(".apnea/")
|
|
719
|
+
return !t.toLowerCase().includes(".apnea/")
|
|
375
720
|
})
|
|
376
721
|
.join("\n")
|
|
377
722
|
}
|
|
378
723
|
|
|
724
|
+
/**
|
|
725
|
+
* Current branch via `symbolic-ref -q`. Returns null for detached HEAD —
|
|
726
|
+
* with `-q`, Git signals that case as exit 1 with empty stdout, which
|
|
727
|
+
* `requireCommand` would otherwise swallow into a generic failure.
|
|
728
|
+
*/
|
|
729
|
+
function gitCurrentBranchWithCommand(
|
|
730
|
+
root: string,
|
|
731
|
+
runCommand: VcsCommandRunner,
|
|
732
|
+
): Effect.Effect<string | null, VcsError> {
|
|
733
|
+
return Effect.sync(() =>
|
|
734
|
+
runCommand("git", ["symbolic-ref", "-q", "HEAD"], root),
|
|
735
|
+
).pipe(
|
|
736
|
+
Effect.flatMap((result) => {
|
|
737
|
+
if (result.ok) return Effect.succeed(result.stdout.trim())
|
|
738
|
+
if (result.code === 1 && result.stdout.trim() === "") {
|
|
739
|
+
return Effect.succeed(null)
|
|
740
|
+
}
|
|
741
|
+
return Effect.fail(
|
|
742
|
+
new VcsError({
|
|
743
|
+
message:
|
|
744
|
+
result.stderr || result.stdout || "git symbolic-ref -q HEAD failed",
|
|
745
|
+
command: "git symbolic-ref -q HEAD",
|
|
746
|
+
}),
|
|
747
|
+
)
|
|
748
|
+
}),
|
|
749
|
+
)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Prepare a Git commit transaction: stage everything except case-folded
|
|
754
|
+
* `.apnea` aliases in an isolated index, persist the resulting tree id, and
|
|
755
|
+
* append the `Apnea-Transaction:` trailer to the message body. No ref moves
|
|
756
|
+
* and no real-index mutation — safe to retry after a crash before completion.
|
|
757
|
+
*/
|
|
758
|
+
export function gitPrepareWithCommand(
|
|
759
|
+
root: string,
|
|
760
|
+
message: string,
|
|
761
|
+
runCommand: VcsCommandRunner = run,
|
|
762
|
+
): Effect.Effect<PreparedCommit, VcsError> {
|
|
763
|
+
return Effect.gen(function* () {
|
|
764
|
+
yield* rejectCaseFoldedApneaAlias(root)
|
|
765
|
+
const trackedRuntime = yield* requireCommand(
|
|
766
|
+
runCommand("git", ["ls-files", "-z", "--", APNEA_ICASE_PATHSPEC], root),
|
|
767
|
+
`git ls-files -- ${APNEA_ICASE_PATHSPEC}`,
|
|
768
|
+
)
|
|
769
|
+
const stagedRuntime = yield* requireCommand(
|
|
770
|
+
runCommand(
|
|
771
|
+
"git",
|
|
772
|
+
["diff", "--cached", "--name-only", "-z", "--", APNEA_ICASE_PATHSPEC],
|
|
773
|
+
root,
|
|
774
|
+
),
|
|
775
|
+
`git diff --cached --name-only -- ${APNEA_ICASE_PATHSPEC}`,
|
|
776
|
+
)
|
|
777
|
+
if (trackedRuntime.stdout.length > 0 || stagedRuntime.stdout.length > 0) {
|
|
778
|
+
return yield* new VcsError({
|
|
779
|
+
message: "refusing commit: .apnea is already tracked or staged",
|
|
780
|
+
command: "git ls-files/diff --cached with :(icase).apnea pathspec",
|
|
781
|
+
})
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const head = yield* requireCommand(
|
|
785
|
+
runCommand("git", ["rev-parse", "--verify", "HEAD"], root),
|
|
786
|
+
"git rev-parse --verify HEAD",
|
|
787
|
+
)
|
|
788
|
+
const branch = yield* gitCurrentBranchWithCommand(root, runCommand)
|
|
789
|
+
if (branch === null) {
|
|
790
|
+
return yield* new VcsError({
|
|
791
|
+
message: "refusing commit: detached HEAD has no branch to update",
|
|
792
|
+
command: "git symbolic-ref -q HEAD",
|
|
793
|
+
})
|
|
794
|
+
}
|
|
795
|
+
const temporary = yield* Effect.try({
|
|
796
|
+
try: () => mkdtempSync(path.join(tmpdir(), "apnea-index-")),
|
|
797
|
+
catch: (error) =>
|
|
798
|
+
new VcsError({
|
|
799
|
+
message: `could not create isolated Git index: ${error instanceof Error ? error.message : String(error)}`,
|
|
800
|
+
}),
|
|
801
|
+
})
|
|
802
|
+
const index = path.join(temporary, "index")
|
|
803
|
+
const indexEnv = { GIT_INDEX_FILE: index }
|
|
804
|
+
try {
|
|
805
|
+
yield* requireCommand(
|
|
806
|
+
runCommand("git", ["read-tree", head.stdout.trim()], root, indexEnv),
|
|
807
|
+
"git read-tree HEAD",
|
|
808
|
+
)
|
|
809
|
+
yield* requireCommand(
|
|
810
|
+
runCommand(
|
|
811
|
+
"git",
|
|
812
|
+
["add", "-A", "--", ".", ...APNEA_ICASE_EXCLUDES],
|
|
813
|
+
root,
|
|
814
|
+
indexEnv,
|
|
815
|
+
),
|
|
816
|
+
"git add with isolated index",
|
|
817
|
+
)
|
|
818
|
+
yield* rejectCaseFoldedApneaAlias(root)
|
|
819
|
+
const isolatedRuntime = yield* requireCommand(
|
|
820
|
+
runCommand(
|
|
821
|
+
"git",
|
|
822
|
+
["ls-files", "-z", "--", APNEA_ICASE_PATHSPEC],
|
|
823
|
+
root,
|
|
824
|
+
indexEnv,
|
|
825
|
+
),
|
|
826
|
+
"git ls-files isolated index",
|
|
827
|
+
)
|
|
828
|
+
if (isolatedRuntime.stdout.length > 0) {
|
|
829
|
+
return yield* new VcsError({
|
|
830
|
+
message: "refusing commit: isolated tree contains .apnea",
|
|
831
|
+
})
|
|
832
|
+
}
|
|
833
|
+
const tree = yield* requireCommand(
|
|
834
|
+
runCommand("git", ["write-tree"], root, indexEnv),
|
|
835
|
+
"git write-tree",
|
|
836
|
+
)
|
|
837
|
+
const treeRuntime = yield* requireCommand(
|
|
838
|
+
runCommand(
|
|
839
|
+
"git",
|
|
840
|
+
["ls-tree", "-r", "--name-only", "-z", tree.stdout.trim()],
|
|
841
|
+
root,
|
|
842
|
+
),
|
|
843
|
+
"git ls-tree isolated tree",
|
|
844
|
+
)
|
|
845
|
+
if (
|
|
846
|
+
treeRuntime.stdout
|
|
847
|
+
.split("\0")
|
|
848
|
+
.filter(Boolean)
|
|
849
|
+
.some((file) => file.split("/", 1)[0]!.toLowerCase() === ".apnea")
|
|
850
|
+
) {
|
|
851
|
+
return yield* new VcsError({
|
|
852
|
+
message: "refusing commit: written tree contains .apnea",
|
|
853
|
+
})
|
|
854
|
+
}
|
|
855
|
+
const id = randomUUID()
|
|
856
|
+
return {
|
|
857
|
+
backend: "git" as const,
|
|
858
|
+
id,
|
|
859
|
+
message: withTransactionTrailer(message, id),
|
|
860
|
+
branch,
|
|
861
|
+
parent_commit: head.stdout.trim(),
|
|
862
|
+
tree_id: tree.stdout.trim(),
|
|
863
|
+
}
|
|
864
|
+
} finally {
|
|
865
|
+
yield* Effect.try({
|
|
866
|
+
try: () => rmSync(temporary, { recursive: true, force: true }),
|
|
867
|
+
catch: (error) =>
|
|
868
|
+
new VcsError({
|
|
869
|
+
message: `could not remove isolated Git index: ${error instanceof Error ? error.message : String(error)}`,
|
|
870
|
+
}),
|
|
871
|
+
})
|
|
872
|
+
}
|
|
873
|
+
})
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
type GitHeadInfo = {
|
|
877
|
+
hash: string
|
|
878
|
+
tree: string
|
|
879
|
+
firstParent: string | null
|
|
880
|
+
body: string
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function gitHeadInfoWithCommand(
|
|
884
|
+
root: string,
|
|
885
|
+
runCommand: VcsCommandRunner,
|
|
886
|
+
): Effect.Effect<GitHeadInfo, VcsError> {
|
|
887
|
+
return Effect.gen(function* () {
|
|
888
|
+
const shown = yield* requireCommand(
|
|
889
|
+
runCommand(
|
|
890
|
+
"git",
|
|
891
|
+
["show", "-s", "--format=%H%n%T%n%P%n%B", "HEAD"],
|
|
892
|
+
root,
|
|
893
|
+
),
|
|
894
|
+
"git show -s HEAD",
|
|
895
|
+
)
|
|
896
|
+
const lines = shown.stdout.split("\n")
|
|
897
|
+
const parents = (lines[2] ?? "").trim()
|
|
898
|
+
return {
|
|
899
|
+
hash: (lines[0] ?? "").trim(),
|
|
900
|
+
tree: (lines[1] ?? "").trim(),
|
|
901
|
+
firstParent: parents === "" ? null : parents.split(" ")[0]!,
|
|
902
|
+
body: lines.slice(3).join("\n"),
|
|
903
|
+
}
|
|
904
|
+
})
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Complete (or recognize) a prepared Git transaction exactly once:
|
|
909
|
+
*
|
|
910
|
+
* - HEAD still at the recorded parent → validate branch and tree, then create
|
|
911
|
+
* the commit (`commit-tree` + CAS `update-ref`, signing preserved).
|
|
912
|
+
* - HEAD is a commit whose message carries this transaction's
|
|
913
|
+
* `Apnea-Transaction:` marker and whose parent and tree match the anchor →
|
|
914
|
+
* treat as completed and return its id.
|
|
915
|
+
* - Anything else → refuse, naming the drift.
|
|
916
|
+
*/
|
|
917
|
+
export function gitCompleteWithCommand(
|
|
918
|
+
root: string,
|
|
919
|
+
pending: GitPendingCommit,
|
|
920
|
+
runCommand: VcsCommandRunner = run,
|
|
921
|
+
runMutation: VcsMutationRunner = syncMutationRunner(run),
|
|
922
|
+
): Effect.Effect<string, VcsError> {
|
|
923
|
+
return Effect.gen(function* () {
|
|
924
|
+
if (!isUuid(pending.id)) {
|
|
925
|
+
return yield* new VcsError({
|
|
926
|
+
message: `refusing commit: pending_commit.id is not a uuid: ${pending.id}`,
|
|
927
|
+
})
|
|
928
|
+
}
|
|
929
|
+
const currentBranch = yield* gitCurrentBranchWithCommand(root, runCommand)
|
|
930
|
+
if (currentBranch !== pending.branch) {
|
|
931
|
+
return yield* new VcsError({
|
|
932
|
+
message: `refusing commit: branch drifted since preparation (expected ${pending.branch}, found ${currentBranch ?? "(detached HEAD)"})`,
|
|
933
|
+
command: "git symbolic-ref -q HEAD",
|
|
934
|
+
})
|
|
935
|
+
}
|
|
936
|
+
const head = yield* gitHeadInfoWithCommand(root, runCommand)
|
|
937
|
+
|
|
938
|
+
if (head.hash !== pending.parent_commit) {
|
|
939
|
+
// Either the crash hit after the commit landed (recognize it), or the
|
|
940
|
+
// repository drifted for unrelated reasons (refuse). The marker alone
|
|
941
|
+
// is not proof — parent and tree must match the prepared anchor too.
|
|
942
|
+
if (!head.body.includes(`${TRANSACTION_TRAILER_PREFIX} ${pending.id}`)) {
|
|
943
|
+
return yield* new VcsError({
|
|
944
|
+
message: `refusing commit: HEAD moved since preparation without this transaction's marker (expected ${pending.parent_commit}, found ${head.hash})`,
|
|
945
|
+
})
|
|
946
|
+
}
|
|
947
|
+
if (head.firstParent !== pending.parent_commit) {
|
|
948
|
+
return yield* new VcsError({
|
|
949
|
+
message: `refusing commit: marked transaction commit has unexpected parent (expected ${pending.parent_commit}, found ${head.firstParent ?? "(root)"})`,
|
|
950
|
+
})
|
|
951
|
+
}
|
|
952
|
+
if (head.tree !== pending.tree_id) {
|
|
953
|
+
return yield* new VcsError({
|
|
954
|
+
message: `refusing commit: marked transaction commit has unexpected tree (expected ${pending.tree_id}, found ${head.tree})`,
|
|
955
|
+
})
|
|
956
|
+
}
|
|
957
|
+
return head.hash
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Create case: HEAD sits at the recorded parent. The tree was validated
|
|
961
|
+
// at preparation time and tree objects are immutable, so only its
|
|
962
|
+
// continued existence needs checking.
|
|
963
|
+
yield* requireCommand(
|
|
964
|
+
runCommand("git", ["cat-file", "-e", `${pending.tree_id}^{tree}`], root),
|
|
965
|
+
`git cat-file -e ${pending.tree_id}^{tree}`,
|
|
966
|
+
)
|
|
967
|
+
const signing = runCommand(
|
|
968
|
+
"git",
|
|
969
|
+
["config", "--bool", "commit.gpgsign"],
|
|
970
|
+
root,
|
|
971
|
+
)
|
|
972
|
+
if (!signing.ok && signing.code !== 1) {
|
|
973
|
+
return yield* new VcsError({
|
|
974
|
+
message: signing.stderr || signing.stdout,
|
|
975
|
+
command: "git config --bool commit.gpgsign",
|
|
976
|
+
})
|
|
977
|
+
}
|
|
978
|
+
const commitArgs = [
|
|
979
|
+
"commit-tree",
|
|
980
|
+
pending.tree_id,
|
|
981
|
+
"-p",
|
|
982
|
+
pending.parent_commit,
|
|
983
|
+
"-m",
|
|
984
|
+
pending.message,
|
|
985
|
+
...(signing.ok && signing.stdout.trim() === "true" ? ["-S"] : []),
|
|
986
|
+
]
|
|
987
|
+
const committed = yield* requireCommand(
|
|
988
|
+
yield* runMutation("git", commitArgs, root),
|
|
989
|
+
"git commit-tree",
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
// The real index must match the validated tree before the branch can move.
|
|
993
|
+
// Accepted tradeoff: if the CAS update-ref below loses a race, the index
|
|
994
|
+
// briefly describes an unreachable commit until the next Git command
|
|
995
|
+
// re-reads HEAD. Rewinding it here would add another mutating window to
|
|
996
|
+
// recover from; staleness is safe because the index is rebuilt from the
|
|
997
|
+
// branch tip on the next checkout/reset.
|
|
998
|
+
yield* requireCommand(
|
|
999
|
+
yield* runMutation("git", ["read-tree", committed.stdout.trim()], root),
|
|
1000
|
+
"git read-tree committed tree",
|
|
1001
|
+
)
|
|
1002
|
+
yield* requireCommand(
|
|
1003
|
+
yield* runMutation(
|
|
1004
|
+
"git",
|
|
1005
|
+
[
|
|
1006
|
+
"update-ref",
|
|
1007
|
+
pending.branch,
|
|
1008
|
+
committed.stdout.trim(),
|
|
1009
|
+
pending.parent_commit,
|
|
1010
|
+
],
|
|
1011
|
+
root,
|
|
1012
|
+
),
|
|
1013
|
+
"git update-ref (compare-and-swap)",
|
|
1014
|
+
)
|
|
1015
|
+
return committed.stdout.trim()
|
|
1016
|
+
})
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
export function runVerifyWithProcess(
|
|
1020
|
+
root: string,
|
|
1021
|
+
blocks: readonly VerifyBlock[],
|
|
1022
|
+
timeoutMs: number,
|
|
1023
|
+
processService: ProcessService,
|
|
1024
|
+
): Effect.Effect<{ ok: boolean; log: string }> {
|
|
1025
|
+
return Effect.gen(function* () {
|
|
1026
|
+
const log = new VerificationLog(VERIFY_LOG_LIMIT)
|
|
1027
|
+
const startedAt = yield* Clock.currentTimeNanos
|
|
1028
|
+
const deadline =
|
|
1029
|
+
startedAt + BigInt(Math.max(0, Math.floor(timeoutMs))) * 1_000_000n
|
|
1030
|
+
let temporaryDirectory: string | undefined
|
|
1031
|
+
let ok = true
|
|
1032
|
+
let operation = "create temporary verification directory"
|
|
1033
|
+
|
|
1034
|
+
const remainingMs = (): Effect.Effect<number> =>
|
|
1035
|
+
Effect.gen(function* () {
|
|
1036
|
+
const now = yield* Clock.currentTimeNanos
|
|
1037
|
+
return Number((deadline - now) / 1_000_000n)
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
const work = Effect.gen(function* () {
|
|
1041
|
+
temporaryDirectory = yield* Effect.try({
|
|
1042
|
+
try: () => mkdtempSync(path.join(tmpdir(), "apnea-verify-")),
|
|
1043
|
+
catch: (error) => error,
|
|
1044
|
+
})
|
|
1045
|
+
const wrapper = path.join(temporaryDirectory, "run-block.sh")
|
|
1046
|
+
operation = "write verification wrapper"
|
|
1047
|
+
yield* Effect.try({
|
|
1048
|
+
try: () =>
|
|
1049
|
+
writeFileSync(wrapper, VERIFY_WRAPPER_SOURCE, {
|
|
1050
|
+
encoding: "utf8",
|
|
1051
|
+
mode: 0o600,
|
|
1052
|
+
}),
|
|
1053
|
+
catch: (error) => error,
|
|
1054
|
+
})
|
|
1055
|
+
for (const [index, block] of blocks.entries()) {
|
|
1056
|
+
const source = normalizeVerifySource(block.source)
|
|
1057
|
+
const normalizedBlock = { ...block, source }
|
|
1058
|
+
const script = path.join(
|
|
1059
|
+
temporaryDirectory,
|
|
1060
|
+
`block-${index + 1}.${block.interpreter}`,
|
|
1061
|
+
)
|
|
1062
|
+
const displayBytes =
|
|
1063
|
+
2 + verifyBlockDisplayByteLength(normalizedBlock) + 1
|
|
1064
|
+
if (!log.canAppendBytes(displayBytes)) {
|
|
1065
|
+
log.addLimitNotice(VERIFY_DISPLAY_LIMIT_NOTICE)
|
|
1066
|
+
ok = false
|
|
1067
|
+
break
|
|
1068
|
+
}
|
|
1069
|
+
log.append(`$ ${formatVerifyBlock(normalizedBlock)}\n`)
|
|
1070
|
+
operation = `write ${block.interpreter} verification block`
|
|
1071
|
+
yield* Effect.try({
|
|
1072
|
+
try: () =>
|
|
1073
|
+
writeFileSync(script, source, {
|
|
1074
|
+
encoding: "utf8",
|
|
1075
|
+
mode: 0o600,
|
|
1076
|
+
}),
|
|
1077
|
+
catch: (error) => error,
|
|
1078
|
+
})
|
|
1079
|
+
const remaining = yield* remainingMs()
|
|
1080
|
+
if (remaining <= 0) {
|
|
1081
|
+
log.append(`verification timed out after ${timeoutMs}ms\n`)
|
|
1082
|
+
ok = false
|
|
1083
|
+
break
|
|
1084
|
+
}
|
|
1085
|
+
operation = `run ${block.interpreter} verification block`
|
|
1086
|
+
const result = yield* runVerificationProcess(
|
|
1087
|
+
processService,
|
|
1088
|
+
wrapper,
|
|
1089
|
+
block.interpreter,
|
|
1090
|
+
script,
|
|
1091
|
+
root,
|
|
1092
|
+
remaining,
|
|
1093
|
+
Math.max(0, log.remaining - VERIFY_RESULT_RESERVE),
|
|
1094
|
+
timeoutMs,
|
|
1095
|
+
)
|
|
1096
|
+
const output = verificationError(
|
|
1097
|
+
result.output.trimEnd(),
|
|
1098
|
+
temporaryDirectory,
|
|
1099
|
+
)
|
|
1100
|
+
if (output && !log.append(`${output}\n`)) {
|
|
1101
|
+
log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
|
|
1102
|
+
ok = false
|
|
1103
|
+
break
|
|
1104
|
+
}
|
|
1105
|
+
if (!log.append(`exit=${result.code}\n`)) {
|
|
1106
|
+
log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
|
|
1107
|
+
ok = false
|
|
1108
|
+
break
|
|
1109
|
+
}
|
|
1110
|
+
if (result.error) {
|
|
1111
|
+
const error = verificationError(result.error, temporaryDirectory)
|
|
1112
|
+
if (!log.append(`${error}\n`))
|
|
1113
|
+
log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
|
|
1114
|
+
ok = false
|
|
1115
|
+
break
|
|
1116
|
+
}
|
|
1117
|
+
if (result.code !== 0) {
|
|
1118
|
+
ok = false
|
|
1119
|
+
break
|
|
1120
|
+
}
|
|
1121
|
+
if (index < blocks.length - 1 && !log.append("\n")) {
|
|
1122
|
+
log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
|
|
1123
|
+
ok = false
|
|
1124
|
+
break
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return { ok, log: log.toString() }
|
|
1128
|
+
}).pipe(
|
|
1129
|
+
Effect.catch((error) => {
|
|
1130
|
+
const message = `${operation} failed: ${verificationError(error, temporaryDirectory)}\n`
|
|
1131
|
+
if (!log.append(message)) log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
|
|
1132
|
+
ok = false
|
|
1133
|
+
return Effect.succeed({ ok, log: log.toString() })
|
|
1134
|
+
}),
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
return yield* Effect.ensuring(
|
|
1138
|
+
work,
|
|
1139
|
+
Effect.sync(() => {
|
|
1140
|
+
if (temporaryDirectory) {
|
|
1141
|
+
rmSync(temporaryDirectory, { recursive: true, force: true })
|
|
1142
|
+
}
|
|
1143
|
+
}).pipe(Effect.ignore),
|
|
1144
|
+
)
|
|
1145
|
+
})
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** Sentinel fingerprint of an empty (content-free) diff. */
|
|
1149
|
+
export const EMPTY_JJ_DIFF_FINGERPRINT = ""
|
|
1150
|
+
|
|
1151
|
+
const JJ_DRIFT_RECOVERY_GUIDANCE =
|
|
1152
|
+
"Inspect `.apnea/state.json` (pending_commit) and `jj log -r @- --no-graph -T 'description ++ \"\\n\" ++ change_id'`. " +
|
|
1153
|
+
"Recovery requires clearing pending_commit manually; Apnea never clears it automatically because the prepared commit may have already landed, and clearing would let the same phase commit twice."
|
|
1154
|
+
|
|
1155
|
+
/** Change id of a jj revision, empty when the revision is absent. */
|
|
1156
|
+
function jjChangeIdWithCommand(
|
|
1157
|
+
root: string,
|
|
1158
|
+
revision: string,
|
|
1159
|
+
runCommand: VcsCommandRunner,
|
|
1160
|
+
): CommandResult {
|
|
1161
|
+
return runCommand(
|
|
1162
|
+
"jj",
|
|
1163
|
+
["log", "-r", revision, "--no-graph", "-T", "change_id"],
|
|
1164
|
+
root,
|
|
1165
|
+
)
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Prepare a jj commit transaction: describe `@` with the message (trailer
|
|
1170
|
+
* included) and persist its change id plus the non-`.apnea` content
|
|
1171
|
+
* fingerprint. Describing is idempotent and moves no ref: a crash before
|
|
1172
|
+
* `pending_commit` is saved simply describes again on retry.
|
|
1173
|
+
*
|
|
1174
|
+
* A change whose diff is only `.apnea` is refused here, before anything is
|
|
1175
|
+
* persisted or described: completing such a transaction would evict every
|
|
1176
|
+
* diff from the terminus during recovery, leaving an empty change that jj
|
|
1177
|
+
* abandons — wedging the transaction permanently.
|
|
1178
|
+
*/
|
|
1179
|
+
export function jjPrepareWithCommand(
|
|
1180
|
+
root: string,
|
|
1181
|
+
message: string,
|
|
1182
|
+
runCommand: VcsCommandRunner = run,
|
|
1183
|
+
runMutation: VcsMutationRunner = syncMutationRunner(run),
|
|
1184
|
+
): Effect.Effect<PreparedCommit, VcsError> {
|
|
1185
|
+
return Effect.gen(function* () {
|
|
1186
|
+
yield* rejectCaseFoldedApneaAlias(root)
|
|
1187
|
+
const trackedRuntime = yield* requireCommand(
|
|
1188
|
+
runCommand(
|
|
1189
|
+
"jj",
|
|
1190
|
+
["file", "list", "-r", "@-", "--", JJ_APNEA_ICASE],
|
|
1191
|
+
root,
|
|
1192
|
+
),
|
|
1193
|
+
`jj file list -r @- -- ${JJ_APNEA_ICASE}`,
|
|
1194
|
+
)
|
|
1195
|
+
if (trackedRuntime.stdout.trim()) {
|
|
1196
|
+
return yield* new VcsError({
|
|
1197
|
+
message:
|
|
1198
|
+
"refusing commit: .apnea exists in the committed parent snapshot",
|
|
1199
|
+
command: `jj file list -r @- -- ${JJ_APNEA_ICASE}`,
|
|
1200
|
+
})
|
|
1201
|
+
}
|
|
1202
|
+
const at = yield* requireCommand(
|
|
1203
|
+
jjChangeIdWithCommand(root, "@", runCommand),
|
|
1204
|
+
"jj log -r @",
|
|
1205
|
+
)
|
|
1206
|
+
const changeId = at.stdout.trim()
|
|
1207
|
+
if (!changeId) {
|
|
1208
|
+
return yield* new VcsError({
|
|
1209
|
+
message: "refusing commit: could not resolve the @ change id",
|
|
1210
|
+
command: "jj log -r @",
|
|
1211
|
+
})
|
|
1212
|
+
}
|
|
1213
|
+
// Fingerprint the non-.apnea diff of @ before describing; completion
|
|
1214
|
+
// recomputes it over the same revision to detect content drift.
|
|
1215
|
+
const fingerprint = yield* jjRevisionFingerprintWithCommand(
|
|
1216
|
+
root,
|
|
1217
|
+
changeId,
|
|
1218
|
+
runCommand,
|
|
1219
|
+
)
|
|
1220
|
+
if (fingerprint === EMPTY_JJ_DIFF_FINGERPRINT) {
|
|
1221
|
+
return yield* new VcsError({
|
|
1222
|
+
message:
|
|
1223
|
+
`refusing commit: @ (${changeId}) has no non-.apnea changes to commit; ` +
|
|
1224
|
+
"a transaction anchored here would abandon the change during recovery. Commit or stash the working copy first.",
|
|
1225
|
+
command: "jj diff -r @",
|
|
1226
|
+
})
|
|
1227
|
+
}
|
|
1228
|
+
const id = randomUUID()
|
|
1229
|
+
const trailerMessage = withTransactionTrailer(message, id)
|
|
1230
|
+
yield* requireCommand(
|
|
1231
|
+
yield* runMutation("jj", ["describe", "-m", trailerMessage], root),
|
|
1232
|
+
"jj describe",
|
|
1233
|
+
)
|
|
1234
|
+
return {
|
|
1235
|
+
backend: "jj" as const,
|
|
1236
|
+
id,
|
|
1237
|
+
message: trailerMessage,
|
|
1238
|
+
change_id: changeId,
|
|
1239
|
+
content_fingerprint: fingerprint,
|
|
1240
|
+
}
|
|
1241
|
+
})
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Move any `.apnea` diffs the described terminus still carries back into
|
|
1246
|
+
* the working copy. `jj describe` snapshots all of `@`, so untracked
|
|
1247
|
+
* `.apnea` changes ride along; eviction keeps the commit's complement
|
|
1248
|
+
* invariant. Idempotent: a no-op once the diffs are already in `@`.
|
|
1249
|
+
*/
|
|
1250
|
+
function evictApneaFromTerminus(
|
|
1251
|
+
root: string,
|
|
1252
|
+
changeId: string,
|
|
1253
|
+
runCommand: VcsCommandRunner,
|
|
1254
|
+
runMutation: VcsMutationRunner,
|
|
1255
|
+
): Effect.Effect<void, VcsError> {
|
|
1256
|
+
return Effect.gen(function* () {
|
|
1257
|
+
const present = yield* requireCommand(
|
|
1258
|
+
runCommand(
|
|
1259
|
+
"jj",
|
|
1260
|
+
["file", "list", "-r", changeId, "--", JJ_APNEA_ICASE],
|
|
1261
|
+
root,
|
|
1262
|
+
),
|
|
1263
|
+
`jj file list -r ${changeId} -- ${JJ_APNEA_ICASE}`,
|
|
1264
|
+
)
|
|
1265
|
+
if (!present.stdout.trim()) return
|
|
1266
|
+
yield* requireCommand(
|
|
1267
|
+
yield* runMutation(
|
|
1268
|
+
"jj",
|
|
1269
|
+
["squash", "--from", changeId, "--into", "@", "--", JJ_APNEA_ICASE],
|
|
1270
|
+
root,
|
|
1271
|
+
),
|
|
1272
|
+
`jj squash --from ${changeId} --into @ -- ${JJ_APNEA_ICASE}`,
|
|
1273
|
+
)
|
|
1274
|
+
})
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Complete (or recognize) a prepared jj transaction exactly once:
|
|
1279
|
+
*
|
|
1280
|
+
* - Target is still `@` → crash hit between describe and `jj new`; verify
|
|
1281
|
+
* marker and fingerprint, advance with `jj new`, evict `.apnea`.
|
|
1282
|
+
* - Target is `@-` → completion ran before the crash; verify marker and
|
|
1283
|
+
* fingerprint, finish an interrupted `.apnea` eviction.
|
|
1284
|
+
* - Anything else, or drifted content → refuse with typed guidance.
|
|
1285
|
+
*/
|
|
1286
|
+
export function jjCompleteWithCommand(
|
|
1287
|
+
root: string,
|
|
1288
|
+
pending: JjPendingCommit,
|
|
1289
|
+
runCommand: VcsCommandRunner = run,
|
|
1290
|
+
runMutation: VcsMutationRunner = syncMutationRunner(run),
|
|
1291
|
+
): Effect.Effect<string, VcsError> {
|
|
1292
|
+
return Effect.gen(function* () {
|
|
1293
|
+
if (!isUuid(pending.id)) {
|
|
1294
|
+
return yield* new VcsError({
|
|
1295
|
+
message: `refusing commit: pending_commit.id is not a uuid: ${pending.id}`,
|
|
1296
|
+
})
|
|
1297
|
+
}
|
|
1298
|
+
const at = (yield* requireCommand(
|
|
1299
|
+
jjChangeIdWithCommand(root, "@", runCommand),
|
|
1300
|
+
"jj log -r @",
|
|
1301
|
+
)).stdout.trim()
|
|
1302
|
+
const atMinus = (yield* requireCommand(
|
|
1303
|
+
jjChangeIdWithCommand(root, "@-", runCommand),
|
|
1304
|
+
"jj log -r @-",
|
|
1305
|
+
)).stdout.trim()
|
|
1306
|
+
|
|
1307
|
+
const marker = `${TRANSACTION_TRAILER_PREFIX} ${pending.id}`
|
|
1308
|
+
const descriptionOf = (
|
|
1309
|
+
rev: string,
|
|
1310
|
+
): Effect.Effect<string | null, VcsError> =>
|
|
1311
|
+
Effect.gen(function* () {
|
|
1312
|
+
const r = yield* requireCommand(
|
|
1313
|
+
runCommand(
|
|
1314
|
+
"jj",
|
|
1315
|
+
["log", "-r", rev, "--no-graph", "-T", "description"],
|
|
1316
|
+
root,
|
|
1317
|
+
),
|
|
1318
|
+
`jj log -r ${rev} description`,
|
|
1319
|
+
)
|
|
1320
|
+
return r.stdout.includes(marker) ? r.stdout : null
|
|
1321
|
+
})
|
|
1322
|
+
|
|
1323
|
+
// Case 1: target already sits at @- — completion ran before the crash.
|
|
1324
|
+
if (atMinus === pending.change_id) {
|
|
1325
|
+
const description = yield* descriptionOf("@-")
|
|
1326
|
+
if (description === null) {
|
|
1327
|
+
return yield* new VcsError({
|
|
1328
|
+
message: `refusing commit: @- is ${pending.change_id} but its description lacks this transaction's marker`,
|
|
1329
|
+
})
|
|
1330
|
+
}
|
|
1331
|
+
const fingerprint = yield* jjRevisionFingerprintWithCommand(
|
|
1332
|
+
root,
|
|
1333
|
+
pending.change_id,
|
|
1334
|
+
runCommand,
|
|
1335
|
+
)
|
|
1336
|
+
if (fingerprint !== pending.content_fingerprint) {
|
|
1337
|
+
return yield* new VcsError({
|
|
1338
|
+
message: `refusing commit: prepared jj change ${pending.change_id} drifted from its recorded content fingerprint. ${JJ_DRIFT_RECOVERY_GUIDANCE}`,
|
|
1339
|
+
})
|
|
1340
|
+
}
|
|
1341
|
+
if (fingerprint === EMPTY_JJ_DIFF_FINGERPRINT) {
|
|
1342
|
+
return yield* new VcsError({
|
|
1343
|
+
message: `refusing commit: prepared jj change ${pending.change_id} has no non-.apnea content; completing would abandon it and wedge the transaction. ${JJ_DRIFT_RECOVERY_GUIDANCE}`,
|
|
1344
|
+
})
|
|
1345
|
+
}
|
|
1346
|
+
yield* evictApneaFromTerminus(
|
|
1347
|
+
root,
|
|
1348
|
+
pending.change_id,
|
|
1349
|
+
runCommand,
|
|
1350
|
+
runMutation,
|
|
1351
|
+
)
|
|
1352
|
+
return pending.change_id
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// Case 2: target is still @ — crash hit between describe and `jj new`.
|
|
1356
|
+
if (at === pending.change_id) {
|
|
1357
|
+
const description = yield* descriptionOf("@")
|
|
1358
|
+
if (description === null) {
|
|
1359
|
+
return yield* new VcsError({
|
|
1360
|
+
message: `refusing commit: @ is ${pending.change_id} but its description lacks this transaction's marker`,
|
|
1361
|
+
})
|
|
1362
|
+
}
|
|
1363
|
+
const fingerprint = yield* jjRevisionFingerprintWithCommand(
|
|
1364
|
+
root,
|
|
1365
|
+
pending.change_id,
|
|
1366
|
+
runCommand,
|
|
1367
|
+
)
|
|
1368
|
+
if (fingerprint !== pending.content_fingerprint) {
|
|
1369
|
+
return yield* new VcsError({
|
|
1370
|
+
message: `refusing commit: prepared jj change ${pending.change_id} drifted from its recorded content fingerprint. ${JJ_DRIFT_RECOVERY_GUIDANCE}`,
|
|
1371
|
+
})
|
|
1372
|
+
}
|
|
1373
|
+
if (fingerprint === EMPTY_JJ_DIFF_FINGERPRINT) {
|
|
1374
|
+
return yield* new VcsError({
|
|
1375
|
+
message: `refusing commit: prepared jj change ${pending.change_id} has no non-.apnea content; completing would abandon it and wedge the transaction. ${JJ_DRIFT_RECOVERY_GUIDANCE}`,
|
|
1376
|
+
})
|
|
1377
|
+
}
|
|
1378
|
+
yield* requireCommand(
|
|
1379
|
+
yield* runMutation("jj", ["new", pending.change_id], root),
|
|
1380
|
+
`jj new ${pending.change_id}`,
|
|
1381
|
+
)
|
|
1382
|
+
yield* evictApneaFromTerminus(
|
|
1383
|
+
root,
|
|
1384
|
+
pending.change_id,
|
|
1385
|
+
runCommand,
|
|
1386
|
+
runMutation,
|
|
1387
|
+
)
|
|
1388
|
+
return pending.change_id
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
return yield* new VcsError({
|
|
1392
|
+
message: `refusing commit: prepared jj change ${pending.change_id} is neither @ nor @- (repository moved on since preparation). ${JJ_DRIFT_RECOVERY_GUIDANCE}`,
|
|
1393
|
+
command: "jj log -r @-",
|
|
1394
|
+
})
|
|
1395
|
+
})
|
|
1396
|
+
}
|
|
1397
|
+
|
|
379
1398
|
export const VcsLive = Layer.effect(
|
|
380
1399
|
Vcs,
|
|
381
1400
|
Effect.gen(function* () {
|
|
382
1401
|
const fs = yield* FileSystem
|
|
1402
|
+
const processService = yield* Process
|
|
383
1403
|
|
|
384
1404
|
const detect = (root: string): Effect.Effect<VcsBackend | null> =>
|
|
385
1405
|
Effect.gen(function* () {
|
|
@@ -391,17 +1411,16 @@ export const VcsLive = Layer.effect(
|
|
|
391
1411
|
const treeFingerprint = (
|
|
392
1412
|
root: string,
|
|
393
1413
|
vcs: VcsBackend,
|
|
394
|
-
): Effect.Effect<string> =>
|
|
395
|
-
Effect.
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
return filterAppPaths(r.stdout)
|
|
399
|
-
}
|
|
400
|
-
const r = run("git", ["status", "--porcelain"], root)
|
|
401
|
-
return filterAppPaths(r.stdout)
|
|
1414
|
+
): Effect.Effect<string, VcsError> =>
|
|
1415
|
+
Effect.gen(function* () {
|
|
1416
|
+
yield* rejectCaseFoldedApneaAlias(root)
|
|
1417
|
+
return yield* treeFingerprintWithCommand(root, vcs, run)
|
|
402
1418
|
})
|
|
403
1419
|
|
|
404
|
-
const isDirty = (
|
|
1420
|
+
const isDirty = (
|
|
1421
|
+
root: string,
|
|
1422
|
+
vcs: VcsBackend,
|
|
1423
|
+
): Effect.Effect<boolean, VcsError> =>
|
|
405
1424
|
Effect.gen(function* () {
|
|
406
1425
|
const fp = yield* treeFingerprint(root, vcs)
|
|
407
1426
|
return fp.trim().length > 0
|
|
@@ -448,59 +1467,63 @@ export const VcsLive = Layer.effect(
|
|
|
448
1467
|
return branch
|
|
449
1468
|
})
|
|
450
1469
|
|
|
451
|
-
const
|
|
1470
|
+
const prepareCommit = (
|
|
452
1471
|
root: string,
|
|
453
1472
|
vcs: VcsBackend,
|
|
454
1473
|
message: string,
|
|
455
|
-
): Effect.Effect<
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
1474
|
+
): Effect.Effect<PreparedCommit, VcsError> => {
|
|
1475
|
+
const mutate = processMutationRunner(processService)
|
|
1476
|
+
return vcs === "jj"
|
|
1477
|
+
? jjPrepareWithCommand(root, message, run, mutate)
|
|
1478
|
+
: gitPrepareWithCommand(root, message, run)
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
const completeCommit = (
|
|
1482
|
+
root: string,
|
|
1483
|
+
vcs: VcsBackend,
|
|
1484
|
+
pending: PendingCommit,
|
|
1485
|
+
): Effect.Effect<string, VcsError> => {
|
|
1486
|
+
const mutate = processMutationRunner(processService)
|
|
1487
|
+
if (vcs === "jj") {
|
|
1488
|
+
if (pending.backend !== "jj") {
|
|
1489
|
+
return Effect.fail(
|
|
1490
|
+
new VcsError({
|
|
1491
|
+
message: `pending_commit anchor is ${pending.backend} but this run uses jj`,
|
|
1492
|
+
}),
|
|
460
1493
|
)
|
|
461
|
-
if (!d.ok) {
|
|
462
|
-
return yield* new VcsError({
|
|
463
|
-
message: d.stderr || d.stdout,
|
|
464
|
-
command: "jj describe",
|
|
465
|
-
})
|
|
466
|
-
}
|
|
467
|
-
const n = yield* Effect.sync(() => run("jj", ["new"], root))
|
|
468
|
-
if (!n.ok) {
|
|
469
|
-
return yield* new VcsError({
|
|
470
|
-
message: n.stderr || n.stdout,
|
|
471
|
-
command: "jj new",
|
|
472
|
-
})
|
|
473
|
-
}
|
|
474
|
-
return "jj describe + new"
|
|
475
|
-
}
|
|
476
|
-
const add = yield* Effect.sync(() => run("git", ["add", "-A"], root))
|
|
477
|
-
if (!add.ok) {
|
|
478
|
-
return yield* new VcsError({
|
|
479
|
-
message: add.stderr,
|
|
480
|
-
command: "git add -A",
|
|
481
|
-
})
|
|
482
1494
|
}
|
|
483
|
-
|
|
484
|
-
|
|
1495
|
+
return jjCompleteWithCommand(root, pending, run, mutate)
|
|
1496
|
+
}
|
|
1497
|
+
if (pending.backend !== "git") {
|
|
1498
|
+
return Effect.fail(
|
|
1499
|
+
new VcsError({
|
|
1500
|
+
message: `pending_commit anchor is ${pending.backend} but this run uses git`,
|
|
1501
|
+
}),
|
|
485
1502
|
)
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
command: "git commit",
|
|
490
|
-
})
|
|
491
|
-
}
|
|
492
|
-
return "git commit"
|
|
493
|
-
})
|
|
1503
|
+
}
|
|
1504
|
+
return gitCompleteWithCommand(root, pending, run, mutate)
|
|
1505
|
+
}
|
|
494
1506
|
|
|
495
1507
|
const setBookmarkAtTerminus = (
|
|
496
1508
|
root: string,
|
|
497
1509
|
slug: string,
|
|
498
|
-
): Effect.Effect<void> =>
|
|
499
|
-
Effect.
|
|
1510
|
+
): Effect.Effect<void, VcsError> =>
|
|
1511
|
+
Effect.gen(function* () {
|
|
500
1512
|
const name = `apnea/${slug}`
|
|
501
1513
|
const r = run("jj", ["bookmark", "set", name, "-r", "@-"], root)
|
|
502
1514
|
if (!r.ok) {
|
|
503
|
-
|
|
1515
|
+
const fallback = run(
|
|
1516
|
+
"jj",
|
|
1517
|
+
["bookmark", "create", name, "-r", "@-"],
|
|
1518
|
+
root,
|
|
1519
|
+
)
|
|
1520
|
+
if (!fallback.ok) {
|
|
1521
|
+
return yield* new VcsError({
|
|
1522
|
+
message:
|
|
1523
|
+
fallback.stderr || fallback.stdout || r.stderr || r.stdout,
|
|
1524
|
+
command: `jj bookmark set ${name} -r @-`,
|
|
1525
|
+
})
|
|
1526
|
+
}
|
|
504
1527
|
}
|
|
505
1528
|
})
|
|
506
1529
|
|
|
@@ -509,108 +1532,15 @@ export const VcsLive = Layer.effect(
|
|
|
509
1532
|
blocks: readonly VerifyBlock[],
|
|
510
1533
|
timeoutMs: number,
|
|
511
1534
|
): Effect.Effect<{ ok: boolean; log: string }> =>
|
|
512
|
-
|
|
513
|
-
const log = new VerificationLog(VERIFY_LOG_LIMIT)
|
|
514
|
-
let temporaryDirectory: string | undefined
|
|
515
|
-
let ok = true
|
|
516
|
-
let operation = "create temporary verification directory"
|
|
517
|
-
try {
|
|
518
|
-
temporaryDirectory = mkdtempSync(path.join(tmpdir(), "apnea-verify-"))
|
|
519
|
-
const wrapper = path.join(temporaryDirectory, "run-block.sh")
|
|
520
|
-
operation = "write verification wrapper"
|
|
521
|
-
writeFileSync(wrapper, VERIFY_WRAPPER_SOURCE, {
|
|
522
|
-
encoding: "utf8",
|
|
523
|
-
mode: 0o600,
|
|
524
|
-
})
|
|
525
|
-
for (const [index, block] of blocks.entries()) {
|
|
526
|
-
const source = normalizeVerifySource(block.source)
|
|
527
|
-
const normalizedBlock = { ...block, source }
|
|
528
|
-
const script = path.join(
|
|
529
|
-
temporaryDirectory,
|
|
530
|
-
`block-${index + 1}.${block.interpreter}`,
|
|
531
|
-
)
|
|
532
|
-
const displayBytes =
|
|
533
|
-
2 + verifyBlockDisplayByteLength(normalizedBlock) + 1
|
|
534
|
-
if (!log.canAppendBytes(displayBytes)) {
|
|
535
|
-
log.addLimitNotice(VERIFY_DISPLAY_LIMIT_NOTICE)
|
|
536
|
-
ok = false
|
|
537
|
-
break
|
|
538
|
-
}
|
|
539
|
-
log.append(`$ ${formatVerifyBlock(normalizedBlock)}\n`)
|
|
540
|
-
operation = `write ${block.interpreter} verification block`
|
|
541
|
-
writeFileSync(script, source, {
|
|
542
|
-
encoding: "utf8",
|
|
543
|
-
mode: 0o600,
|
|
544
|
-
})
|
|
545
|
-
operation = `run ${block.interpreter} verification block`
|
|
546
|
-
const result = await runVerificationProcess(
|
|
547
|
-
wrapper,
|
|
548
|
-
block.interpreter,
|
|
549
|
-
script,
|
|
550
|
-
root,
|
|
551
|
-
timeoutMs,
|
|
552
|
-
Math.max(0, log.remaining - VERIFY_RESULT_RESERVE),
|
|
553
|
-
)
|
|
554
|
-
const output = verificationError(
|
|
555
|
-
result.output.trimEnd(),
|
|
556
|
-
temporaryDirectory,
|
|
557
|
-
)
|
|
558
|
-
if (output && !log.append(`${output}\n`)) {
|
|
559
|
-
log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
|
|
560
|
-
ok = false
|
|
561
|
-
break
|
|
562
|
-
}
|
|
563
|
-
if (!log.append(`exit=${result.code}\n`)) {
|
|
564
|
-
log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
|
|
565
|
-
ok = false
|
|
566
|
-
break
|
|
567
|
-
}
|
|
568
|
-
if (result.error) {
|
|
569
|
-
const error = verificationError(result.error, temporaryDirectory)
|
|
570
|
-
if (!log.append(`${error}\n`)) {
|
|
571
|
-
log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
|
|
572
|
-
}
|
|
573
|
-
ok = false
|
|
574
|
-
break
|
|
575
|
-
}
|
|
576
|
-
if (result.code !== 0) {
|
|
577
|
-
ok = false
|
|
578
|
-
break
|
|
579
|
-
}
|
|
580
|
-
if (index < blocks.length - 1 && !log.append("\n")) {
|
|
581
|
-
log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
|
|
582
|
-
ok = false
|
|
583
|
-
break
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
} catch (error) {
|
|
587
|
-
const message = `${operation} failed: ${verificationError(error, temporaryDirectory)}\n`
|
|
588
|
-
if (!log.append(message)) {
|
|
589
|
-
log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
|
|
590
|
-
}
|
|
591
|
-
ok = false
|
|
592
|
-
} finally {
|
|
593
|
-
if (temporaryDirectory) {
|
|
594
|
-
try {
|
|
595
|
-
rmSync(temporaryDirectory, { recursive: true, force: true })
|
|
596
|
-
} catch (error) {
|
|
597
|
-
const message = `clean up temporary verification directory failed: ${verificationError(error, temporaryDirectory)}\n`
|
|
598
|
-
if (!log.append(message)) {
|
|
599
|
-
log.addLimitNotice(VERIFY_LOG_LIMIT_NOTICE)
|
|
600
|
-
}
|
|
601
|
-
ok = false
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
return { ok, log: log.toString() }
|
|
606
|
-
})
|
|
1535
|
+
runVerifyWithProcess(root, blocks, timeoutMs, processService)
|
|
607
1536
|
|
|
608
1537
|
return Vcs.of({
|
|
609
1538
|
detect,
|
|
610
1539
|
isDirty,
|
|
611
1540
|
treeFingerprint,
|
|
612
1541
|
ensureGitBranch,
|
|
613
|
-
|
|
1542
|
+
prepareCommit,
|
|
1543
|
+
completeCommit,
|
|
614
1544
|
setBookmarkAtTerminus,
|
|
615
1545
|
runVerify,
|
|
616
1546
|
})
|