@markjaquith/agency 2.29.0 → 2.30.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 (50) hide show
  1. package/README.md +72 -16
  2. package/cli.ts +3 -0
  3. package/fixtures/protocol/skill-setup-commands.json +18 -0
  4. package/package.json +1 -1
  5. package/skills/agency/SKILL.md +30 -15
  6. package/skills/agency/references/commands.md +38 -17
  7. package/skills/agency/references/contracts.md +46 -19
  8. package/skills/agency/references/recipes.md +52 -12
  9. package/src/cli-parser.test.ts +7 -0
  10. package/src/cli-parser.ts +13 -2
  11. package/src/cli.test.ts +422 -3
  12. package/src/commands/doctor.test.ts +22 -0
  13. package/src/commands/init.test.ts +3 -2
  14. package/src/commands/integration.test.ts +21 -1
  15. package/src/commands/integration.ts +8 -4
  16. package/src/commands/pr.test.ts +20 -1
  17. package/src/commands/repo.test.ts +66 -1
  18. package/src/commands/repo.ts +35 -8
  19. package/src/commands/status.test.ts +22 -0
  20. package/src/commands/status.ts +1 -0
  21. package/src/commands/sync.ts +5 -3
  22. package/src/commands/work.test.ts +74 -1
  23. package/src/commands/work.ts +26 -3
  24. package/src/graph-schema.test.ts +52 -4
  25. package/src/protocol.test.ts +41 -8
  26. package/src/readiness.test.ts +75 -17
  27. package/src/services/DoctorService.ts +32 -18
  28. package/src/services/EpicService.ts +1 -1
  29. package/src/services/GraphMutationService.ts +3 -3
  30. package/src/services/GraphService.ts +13 -4
  31. package/src/services/IntegrationService.test.ts +37 -12
  32. package/src/services/IntegrationService.ts +70 -21
  33. package/src/services/PhaseService.ts +1 -1
  34. package/src/services/ReadinessService.test.ts +47 -0
  35. package/src/services/RepositoryService.test.ts +299 -5
  36. package/src/services/RepositoryService.ts +725 -98
  37. package/src/services/SyncService.test.ts +36 -0
  38. package/src/services/SyncService.ts +20 -1
  39. package/src/services/TaskService.ts +1 -1
  40. package/src/services/WorkbaseService.test.ts +32 -0
  41. package/src/services/WorkbaseService.ts +22 -14
  42. package/src/services/WorktreeLock.test.ts +122 -0
  43. package/src/services/WorktreeService.test.ts +17 -2
  44. package/src/services/WorktreeService.ts +3 -3
  45. package/src/utils/process.test.ts +4 -3
  46. package/src/workbase/AGENTS.md +9 -1
  47. package/src/workbase/dependency-graph.test.ts +50 -0
  48. package/src/workbase/opencode-file.ts +3 -13
  49. package/src/workbase/schemas.test.ts +52 -0
  50. package/src/workbase/schemas.ts +19 -0
@@ -1,22 +1,44 @@
1
1
  import { Schema, TreeFormatter } from "@effect/schema"
2
2
  import { Data, Effect, Either } from "effect"
3
3
  import { join, resolve } from "node:path"
4
+ import { lstat, rename, rm } from "node:fs/promises"
4
5
  import { FileSystemService } from "./FileSystemService"
5
6
  import { GraphService } from "./GraphService"
6
7
  import { WorkbaseService } from "./WorkbaseService"
7
- import { RepositoryAlias } from "../workbase/schemas"
8
+ import {
9
+ directoryMoveStep,
10
+ documentWriteStep,
11
+ runLifecycleTransaction,
12
+ type TransactionStep,
13
+ } from "./LifecycleTransaction"
14
+ import {
15
+ RepositoryAlias,
16
+ RepositoryRemote,
17
+ WorkbaseConfig,
18
+ } from "../workbase/schemas"
19
+ import { documentRevision } from "../workbase/document-revision"
8
20
 
9
21
  class RepositoryError extends Data.TaggedError("RepositoryError")<{
10
22
  readonly message: string
11
23
  readonly cause?: unknown
12
24
  }> {}
13
25
 
26
+ type RepositoryState =
27
+ | "declared"
28
+ | "materialized"
29
+ | "linked"
30
+ | "missing"
31
+ | "invalid"
32
+ | "remote-drifted"
33
+
14
34
  interface RepositoryInfo {
15
35
  readonly alias: string
16
36
  readonly path: string
17
- readonly kind: "bare" | "repository" | "symlink"
37
+ readonly kind: "bare" | "repository" | "symlink" | null
18
38
  readonly remote: string | null
39
+ readonly declaredRemote: string | null
19
40
  readonly target: string | null
41
+ readonly states: readonly RepositoryState[]
20
42
  }
21
43
 
