@markjaquith/agency 2.54.4 → 2.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,576 @@
1
+ import { Data, Effect } from "effect"
2
+ import { ContextService } from "./ContextService"
3
+ import { FileSystemService } from "./FileSystemService"
4
+ import { PhaseService } from "./PhaseService"
5
+ import { TaskService } from "./TaskService"
6
+ import { WorkbaseService } from "./WorkbaseService"
7
+
8
+ class PushError extends Data.TaggedError("PushError")<{
9
+ readonly message: string
10
+ }> {}
11
+
12
+ interface CommandResult {
13
+ readonly exitCode: number
14
+ readonly stdout: string
15
+ readonly stderr: string
16
+ }
17
+
18
+ interface CommitMetadata {
19
+ readonly commitId: string
20
+ readonly changeId?: string
21
+ readonly description: string
22
+ readonly empty: boolean
23
+ readonly authorName: string
24
+ readonly authorEmail: string
25
+ readonly conflict: boolean
26
+ readonly parents: readonly string[]
27
+ }
28
+
29
+ interface PushResult {
30
+ readonly vcs: "git" | "jj"
31
+ readonly taskId: string
32
+ readonly phaseId?: string
33
+ readonly branch: string
34
+ readonly base: string
35
+ readonly remote: string
36
+ readonly tip: string
37
+ readonly changeId?: string
38
+ }
39
+
40
+ const validEmail = (email: string) => /^[^@\s]+@[^@\s]+$/.test(email)
41
+
42
+ const requireCommand = (
43
+ fs: FileSystemService,
44
+ args: readonly string[],
45
+ cwd: string,
46
+ label: string,
47
+ ) =>
48
+ fs.runCommand(args, { cwd, captureOutput: true }).pipe(
49
+ Effect.flatMap((result) =>
50
+ result.exitCode === 0
51
+ ? Effect.succeed(result)
52
+ : Effect.fail(
53
+ new PushError({
54
+ message: `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
55
+ }),
56
+ ),
57
+ ),
58
+ )
59
+
60
+ const git = (
61
+ fs: FileSystemService,
62
+ cwd: string,
63
+ args: readonly string[],
64
+ label: string,
65
+ ) => requireCommand(fs, ["git", ...args], cwd, label)
66
+
67
+ const gitRevision = (fs: FileSystemService, cwd: string, revision: string) =>
68
+ fs
69
+ .runCommand(["git", "rev-parse", "--verify", `${revision}^{commit}`], {
70
+ cwd,
71
+ captureOutput: true,
72
+ })
73
+ .pipe(
74
+ Effect.map((result) =>
75
+ result.exitCode === 0 ? result.stdout.trim() || null : null,
76
+ ),
77
+ )
78
+
79
+ const gitAncestor = (
80
+ fs: FileSystemService,
81
+ cwd: string,
82
+ ancestor: string,
83
+ descendant: string,
84
+ ) =>
85
+ fs
86
+ .runCommand(["git", "merge-base", "--is-ancestor", ancestor, descendant], {
87
+ cwd,
88
+ captureOutput: true,
89
+ })
90
+ .pipe(
91
+ Effect.flatMap((result) => {
92
+ if (result.exitCode === 0) return Effect.succeed(true)
93
+ if (result.exitCode === 1) return Effect.succeed(false)
94
+ return Effect.fail(
95
+ new PushError({
96
+ message: `Failed to inspect Git ancestry: ${result.stderr.trim()}`,
97
+ }),
98
+ )
99
+ }),
100
+ )
101
+
102
+ const parseGitCommits = (output: string): readonly CommitMetadata[] =>
103
+ output
104
+ .split("\x1e")
105
+ .map((record) => record.replace(/^\n+|\n+$/g, ""))
106
+ .filter(Boolean)
107
+ .map((record) => {
108
+ const [
109
+ commitId = "",
110
+ authorName = "",
111
+ authorEmail = "",
112
+ description = "",
113
+ ] = record.split("\0")
114
+ return {
115
+ commitId,
116
+ description,
117
+ empty: false,
118
+ authorName,
119
+ authorEmail,
120
+ conflict: false,
121
+ parents: [],
122
+ }
123
+ })
124
+
125
+ const validateGitCommits = (
126
+ commits: readonly CommitMetadata[],
127
+ base: string,
128
+ ) => {
129
+ if (commits.length === 0) {
130
+ throw new PushError({
131
+ message: `No commits to publish after base '${base}'`,
132
+ })
133
+ }
134
+ const issues: string[] = []
135
+ for (const commit of commits) {
136
+ if (!commit.description.trim()) {
137
+ issues.push(
138
+ `Commit ${commit.commitId} has an empty message. Run: git rebase -i ${base}`,
139
+ )
140
+ }
141
+ if (!commit.authorName.trim() || !validEmail(commit.authorEmail.trim())) {
142
+ issues.push(
143
+ `Commit ${commit.commitId} has an invalid author. Run: git rebase -i ${base}`,
144
+ )
145
+ }
146
+ }
147
+ if (issues.length > 0) throw new PushError({ message: issues.join("\n") })
148
+ }
149
+
150
+ const publishGit = (
151
+ fs: FileSystemService,
152
+ checkout: string,
153
+ remote: string,
154
+ branch: string,
155
+ base: string,
156
+ ) =>
157
+ Effect.gen(function* () {
158
+ const currentBranch = yield* git(
159
+ fs,
160
+ checkout,
161
+ ["symbolic-ref", "--quiet", "--short", "HEAD"],
162
+ "Git checkout must be attached to the declared branch",
163
+ )
164
+ if (currentBranch.stdout.trim() !== branch) {
165
+ return yield* new PushError({
166
+ message: `Declared delivery branch '${branch}' does not match checked-out Git branch '${currentBranch.stdout.trim()}'`,
167
+ })
168
+ }
169
+ const status = yield* git(
170
+ fs,
171
+ checkout,
172
+ ["status", "--porcelain=v1"],
173
+ "Failed to inspect Git status",
174
+ )
175
+ if (status.stdout.length > 0) {
176
+ return yield* new PushError({
177
+ message:
178
+ "Cannot publish a dirty Git worktree; commit or discard changes first",
179
+ })
180
+ }
181
+
182
+ yield* git(
183
+ fs,
184
+ checkout,
185
+ ["fetch", remote, `+refs/heads/*:refs/remotes/${remote}/*`],
186
+ `Failed to fetch remote '${remote}'`,
187
+ )
188
+ const tip = yield* gitRevision(fs, checkout, "HEAD")
189
+ const baseRevision = yield* gitRevision(
190
+ fs,
191
+ checkout,
192
+ `refs/remotes/${remote}/${base}`,
193
+ )
194
+ if (!tip || !baseRevision) {
195
+ return yield* new PushError({
196
+ message: `Declared base '${base}' was not found on remote '${remote}'`,
197
+ })
198
+ }
199
+ if (!(yield* gitAncestor(fs, checkout, baseRevision, tip))) {
200
+ return yield* new PushError({
201
+ message: `Declared base '${base}' (${baseRevision}) is not an ancestor of Git HEAD (${tip})`,
202
+ })
203
+ }
204
+
205
+ const log = yield* git(
206
+ fs,
207
+ checkout,
208
+ [
209
+ "log",
210
+ "--format=%H%x00%an%x00%ae%x00%B%x00%x1e",
211
+ `${baseRevision}..${tip}`,
212
+ ],
213
+ "Failed to inspect outgoing Git commits",
214
+ )
215
+ yield* Effect.try({
216
+ try: () => validateGitCommits(parseGitCommits(log.stdout), base),
217
+ catch: (cause) => cause as PushError,
218
+ })
219
+
220
+ const remoteTip = yield* gitRevision(
221
+ fs,
222
+ checkout,
223
+ `refs/remotes/${remote}/${branch}`,
224
+ )
225
+ if (remoteTip && !(yield* gitAncestor(fs, checkout, remoteTip, tip))) {
226
+ return yield* new PushError({
227
+ message: `Remote branch '${branch}' on '${remote}' is not an ancestor of Git HEAD; refusing a non-fast-forward update`,
228
+ })
229
+ }
230
+
231
+ yield* git(
232
+ fs,
233
+ checkout,
234
+ ["push", remote, `HEAD:refs/heads/${branch}`],
235
+ `Failed to push declared branch '${branch}'`,
236
+ )
237
+ yield* git(
238
+ fs,
239
+ checkout,
240
+ [
241
+ "config",
242
+ `remote.${remote}.fetch`,
243
+ `+refs/heads/*:refs/remotes/${remote}/*`,
244
+ ],
245
+ `Failed to configure remote '${remote}' tracking`,
246
+ )
247
+ yield* git(
248
+ fs,
249
+ checkout,
250
+ [
251
+ "fetch",
252
+ remote,
253
+ `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
254
+ ],
255
+ `Failed to refresh published branch '${branch}'`,
256
+ )
257
+ yield* git(
258
+ fs,
259
+ checkout,
260
+ ["branch", "--set-upstream-to", `${remote}/${branch}`, branch],
261
+ `Failed to establish upstream tracking for branch '${branch}'`,
262
+ )
263
+ return { tip }
264
+ })
265
+
266
+ const jjTemplate =
267
+ '"{\\"commitId\\":" ++ json(commit_id) ++ ",\\"changeId\\":" ++ json(change_id) ++ ",\\"description\\":" ++ json(description) ++ ",\\"empty\\":" ++ json(empty) ++ ",\\"authorName\\":" ++ json(author.name()) ++ ",\\"authorEmail\\":" ++ json(author.email()) ++ ",\\"conflict\\":" ++ json(conflict) ++ ",\\"parents\\":" ++ json(parents.map(|parent| parent.commit_id())) ++ "}\\n"'
268
+
269
+ const jjExact = (value: string) =>
270
+ value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')
271
+
272
+ const jj = (
273
+ fs: FileSystemService,
274
+ cwd: string,
275
+ args: readonly string[],
276
+ label: string,
277
+ ) => requireCommand(fs, ["jj", "--no-pager", ...args], cwd, label)
278
+
279
+ const jjCommits = (fs: FileSystemService, cwd: string, revision: string) =>
280
+ jj(
281
+ fs,
282
+ cwd,
283
+ ["log", "--no-graph", "-r", revision, "-T", jjTemplate],
284
+ "Failed to inspect jj changes",
285
+ ).pipe(
286
+ Effect.map((result) =>
287
+ result.stdout
288
+ .split("\n")
289
+ .filter(Boolean)
290
+ .map((line) => JSON.parse(line) as CommitMetadata),
291
+ ),
292
+ )
293
+
294
+ const jjRevision = (fs: FileSystemService, cwd: string, revision: string) =>
295
+ jjCommits(fs, cwd, revision).pipe(
296
+ Effect.map((commits) => {
297
+ if (commits.length === 0) return null
298
+ if (commits.length === 1) return commits[0]!
299
+ throw new PushError({
300
+ message: `Revision '${revision}' resolves to multiple jj commits`,
301
+ })
302
+ }),
303
+ )
304
+
305
+ const optionalJjRevision = (
306
+ fs: FileSystemService,
307
+ cwd: string,
308
+ revision: string,
309
+ ) =>
310
+ jjCommits(fs, cwd, revision).pipe(
311
+ Effect.catchTag("PushError", () => Effect.succeed([])),
312
+ Effect.map((commits) => {
313
+ if (commits.length === 0) return null
314
+ if (commits.length === 1) return commits[0]!
315
+ throw new PushError({
316
+ message: `Bookmark '${revision}' is conflicted or resolves to multiple commits`,
317
+ })
318
+ }),
319
+ )
320
+
321
+ const jjAncestor = (
322
+ fs: FileSystemService,
323
+ cwd: string,
324
+ ancestor: string,
325
+ descendant: string,
326
+ ) =>
327
+ jjCommits(fs, cwd, `${ancestor} & ::${descendant}`).pipe(
328
+ Effect.map((commits) => commits.length === 1),
329
+ )
330
+
331
+ const validateJjCommits = (
332
+ commits: readonly CommitMetadata[],
333
+ base: string,
334
+ ) => {
335
+ if (commits.length === 0) {
336
+ throw new PushError({
337
+ message: `No changes to publish after base '${base}'`,
338
+ })
339
+ }
340
+ const issues: string[] = []
341
+ for (const commit of commits) {
342
+ const id = commit.changeId!
343
+ if (commit.conflict) {
344
+ issues.push(`Change ${id} contains conflicts. Run: jj resolve -r ${id}`)
345
+ }
346
+ if (!commit.description.trim()) {
347
+ issues.push(`Change ${id} has no description. Run: jj describe -r ${id}`)
348
+ }
349
+ if (!commit.authorName.trim() || !validEmail(commit.authorEmail.trim())) {
350
+ issues.push(
351
+ `Change ${id} has an invalid author. Run: jj metaedit -r ${id} --author 'Name <email>'`,
352
+ )
353
+ }
354
+ }
355
+ if (issues.length > 0) throw new PushError({ message: issues.join("\n") })
356
+ }
357
+
358
+ const publishJj = (
359
+ fs: FileSystemService,
360
+ checkout: string,
361
+ remote: string,
362
+ branch: string,
363
+ base: string,
364
+ ) =>
365
+ Effect.gen(function* () {
366
+ yield* jj(
367
+ fs,
368
+ checkout,
369
+ ["git", "fetch", "--remote", remote],
370
+ `Failed to fetch remote '${remote}'`,
371
+ )
372
+ const workingCopy = yield* jjRevision(fs, checkout, "@")
373
+ if (!workingCopy) {
374
+ return yield* new PushError({
375
+ message: "jj working copy commit was not found",
376
+ })
377
+ }
378
+ const canonicalPostCommit =
379
+ workingCopy.empty &&
380
+ !workingCopy.description.trim() &&
381
+ workingCopy.parents.length === 1
382
+ const tip = canonicalPostCommit
383
+ ? yield* jjRevision(fs, checkout, "@-")
384
+ : workingCopy
385
+ if (!tip) {
386
+ return yield* new PushError({
387
+ message: "jj publication tip was not found",
388
+ })
389
+ }
390
+
391
+ const baseBookmark = `${base}@${remote}`
392
+ const baseRevision = yield* optionalJjRevision(
393
+ fs,
394
+ checkout,
395
+ `remote_bookmarks(exact:"${jjExact(base)}", exact:"${jjExact(remote)}")`,
396
+ )
397
+ if (!baseRevision) {
398
+ return yield* new PushError({
399
+ message: `Declared base '${base}' was not found on remote '${remote}'`,
400
+ })
401
+ }
402
+ if (
403
+ !(yield* jjAncestor(fs, checkout, baseRevision.commitId, tip.commitId))
404
+ ) {
405
+ return yield* new PushError({
406
+ message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId})`,
407
+ })
408
+ }
409
+
410
+ const outgoing = yield* jjCommits(
411
+ fs,
412
+ checkout,
413
+ `${baseRevision.commitId}..${tip.commitId}`,
414
+ )
415
+ yield* Effect.try({
416
+ try: () => validateJjCommits(outgoing, base),
417
+ catch: (cause) => cause as PushError,
418
+ })
419
+
420
+ const localBookmark = yield* optionalJjRevision(
421
+ fs,
422
+ checkout,
423
+ `bookmarks(exact:"${jjExact(branch)}")`,
424
+ )
425
+ if (
426
+ localBookmark &&
427
+ !(yield* jjAncestor(fs, checkout, localBookmark.commitId, tip.commitId))
428
+ ) {
429
+ return yield* new PushError({
430
+ message: `Local bookmark '${branch}' is not an ancestor of jj tip ${tip.changeId}; refusing to move it`,
431
+ })
432
+ }
433
+ const remoteBookmark = yield* optionalJjRevision(
434
+ fs,
435
+ checkout,
436
+ `remote_bookmarks(exact:"${jjExact(branch)}", exact:"${jjExact(remote)}")`,
437
+ )
438
+ if (
439
+ remoteBookmark &&
440
+ !(yield* jjAncestor(fs, checkout, remoteBookmark.commitId, tip.commitId))
441
+ ) {
442
+ return yield* new PushError({
443
+ message: `Remote bookmark '${branch}@${remote}' is not an ancestor of jj tip ${tip.changeId}; refusing a non-fast-forward update`,
444
+ })
445
+ }
446
+
447
+ yield* jj(
448
+ fs,
449
+ checkout,
450
+ ["bookmark", "set", branch, "-r", tip.commitId],
451
+ `Failed to set declared bookmark '${branch}'`,
452
+ )
453
+ yield* jj(
454
+ fs,
455
+ checkout,
456
+ ["git", "push", "--remote", remote, "--bookmark", branch],
457
+ `Failed to push declared bookmark '${branch}'`,
458
+ )
459
+ yield* jj(
460
+ fs,
461
+ checkout,
462
+ ["bookmark", "track", `${branch}@${remote}`],
463
+ `Failed to track remote bookmark '${branch}@${remote}'`,
464
+ )
465
+ return { tip: tip.commitId, changeId: tip.changeId }
466
+ })
467
+
468
+ export class PushService extends Effect.Service<PushService>()("PushService", {
469
+ sync: () => ({
470
+ publish: (startPath: string = process.cwd()) =>
471
+ Effect.gen(function* () {
472
+ const contexts = yield* ContextService
473
+ const fs = yield* FileSystemService
474
+ const tasks = yield* TaskService
475
+ const phases = yield* PhaseService
476
+ const workbase = yield* WorkbaseService
477
+ const context = yield* contexts.get({
478
+ cwd: startPath,
479
+ target: ".",
480
+ compact: true,
481
+ })
482
+ if (!context.validation.valid) {
483
+ return yield* new PushError({
484
+ message: "Cannot publish from an invalid Agency workbase",
485
+ })
486
+ }
487
+ if (context.target.kind !== "task" && context.target.kind !== "phase") {
488
+ return yield* new PushError({
489
+ message: "agency push must run from an execution task or phase",
490
+ })
491
+ }
492
+ if (
493
+ context.authority.mode !== "execution" ||
494
+ !context.authority.writable
495
+ ) {
496
+ return yield* new PushError({
497
+ message:
498
+ "Current Agency target has no writable execution authority",
499
+ })
500
+ }
501
+ if (
502
+ !context.workspace?.writable?.materialized ||
503
+ !context.workspace.writable.registered
504
+ ) {
505
+ return yield* new PushError({
506
+ message:
507
+ "Current Agency writable checkout is not materialized and registered",
508
+ })
509
+ }
510
+ const blockers = context.graph.readiness.blockers.filter(
511
+ (blocker) =>
512
+ blocker.kind === "dependency" || blocker.kind === "validation",
513
+ )
514
+ if (blockers.length > 0) {
515
+ return yield* new PushError({
516
+ message: `Cannot publish blocked Agency work: ${blockers.map((blocker) => blocker.reason).join("; ")}`,
517
+ })
518
+ }
519
+
520
+ const taskId = context.target.taskId
521
+ if (!taskId) {
522
+ return yield* new PushError({
523
+ message: "Current Agency execution target has no task ID",
524
+ })
525
+ }
526
+ const phaseId =
527
+ context.target.kind === "phase" ? context.target.phaseId : undefined
528
+ const task = yield* tasks.show(taskId, context.workbase.root)
529
+ const execution =
530
+ "phases" in task.data
531
+ ? phaseId
532
+ ? (yield* phases.show(taskId, phaseId, context.workbase.root))
533
+ .data
534
+ : null
535
+ : task.data
536
+ if (!execution || "review" in execution) {
537
+ return yield* new PushError({
538
+ message: "Current Agency target is not a delivery execution unit",
539
+ })
540
+ }
541
+ if (execution.status !== "working") {
542
+ return yield* new PushError({
543
+ message: `Cannot publish Agency work with status '${execution.status}'; status must be working`,
544
+ })
545
+ }
546
+ const checkout = context.authority.writable.checkoutPath
547
+ const { config } = yield* workbase.loadConfig(context.workbase.root)
548
+ const remote = config.delivery?.remote ?? "origin"
549
+ const published =
550
+ context.workbase.vcs === "jj"
551
+ ? yield* publishJj(
552
+ fs,
553
+ checkout,
554
+ remote,
555
+ execution.branch,
556
+ execution.base,
557
+ )
558
+ : yield* publishGit(
559
+ fs,
560
+ checkout,
561
+ remote,
562
+ execution.branch,
563
+ execution.base,
564
+ )
565
+ return {
566
+ vcs: context.workbase.vcs,
567
+ taskId,
568
+ ...(phaseId ? { phaseId } : {}),
569
+ branch: execution.branch,
570
+ base: execution.base,
571
+ remote,
572
+ ...published,
573
+ } satisfies PushResult
574
+ }),
575
+ }),
576
+ }) {}
@@ -371,6 +371,30 @@ status: done
371
371
  ).rejects.toThrow("{worktree}")
372
372
  })
373
373
 
374
+ test("rejects an unknown post-checkout command placeholder", async () => {
375
+ await write(
376
+ root,
377
+ "agency.json",
378
+ JSON.stringify({
379
+ version: 2,
380
+ repositories: {
381
+ agency: {
382
+ remote: "https://example.com/agency.git",
383
+ postCheckoutCommand: ["tool", "{unknown}"],
384
+ },
385
+ },
386
+ }),
387
+ )
388
+
389
+ await expect(
390
+ runTestEffect(
391
+ WorkbaseService.pipe(
392
+ Effect.flatMap((service) => service.discover(root)),
393
+ ),
394
+ ),
395
+ ).rejects.toThrow("Repository 'agency'")
396
+ })
397
+
374
398
  test("rejects an unknown runner command placeholder", async () => {
375
399
  await write(
376
400
  root,
@@ -20,6 +20,7 @@ import {
20
20
  type WorkbaseRegistration,
21
21
  } from "../workbase/schemas"
22
22
  import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
23
+ import { validatePostCheckoutCommand } from "../workbase/checkout-command"
23
24
  import { validateRunners } from "../workbase/runner-command"
24
25
  import { findDependencyCycles } from "../workbase/dependency-graph"
25
26
  import { validateDelivery } from "../workbase/delivery-command"
@@ -274,6 +275,23 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
274
275
  })
275
276
  }
276
277
  }
278
+ for (const [alias, repository] of Object.entries(
279
+ decoded.value.repositories ?? {},
280
+ )) {
281
+ if (!repository.postCheckoutCommand) continue
282
+ try {
283
+ validatePostCheckoutCommand(repository.postCheckoutCommand)
284
+ } catch (cause) {
285
+ return yield* new WorkbaseConfigError({
286
+ path: configPath,
287
+ message: `Repository '${alias}': ${
288
+ cause instanceof Error
289
+ ? cause.message
290
+ : "Invalid postCheckoutCommand"
291
+ }`,
292
+ })
293
+ }
294
+ }
277
295
  try {
278
296
  validateRunners(decoded.value.runners)
279
297
  validateDelivery(decoded.value.delivery)