@markjaquith/agency 3.2.4 → 3.2.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.2.4",
3
+ "version": "3.2.6",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -89,16 +89,28 @@ describe("PullRequestService", () => {
89
89
  stdout = "",
90
90
  stderr = "",
91
91
  exitCode = 0,
92
+ listStdouts = ["[]"],
92
93
  }: {
93
94
  stdout?: string
94
95
  stderr?: string
95
96
  exitCode?: number
97
+ listStdouts?: readonly string[]
96
98
  }) => {
97
99
  const path = join(root, "bin", "gh")
98
100
  await Bun.write(
99
101
  path,
100
102
  `#!/usr/bin/env bun
101
- await Bun.write(${JSON.stringify(ghCallPath)}, JSON.stringify({ args: Bun.argv.slice(2), cwd: process.cwd() }))
103
+ const args = Bun.argv.slice(2)
104
+ const callFile = Bun.file(${JSON.stringify(ghCallPath)})
105
+ const calls = await callFile.exists() ? await callFile.json() : []
106
+ const listIndex = calls.filter((call) => call.args[1] === "list").length
107
+ calls.push({ args, cwd: process.cwd() })
108
+ await Bun.write(${JSON.stringify(ghCallPath)}, JSON.stringify(calls))
109
+ if (args[1] === "list") {
110
+ const responses = ${JSON.stringify(listStdouts)}
111
+ process.stdout.write(responses[Math.min(listIndex, responses.length - 1)] ?? "[]")
112
+ process.exit(0)
113
+ }
102
114
  process.stdout.write(${JSON.stringify(stdout)})
103
115
  process.stderr.write(${JSON.stringify(stderr)})
104
116
  process.exit(${exitCode})
@@ -108,10 +120,33 @@ process.exit(${exitCode})
108
120
  }
109
121
 
110
122
  const readGhCall = async () =>
123
+ (
124
+ (await Bun.file(ghCallPath).json()) as {
125
+ args: string[]
126
+ cwd: string
127
+ }[]
128
+ ).at(-1)!
129
+
130
+ const readGhCalls = async () =>
111
131
  (await Bun.file(ghCallPath).json()) as {
112
132
  args: string[]
113
133
  cwd: string
114
- }
134
+ }[]
135
+
136
+ const githubRecord = (
137
+ number: number,
138
+ branch = "task/example",
139
+ base = "main",
140
+ ) => ({
141
+ number,
142
+ url: `https://github.com/example/agency/pull/${number}`,
143
+ state: "OPEN",
144
+ isDraft: false,
145
+ headRefName: branch,
146
+ baseRefName: base,
147
+ headRepository: { nameWithOwner: "example/agency" },
148
+ mergeable: "UNKNOWN",
149
+ })
115
150
 
116
151
  const expectRemoteBranch = async (branch: string, exists = true) => {
117
152
  const result = await runCommand([
@@ -233,7 +268,17 @@ process.exit(${exitCode})
233
268
  await expectRemoteBranch("task/example")
234
269
  const ghCall = await readGhCall()
235
270
  expect(ghCall).toEqual({
236
- args: ["pr", "create", "--fill", "--base", "main"],
271
+ args: [
272
+ "pr",
273
+ "create",
274
+ "--fill",
275
+ "--repo",
276
+ remotePath.replace(/\.git$/, ""),
277
+ "--base",
278
+ "main",
279
+ "--head",
280
+ "task/example",
281
+ ],
237
282
  cwd: await realpath(join(root, "tasks", "example", "code", "agency")),
238
283
  })
239
284
  const updated = await Bun.file(taskPath).text()
@@ -254,8 +299,12 @@ process.exit(${exitCode})
254
299
  "pr",
255
300
  "create",
256
301
  "--fill",
302
+ "--repo",
303
+ remotePath.replace(/\.git$/, ""),
257
304
  "--base",
258
305
  "main",
306
+ "--head",
307
+ "task/example",
259
308
  "--draft",
260
309
  ])
261
310
  })
@@ -279,6 +328,8 @@ process.exit(${exitCode})
279
328
  "--fill",
280
329
  "--title",
281
330
  "Ship the workflow",
331
+ "--repo",
332
+ remotePath.replace(/\.git$/, ""),
282
333
  "--base",
283
334
  "main",
284
335
  "--head",
