@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 +1 -1
- package/src/services/PullRequestService.test.ts +142 -3
- package/src/services/PullRequestService.ts +151 -27
- package/src/services/RepositoryService.test.ts +42 -2
- package/src/services/RepositoryService.ts +81 -83
- package/src/services/VersionControlService.ts +9 -1
- package/src/workbase/delivery-command.test.ts +6 -9
- package/src/workbase/delivery-command.ts +7 -2
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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: [
|
|
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:
|
|
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
|
|
235
|
-
try
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
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 '${
|
|
212
|
+
message: `Unknown repository alias '${alias}'`,
|
|
198
213
|
})
|
|
199
214
|
}
|
|
200
|
-
|
|
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
|
|
454
|
-
|
|
455
|
-
.
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
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 '${
|
|
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
|
|
862
|
-
const
|
|
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
|
-
...
|
|
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
|
-
[
|
|
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
|
|
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
|
-
|
|
45
|
+
"--head",
|
|
46
|
+
head,
|
|
42
47
|
...(draft ? ["--draft"] : []),
|
|
43
48
|
...labels.flatMap((label) => ["--label", label]),
|
|
44
49
|
],
|