22
44
  interface RepositoryVerification extends RepositoryInfo {
@@ -24,17 +46,138 @@ interface RepositoryVerification extends RepositoryInfo {
24
46
  readonly issues: readonly string[]
25
47
  }
26
48
 
27
- const validateAlias = (alias: string) => {
28
- const result = Schema.decodeUnknownEither(RepositoryAlias)(alias)
49
+ interface RepositorySetupAction {
50
+ readonly kind: "materialize" | "adopt"
51
+ readonly alias: string
52
+ readonly remote: string
53
+ readonly status: "planned" | "applied"
54
+ }
55
+
56
+ interface RepositorySetupIssue {
57
+ readonly alias: string
58
+ readonly state: "invalid" | "remote-drifted" | "undeclared"
59
+ readonly message: string
60
+ readonly action: string
61
+ }
62
+
63
+ export interface RepositorySetupResult {
64
+ readonly root: string
65
+ readonly mode: "dry-run" | "apply"
66
+ readonly actions: readonly RepositorySetupAction[]
67
+ readonly unresolved: readonly RepositorySetupIssue[]
68
+ readonly repositories: readonly RepositoryInfo[]
69
+ }
70
+
71
+ const validate = <S extends Schema.Schema.AnyNoContext>(
72
+ schema: S,
73
+ value: unknown,
74
+ label: string,
75
+ ) => {
76
+ const result = Schema.decodeUnknownEither(schema)(value)
29
77
  return Either.isLeft(result)
30
78
  ? Effect.fail(
31
79
  new RepositoryError({
32
- message: `Invalid repository alias '${alias}': ${TreeFormatter.formatErrorSync(result.left)}`,
80
+ message: `Invalid ${label} '${String(value)}': ${TreeFormatter.formatErrorSync(result.left)}`,
33
81
  }),
34
82
  )
35
83
  : Effect.succeed(result.right)
36
84
  }
37
85
 
86
+ const validateAlias = (alias: string) =>
87
+ validate(RepositoryAlias, alias, "repository alias")
88
+
89
+ const validateRemote = (remote: string) => {
90
+ if (!remote.trim()) {
91
+ return Effect.fail(
92
+ new RepositoryError({ message: "Repository remote is required" }),
93
+ )
94
+ }
95
+ return validate(RepositoryRemote, remote, "portable repository remote")
96
+ }
97
+
98
+ const sortedDeclarations = (
99
+ repositories: WorkbaseConfig["repositories"] | undefined,
100
+ ) =>
101
+ Object.fromEntries(
102
+ Object.entries(repositories ?? {}).sort(([left], [right]) =>
103
+ left.localeCompare(right),
104
+ ),
105
+ )
106
+
107
+ const configContent = (config: WorkbaseConfig) =>
108
+ JSON.stringify(
109
+ {
110
+ ...config,
111
+ ...(config.repositories
112
+ ? { repositories: sortedDeclarations(config.repositories) }
113
+ : {}),
114
+ },
115
+ null,
116
+ 2,
117
+ ) + "\n"
118
+
119
+ const configState = (startPath: string) =>
120
+ Effect.gen(function* () {
121
+ const fs = yield* FileSystemService
122
+ const workbase = yield* WorkbaseService
123
+ const root = yield* workbase.discover(startPath)
124
+ const path = join(root, "agency.json")
125
+ const content = yield* fs.readFile(path)
126
+ let input: unknown
127
+ try {
128
+ input = JSON.parse(content)
129
+ } catch (cause) {
130
+ return yield* new RepositoryError({
131
+ message: `Invalid JSON in ${path}`,
132
+ cause,
133
+ })
134
+ }
135
+ const decoded = Schema.decodeUnknownEither(WorkbaseConfig, {
136
+ errors: "all",
137
+ onExcessProperty: "error",
138
+ })(input)
139
+ if (Either.isLeft(decoded)) {
140
+ return yield* new RepositoryError({
141
+ message: `Invalid workbase configuration in ${path}:\n${TreeFormatter.formatErrorSync(decoded.left)}`,
142
+ })
143
+ }
144
+ return {
145
+ root,
146
+ config: decoded.right,
147
+ path,
148
+ revision: documentRevision(content),
149
+ }
150
+ })
151
+
152
+ const withDeclarations = (
153
+ config: WorkbaseConfig,
154
+ repositories: NonNullable<WorkbaseConfig["repositories"]>,
155
+ ): WorkbaseConfig => ({
156
+ ...config,
157
+ repositories: sortedDeclarations(repositories),
158
+ })
159
+
160
+ const inspectRemote = (path: string) =>
161
+ Effect.gen(function* () {
162
+ const fs = yield* FileSystemService
163
+ const result = yield* fs.runCommand(
164
+ ["git", "-C", path, "remote", "get-url", "origin"],
165
+ { captureOutput: true },
166
+ )
167
+ return result.exitCode === 0 ? result.stdout.trim() : null
168
+ })
169
+
170
+ const portableRemote = (path: string) =>
171
+ Effect.gen(function* () {
172
+ const remote = yield* inspectRemote(path)
173
+ if (!remote) {
174
+ return yield* new RepositoryError({
175
+ message: `Repository '${path}' has no portable origin remote`,
176
+ })
177
+ }
178
+ return yield* validateRemote(remote)
179
+ })
180
+
38
181
  const find = (alias: string, startPath: string) =>
39
182
  Effect.gen(function* () {
40
183
  const service = yield* RepositoryService
@@ -49,6 +192,21 @@ const find = (alias: string, startPath: string) =>
49
192
  return repository
50
193
  })
51
194
 
195
+ const requireMaterialized = (repository: RepositoryInfo) =>
196
+ repository.states.includes("missing")
197
+ ? Effect.fail(
198
+ new RepositoryError({
199
+ message: `Repository alias '${repository.alias}' is declared but missing; run 'agency repo setup --apply'`,
200
+ }),
201
+ )
202
+ : repository.states.includes("invalid")
203
+ ? Effect.fail(
204
+ new RepositoryError({
205
+ message: `Repository alias '${repository.alias}' has an invalid local path`,
206
+ }),
207
+ )
208
+ : Effect.succeed(repository)
209
+
52
210
  const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
53
211
  Effect.gen(function* () {
54
212
  const fs = yield* FileSystemService
@@ -64,7 +222,11 @@ const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
64
222
  .map((edge) => edge.from)
65
223
  .sort()
66
224
  const worktrees: string[] = []
67
- if (repository.kind !== "symlink") {
225
+ if (repository.kind !== null && !repository.states.includes("invalid")) {
226
+ const linkedTarget =
227
+ repository.kind === "symlink"
228
+ ? yield* fs.realPath(repository.path)
229
+ : null
68
230
  const result = yield* fs.runCommand(
69
231
  ["git", "-C", repository.path, "worktree", "list", "--porcelain"],
70
232
  { captureOutput: true },
@@ -73,7 +235,7 @@ const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
73
235
  for (const block of result.stdout.trim().split(/\n\n+/)) {
74
236
  if (!block || /(^|\n)bare(\n|$)/.test(block)) continue
75
237
  const path = block.match(/^worktree (.+)$/m)?.[1]
76
- if (path) worktrees.push(path)
238
+ if (path && path !== linkedTarget) worktrees.push(path)
77
239
  }
78
240
  }
79
241
  }
@@ -94,6 +256,84 @@ const assertRemovable = (repository: RepositoryInfo, startPath: string) =>
94
256
  }
95
257
  })
96
258
 
259
+ const effectPreflightStep = (
260
+ label: string,
261
+ check: Effect.Effect<void, unknown, never>,
262
+ ): TransactionStep => ({
263
+ label,
264
+ preflight: () => Effect.runPromise(check),
265
+ apply: async () => undefined,
266
+ })
267
+
268
+ const deleteAfterMoveStep = (
269
+ root: string,
270
+ from: string,
271
+ to: string,
272
+ ): TransactionStep => ({
273
+ ...directoryMoveStep(root, from, to),
274
+ finalize: () => rm(to, { recursive: true, force: true }),
275
+ manualRecovery: `Remove ${to} or move it back to ${from}`,
276
+ })
277
+
278
+ const replaceWithMoveStep = (
279
+ current: string,
280
+ replacement: string,
281
+ backup: string,
282
+ ): TransactionStep => ({
283
+ label: `replace ${current} with ${replacement}`,
284
+ preflight: async () => {
285
+ await lstat(current)
286
+ await lstat(replacement)
287
+ try {
288
+ await lstat(backup)
289
+ throw new Error(`Replacement backup already exists: ${backup}`)
290
+ } catch (cause) {
291
+ if (
292
+ typeof cause !== "object" ||
293
+ cause === null ||
294
+ !("code" in cause) ||
295
+ cause.code !== "ENOENT"
296
+ )
297
+ throw cause
298
+ }
299
+ },
300
+ apply: async () => {
301
+ await rename(current, backup)
302
+ try {
303
+ await rename(replacement, current)
304
+ } catch (cause) {
305
+ await rename(backup, current)
306
+ throw cause
307
+ }
308
+ },
309
+ rollback: async () => {
310
+ await rename(current, replacement)
311
+ await rename(backup, current)
312
+ },
313
+ finalize: () => rm(backup, { recursive: true, force: true }),
314
+ manualRecovery: `Restore ${backup} to ${current}`,
315
+ })
316
+
317
+ const runTransaction = (
318
+ state: Effect.Effect.Success<ReturnType<typeof configState>>,
319
+ config: WorkbaseConfig,
320
+ steps: readonly TransactionStep[],
321
+ ) =>
322
+ runLifecycleTransaction({
323
+ root: state.root,
324
+ preconditions: [{ path: state.path, revision: state.revision }],
325
+ steps: [
326
+ ...steps,
327
+ documentWriteStep(state.root, [
328
+ { path: state.path, content: configContent(config) },
329
+ ]),
330
+ ],
331
+ }).pipe(
332
+ Effect.mapError(
333
+ (cause) => new RepositoryError({ message: cause.message, cause }),
334
+ ),
335
+ )
336
+
97
337
  export class RepositoryService extends Effect.Service<RepositoryService>()(
98
338
  "RepositoryService",
99
339
  {
@@ -101,35 +341,72 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
101
341
  add: (alias: string, remote: string, startPath: string = process.cwd()) =>
102
342
  Effect.gen(function* () {
103
343
  const fs = yield* FileSystemService
104
- const workbase = yield* WorkbaseService
105
344
  const validAlias = yield* validateAlias(alias)
106
- const root = yield* workbase.discover(startPath)
107
- const reposPath = join(root, "repos")
108
- const destination = join(reposPath, validAlias)
109
-
110
- if (!remote.trim()) {
111
- return yield* new RepositoryError({
112
- message: "Repository remote is required",
113
- })
114
- }
115
- if (yield* fs.exists(destination)) {
345
+ const state = yield* configState(startPath)
346
+ const destination = join(state.root, "repos", validAlias)
347
+ if (
348
+ state.config.repositories?.[validAlias] ||
349
+ (yield* fs.exists(destination))
350
+ ) {
116
351
  return yield* new RepositoryError({
117
352
  message: `Repository alias '${validAlias}' already exists`,
118
353
  })
119
354
  }
120
355
 
121
- yield* fs.createDirectory(reposPath)
122
- const result = yield* fs.runCommand(
123
- ["git", "clone", "--bare", remote, destination],
356
+ const inputIsPortable = Either.isRight(
357
+ Schema.decodeUnknownEither(RepositoryRemote)(remote),
358
+ )
359
+ const cloneSource = inputIsPortable
360
+ ? remote
361
+ : resolve(startPath, remote)
362
+ const declaredRemote = inputIsPortable
363
+ ? yield* validateRemote(remote)
364
+ : yield* portableRemote(cloneSource)
365
+ const staging = join(
366
+ state.root,
367
+ "repos",
368
+ `.agency-clone-${validAlias}-${process.pid}-${Date.now()}`,
369
+ )
370
+ yield* fs.createDirectory(join(state.root, "repos"))
371
+ const cloned = yield* fs.runCommand(
372
+ ["git", "clone", "--bare", "--", cloneSource, staging],
124
373
  { captureOutput: true },
125
374
  )
126
- if (result.exitCode !== 0) {
127
- yield* fs.deleteDirectory(destination).pipe(Effect.ignore)
375
+ if (cloned.exitCode !== 0) {
376
+ yield* fs.deleteDirectory(staging).pipe(Effect.ignore)
128
377
  return yield* new RepositoryError({
129
- message: `Failed to clone repository '${remote}': ${result.stderr.trim()}`,
378
+ message: `Failed to clone repository '${remote}': ${cloned.stderr.trim()}`,
130
379
  })
131
380
  }
132
-
381
+ if (declaredRemote !== remote) {
382
+ const setRemote = yield* fs.runCommand(
383
+ [
384
+ "git",
385
+ "-C",
386
+ staging,
387
+ "remote",
388
+ "set-url",
389
+ "origin",
390
+ declaredRemote,
391
+ ],
392
+ { captureOutput: true },
393
+ )
394
+ if (setRemote.exitCode !== 0) {
395
+ yield* fs.deleteDirectory(staging).pipe(Effect.ignore)
396
+ return yield* new RepositoryError({
397
+ message: `Failed to record portable remote for repository '${validAlias}': ${setRemote.stderr.trim()}`,
398
+ })
399
+ }
400
+ }
401
+ const config = withDeclarations(state.config, {
402
+ ...(state.config.repositories ?? {}),
403
+ [validAlias]: { remote: declaredRemote },
404
+ })
405
+ yield* runTransaction(state, config, [
406
+ directoryMoveStep(state.root, staging, destination),
407
+ ]).pipe(
408
+ Effect.ensuring(fs.deleteDirectory(staging).pipe(Effect.ignore)),
409
+ )
133
410
  return destination
134
411
  }),
135
412
 
@@ -140,16 +417,25 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
140
417
  ) =>
141
418
  Effect.gen(function* () {
142
419
  const fs = yield* FileSystemService
420
+ const graph = yield* GraphService
143
421
  const workbase = yield* WorkbaseService
144
422
  const validAlias = yield* validateAlias(alias)
145
- const root = yield* workbase.discover(startPath)
146
- const reposPath = join(root, "repos")
147
- const destination = join(reposPath, validAlias)
423
+ const state = yield* configState(startPath)
424
+ const destination = join(state.root, "repos", validAlias)
148
425
  const resolvedTarget = resolve(startPath, target)
149
-
150
- if (yield* fs.exists(destination)) {
426
+ const existing = (yield* RepositoryService)
427
+ .list(state.root)
428
+ .pipe(
429
+ Effect.map((items) =>
430
+ items.find((item) => item.alias === validAlias),
431
+ ),
432
+ )
433
+ const current = yield* existing
434
+ const localCurrent =
435
+ current && !current.states.includes("missing") ? current : undefined
436
+ if (localCurrent?.kind === "symlink") {
151
437
  return yield* new RepositoryError({
152
- message: `Repository alias '${validAlias}' already exists`,
438
+ message: `Repository alias '${validAlias}' is already linked`,
153
439
  })
154
440
  }
155
441
  if (!(yield* fs.isDirectory(resolvedTarget))) {
@@ -157,66 +443,139 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
157
443
  message: `Repository path does not exist: ${resolvedTarget}`,
158
444
  })
159
445
  }
160
-
161
- const result = yield* fs.runCommand(
446
+ const git = yield* fs.runCommand(
162
447
  ["git", "-C", resolvedTarget, "rev-parse", "--git-dir"],
163
448
  { captureOutput: true },
164
449
  )
165
- if (result.exitCode !== 0) {
450
+ if (git.exitCode !== 0) {
166
451
  return yield* new RepositoryError({
167
452
  message: `Path is not a Git repository: ${resolvedTarget}`,
168
453
  })
169
454
  }
170
-
171
- yield* fs.createDirectory(reposPath)
172
- yield* fs.createSymlink(resolvedTarget, destination)
455
+ const declaredRemote =
456
+ state.config.repositories?.[validAlias]?.remote ??
457
+ (yield* portableRemote(resolvedTarget))
458
+ const staging = join(
459
+ state.root,
460
+ "repos",
461
+ `.agency-link-${validAlias}-${process.pid}-${Date.now()}`,
462
+ )
463
+ yield* fs.createDirectory(join(state.root, "repos"))
464
+ yield* fs.createSymlink(resolvedTarget, staging)
465
+ const config = withDeclarations(state.config, {
466
+ ...(state.config.repositories ?? {}),
467
+ [validAlias]: { remote: declaredRemote },
468
+ })
469
+ const replaced = join(
470
+ state.root,
471
+ "repos",
472
+ `.agency-replaced-${validAlias}-${process.pid}-${Date.now()}`,
473
+ )
474
+ const safety = localCurrent
475
+ ? effectPreflightStep(
476
+ `verify repository safety for ${validAlias}`,
477
+ assertRemovable(localCurrent, state.root).pipe(
478
+ Effect.provideService(FileSystemService, fs),
479
+ Effect.provideService(GraphService, graph),
480
+ Effect.provideService(WorkbaseService, workbase),
481
+ ),
482
+ )
483
+ : null
484
+ yield* runTransaction(
485
+ state,
486
+ config,
487
+ localCurrent
488
+ ? [safety!, replaceWithMoveStep(destination, staging, replaced)]
489
+ : [directoryMoveStep(state.root, staging, destination)],
490
+ ).pipe(
491
+ Effect.ensuring(fs.deleteDirectory(staging).pipe(Effect.ignore)),
492
+ )
173
493
  return destination
174
494
  }),
175
495
 
176
496
  list: (startPath: string = process.cwd()) =>
177
497
  Effect.gen(function* () {
178
498
  const fs = yield* FileSystemService
179
- const workbase = yield* WorkbaseService
180
- const root = yield* workbase.discover(startPath)
499
+ const { root, config } = yield* WorkbaseService.pipe(
500
+ Effect.flatMap((service) => service.loadConfig(startPath)),
501
+ )
181
502
  const reposPath = join(root, "repos")
182
-
183
- if (!(yield* fs.isDirectory(reposPath))) {
184
- return [] as RepositoryInfo[]
185
- }
186
-
187
- const entries = (yield* fs.readDirectory(reposPath))
188
- .filter((entry) => entry.isDirectory || entry.isSymlink)
189
- .sort((a, b) => a.name.localeCompare(b.name))
503
+ const entries = (yield* fs.isDirectory(reposPath))
504
+ ? (yield* fs.readDirectory(reposPath)).filter(
505
+ (entry) => !entry.name.startsWith(".agency-"),
506
+ )
507
+ : []
508
+ const local = new Map(entries.map((entry) => [entry.name, entry]))
509
+ const aliases = new Set([
510
+ ...Object.keys(config.repositories ?? {}),
511
+ ...local.keys(),
512
+ ])
190
513
  const repositories: RepositoryInfo[] = []
191
514
 
192
- for (const entry of entries) {
193
- const path = join(reposPath, entry.name)
515
+ for (const alias of [...aliases].sort()) {
516
+ const path = join(reposPath, alias)
517
+ const entry = local.get(alias)
518
+ const declaredRemote = config.repositories?.[alias]?.remote ?? null
519
+ if (!entry) {
520
+ repositories.push({
521
+ alias,
522
+ path,
523
+ kind: null,
524
+ remote: null,
525
+ declaredRemote,
526
+ target: null,
527
+ states: ["declared", "missing"],
528
+ })
529
+ continue
530
+ }
531
+ if (!entry.isDirectory && !entry.isSymlink) {
532
+ repositories.push({
533
+ alias,
534
+ path,
535
+ kind: null,
536
+ remote: null,
537
+ declaredRemote,
538
+ target: null,
539
+ states: [
540
+ ...(declaredRemote ? (["declared"] as const) : []),
541
+ "invalid",
542
+ ],
543
+ })
544
+ continue
545
+ }
194
546
  const target = entry.isSymlink
195
547
  ? yield* fs.readSymlinkTarget(path)
196
548
  : null
197
- const bareResult = yield* fs.runCommand(
198
- ["git", "-C", path, "rev-parse", "--is-bare-repository"],
549
+ const git = yield* fs.runCommand(
550
+ ["git", "-C", path, "rev-parse", "--git-dir"],
199
551
  { captureOutput: true },
200
552
  )
201
- const remoteResult = yield* fs.runCommand(
202
- ["git", "-C", path, "remote", "get-url", "origin"],
553
+ const bare = yield* fs.runCommand(
554
+ ["git", "-C", path, "rev-parse", "--is-bare-repository"],
203
555
  { captureOutput: true },
204
556
  )
205
-
557
+ const remote =
558
+ git.exitCode === 0 ? yield* inspectRemote(path) : null
559
+ const states: RepositoryState[] = []
560
+ if (declaredRemote) states.push("declared")
561
+ states.push(entry.isSymlink ? "linked" : "materialized")
562
+ if (git.exitCode !== 0) states.push("invalid")
563
+ if (declaredRemote && remote !== declaredRemote)
564
+ states.push("remote-drifted")
206
565
  repositories.push({
207
- alias: entry.name,
566
+ alias,
208
567
  path,
209
568
  kind: entry.isSymlink
210
569
  ? "symlink"
211
- : bareResult.stdout.trim() === "true"
570
+ : bare.stdout.trim() === "true"
212
571
  ? "bare"
213
572
  : "repository",
214
- remote:
215
- remoteResult.exitCode === 0 ? remoteResult.stdout.trim() : null,
573
+ remote,
574
+ declaredRemote,
216
575
  target,
576
+ states,
217
577
  })
218
578
  }
219
-
220
579
  return repositories
221
580
  }),
222
581
 
@@ -226,7 +585,9 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
226
585
  fetch: (alias: string, startPath: string = process.cwd()) =>
227
586
  Effect.gen(function* () {
228
587
  const fs = yield* FileSystemService
229
- const repository = yield* find(alias, startPath)
588
+ const repository = yield* find(alias, startPath).pipe(
589
+ Effect.flatMap(requireMaterialized),
590
+ )
230
591
  const result = yield* fs.runCommand(
231
592
  ["git", "-C", repository.path, "fetch", "--prune"],
232
593
  { captureOutput: true },
@@ -242,22 +603,77 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
242
603
  remove: (alias: string, startPath: string = process.cwd()) =>
243
604
  Effect.gen(function* () {
244
605
  const fs = yield* FileSystemService
606
+ const graph = yield* GraphService
607
+ const workbase = yield* WorkbaseService
245
608
  const repository = yield* find(alias, startPath)
246
- yield* assertRemovable(repository, startPath)
247
- yield* fs.deleteDirectory(repository.path)
609
+ const state = yield* configState(startPath)
610
+ const declarations = { ...(state.config.repositories ?? {}) }
611
+ delete declarations[repository.alias]
612
+ const config = withDeclarations(state.config, declarations)
613
+ const exists = yield* fs.exists(repository.path)
614
+ const staging = join(
615
+ state.root,
616
+ "repos",
617
+ `.agency-remove-${repository.alias}-${process.pid}-${Date.now()}`,
618
+ )
619
+ const safety = effectPreflightStep(
620
+ `verify repository safety for ${repository.alias}`,
621
+ assertRemovable(repository, startPath).pipe(
622
+ Effect.provideService(FileSystemService, fs),
623
+ Effect.provideService(GraphService, graph),
624
+ Effect.provideService(WorkbaseService, workbase),
625
+ ),
626
+ )
627
+ yield* runTransaction(
628
+ state,
629
+ config,
630
+ exists
631
+ ? [
632
+ safety,
633
+ deleteAfterMoveStep(state.root, repository.path, staging),
634
+ ]
635
+ : [safety],
636
+ )
248
637
  return repository
249
638
  }),
250
639
 
251
640
  unlink: (alias: string, startPath: string = process.cwd()) =>
252
641
  Effect.gen(function* () {
253
- const service = yield* RepositoryService
642
+ const fs = yield* FileSystemService
643
+ const graph = yield* GraphService
644
+ const workbase = yield* WorkbaseService
254
645
  const repository = yield* find(alias, startPath)
255
646
  if (repository.kind !== "symlink") {
256
647
  return yield* new RepositoryError({
257
648
  message: `Repository alias '${alias}' is not a link; use 'agency repo remove ${alias}'`,
258
649
  })
259
650
  }
260
- return yield* service.remove(alias, startPath)
651
+ const state = yield* configState(startPath)
652
+ const staging = join(
653
+ state.root,
654
+ "repos",
655
+ `.agency-unlink-${repository.alias}-${process.pid}-${Date.now()}`,
656
+ )
657
+ yield* runLifecycleTransaction({
658
+ root: state.root,
659
+ preconditions: [{ path: state.path, revision: state.revision }],
660
+ steps: [
661
+ effectPreflightStep(
662
+ `verify repository safety for ${repository.alias}`,
663
+ assertRemovable(repository, startPath).pipe(
664
+ Effect.provideService(FileSystemService, fs),
665
+ Effect.provideService(GraphService, graph),
666
+ Effect.provideService(WorkbaseService, workbase),
667
+ ),
668
+ ),
669
+ deleteAfterMoveStep(state.root, repository.path, staging),
670
+ ],
671
+ }).pipe(
672
+ Effect.mapError(
673
+ (cause) => new RepositoryError({ message: cause.message, cause }),
674
+ ),
675
+ )
676
+ return repository
261
677
  }),
262
678
 
263
679
  rename: (
@@ -267,18 +683,51 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
267
683
  ) =>
268
684
  Effect.gen(function* () {
269
685
  const fs = yield* FileSystemService
686
+ const graph = yield* GraphService
270
687
  const workbase = yield* WorkbaseService
271
688
  const repository = yield* find(alias, startPath)
272
689
  const validNewAlias = yield* validateAlias(newAlias)
273
- const root = yield* workbase.discover(startPath)
274
- const destination = join(root, "repos", validNewAlias)
275
- if (yield* fs.exists(destination)) {
690
+ const state = yield* configState(startPath)
691
+ const destination = join(state.root, "repos", validNewAlias)
692
+ if (
693
+ state.config.repositories?.[validNewAlias] ||
694
+ (yield* fs.exists(destination))
695
+ ) {
276
696
  return yield* new RepositoryError({
277
697
  message: `Repository alias '${validNewAlias}' already exists`,
278
698
  })
279
699
  }
280
- yield* assertRemovable(repository, startPath)
281
- yield* fs.moveDirectory(repository.path, destination)
700
+ const remote =
701
+ state.config.repositories?.[repository.alias]?.remote ??
702
+ repository.remote
703
+ if (!remote) {
704
+ return yield* new RepositoryError({
705
+ message: `Repository alias '${alias}' has no portable remote and cannot be renamed before adoption`,
706
+ })
707
+ }
708
+ const portable = yield* validateRemote(remote)
709
+ const declarations = { ...(state.config.repositories ?? {}) }
710
+ delete declarations[repository.alias]
711
+ declarations[validNewAlias] = { remote: portable }
712
+ const exists = yield* fs.exists(repository.path)
713
+ const safety = effectPreflightStep(
714
+ `verify repository safety for ${repository.alias}`,
715
+ assertRemovable(repository, startPath).pipe(
716
+ Effect.provideService(FileSystemService, fs),
717
+ Effect.provideService(GraphService, graph),
718
+ Effect.provideService(WorkbaseService, workbase),
719
+ ),
720
+ )
721
+ yield* runTransaction(
722
+ state,
723
+ withDeclarations(state.config, declarations),
724
+ exists
725
+ ? [
726
+ safety,
727
+ directoryMoveStep(state.root, repository.path, destination),
728
+ ]
729
+ : [safety],
730
+ )
282
731
  return yield* find(validNewAlias, startPath)
283
732
  }),
284
733
 
@@ -291,50 +740,228 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
291
740
  const fs = yield* FileSystemService
292
741
  const repository = yield* find(alias, startPath)
293
742
  if (remote === undefined) return repository
294
- if (!remote.trim()) {
295
- return yield* new RepositoryError({
296
- message: "Repository remote is required",
297
- })
298
- }
299
- const hasOrigin = repository.remote !== null
300
- const result = yield* fs.runCommand(
301
- [
302
- "git",
303
- "-C",
304
- repository.path,
305
- "remote",
306
- hasOrigin ? "set-url" : "add",
307
- "origin",
308
- remote,
309
- ],
310
- { captureOutput: true },
311
- )
312
- if (result.exitCode !== 0) {
313
- return yield* new RepositoryError({
314
- message: `Failed to update remote for repository '${alias}': ${result.stderr.trim()}`,
743
+ const portable = yield* validateRemote(remote)
744
+ const state = yield* configState(startPath)
745
+ const config = withDeclarations(state.config, {
746
+ ...(state.config.repositories ?? {}),
747
+ [repository.alias]: { remote: portable },
748
+ })
749
+ const steps: TransactionStep[] = []
750
+ if (
751
+ repository.kind !== null &&
752
+ repository.kind !== "symlink" &&
753
+ !repository.states.includes("invalid")
754
+ ) {
755
+ const previous = repository.remote
756
+ const update = (value: string | null) =>
757
+ Effect.runPromise(
758
+ fs.runCommand(
759
+ value === null
760
+ ? [
761
+ "git",
762
+ "-C",
763
+ repository.path,
764
+ "remote",
765
+ "remove",
766
+ "origin",
767
+ ]
768
+ : [
769
+ "git",
770
+ "-C",
771
+ repository.path,
772
+ "remote",
773
+ previous === null ? "add" : "set-url",
774
+ "origin",
775
+ value,
776
+ ],
777
+ { captureOutput: true },
778
+ ),
779
+ ).then((result) => {
780
+ if (result.exitCode !== 0) throw new Error(result.stderr.trim())
781
+ })
782
+ steps.push({
783
+ label: `update origin for repos/${repository.alias}`,
784
+ preflight: async () => {
785
+ const stats = await lstat(repository.path)
786
+ if (stats.isSymbolicLink()) {
787
+ throw new Error(
788
+ `Repository alias '${repository.alias}' changed to a linked checkout; retry the remote update`,
789
+ )
790
+ }
791
+ const current = await Effect.runPromise(
792
+ fs.runCommand(
793
+ [
794
+ "git",
795
+ "-C",
796
+ repository.path,
797
+ "remote",
798
+ "get-url",
799
+ "origin",
800
+ ],
801
+ { captureOutput: true },
802
+ ),
803
+ )
804
+ const currentRemote =
805
+ current.exitCode === 0 ? current.stdout.trim() : null
806
+ if (currentRemote !== previous) {
807
+ throw new Error(
808
+ `Origin for repository '${repository.alias}' changed; retry the remote update`,
809
+ )
810
+ }
811
+ },
812
+ apply: () => update(portable),
813
+ rollback: () => update(previous),
814
+ manualRecovery: `Restore origin for ${repository.path} to ${previous ?? "no remote"}`,
315
815
  })
316
816
  }
817
+ yield* runTransaction(state, config, steps)
317
818
  return yield* find(alias, startPath)
318
819
  }),
319
820
 
320
821
  verify: (alias: string, startPath: string = process.cwd()) =>
321
822
  Effect.gen(function* () {
322
- const fs = yield* FileSystemService
323
823
  const repository = yield* find(alias, startPath)
324
824
  const issues: string[] = []
325
- const git = yield* fs.runCommand(
326
- ["git", "-C", repository.path, "rev-parse", "--git-dir"],
327
- { captureOutput: true },
328
- )
329
- if (git.exitCode !== 0) issues.push("Path is not a Git repository")
330
- if (repository.remote === null)
331
- issues.push("Origin remote is not configured")
825
+ if (repository.states.includes("missing"))
826
+ issues.push("Local materialization is missing")
827
+ if (repository.states.includes("invalid"))
828
+ issues.push("Path is not a Git repository")
829
+ if (!repository.declaredRemote)
830
+ issues.push("Portable remote is not declared")
831
+ if (repository.states.includes("remote-drifted"))
832
+ issues.push("Origin remote differs from the portable declaration")
332
833
  return {
333
834
  ...repository,
334
835
  valid: issues.length === 0,
335
836
  issues,
336
837
  } satisfies RepositoryVerification
337
838
  }),
839
+
840
+ setup: (
841
+ options: { readonly cwd?: string; readonly apply?: boolean } = {},
842
+ ) =>
843
+ Effect.gen(function* () {
844
+ const service = yield* RepositoryService
845
+ const fs = yield* FileSystemService
846
+ const state = yield* configState(options.cwd ?? process.cwd())
847
+ const repositories = yield* service.list(state.root)
848
+ const planned: Omit<RepositorySetupAction, "status">[] = []
849
+ const unresolved: RepositorySetupIssue[] = []
850
+
851
+ for (const repository of repositories) {
852
+ if (repository.states.includes("invalid")) {
853
+ unresolved.push({
854
+ alias: repository.alias,
855
+ state: "invalid",
856
+ message: `Local path for '${repository.alias}' is not a valid Git repository`,
857
+ action: `Repair the path or run 'agency repo remove ${repository.alias}' before setup`,
858
+ })
859
+ continue
860
+ }
861
+ if (repository.states.includes("remote-drifted")) {
862
+ unresolved.push({
863
+ alias: repository.alias,
864
+ state: "remote-drifted",
865
+ message: `Origin for '${repository.alias}' differs from its portable declaration`,
866
+ action: `Choose the intended remote explicitly with 'agency repo remote ${repository.alias} <remote>'`,
867
+ })
868
+ continue
869
+ }
870
+ if (
871
+ repository.states.includes("missing") &&
872
+ repository.declaredRemote
873
+ ) {
874
+ planned.push({
875
+ kind: "materialize",
876
+ alias: repository.alias,
877
+ remote: repository.declaredRemote,
878
+ })
879
+ continue
880
+ }
881
+ if (!repository.states.includes("declared")) {
882
+ const decoded = repository.remote
883
+ ? Schema.decodeUnknownEither(RepositoryRemote)(
884
+ repository.remote,
885
+ )
886
+ : null
887
+ if (decoded && Either.isRight(decoded)) {
888
+ planned.push({
889
+ kind: "adopt",
890
+ alias: repository.alias,
891
+ remote: decoded.right,
892
+ })
893
+ } else {
894
+ unresolved.push({
895
+ alias: repository.alias,
896
+ state: "undeclared",
897
+ message: `Local repository '${repository.alias}' has no portable remote declaration`,
898
+ action: `Set a portable origin, then rerun 'agency repo setup --apply'`,
899
+ })
900
+ }
901
+ }
902
+ }
903
+
904
+ if (options.apply === true && planned.length > 0) {
905
+ const staging: { alias: string; from: string; to: string }[] = []
906
+ for (const action of planned.filter(
907
+ (action) => action.kind === "materialize",
908
+ )) {
909
+ const from = join(
910
+ state.root,
911
+ "repos",
912
+ `.agency-setup-${action.alias}-${process.pid}-${Date.now()}`,
913
+ )
914
+ yield* fs.createDirectory(join(state.root, "repos"))
915
+ const cloned = yield* fs.runCommand(
916
+ ["git", "clone", "--bare", "--", action.remote, from],
917
+ { captureOutput: true },
918
+ )
919
+ if (cloned.exitCode !== 0) {
920
+ for (const item of staging)
921
+ yield* fs.deleteDirectory(item.from).pipe(Effect.ignore)
922
+ return yield* new RepositoryError({
923
+ message: `Failed to materialize repository '${action.alias}': ${cloned.stderr.trim()}`,
924
+ })
925
+ }
926
+ staging.push({
927
+ alias: action.alias,
928
+ from,
929
+ to: join(state.root, "repos", action.alias),
930
+ })
931
+ }
932
+ const declarations = { ...(state.config.repositories ?? {}) }
933
+ for (const action of planned) {
934
+ declarations[action.alias] = { remote: action.remote }
935
+ }
936
+ yield* runTransaction(
937
+ state,
938
+ withDeclarations(state.config, declarations),
939
+ staging.map((item) =>
940
+ directoryMoveStep(state.root, item.from, item.to),
941
+ ),
942
+ ).pipe(
943
+ Effect.ensuring(
944
+ Effect.forEach(staging, (item) =>
945
+ fs.deleteDirectory(item.from).pipe(Effect.ignore),
946
+ ).pipe(Effect.asVoid),
947
+ ),
948
+ )
949
+ }
950
+
951
+ return {
952
+ root: state.root,
953
+ mode: options.apply === true ? "apply" : "dry-run",
954
+ actions: planned.map((action) => ({
955
+ ...action,
956
+ status: options.apply === true ? "applied" : "planned",
957
+ })),
958
+ unresolved,
959
+ repositories:
960
+ options.apply === true
961
+ ? yield* service.list(state.root)
962
+ : repositories,
963
+ } satisfies RepositorySetupResult
964
+ }),
338
965
  }),
339
966
  },
340
967
  ) {}