@@ -291,6 +342,94 @@ process.exit(${exitCode})
291
342
  ])
292
343
  })
293
344
 
345
+ test("records an existing matching GitHub PR without creating another", async () => {
346
+ await createTask()
347
+ const existing = githubRecord(46)
348
+ await writeFakeGh({ listStdouts: [JSON.stringify([existing])] })
349
+
350
+ expect(await createPullRequest()).toBe(existing.url)
351
+ const calls = await readGhCalls()
352
+ expect(calls).toHaveLength(1)
353
+ expect(calls[0]!.args).toEqual([
354
+ "pr",
355
+ "list",
356
+ "--repo",
357
+ remotePath.replace(/\.git$/, ""),
358
+ "--head",
359
+ "task/example",
360
+ "--base",
361
+ "main",
362
+ "--state",
363
+ "all",
364
+ "--json",
365
+ "number,state,isDraft,headRefName,baseRefName,headRepository,url,mergedAt,mergeable",
366
+ ])
367
+ })
368
+
369
+ test("recovers a matching GitHub PR after an ambiguous create failure", async () => {
370
+ await createTask()
371
+ const recovered = githubRecord(47)
372
+ await writeFakeGh({
373
+ stderr: "a pull request already exists",
374
+ exitCode: 1,
375
+ listStdouts: ["[]", JSON.stringify([recovered])],
376
+ })
377
+
378
+ expect(await createPullRequest()).toBe(recovered.url)
379
+ expect((await readGhCalls()).map((call) => call.args[1])).toEqual([
380
+ "list",
381
+ "create",
382
+ "list",
383
+ ])
384
+ const task = await runTestEffect(
385
+ TaskService.pipe(
386
+ Effect.flatMap((service) => service.show("example", root)),
387
+ ),
388
+ )
389
+ expect("pr" in task.data && task.data.pr).toMatchObject({
390
+ url: recovered.url,
391
+ headBranch: "task/example",
392
+ baseBranch: "main",
393
+ })
394
+ })
395
+
396
+ test("recovers a matching GitHub PR after create output is lost", async () => {
397
+ await createTask()
398
+ const recovered = githubRecord(48)
399
+ await writeFakeGh({
400
+ stdout: "Pull request created successfully",
401
+ listStdouts: ["[]", JSON.stringify([recovered])],
402
+ })
403
+
404
+ expect(await createPullRequest()).toBe(recovered.url)
405
+ expect((await readGhCalls()).map((call) => call.args[1])).toEqual([
406
+ "list",
407
+ "create",
408
+ "list",
409
+ ])
410
+ })
411
+
412
+ test("does not adopt a GitHub PR with mismatched refs", async () => {
413
+ await createTask()
414
+ await writeFakeGh({
415
+ stdout: "Pull request created successfully",
416
+ listStdouts: [
417
+ JSON.stringify([githubRecord(49, "task/other")]),
418
+ JSON.stringify([githubRecord(49, "task/other")]),
419
+ ],
420
+ })
421
+
422
+ await expect(createPullRequest()).rejects.toThrow(
423
+ "GitHub CLI did not return a pull request URL",
424
+ )
425
+ const task = await runTestEffect(
426
+ TaskService.pipe(
427
+ Effect.flatMap((service) => service.show("example", root)),
428
+ ),
429
+ )
430
+ expect("pr" in task.data && task.data.pr).toBeNull()
431
+ })
432
+
294
433
  test("rejects task-aware head and base values that contradict declarations", async () => {
295
434
  await createTask()
296
435
  await expect(
@@ -15,12 +15,16 @@ import type { PullRequestRecord } from "../workbase/schemas"
15
15
  import {
16
16
  normalizePullRequestRecord,
17
17
  parsePullRequestRecord,
18
+ recordFromGitHubJson,
18
19
  recordFromGitHubUrl,
19
20
  repositoryFromRemote,
20
21
  resolveDeliveryCommand,
21
22
  resolveGitHubCreateCommand,
22
23
  } from "../workbase/delivery-command"
23
24
 
25
+ const GITHUB_PR_FIELDS =
26
+ "number,state,isDraft,headRefName,baseRefName,headRepository,url,mergedAt,mergeable"
27
+
24
28
  class PullRequestError extends Data.TaggedError("PullRequestError")<{
25
29
  readonly message: string
26
30
  }> {}
@@ -205,6 +209,84 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
205
209
  })
206
210
  }
