@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.
@@ -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 = yield* versionControl.forWorkbase(root)
192
- const task = yield* tasks.show(taskId, root)
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 = yield* phases.show(taskId, phaseId, root)
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
- const ownership = new Map<string, WorktreeOwner[]>()
231
- for (const taskRecord of yield* tasks.list(root)) {
232
- if ("phases" in taskRecord.data) {
233
- for (const phaseRecord of yield* phases.list(taskRecord.id, root)) {
234
- const key = `${phaseRecord.data.repo}:${phaseRecord.data.branch}`
235
- const owners = ownership.get(key) ?? []
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: "phase",
296
+ kind: "task",
238
297
  taskId: taskRecord.id,
239
- phaseId: phaseRecord.id,
240
- documentPath: phaseRecord.path,
298
+ documentPath: taskRecord.path,
241
299
  })
242
- ownership.set(key, owners)
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 = yield* backend.listWorkspaces(repositoryPath)
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
- ? yield* backend.workspaceHead(checkoutPath)
377
+ ? atPath.head !== undefined
378
+ ? atPath.head
379
+ : yield* backend.workspaceHead(checkoutPath)
327
380
  : null
328
381
  const dirty =
329
- exists && atPath ? yield* backend.workspaceDirty(checkoutPath) : null
330
- const expectedCommit = yield* backend.resolveRevision(
331
- repositoryPath,
332
- requestedRef,
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 workbase = yield* WorkbaseService
941
- const tasks = yield* TaskService
942
- const phases = yield* PhaseService
943
- const root = yield* workbase.discover(startPath)
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
- for (const task of yield* tasks.list(root)) {
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 yield* phases.list(task.id, root)) {
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(task.id, phase.id, root),
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(task.id, undefined, root),
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
+ }