@markjaquith/agency 2.52.0 → 2.52.2
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/cli-main.ts +854 -0
- package/cli.ts +15 -850
- package/package.json +2 -1
- package/src/services/GraphMutationService.test.ts +71 -0
- package/src/services/GraphMutationService.ts +14 -2
- package/src/services/TaskService.ts +1 -1
- package/src/services/VcsMigrationService.test.ts +46 -0
- package/src/services/VcsMigrationService.ts +35 -21
- package/src/services/VersionControlService.ts +9 -3
- package/src/services/WorktreeService.ts +234 -40
- package/src/vcs-status-fast.ts +315 -0
- package/src/workbase/opencode-plugin-file.ts +31 -4
|
@@ -13,6 +13,12 @@ import type { BaseCommandOptions } from "../utils/command"
|
|
|
13
13
|
import { createLoggers } from "../utils/effect"
|
|
14
14
|
import { withWorktreeLocks } from "./WorktreeLock"
|
|
15
15
|
import { VersionControlService } from "./VersionControlService"
|
|
16
|
+
import type {
|
|
17
|
+
RegisteredWorkspace,
|
|
18
|
+
VersionControlBackend,
|
|
19
|
+
} from "./VersionControlService"
|
|
20
|
+
import type { TaskRecord } from "./TaskService"
|
|
21
|
+
import type { PhaseRecord } from "./PhaseService"
|
|
16
22
|
|
|
17
23
|
class WorktreeError extends Data.TaggedError("WorktreeError")<{
|
|
18
24
|
readonly message: string
|
|
@@ -113,6 +119,19 @@ interface WorktreeInspection {
|
|
|
113
119
|
readonly conflicts: readonly WorktreeConflict[]
|
|
114
120
|
}
|
|
115
121
|
|
|
122
|
+
interface InspectionContext {
|
|
123
|
+
readonly root: string
|
|
124
|
+
readonly backend: VersionControlBackend
|
|
125
|
+
readonly tasks: readonly TaskRecord[]
|
|
126
|
+
readonly phasesByTask: ReadonlyMap<string, readonly PhaseRecord[]>
|
|
127
|
+
readonly ownership: ReadonlyMap<string, readonly WorktreeOwner[]>
|
|
128
|
+
readonly workspacesByRepository: ReadonlyMap<
|
|
129
|
+
string,
|
|
130
|
+
readonly RegisteredWorkspace[]
|
|
131
|
+
>
|
|
132
|
+
readonly skipWritableRevisionResolution: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
116
135
|
interface WorktreeLifecycleResult {
|
|
117
136
|
readonly operation: "remove" | "rebuild" | "repair"
|
|
118
137
|
readonly dryRun: boolean
|
|
@@ -176,10 +195,17 @@ interface LifecycleOptions extends BaseCommandOptions {
|
|
|
176
195
|
readonly lockHeld?: boolean
|
|
177
196
|
}
|
|
178
197
|
|
|
198
|
+
interface ListOptions {
|
|
199
|
+
readonly materializedOnly?: boolean
|
|
200
|
+
readonly tasks?: readonly TaskRecord[]
|
|
201
|
+
readonly phasesByTask?: ReadonlyMap<string, readonly PhaseRecord[]>
|
|
202
|
+
}
|
|
203
|
+
|
|
179
204
|
const inspectExecution = (
|
|
180
205
|
taskId: string,
|
|
181
206
|
phaseId: string | undefined,
|
|
182
207
|
startPath: string,
|
|
208
|
+
context?: InspectionContext,
|
|
183
209
|
) =>
|
|
184
210
|
Effect.gen(function* () {
|
|
185
211
|
const fs = yield* FileSystemService
|
|
@@ -187,9 +213,17 @@ const inspectExecution = (
|
|
|
187
213
|
const versionControl = yield* VersionControlService
|
|
188
214
|
const tasks = yield* TaskService
|
|
189
215
|
const phases = yield* PhaseService
|
|
190
|
-
const root = yield* workbase.discover(startPath)
|
|
191
|
-
const backend =
|
|
192
|
-
|
|
216
|
+
const root = context?.root ?? (yield* workbase.discover(startPath))
|
|
217
|
+
const backend =
|
|
218
|
+
context?.backend ?? (yield* versionControl.forWorkbase(root))
|
|
219
|
+
const task = context
|
|
220
|
+
? context.tasks.find((record) => record.id === taskId)
|
|
221
|
+
: yield* tasks.show(taskId, root)
|
|
222
|
+
if (!task) {
|
|
223
|
+
return yield* new WorktreeError({
|
|
224
|
+
message: `Task '${taskId}' does not exist`,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
193
227
|
|
|
194
228
|
let execution:
|
|
195
229
|
| {
|
|
@@ -207,7 +241,16 @@ const inspectExecution = (
|
|
|
207
241
|
message: `Task '${taskId}' has multiple phases; phase ID is required`,
|
|
208
242
|
})
|
|
209
243
|
}
|
|
210
|
-
const phase =
|
|
244
|
+
const phase = context
|
|
245
|
+
? context.phasesByTask
|
|
246
|
+
.get(taskId)
|
|
247
|
+
?.find((record) => record.id === phaseId)
|
|
248
|
+
: yield* phases.show(taskId, phaseId, root)
|
|
249
|
+
if (!phase) {
|
|
250
|
+
return yield* new WorktreeError({
|
|
251
|
+
message: `Phase '${phaseId}' does not exist on task '${taskId}'`,
|
|
252
|
+
})
|
|
253
|
+
}
|
|
211
254
|
execution = phase.data
|
|
212
255
|
owner = {
|
|
213
256
|
kind: "phase",
|
|
@@ -227,31 +270,37 @@ const inspectExecution = (
|
|
|
227
270
|
codePath = join(dirname(task.path), "code")
|
|
228
271
|
}
|
|
229
272
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
273
|
+
let ownership: ReadonlyMap<string, readonly WorktreeOwner[]>
|
|
274
|
+
if (context) {
|
|
275
|
+
ownership = context.ownership
|
|
276
|
+
} else {
|
|
277
|
+
const discoveredOwnership = new Map<string, WorktreeOwner[]>()
|
|
278
|
+
for (const taskRecord of yield* tasks.list(root)) {
|
|
279
|
+
if ("phases" in taskRecord.data) {
|
|
280
|
+
for (const phaseRecord of yield* phases.list(taskRecord.id, root)) {
|
|
281
|
+
const key = `${phaseRecord.data.repo}:${phaseRecord.data.branch}`
|
|
282
|
+
const owners = discoveredOwnership.get(key) ?? []
|
|
283
|
+
owners.push({
|
|
284
|
+
kind: "phase",
|
|
285
|
+
taskId: taskRecord.id,
|
|
286
|
+
phaseId: phaseRecord.id,
|
|
287
|
+
documentPath: phaseRecord.path,
|
|
288
|
+
})
|
|
289
|
+
discoveredOwnership.set(key, owners)
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
if (!("repo" in taskRecord.data)) continue
|
|
293
|
+
const key = `${taskRecord.data.repo}:${taskRecord.data.branch}`
|
|
294
|
+
const owners = discoveredOwnership.get(key) ?? []
|
|
236
295
|
owners.push({
|
|
237
|
-
kind: "
|
|
296
|
+
kind: "task",
|
|
238
297
|
taskId: taskRecord.id,
|
|
239
|
-
|
|
240
|
-
documentPath: phaseRecord.path,
|
|
298
|
+
documentPath: taskRecord.path,
|
|
241
299
|
})
|
|
242
|
-
|
|
300
|
+
discoveredOwnership.set(key, owners)
|
|
243
301
|
}
|
|
244
|
-
} else {
|
|
245
|
-
if (!("repo" in taskRecord.data)) continue
|
|
246
|
-
const key = `${taskRecord.data.repo}:${taskRecord.data.branch}`
|
|
247
|
-
const owners = ownership.get(key) ?? []
|
|
248
|
-
owners.push({
|
|
249
|
-
kind: "task",
|
|
250
|
-
taskId: taskRecord.id,
|
|
251
|
-
documentPath: taskRecord.path,
|
|
252
|
-
})
|
|
253
|
-
ownership.set(key, owners)
|
|
254
302
|
}
|
|
303
|
+
ownership = discoveredOwnership
|
|
255
304
|
}
|
|
256
305
|
|
|
257
306
|
const declared: readonly (
|
|
@@ -318,19 +367,27 @@ const inspectExecution = (
|
|
|
318
367
|
const expectedPath = exists
|
|
319
368
|
? yield* fs.realPath(checkoutPath)
|
|
320
369
|
: resolve(checkoutPath)
|
|
321
|
-
const registered =
|
|
370
|
+
const registered =
|
|
371
|
+
context?.workspacesByRepository.get(repositoryPath) ??
|
|
372
|
+
(yield* backend.listWorkspaces(repositoryPath))
|
|
322
373
|
const atPath = registered.find(
|
|
323
374
|
(workspace) => workspace.path === expectedPath,
|
|
324
375
|
)
|
|
325
376
|
const actualCommit = atPath
|
|
326
|
-
?
|
|
377
|
+
? atPath.head !== undefined
|
|
378
|
+
? atPath.head
|
|
379
|
+
: yield* backend.workspaceHead(checkoutPath)
|
|
327
380
|
: null
|
|
328
381
|
const dirty =
|
|
329
|
-
exists && atPath
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
382
|
+
exists && atPath
|
|
383
|
+
? atPath.dirty !== undefined
|
|
384
|
+
? atPath.dirty
|
|
385
|
+
: yield* backend.workspaceDirty(checkoutPath)
|
|
386
|
+
: null
|
|
387
|
+
const expectedCommit =
|
|
388
|
+
"branch" in checkout && context?.skipWritableRevisionResolution
|
|
389
|
+
? actualCommit
|
|
390
|
+
: yield* backend.resolveRevision(repositoryPath, requestedRef)
|
|
334
391
|
|
|
335
392
|
if (owners.length > 1) {
|
|
336
393
|
conflict(
|
|
@@ -647,6 +704,95 @@ const jjWorkspaceName = (
|
|
|
647
704
|
repo: string,
|
|
648
705
|
) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
|
|
649
706
|
|
|
707
|
+
const buildInspectionContext = (
|
|
708
|
+
startPath: string,
|
|
709
|
+
skipWritableRevisionResolution: boolean,
|
|
710
|
+
preloadedTasks?: readonly TaskRecord[],
|
|
711
|
+
preloadedPhasesByTask?: ReadonlyMap<string, readonly PhaseRecord[]>,
|
|
712
|
+
) =>
|
|
713
|
+
Effect.gen(function* () {
|
|
714
|
+
const fs = yield* FileSystemService
|
|
715
|
+
const workbase = yield* WorkbaseService
|
|
716
|
+
const versionControl = yield* VersionControlService
|
|
717
|
+
const taskService = yield* TaskService
|
|
718
|
+
const phaseService = yield* PhaseService
|
|
719
|
+
const root = yield* workbase.discover(startPath)
|
|
720
|
+
const backend = yield* versionControl.forWorkbase(root)
|
|
721
|
+
const tasks = preloadedTasks ?? (yield* taskService.list(root))
|
|
722
|
+
const phasesByTask = new Map<string, readonly PhaseRecord[]>()
|
|
723
|
+
const ownership = new Map<string, WorktreeOwner[]>()
|
|
724
|
+
const repositoryAliases = new Set<string>()
|
|
725
|
+
|
|
726
|
+
const addExecution = (
|
|
727
|
+
execution:
|
|
728
|
+
| {
|
|
729
|
+
repo: string
|
|
730
|
+
repos?: readonly RepositoryReference[]
|
|
731
|
+
branch: string
|
|
732
|
+
}
|
|
733
|
+
| { review: { repo: string } },
|
|
734
|
+
owner: WorktreeOwner,
|
|
735
|
+
) => {
|
|
736
|
+
if ("review" in execution) {
|
|
737
|
+
repositoryAliases.add(execution.review.repo)
|
|
738
|
+
return
|
|
739
|
+
}
|
|
740
|
+
repositoryAliases.add(execution.repo)
|
|
741
|
+
for (const reference of execution.repos ?? [])
|
|
742
|
+
repositoryAliases.add(reference.repo)
|
|
743
|
+
const key = `${execution.repo}:${execution.branch}`
|
|
744
|
+
const owners = ownership.get(key) ?? []
|
|
745
|
+
owners.push(owner)
|
|
746
|
+
ownership.set(key, owners)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
for (const task of tasks) {
|
|
750
|
+
if ("phases" in task.data) {
|
|
751
|
+
const phaseRecords =
|
|
752
|
+
preloadedPhasesByTask?.get(task.id) ??
|
|
753
|
+
(yield* phaseService.list(task.id, root))
|
|
754
|
+
phasesByTask.set(task.id, phaseRecords)
|
|
755
|
+
for (const phase of phaseRecords) {
|
|
756
|
+
addExecution(phase.data, {
|
|
757
|
+
kind: "phase",
|
|
758
|
+
taskId: task.id,
|
|
759
|
+
phaseId: phase.id,
|
|
760
|
+
documentPath: phase.path,
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
} else {
|
|
764
|
+
addExecution(task.data, {
|
|
765
|
+
kind: "task",
|
|
766
|
+
taskId: task.id,
|
|
767
|
+
documentPath: task.path,
|
|
768
|
+
})
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const workspacesByRepository = new Map<
|
|
773
|
+
string,
|
|
774
|
+
readonly RegisteredWorkspace[]
|
|
775
|
+
>()
|
|
776
|
+
for (const alias of repositoryAliases) {
|
|
777
|
+
const repositoryPath = join(root, "repos", alias)
|
|
778
|
+
if (!(yield* fs.exists(repositoryPath))) continue
|
|
779
|
+
workspacesByRepository.set(
|
|
780
|
+
repositoryPath,
|
|
781
|
+
yield* backend.listWorkspaces(repositoryPath),
|
|
782
|
+
)
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return {
|
|
786
|
+
root,
|
|
787
|
+
backend,
|
|
788
|
+
tasks,
|
|
789
|
+
phasesByTask,
|
|
790
|
+
ownership,
|
|
791
|
+
workspacesByRepository,
|
|
792
|
+
skipWritableRevisionResolution,
|
|
793
|
+
} satisfies InspectionContext
|
|
794
|
+
})
|
|
795
|
+
|
|
650
796
|
const materializeJj = (options: {
|
|
651
797
|
readonly root: string
|
|
652
798
|
readonly taskId: string
|
|
@@ -935,23 +1081,71 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
935
1081
|
"WorktreeService",
|
|
936
1082
|
{
|
|
937
1083
|
sync: () => ({
|
|
938
|
-
list: (startPath: string = process.cwd()) =>
|
|
1084
|
+
list: (startPath: string = process.cwd(), options: ListOptions = {}) =>
|
|
939
1085
|
Effect.gen(function* () {
|
|
940
|
-
const
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
1086
|
+
const fs = yield* FileSystemService
|
|
1087
|
+
const context = yield* buildInspectionContext(
|
|
1088
|
+
startPath,
|
|
1089
|
+
options.materializedOnly === true,
|
|
1090
|
+
options.tasks,
|
|
1091
|
+
options.phasesByTask,
|
|
1092
|
+
)
|
|
944
1093
|
const inspections: WorktreeInspection[] = []
|
|
945
|
-
|
|
1094
|
+
const shouldInspect = (
|
|
1095
|
+
documentPath: string,
|
|
1096
|
+
execution:
|
|
1097
|
+
| {
|
|
1098
|
+
repo: string
|
|
1099
|
+
repos?: readonly RepositoryReference[]
|
|
1100
|
+
}
|
|
1101
|
+
| { review: { repo: string } },
|
|
1102
|
+
) =>
|
|
1103
|
+
Effect.gen(function* () {
|
|
1104
|
+
if (!options.materializedOnly) return true
|
|
1105
|
+
const codePath = join(dirname(documentPath), "code")
|
|
1106
|
+
const aliases =
|
|
1107
|
+
"review" in execution
|
|
1108
|
+
? [execution.review.repo]
|
|
1109
|
+
: [
|
|
1110
|
+
execution.repo,
|
|
1111
|
+
...(execution.repos ?? []).map(
|
|
1112
|
+
(reference) => reference.repo,
|
|
1113
|
+
),
|
|
1114
|
+
]
|
|
1115
|
+
for (const alias of aliases) {
|
|
1116
|
+
if (yield* fs.isDirectory(join(codePath, alias))) return true
|
|
1117
|
+
}
|
|
1118
|
+
return [...context.workspacesByRepository.values()].some(
|
|
1119
|
+
(workspaces) =>
|
|
1120
|
+
workspaces.some(
|
|
1121
|
+
(workspace) =>
|
|
1122
|
+
workspace.path === codePath ||
|
|
1123
|
+
workspace.path.startsWith(`${codePath}/`),
|
|
1124
|
+
),
|
|
1125
|
+
)
|
|
1126
|
+
})
|
|
1127
|
+
for (const task of context.tasks) {
|
|
946
1128
|
if ("phases" in task.data) {
|
|
947
|
-
for (const phase of
|
|
1129
|
+
for (const phase of context.phasesByTask.get(task.id) ?? []) {
|
|
1130
|
+
if (!(yield* shouldInspect(phase.path, phase.data))) continue
|
|
948
1131
|
inspections.push(
|
|
949
|
-
yield* inspectExecution(
|
|
1132
|
+
yield* inspectExecution(
|
|
1133
|
+
task.id,
|
|
1134
|
+
phase.id,
|
|
1135
|
+
context.root,
|
|
1136
|
+
context,
|
|
1137
|
+
),
|
|
950
1138
|
)
|
|
951
1139
|
}
|
|
952
1140
|
} else {
|
|
1141
|
+
if (!(yield* shouldInspect(task.path, task.data))) continue
|
|
953
1142
|
inspections.push(
|
|
954
|
-
yield* inspectExecution(
|
|
1143
|
+
yield* inspectExecution(
|
|
1144
|
+
task.id,
|
|
1145
|
+
undefined,
|
|
1146
|
+
context.root,
|
|
1147
|
+
context,
|
|
1148
|
+
),
|
|
955
1149
|
)
|
|
956
1150
|
}
|
|
957
1151
|
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { lstat, readdir, realpath, stat } from "node:fs/promises"
|
|
2
|
+
import { dirname, join, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
interface Execution {
|
|
5
|
+
readonly taskId: string
|
|
6
|
+
readonly phaseId?: string
|
|
7
|
+
readonly documentPath: string
|
|
8
|
+
readonly repo: string
|
|
9
|
+
readonly branch: string
|
|
10
|
+
readonly claimActive: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Workspace {
|
|
14
|
+
readonly path: string
|
|
15
|
+
readonly dirty: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Blocker {
|
|
19
|
+
readonly kind: string
|
|
20
|
+
readonly target: string
|
|
21
|
+
readonly message: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const run = async (args: readonly string[]) => {
|
|
25
|
+
const process = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
|
|
26
|
+
const [exitCode, stdout] = await Promise.all([
|
|
27
|
+
process.exited,
|
|
28
|
+
new Response(process.stdout).text(),
|
|
29
|
+
])
|
|
30
|
+
return { exitCode, stdout: stdout.trim() }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const directoryExists = async (path: string) => {
|
|
34
|
+
try {
|
|
35
|
+
return (await stat(path)).isDirectory()
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const frontmatter = (content: string) => {
|
|
42
|
+
if (!content.startsWith("---\n")) return null
|
|
43
|
+
const end = content.indexOf("\n---\n", 4)
|
|
44
|
+
return end === -1 ? null : content.slice(4, end)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const scalar = (content: string, key: string) => {
|
|
48
|
+
const match = content.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, "m"))
|
|
49
|
+
if (!match) return null
|
|
50
|
+
const value = match[1]!
|
|
51
|
+
return value.startsWith('"') && value.endsWith('"')
|
|
52
|
+
? value.slice(1, -1)
|
|
53
|
+
: value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const activeClaim = (content: string) => {
|
|
57
|
+
const claim = content.match(/^claim:\s*\n((?:^[ \t]+.*(?:\n|$))*)/m)?.[1]
|
|
58
|
+
return claim ? /^\s+state:\s*active\s*$/m.test(claim) : false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const discoverRoot = async (startPath: string) => {
|
|
62
|
+
let current = startPath
|
|
63
|
+
while (true) {
|
|
64
|
+
const configPath = join(current, "agency.json")
|
|
65
|
+
if (await Bun.file(configPath).exists()) return current
|
|
66
|
+
const parent = dirname(current)
|
|
67
|
+
if (parent === current) return null
|
|
68
|
+
current = parent
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const readExecution = async (
|
|
73
|
+
documentPath: string,
|
|
74
|
+
taskId: string,
|
|
75
|
+
phaseId?: string,
|
|
76
|
+
): Promise<Execution | null> => {
|
|
77
|
+
const content = frontmatter(await Bun.file(documentPath).text())
|
|
78
|
+
if (!content || /^repos:/m.test(content) || /^review:/m.test(content))
|
|
79
|
+
return null
|
|
80
|
+
const repo = scalar(content, "repo")
|
|
81
|
+
const branch = scalar(content, "branch")
|
|
82
|
+
if (!repo || !branch) return null
|
|
83
|
+
return {
|
|
84
|
+
taskId,
|
|
85
|
+
...(phaseId ? { phaseId } : {}),
|
|
86
|
+
documentPath,
|
|
87
|
+
repo,
|
|
88
|
+
branch,
|
|
89
|
+
claimActive: activeClaim(content),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const readExecutions = async (root: string) => {
|
|
94
|
+
const tasksPath = join(root, "tasks")
|
|
95
|
+
const entries = (await readdir(tasksPath, { withFileTypes: true }))
|
|
96
|
+
.filter((entry) => entry.isDirectory())
|
|
97
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
98
|
+
const executions: Execution[] = []
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
const taskPath = join(tasksPath, entry.name, "TASK.md")
|
|
101
|
+
const taskContent = frontmatter(await Bun.file(taskPath).text())
|
|
102
|
+
if (!taskContent) return null
|
|
103
|
+
if (/^phases:/m.test(taskContent)) {
|
|
104
|
+
const phasesPath = join(tasksPath, entry.name, "phases")
|
|
105
|
+
const phases = (await readdir(phasesPath, { withFileTypes: true }))
|
|
106
|
+
.filter((phase) => phase.isDirectory())
|
|
107
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
108
|
+
for (const phase of phases) {
|
|
109
|
+
const execution = await readExecution(
|
|
110
|
+
join(phasesPath, phase.name, "PHASE.md"),
|
|
111
|
+
entry.name,
|
|
112
|
+
phase.name,
|
|
113
|
+
)
|
|
114
|
+
if (!execution) return null
|
|
115
|
+
executions.push(execution)
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
const execution = await readExecution(taskPath, entry.name)
|
|
119
|
+
if (!execution) return null
|
|
120
|
+
executions.push(execution)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return executions
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const inspectRepository = async (root: string, alias: string) => {
|
|
127
|
+
const path = join(root, "repos", alias)
|
|
128
|
+
let stats
|
|
129
|
+
try {
|
|
130
|
+
stats = await lstat(path)
|
|
131
|
+
} catch {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
if (!stats.isDirectory() && !stats.isSymbolicLink()) return null
|
|
135
|
+
const [git, bare] = await Promise.all([
|
|
136
|
+
run(["git", "-C", path, "rev-parse", "--git-dir"]),
|
|
137
|
+
run(["git", "-C", path, "rev-parse", "--is-bare-repository"]),
|
|
138
|
+
])
|
|
139
|
+
if (git.exitCode !== 0 || bare.exitCode !== 0) return null
|
|
140
|
+
return {
|
|
141
|
+
alias,
|
|
142
|
+
path,
|
|
143
|
+
kind: stats.isSymbolicLink()
|
|
144
|
+
? ("symlink" as const)
|
|
145
|
+
: bare.stdout === "true"
|
|
146
|
+
? ("bare" as const)
|
|
147
|
+
: ("repository" as const),
|
|
148
|
+
initialized: await directoryExists(join(path, ".jj")),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const listWorkspaces = async (repositoryPath: string) => {
|
|
153
|
+
const result = await run([
|
|
154
|
+
"jj",
|
|
155
|
+
"-R",
|
|
156
|
+
repositoryPath,
|
|
157
|
+
"--no-pager",
|
|
158
|
+
"workspace",
|
|
159
|
+
"list",
|
|
160
|
+
"-T",
|
|
161
|
+
'name ++ "\\t" ++ root ++ "\\t" ++ target.empty() ++ "\\n"',
|
|
162
|
+
])
|
|
163
|
+
if (result.exitCode !== 0) return null
|
|
164
|
+
return result.stdout
|
|
165
|
+
.split("\n")
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.map((line): Workspace => {
|
|
168
|
+
const [, path, empty] = line.split("\t")
|
|
169
|
+
return { path: path!, dirty: empty === "false" }
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const inspectVcsStatusFast = async (startPath: string) => {
|
|
174
|
+
const root = await discoverRoot(startPath)
|
|
175
|
+
if (!root) return null
|
|
176
|
+
const config = await Bun.file(join(root, "agency.json")).json()
|
|
177
|
+
if (config?.version !== 2 || config?.vcs !== "jj") return null
|
|
178
|
+
const executions = await readExecutions(root)
|
|
179
|
+
if (!executions) return null
|
|
180
|
+
|
|
181
|
+
const localRepositories = (
|
|
182
|
+
await readdir(join(root, "repos"), {
|
|
183
|
+
withFileTypes: true,
|
|
184
|
+
})
|
|
185
|
+
)
|
|
186
|
+
.filter((entry) => !entry.name.startsWith(".agency-"))
|
|
187
|
+
.map((entry) => entry.name)
|
|
188
|
+
const aliases = [
|
|
189
|
+
...new Set([
|
|
190
|
+
...Object.keys(config.repositories ?? {}),
|
|
191
|
+
...localRepositories,
|
|
192
|
+
]),
|
|
193
|
+
].sort()
|
|
194
|
+
const repositories = await Promise.all(
|
|
195
|
+
aliases.map((alias) => inspectRepository(root, alias)),
|
|
196
|
+
)
|
|
197
|
+
if (repositories.some((repository) => repository === null)) return null
|
|
198
|
+
const repositoryRecords = repositories.filter(
|
|
199
|
+
(repository) => repository !== null,
|
|
200
|
+
)
|
|
201
|
+
if (repositoryRecords.some((repository) => !repository.initialized))
|
|
202
|
+
return null
|
|
203
|
+
|
|
204
|
+
const workspaceLists = await Promise.all(
|
|
205
|
+
repositoryRecords.map(async (repository) => ({
|
|
206
|
+
alias: repository.alias,
|
|
207
|
+
workspaces: await listWorkspaces(repository.path),
|
|
208
|
+
})),
|
|
209
|
+
)
|
|
210
|
+
if (workspaceLists.some(({ workspaces }) => workspaces === null)) return null
|
|
211
|
+
const workspacesByRepo = new Map(
|
|
212
|
+
workspaceLists.map(({ alias, workspaces }) => [alias, workspaces!]),
|
|
213
|
+
)
|
|
214
|
+
const owners = new Map<string, Execution[]>()
|
|
215
|
+
for (const execution of executions) {
|
|
216
|
+
const key = `${execution.repo}:${execution.branch}`
|
|
217
|
+
const entries = owners.get(key) ?? []
|
|
218
|
+
entries.push(execution)
|
|
219
|
+
owners.set(key, entries)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const blockers: Blocker[] = []
|
|
223
|
+
for (const execution of executions) {
|
|
224
|
+
if (!execution.claimActive) continue
|
|
225
|
+
const target = execution.phaseId
|
|
226
|
+
? `phase:${execution.taskId}/${execution.phaseId}`
|
|
227
|
+
: `task:${execution.taskId}`
|
|
228
|
+
blockers.push({
|
|
229
|
+
kind: "active-work",
|
|
230
|
+
target,
|
|
231
|
+
message: `${target} is active; finish or release it before migration`,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let workspaceCount = 0
|
|
236
|
+
for (const execution of executions) {
|
|
237
|
+
const checkoutPath = join(
|
|
238
|
+
dirname(execution.documentPath),
|
|
239
|
+
"code",
|
|
240
|
+
execution.repo,
|
|
241
|
+
)
|
|
242
|
+
const exists = await directoryExists(checkoutPath)
|
|
243
|
+
const expectedPath = exists
|
|
244
|
+
? await realpath(checkoutPath)
|
|
245
|
+
: resolve(checkoutPath)
|
|
246
|
+
const registered = workspacesByRepo
|
|
247
|
+
.get(execution.repo)
|
|
248
|
+
?.find((workspace) => workspace.path === expectedPath)
|
|
249
|
+
const conflicts: string[] = []
|
|
250
|
+
if ((owners.get(`${execution.repo}:${execution.branch}`)?.length ?? 0) > 1)
|
|
251
|
+
conflicts.push(
|
|
252
|
+
`Branch '${execution.branch}' for repository '${execution.repo}' has multiple Agency owners`,
|
|
253
|
+
)
|
|
254
|
+
if (registered && !exists)
|
|
255
|
+
conflicts.push(
|
|
256
|
+
`Workspace registry contains a missing checkout at ${checkoutPath}`,
|
|
257
|
+
)
|
|
258
|
+
if (exists && !registered)
|
|
259
|
+
conflicts.push(
|
|
260
|
+
`Existing checkout ${checkoutPath} is not registered as a jj workspace`,
|
|
261
|
+
)
|
|
262
|
+
if (conflicts.length > 0) {
|
|
263
|
+
blockers.push({
|
|
264
|
+
kind: "workspace-conflict",
|
|
265
|
+
target: checkoutPath,
|
|
266
|
+
message: conflicts.join("; "),
|
|
267
|
+
})
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
if (!exists || !registered) continue
|
|
271
|
+
if (registered.dirty) {
|
|
272
|
+
blockers.push({
|
|
273
|
+
kind: "dirty-workspace",
|
|
274
|
+
target: checkoutPath,
|
|
275
|
+
message: `Workspace ${checkoutPath} must be clean before migration`,
|
|
276
|
+
})
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
workspaceCount++
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
root,
|
|
284
|
+
configured: "jj",
|
|
285
|
+
source: "jj",
|
|
286
|
+
target: "jj",
|
|
287
|
+
available: { git: Bun.which("git") !== null, jj: Bun.which("jj") !== null },
|
|
288
|
+
repositories: repositoryRecords,
|
|
289
|
+
workspaceCount,
|
|
290
|
+
blockers,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const runVcsStatusFast = async (
|
|
295
|
+
json: boolean,
|
|
296
|
+
startPath: string = process.cwd(),
|
|
297
|
+
write: (message: string) => void = console.log,
|
|
298
|
+
) => {
|
|
299
|
+
const status = await inspectVcsStatusFast(startPath)
|
|
300
|
+
if (!status) return false
|
|
301
|
+
if (json) {
|
|
302
|
+
write(JSON.stringify({ version: 1, ok: true, result: status }))
|
|
303
|
+
} else {
|
|
304
|
+
write("Version control: jj")
|
|
305
|
+
write(
|
|
306
|
+
`Tools: git=${status.available.git ? "available" : "missing"} jj=${status.available.jj ? "available" : "missing"}`,
|
|
307
|
+
)
|
|
308
|
+
write(
|
|
309
|
+
`Repositories: ${status.repositories.length}; managed workspaces: ${status.workspaceCount}; blockers: ${status.blockers.length}`,
|
|
310
|
+
)
|
|
311
|
+
for (const blocker of status.blockers)
|
|
312
|
+
write(`blocker ${blocker.kind} ${blocker.target}: ${blocker.message}`)
|
|
313
|
+
}
|
|
314
|
+
return true
|
|
315
|
+
}
|