207
211
  const repository = repositoryFromRemote(remoteUrl)
212
+ const isGitHubRepository = /^[^/]+\/[^/]+$/.test(repository)
213
+ const discoverGitHubPullRequest = () =>
214
+ Effect.gen(function* () {
215
+ const listed = yield* fs.runCommand(
216
+ [
217
+ "gh",
218
+ "pr",
219
+ "list",
220
+ "--repo",
221
+ repository,
222
+ "--head",
223
+ execution.branch,
224
+ "--base",
225
+ execution.base,
226
+ "--state",
227
+ "all",
228
+ "--json",
229
+ GITHUB_PR_FIELDS,
230
+ ],
231
+ { cwd: workspace.writablePath!, captureOutput: true },
232
+ )
233
+ if (listed.exitCode !== 0) {
234
+ return yield* new PullRequestError({
235
+ message: `Failed to discover existing pull requests: ${listed.stderr}`,
236
+ })
237
+ }
238
+ const records = yield* Effect.try({
239
+ try: () => {
240
+ const parsed: unknown = JSON.parse(listed.stdout)
241
+ if (!Array.isArray(parsed)) {
242
+ throw new Error(
243
+ "GitHub CLI did not return a pull request list",
244
+ )
245
+ }
246
+ return parsed.map((value) =>
247
+ recordFromGitHubJson(value as Record<string, unknown>),
248
+ )
249
+ },
250
+ catch: (cause) =>
251
+ new PullRequestError({
252
+ message:
253
+ cause instanceof Error ? cause.message : String(cause),
254
+ }),
255
+ })
256
+ const matches = records.filter(
257
+ (record) =>
258
+ (!isGitHubRepository ||
259
+ (record.repository.toLowerCase() ===
260
+ repository.toLowerCase() &&
261
+ record.headRepository?.toLowerCase() ===
262
+ repository.toLowerCase() &&
263
+ record.baseRepository?.toLowerCase() ===
264
+ repository.toLowerCase())) &&
265
+ record.headBranch === execution.branch &&
266
+ record.baseBranch === execution.base,
267
+ )
268
+ if (matches.length > 1) {
269
+ return yield* new PullRequestError({
270
+ message: `Multiple pull requests match '${execution.branch}' -> '${execution.base}'`,
271
+ })
272
+ }
273
+ return matches[0] ?? null
274
+ })
275
+ const recoverGitHubPullRequest = () =>
276
+ discoverGitHubPullRequest().pipe(
277
+ Effect.catchAll(() => Effect.succeed(null)),
278
+ )
279
+ if (!config.delivery) {
280
+ const existing = yield* discoverGitHubPullRequest()
281
+ if (existing) {
282
+ return yield* service.setRecord(
283
+ taskId,
284
+ phaseId,
285
+ existing,
286
+ workspace.root,
287
+ )
288
+ }
289
+ }
208
290
  const resolved = config.delivery
209
291
  ? resolveDeliveryCommand(config.delivery, "create", {
210
292
  repository,
@@ -215,10 +297,11 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
215
297
  identifier: "",
216
298
  })
217
299
  : resolveGitHubCreateCommand({
300
+ repository,
218
301
  base: execution.base,
219
302
  draft,
220
303
  title: options.title,
221
- head: options.head,
304
+ head: execution.branch,
222
305
  labels: options.labels,
223
306
  })
224
307
  const created = yield* fs.runCommand(resolved.argv, {
@@ -227,38 +310,79 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
227
310
  env: resolved.environment,
228
311
  })
229
312
  if (created.exitCode !== 0) {
313
+ if (!config.delivery) {
314
+ const recovered = yield* recoverGitHubPullRequest()
315
+ if (recovered) {
316
+ return yield* service.setRecord(
317
+ taskId,
318
+ phaseId,
319
+ recovered,
320
+ workspace.root,
321
+ )
322
+ }
323
+ }
230
324
  return yield* new PullRequestError({
231
325
  message: `Failed to create pull request: ${created.stderr}`,
232
326
  })
233
327
  }
