@markjaquith/agency 2.50.0 → 2.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -8
- package/cli.ts +33 -1
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +1 -0
- package/src/cli-parser.test.ts +19 -0
- package/src/cli-parser.ts +23 -0
- package/src/cli.test.ts +4 -4
- package/src/commands/init.test.ts +2 -0
- package/src/commands/vcs.test.ts +75 -0
- package/src/commands/vcs.ts +72 -0
- package/src/commands/worktree.ts +1 -1
- package/src/graph-schema.test.ts +1 -1
- package/src/graph-schema.ts +1 -0
- package/src/services/ArchiveService.ts +23 -0
- package/src/services/ContextService.ts +62 -13
- package/src/services/DoctorService.ts +13 -6
- package/src/services/GraphService.ts +1 -0
- package/src/services/PhaseService.ts +191 -70
- package/src/services/PullRequestService.ts +14 -30
- package/src/services/RepositoryService.test.ts +31 -1
- package/src/services/RepositoryService.ts +63 -31
- package/src/services/ReviewService.ts +23 -0
- package/src/services/SyncService.test.ts +67 -0
- package/src/services/SyncService.ts +51 -84
- package/src/services/TaskPhaseService.test.ts +80 -0
- package/src/services/VcsMigrationService.test.ts +245 -0
- package/src/services/VcsMigrationService.ts +812 -0
- package/src/services/VersionControlService.test.ts +100 -0
- package/src/services/VersionControlService.ts +479 -0
- package/src/services/WorkbaseService.ts +5 -1
- package/src/services/WorktreeService.test.ts +72 -1
- package/src/services/WorktreeService.ts +808 -308
- package/src/test-utils.ts +10 -0
- package/src/workbase/AGENTS.md +2 -2
- package/src/workbase/schemas.ts +1 -0
- package/src/workbase/version-control.ts +5 -0
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
buildNonPrCompletion,
|
|
31
31
|
type NonPrCompletionInput,
|
|
32
32
|
} from "../workbase/completion"
|
|
33
|
+
import { VersionControlService } from "./VersionControlService"
|
|
33
34
|
|
|
34
35
|
class PhaseError extends Data.TaggedError("PhaseError")<{
|
|
35
36
|
readonly message: string
|
|
@@ -92,8 +93,10 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
92
93
|
Effect.gen(function* () {
|
|
93
94
|
const fs = yield* FileSystemService
|
|
94
95
|
const workbase = yield* WorkbaseService
|
|
96
|
+
const versionControl = yield* VersionControlService
|
|
95
97
|
const tasks = yield* TaskService
|
|
96
98
|
const root = yield* workbase.discover(startPath)
|
|
99
|
+
const backend = yield* versionControl.forWorkbase(root)
|
|
97
100
|
const taskId = yield* decodeId(input.taskId, "task")
|
|
98
101
|
const id = yield* decodeId(input.id, "phase")
|
|
99
102
|
const task = yield* tasks.show(taskId, root)
|
|
@@ -268,95 +271,213 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
268
271
|
firstData.repo,
|
|
269
272
|
...(firstData.repos ?? []).map((reference) => reference.repo),
|
|
270
273
|
]
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
274
|
+
if (backend.kind === "jj") {
|
|
275
|
+
const workspaces: {
|
|
276
|
+
alias: string
|
|
277
|
+
name: string
|
|
278
|
+
head: string
|
|
279
|
+
oldPath: string
|
|
280
|
+
newPath: string
|
|
281
|
+
}[] = []
|
|
282
|
+
const runBackend = <A>(
|
|
283
|
+
effect: Effect.Effect<A, unknown, any>,
|
|
284
|
+
) =>
|
|
285
|
+
Effect.runPromise(
|
|
286
|
+
effect.pipe(
|
|
287
|
+
Effect.provideService(FileSystemService, fs),
|
|
288
|
+
) as Effect.Effect<A, unknown, never>,
|
|
289
|
+
)
|
|
290
|
+
steps.push({
|
|
291
|
+
label: `move jj workspaces for ${taskId}/${firstPhaseId}`,
|
|
292
|
+
preflight: async () => {
|
|
293
|
+
for (const entry of await readdir(oldCodePath)) {
|
|
294
|
+
if (!checkoutAliases.includes(entry))
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
for (const alias of checkoutAliases) {
|
|
300
|
+
const oldPath = join(oldCodePath, alias)
|
|
301
|
+
try {
|
|
302
|
+
await lstat(oldPath)
|
|
303
|
+
} catch {
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
const expected = await realpath(oldPath)
|
|
307
|
+
const registered = (
|
|
308
|
+
await runBackend(
|
|
309
|
+
backend.listWorkspaces(join(root, "repos", alias)),
|
|
310
|
+
)
|
|
311
|
+
).find((workspace) => workspace.path === expected)
|
|
312
|
+
if (!registered?.name)
|
|
313
|
+
throw new Error(
|
|
314
|
+
`Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a jj workspace`,
|
|
315
|
+
)
|
|
316
|
+
if (await runBackend(backend.workspaceDirty(oldPath)))
|
|
317
|
+
throw new Error(
|
|
318
|
+
`Cannot convert task '${taskId}'; jj workspace '${alias}' has uncommitted changes`,
|
|
319
|
+
)
|
|
320
|
+
const head = await runBackend(
|
|
321
|
+
backend.workspaceHead(oldPath),
|
|
301
322
|
)
|
|
302
|
-
|
|
323
|
+
if (!head)
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Cannot resolve jj workspace '${alias}' before conversion`,
|
|
326
|
+
)
|
|
327
|
+
workspaces.push({
|
|
328
|
+
alias,
|
|
329
|
+
name: registered.name,
|
|
330
|
+
head,
|
|
331
|
+
oldPath,
|
|
332
|
+
newPath: join(firstCodePath, alias),
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
apply: async () => {
|
|
337
|
+
await mkdir(firstCodePath, { recursive: true })
|
|
338
|
+
for (const workspace of workspaces) {
|
|
339
|
+
const repositoryPath = join(
|
|
340
|
+
root,
|
|
341
|
+
"repos",
|
|
342
|
+
workspace.alias,
|
|
343
|
+
)
|
|
344
|
+
await runBackend(
|
|
345
|
+
backend.removeWorkspace({
|
|
346
|
+
repositoryPath,
|
|
347
|
+
workspacePath: workspace.oldPath,
|
|
348
|
+
workspaceName: workspace.name,
|
|
349
|
+
}),
|
|
350
|
+
)
|
|
351
|
+
await runBackend(
|
|
352
|
+
backend.createWorkspace({
|
|
353
|
+
repositoryPath,
|
|
354
|
+
workspacePath: workspace.newPath,
|
|
355
|
+
workspaceName: workspace.name,
|
|
356
|
+
revision: workspace.head,
|
|
357
|
+
}),
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
await rm(oldCodePath, { recursive: true, force: true })
|
|
361
|
+
},
|
|
362
|
+
rollback: async () => {
|
|
363
|
+
await mkdir(oldCodePath, { recursive: true })
|
|
364
|
+
for (const workspace of [...workspaces].reverse()) {
|
|
365
|
+
const repositoryPath = join(
|
|
366
|
+
root,
|
|
367
|
+
"repos",
|
|
368
|
+
workspace.alias,
|
|
369
|
+
)
|
|
370
|
+
await runBackend(
|
|
371
|
+
backend.removeWorkspace({
|
|
372
|
+
repositoryPath,
|
|
373
|
+
workspacePath: workspace.newPath,
|
|
374
|
+
workspaceName: workspace.name,
|
|
375
|
+
}),
|
|
376
|
+
)
|
|
377
|
+
await runBackend(
|
|
378
|
+
backend.createWorkspace({
|
|
379
|
+
repositoryPath,
|
|
380
|
+
workspacePath: workspace.oldPath,
|
|
381
|
+
workspaceName: workspace.name,
|
|
382
|
+
revision: workspace.head,
|
|
383
|
+
}),
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
await rm(firstDirectory, { recursive: true, force: true })
|
|
387
|
+
},
|
|
388
|
+
manualRecovery: `Restore jj workspaces from ${firstCodePath} to ${oldCodePath}`,
|
|
389
|
+
})
|
|
390
|
+
} else {
|
|
391
|
+
const repair = async (basePath: string) => {
|
|
303
392
|
for (const alias of checkoutAliases) {
|
|
304
|
-
const checkoutPath = join(
|
|
393
|
+
const checkoutPath = join(basePath, alias)
|
|
305
394
|
try {
|
|
306
395
|
await lstat(checkoutPath)
|
|
307
396
|
} catch {
|
|
308
397
|
continue
|
|
309
398
|
}
|
|
310
|
-
const
|
|
399
|
+
const result = Bun.spawnSync([
|
|
311
400
|
"git",
|
|
312
401
|
"-C",
|
|
313
402
|
join(root, "repos", alias),
|
|
314
403
|
"worktree",
|
|
315
|
-
"
|
|
316
|
-
|
|
404
|
+
"repair",
|
|
405
|
+
checkoutPath,
|
|
317
406
|
])
|
|
318
|
-
if (
|
|
407
|
+
if (result.exitCode !== 0) {
|
|
319
408
|
throw new Error(
|
|
320
|
-
`Failed to
|
|
409
|
+
`Failed to repair moved worktree for '${alias}': ${new TextDecoder().decode(result.stderr)}`,
|
|
321
410
|
)
|
|
322
|
-
const expected = await realpath(checkoutPath)
|
|
323
|
-
let registered = false
|
|
324
|
-
for (const line of new TextDecoder()
|
|
325
|
-
.decode(listed.stdout)
|
|
326
|
-
.split("\n")) {
|
|
327
|
-
if (!line.startsWith("worktree ")) continue
|
|
328
|
-
try {
|
|
329
|
-
if ((await realpath(line.slice(9))) === expected) {
|
|
330
|
-
registered = true
|
|
331
|
-
break
|
|
332
|
-
}
|
|
333
|
-
} catch {}
|
|
334
411
|
}
|
|
335
|
-
if (!registered)
|
|
336
|
-
throw new Error(
|
|
337
|
-
`Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a Git worktree`,
|
|
338
|
-
)
|
|
339
412
|
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
413
|
+
}
|
|
414
|
+
steps.push({
|
|
415
|
+
label: `move and repair code for ${taskId}/${firstPhaseId}`,
|
|
416
|
+
preflight: async () => {
|
|
417
|
+
for (const entry of await readdir(oldCodePath)) {
|
|
418
|
+
if (!checkoutAliases.includes(entry))
|
|
419
|
+
throw new Error(
|
|
420
|
+
`Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
for (const alias of checkoutAliases) {
|
|
424
|
+
const checkoutPath = join(oldCodePath, alias)
|
|
425
|
+
try {
|
|
426
|
+
await lstat(checkoutPath)
|
|
427
|
+
} catch {
|
|
428
|
+
continue
|
|
429
|
+
}
|
|
430
|
+
const listed = Bun.spawnSync([
|
|
431
|
+
"git",
|
|
432
|
+
"-C",
|
|
433
|
+
join(root, "repos", alias),
|
|
434
|
+
"worktree",
|
|
435
|
+
"list",
|
|
436
|
+
"--porcelain",
|
|
437
|
+
])
|
|
438
|
+
if (listed.exitCode !== 0)
|
|
439
|
+
throw new Error(
|
|
440
|
+
`Failed to inspect worktrees for '${alias}'`,
|
|
441
|
+
)
|
|
442
|
+
const expected = await realpath(checkoutPath)
|
|
443
|
+
let registered = false
|
|
444
|
+
for (const line of new TextDecoder()
|
|
445
|
+
.decode(listed.stdout)
|
|
446
|
+
.split("\n")) {
|
|
447
|
+
if (!line.startsWith("worktree ")) continue
|
|
448
|
+
try {
|
|
449
|
+
if ((await realpath(line.slice(9))) === expected) {
|
|
450
|
+
registered = true
|
|
451
|
+
break
|
|
452
|
+
}
|
|
453
|
+
} catch {}
|
|
454
|
+
}
|
|
455
|
+
if (!registered)
|
|
456
|
+
throw new Error(
|
|
457
|
+
`Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a Git worktree`,
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
apply: async () => {
|
|
462
|
+
await mkdir(firstDirectory, { recursive: true })
|
|
463
|
+
await rename(oldCodePath, firstCodePath)
|
|
464
|
+
try {
|
|
465
|
+
await repair(firstCodePath)
|
|
466
|
+
} catch (cause) {
|
|
467
|
+
await rename(firstCodePath, oldCodePath)
|
|
468
|
+
await repair(oldCodePath)
|
|
469
|
+
await rm(firstDirectory, { recursive: true, force: true })
|
|
470
|
+
throw cause
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
rollback: async () => {
|
|
347
474
|
await rename(firstCodePath, oldCodePath)
|
|
348
475
|
await repair(oldCodePath)
|
|
349
476
|
await rm(firstDirectory, { recursive: true, force: true })
|
|
350
|
-
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
await rename(firstCodePath, oldCodePath)
|
|
355
|
-
await repair(oldCodePath)
|
|
356
|
-
await rm(firstDirectory, { recursive: true, force: true })
|
|
357
|
-
},
|
|
358
|
-
manualRecovery: `Move ${firstCodePath} back to ${oldCodePath} and run git worktree repair`,
|
|
359
|
-
})
|
|
477
|
+
},
|
|
478
|
+
manualRecovery: `Move ${firstCodePath} back to ${oldCodePath} and run git worktree repair`,
|
|
479
|
+
})
|
|
480
|
+
}
|
|
360
481
|
}
|
|
361
482
|
steps.push(
|
|
362
483
|
documentWriteStep(root, [
|
|
@@ -6,6 +6,7 @@ import type { BaseCommandOptions } from "../utils/command"
|
|
|
6
6
|
import { TaskService } from "./TaskService"
|
|
7
7
|
import { PhaseService } from "./PhaseService"
|
|
8
8
|
import { ReadinessService } from "./ReadinessService"
|
|
9
|
+
import { VersionControlService } from "./VersionControlService"
|
|
9
10
|
import {
|
|
10
11
|
formatMarkdownDocument,
|
|
11
12
|
parseFrontmatter,
|
|
@@ -110,6 +111,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
110
111
|
const worktrees = yield* WorktreeService
|
|
111
112
|
const readiness = yield* ReadinessService
|
|
112
113
|
const workbase = yield* WorkbaseService
|
|
114
|
+
const versionControl = yield* VersionControlService
|
|
113
115
|
const requestedTask = yield* tasks.show(taskId, startPath)
|
|
114
116
|
if ("review" in requestedTask.data) {
|
|
115
117
|
return yield* new PullRequestError({
|
|
@@ -154,6 +156,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
154
156
|
? (yield* phases.show(taskId, phaseId!, workspace.root)).data
|
|
155
157
|
: workspaceTask.data
|
|
156
158
|
const { config } = yield* workbase.loadConfig(workspace.root)
|
|
159
|
+
const backend = yield* versionControl.forWorkbase(workspace.root)
|
|
157
160
|
if (!workspace.writablePath) {
|
|
158
161
|
return yield* new PullRequestError({
|
|
159
162
|
message: `Task '${taskId}' has no writable checkout`,
|
|
@@ -161,49 +164,30 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
161
164
|
}
|
|
162
165
|
const remote = config.delivery?.remote ?? "origin"
|
|
163
166
|
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
{ captureOutput: true },
|
|
167
|
-
)
|
|
168
|
-
if (status.exitCode !== 0) {
|
|
167
|
+
const dirty = yield* backend.workspaceDirty(workspace.writablePath)
|
|
168
|
+
if (dirty === null) {
|
|
169
169
|
return yield* new PullRequestError({
|
|
170
|
-
message:
|
|
170
|
+
message: "Failed to inspect worktree status",
|
|
171
171
|
})
|
|
172
172
|
}
|
|
173
|
-
if (
|
|
173
|
+
if (dirty) {
|
|
174
174
|
return yield* new PullRequestError({
|
|
175
175
|
message: "Cannot create a PR with a dirty worktree",
|
|
176
176
|
})
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
|
|
180
|
-
[
|
|
181
|
-
"git",
|
|
182
|
-
"-C",
|
|
183
|
-
workspace.writablePath,
|
|
184
|
-
"push",
|
|
185
|
-
"--set-upstream",
|
|
186
|
-
remote,
|
|
187
|
-
execution.branch,
|
|
188
|
-
],
|
|
189
|
-
{ captureOutput: true },
|
|
190
|
-
)
|
|
191
|
-
if (push.exitCode !== 0) {
|
|
192
|
-
return yield* new PullRequestError({
|
|
193
|
-
message: `Failed to push branch: ${push.stderr}`,
|
|
194
|
-
})
|
|
195
|
-
}
|
|
179
|
+
yield* backend.push(workspace.writablePath, remote, execution.branch)
|
|
196
180
|
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
181
|
+
const remoteUrl = yield* backend.remoteUrl(
|
|
182
|
+
workspace.writablePath,
|
|
183
|
+
remote,
|
|
200
184
|
)
|
|
201
|
-
if (
|
|
185
|
+
if (!remoteUrl) {
|
|
202
186
|
return yield* new PullRequestError({
|
|
203
|
-
message: `Failed to inspect delivery remote '${remote}'
|
|
187
|
+
message: `Failed to inspect delivery remote '${remote}'`,
|
|
204
188
|
})
|
|
205
189
|
}
|
|
206
|
-
const repository = repositoryFromRemote(
|
|
190
|
+
const repository = repositoryFromRemote(remoteUrl)
|
|
207
191
|
const resolved = config.delivery
|
|
208
192
|
? resolveDeliveryCommand(config.delivery, "create", {
|
|
209
193
|
repository,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdir } from "node:fs/promises"
|
|
3
|
+
import { mkdir, 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"
|
|
@@ -98,6 +98,36 @@ describe("RepositoryService", () => {
|
|
|
98
98
|
])
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
+
test("initializes a managed clone with jj for a jj workbase", async () => {
|
|
102
|
+
if (!Bun.which("jj")) return
|
|
103
|
+
await Bun.write(
|
|
104
|
+
join(root, "agency.json"),
|
|
105
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
106
|
+
)
|
|
107
|
+
const source = join(root, "source")
|
|
108
|
+
await runGit(["init", "--initial-branch=main", source])
|
|
109
|
+
await runGit(["-C", source, "config", "user.email", "test@example.com"])
|
|
110
|
+
await runGit(["-C", source, "config", "user.name", "Test"])
|
|
111
|
+
await Bun.write(join(source, "README.md"), "example\n")
|
|
112
|
+
await runGit(["-C", source, "add", "README.md"])
|
|
113
|
+
await runGit(["-C", source, "commit", "-m", "initial"])
|
|
114
|
+
await setPortableOrigin(source, "jj-agency")
|
|
115
|
+
|
|
116
|
+
const destination = await runTestEffect(
|
|
117
|
+
RepositoryService.pipe(
|
|
118
|
+
Effect.flatMap((service) => service.add("agency", source, root)),
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
expect((await stat(join(destination, ".jj"))).isDirectory()).toBe(true)
|
|
122
|
+
expect(
|
|
123
|
+
await runTestEffect(
|
|
124
|
+
RepositoryService.pipe(
|
|
125
|
+
Effect.flatMap((service) => service.show("agency", root)),
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
).toMatchObject({ kind: "repository" })
|
|
129
|
+
})
|
|
130
|
+
|
|
101
131
|
test("links an existing repository", async () => {
|
|
102
132
|
const target = join(root, "linked-repository")
|
|
103
133
|
await mkdir(target, { recursive: true })
|