234
- const record = yield* Effect.try({
235
- try: () => {
236
- if (config.delivery) return parsePullRequestRecord(created.stdout)
237
- const url = created.stdout.split(/\s+/).find((value) => {
238
- try {
239
- recordFromGitHubUrl(value)
240
- return true
241
- } catch {
242
- return false
328
+ const parsedRecord = yield* Effect.either(
329
+ Effect.try({
330
+ try: () => {
331
+ if (config.delivery)
332
+ return parsePullRequestRecord(created.stdout)
333
+ const url = created.stdout.split(/\s+/).find((value) => {
334
+ try {
335
+ recordFromGitHubUrl(value)
336
+ return true
337
+ } catch {
338
+ return false
339
+ }
340
+ })
341
+ if (!url)
342
+ throw new Error(
343
+ "GitHub CLI did not return a pull request URL",
344
+ )
345
+ const normalized = normalizePullRequestRecord(url)
346
+ if (
347
+ isGitHubRepository &&
348
+ normalized.repository.toLowerCase() !==
349
+ repository.toLowerCase()
350
+ ) {
351
+ throw new Error(
352
+ "GitHub CLI returned a pull request for the wrong repository",
353
+ )
243
354
  }
244
- })
245
- if (!url)
246
- throw new Error("GitHub CLI did not return a pull request URL")
247
- const normalized = normalizePullRequestRecord(url)
248
- return {
249
- ...normalized,
250
- headRepository: repository,
251
- headBranch: execution.branch,
252
- baseRepository: normalized.repository,
253
- baseBranch: execution.base,
254
- draft,
355
+ return {
356
+ ...normalized,
357
+ headRepository: repository,
358
+ headBranch: execution.branch,
359
+ baseRepository: normalized.repository,
360
+ baseBranch: execution.base,
361
+ draft,
362
+ }
363
+ },
364
+ catch: (cause) =>
365
+ new PullRequestError({
366
+ message:
367
+ cause instanceof Error ? cause.message : String(cause),
368
+ }),
369
+ }),
370
+ )
371
+ if (parsedRecord._tag === "Left") {
372
+ if (!config.delivery) {
373
+ const recovered = yield* recoverGitHubPullRequest()
374
+ if (recovered) {
375
+ return yield* service.setRecord(
376
+ taskId,
377
+ phaseId,
378
+ recovered,
379
+ workspace.root,
380
+ )
255
381
  }
256
- },
257
- catch: (cause) =>
258
- new PullRequestError({
259
- message: cause instanceof Error ? cause.message : String(cause),
260
- }),
261
- })
382
+ }
383
+ return yield* parsedRecord.left
384
+ }
385
+ const record = parsedRecord.right
262
386
  if (
263
387
  config.delivery &&
264
388
  (record.provider !== config.delivery.provider ||
@@ -1,6 +1,6 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test"
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir, realpath } from "node:fs/promises"
3
+ import { mkdir, realpath, stat } from "node:fs/promises"
4
4
  import { dirname, join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { RepositoryService } from "./RepositoryService"
@@ -136,6 +136,30 @@ describe("RepositoryService", () => {
136
136
  })
137
137
  })
138
138
 
139
+ test("inspects only the requested repository alias", async () => {
140
+ const requested = join(root, "repos/requested")
141
+ const unrelated = join(root, "repos/unrelated")
142
+ await mkdir(requested, { recursive: true })
143
+ await mkdir(unrelated, { recursive: true })
144
+ await runGit(["init", "--initial-branch=main", requested])
145
+ await runGit(["init", "--initial-branch=main", unrelated])
146
+
147
+ const spawn = spyOn(Bun, "spawn")
148
+ try {
149
+ const repository = await runTestEffect(
150
+ RepositoryService.pipe(
151
+ Effect.flatMap((service) => service.show("requested", root)),
152
+ ),
153
+ )
154
+ expect(repository.alias).toBe("requested")
155
+ expect(spawn.mock.calls).toHaveLength(1)
156
+ expect(spawn.mock.calls[0]?.[0]).toContain(requested)
157
+ expect(spawn.mock.calls[0]?.[0]).not.toContain(unrelated)
158
+ } finally {
159
+ spawn.mockRestore()
160
+ }
161
+ })
162
+
139
163
  test("materializes a linked alias without invalidating active worktrees", async () => {
140
164
  const target = join(root, "linked-repository")
141
165
  const checkout = join(root, "tasks/active/code/linked")
@@ -146,6 +170,13 @@ describe("RepositoryService", () => {
146
170
  await Bun.write(join(target, "README.md"), "linked\n")
147
171
  await runGit(["-C", target, "add", "README.md"])
148
172
  await runGit(["-C", target, "commit", "-m", "initial"])
173
+ const commit = await gitOutput(["-C", target, "rev-parse", "HEAD"])
174
+ const sourceObject = join(
175
+ target,
176
+ ".git/objects",
177
+ commit.slice(0, 2),
178
+ commit.slice(2),
179
+ )
149
180
  await setPortableOrigin(target, "materialized")
150
181
  await runTestEffect(
151
182
  RepositoryService.pipe(
@@ -218,6 +249,15 @@ status: working
218
249
  "refs/agency/reviews/active",
219
250
  ]),
220
251
  ).toBe(await gitOutput(["-C", target, "rev-parse", "main"]))
252
+ const materializedObject = join(
253
+ root,
254
+ "repos/linked/objects",
255
+ commit.slice(0, 2),
256
+ commit.slice(2),
257
+ )
258
+ expect((await stat(materializedObject)).ino).not.toBe(
259
+ (await stat(sourceObject)).ino,
260
+ )
221
261
  })
222
262
 
223
263
  test("preserves detached registered worktree commits from a shallow linked repository", async () => {
@@ -186,18 +186,71 @@ const portableRemote = (path: string, backend?: VersionControlBackend) =>
186
186
  return yield* validateRemote(remote)
187
187
  })
188
188
 
189
- const find = (alias: string, startPath: string) =>
189
+ const inspectRepository = (
190
+ alias: string,
191
+ state: Effect.Effect.Success<ReturnType<typeof configState>>,
192
+ backend: VersionControlBackend,
193
+ ) =>
190
194
  Effect.gen(function* () {
191
- const service = yield* RepositoryService
192
- const validAlias = yield* validateAlias(alias)
193
- const repositories = yield* service.list(startPath)
194
- const repository = repositories.find((item) => item.alias === validAlias)
195
- if (!repository) {
195
+ const fs = yield* FileSystemService
196
+ const path = join(state.root, "repos", alias)
197
+ const declaredRemote = state.config.repositories?.[alias]?.remote ?? null
198
+ const entry = yield* fs.inspectFile(path)
199
+ if (entry.kind === "missing") {
200
+ if (declaredRemote) {
201
+ return {
202
+ alias,
203
+ path,
204
+ kind: null,
205
+ remote: null,
206
+ declaredRemote,
207
+ target: null,
208
+ states: ["declared", "missing"],
209
+ } as RepositoryInfo
210
+ }
196
211
  return yield* new RepositoryError({
197
- message: `Unknown repository alias '${validAlias}'`,
212
+ message: `Unknown repository alias '${alias}'`,
198
213
  })
199
214
  }
200
- return repository
215
+ const isSymlink = entry.kind === "symlink"
216
+ if (!isSymlink && !(yield* fs.isDirectory(path))) {
217
+ return {
218
+ alias,
219
+ path,
220
+ kind: null,
221
+ remote: null,
222
+ declaredRemote,
223
+ target: null,
224
+ states: [...(declaredRemote ? (["declared"] as const) : []), "invalid"],
225
+ } as RepositoryInfo
226
+ }
227
+ const target = isSymlink ? yield* fs.readSymlinkTarget(path) : null
228
+ const inspection = yield* backend.inspectRepository(path)
229
+ const remote = inspection?.remote ?? null
230
+ const states: RepositoryState[] = []
231
+ if (declaredRemote) states.push("declared")
232
+ states.push(isSymlink ? "linked" : "materialized")
233
+ if (!inspection) states.push("invalid")
234
+ if (declaredRemote && remote !== declaredRemote)
235
+ states.push("remote-drifted")
236
+ return {
237
+ alias,
238
+ path,
239
+ kind: isSymlink ? "symlink" : (inspection?.kind ?? "repository"),
240
+ remote,
241
+ declaredRemote,
242
+ target,
243
+ states,
244
+ } as RepositoryInfo
245
+ })
246
+
247
+ const find = (alias: string, startPath: string) =>
248
+ Effect.gen(function* () {
249
+ const versionControl = yield* VersionControlService
250
+ const validAlias = yield* validateAlias(alias)
251
+ const state = yield* configState(startPath)
252
+ const backend = yield* versionControl.forWorkbase(state.root)
253
+ return yield* inspectRepository(validAlias, state, backend)
201
254
  })
202
255
 
203
256
  const requireMaterialized = (repository: RepositoryInfo) =>
@@ -450,14 +503,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
450
503
  const backend = yield* versionControl.forWorkbase(state.root)
451
504
  const destination = join(state.root, "repos", validAlias)
452
505
  const resolvedTarget = resolve(startPath, target)
453
- const existing = (yield* RepositoryService)
454
- .list(state.root)
455
- .pipe(
456
- Effect.map((items) =>
457
- items.find((item) => item.alias === validAlias),
458
- ),
459
- )
460
- const current = yield* existing
506
+ const current =
507
+ state.config.repositories?.[validAlias] ||
508
+ (yield* fs.exists(destination))
509
+ ? yield* inspectRepository(validAlias, state, backend)
510
+ : undefined
461
511
  const localCurrent =
462
512
  current && !current.states.includes("missing") ? current : undefined
463
513
  if (localCurrent?.kind === "symlink") {
@@ -521,10 +571,17 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
521
571
  Effect.gen(function* () {
522
572
  const fs = yield* FileSystemService
523
573
  const versionControl = yield* VersionControlService
524
- const repository = yield* find(alias, startPath)
574
+ const validAlias = yield* validateAlias(alias)
575
+ const state = yield* configState(startPath)
576
+ const backend = yield* versionControl.forWorkbase(state.root)
577
+ const repository = yield* inspectRepository(
578
+ validAlias,
579
+ state,
580
+ backend,
581
+ )
525
582
  if (repository.kind !== "symlink" || !repository.target) {
526
583
  return yield* new RepositoryError({
527
- message: `Repository alias '${alias}' is not linked`,
584
+ message: `Repository alias '${validAlias}' is not linked`,
528
585
  })
529
586
  }
530
587
  if (repository.states.includes("invalid")) {
@@ -543,9 +600,6 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
543
600
  })
544
601
  }
545
602
 
546
- const state = yield* configState(startPath)
547
- const backend = yield* versionControl.forWorkbase(state.root)
548
-
549
603
  const source = yield* fs.realPath(repository.path)
550
604
  const registered = yield* backend.listWorkspaces(repository.path)
551
605
  const registeredState = registered
@@ -854,78 +908,22 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
854
908
  list: (startPath: string = process.cwd()) =>
855
909
  Effect.gen(function* () {
856
910
  const fs = yield* FileSystemService
857
- const { root, config } = yield* WorkbaseService.pipe(
858
- Effect.flatMap((service) => service.loadConfig(startPath)),
859
- )
860
911
  const versionControl = yield* VersionControlService
861
- const backend = yield* versionControl.forWorkbase(root)
862
- const reposPath = join(root, "repos")
912
+ const state = yield* configState(startPath)
913
+ const backend = yield* versionControl.forWorkbase(state.root)
914
+ const reposPath = join(state.root, "repos")
863
915
  const entries = (yield* fs.isDirectory(reposPath))
864
916
  ? (yield* fs.readDirectory(reposPath)).filter(
865
917
  (entry) => !entry.name.startsWith(".agency-"),
866
918
  )
867
919
  : []
868
- const local = new Map(entries.map((entry) => [entry.name, entry]))
869
920
  const aliases = new Set([
870
- ...Object.keys(config.repositories ?? {}),
871
- ...local.keys(),
921
+ ...Object.keys(state.config.repositories ?? {}),
922
+ ...entries.map((entry) => entry.name),
872
923
  ])
873
924
  return yield* Effect.forEach(
874
925
  [...aliases].sort(),
875
- (alias) =>
876
- Effect.gen(function* () {
877
- const path = join(reposPath, alias)
878
- const entry = local.get(alias)
879
- const declaredRemote =
880
- config.repositories?.[alias]?.remote ?? null
881
- if (!entry) {
882
- return {
883
- alias,
884
- path,
885
- kind: null,
886
- remote: null,
887
- declaredRemote,
888
- target: null,
889
- states: ["declared", "missing"] as RepositoryState[],
890
- } satisfies RepositoryInfo
891
- }
892
- if (!entry.isDirectory && !entry.isSymlink) {
893
- return {
894
- alias,
895
- path,
896
- kind: null,
897
- remote: null,
898
- declaredRemote,
899
- target: null,
900
- states: [
901
- ...(declaredRemote ? (["declared"] as const) : []),
902
- "invalid",
903
- ] as RepositoryState[],
904
- } satisfies RepositoryInfo
905
- }
906
- const target = entry.isSymlink
907
- ? yield* fs.readSymlinkTarget(path)
908
- : null
909
- const inspection = yield* backend.inspectRepository(path)
910
- const remote = inspection?.remote ?? null
911
- const states: RepositoryState[] = []
912
- if (declaredRemote) states.push("declared")
913
- states.push(entry.isSymlink ? "linked" : "materialized")
914
- if (!inspection) states.push("invalid")
915
- if (declaredRemote && remote !== declaredRemote)
916
- states.push("remote-drifted")
917
- return {
918
- alias,
919
- path,
920
- kind: entry.isSymlink
921
- ? "symlink"
922
- : (inspection?.kind ?? "repository"),
923
- remote,
924
- declaredRemote,
925
- target,
926
- states,
927
- } satisfies RepositoryInfo
928
- }),
926
+ (alias) => inspectRepository(alias, state, backend),
929
927
  { concurrency: 8 },
930
928
  )
931
929
  }),
@@ -171,7 +171,15 @@ export class GitVersionControlService extends Effect.Service<GitVersionControlSe
171
171
  yield* requireSuccess(
172
172
  "Failed to clone Git repository",
173
173
  fs.runCommand(
174
- ["git", "clone", "--bare", "--", source, destination],
174
+ [
175
+ "git",
176
+ "clone",
177
+ "--bare",
178
+ "--no-hardlinks",
179
+ "--",
180
+ source,
181
+ destination,
182
+ ],
175
183
  {
176
184
  captureOutput: true,
177
185
  },
@@ -34,24 +34,19 @@ describe("delivery commands", () => {
34
34
 
35
35
  test("builds the default GitHub create command", () => {
36
36
  const input = {
37
+ repository: "example/agency",
37
38
  base: "main",
38
39
  draft: false,
40
+ head: "feat/example",
39
41
  } as const
40
42
  expect(resolveGitHubCreateCommand(input)).toEqual({
41
- argv: ["gh", "pr", "create", "--fill", "--base", "main"],
42
- environment: {},
43
- })
44
- expect(
45
- resolveGitHubCreateCommand({
46
- ...input,
47
- head: "feat/example",
48
- }),
49
- ).toEqual({
50
43
  argv: [
51
44
  "gh",
52
45
  "pr",
53
46
  "create",
54
47
  "--fill",
48
+ "--repo",
49
+ "example/agency",
55
50
  "--base",
56
51
  "main",
57
52
  "--head",
@@ -74,6 +69,8 @@ describe("delivery commands", () => {
74
69
  "--fill",
75
70
  "--title",
76
71
  "Requested",
72
+ "--repo",
73
+ "example/agency",
77
74
  "--base",
78
75
  "main",
79
76
  "--head",
@@ -19,16 +19,18 @@ export const repositoryFromRemote = (remote: string) =>
19
19
  .replace(/\/$/, "")
20
20
 
21
21
  export const resolveGitHubCreateCommand = ({
22
+ repository,
22
23
  base,
23
24
  draft,
24
25
  title,
25
26
  head,
26
27
  labels = [],
27
28
  }: {
29
+ readonly repository: string
28
30
  readonly base: string
29
31
  readonly draft: boolean
30
32
  readonly title?: string
31
- readonly head?: string
33
+ readonly head: string
32
34
  readonly labels?: readonly string[]
33
35
  }) => ({
34
36
  argv: [
@@ -36,9 +38,12 @@ export const resolveGitHubCreateCommand = ({
36
38
  "pr",
37
39
  "create",
38
40
  ...(title ? ["--fill", "--title", title] : ["--fill"]),
41
+ "--repo",
42
+ repository,
39
43
  "--base",
40
44
  base,
41
- ...(head ? ["--head", head] : []),
45
+ "--head",
46
+ head,
42
47
  ...(draft ? ["--draft"] : []),
43
48
  ...labels.flatMap((label) => ["--label", label]),
44
49
  ],