@markjaquith/agency 2.52.1 → 2.53.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.
@@ -3,11 +3,13 @@ import { FileSystemService } from "./FileSystemService"
3
3
  import { WorkbaseService } from "./WorkbaseService"
4
4
  import type { VersionControlKind } from "../workbase/version-control"
5
5
 
6
- interface RegisteredWorkspace {
6
+ export interface RegisteredWorkspace {
7
7
  readonly name: string | null
8
8
  readonly path: string
9
9
  readonly commit: string | null
10
10
  readonly branch: string | null
11
+ readonly head?: string | null
12
+ readonly dirty?: boolean
11
13
  }
12
14
 
13
15
  export interface VersionControlBackend {
@@ -305,7 +307,7 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
305
307
  "workspace",
306
308
  "list",
307
309
  "-T",
308
- 'name ++ "\\t" ++ root ++ "\\t" ++ target.commit_id() ++ "\\n"',
310
+ 'name ++ "\\t" ++ root ++ "\\t" ++ target.commit_id() ++ "\\t" ++ target.parents().map(|commit| commit.commit_id()).join(",") ++ "\\t" ++ target.empty() ++ "\\n"',
309
311
  ]),
310
312
  { captureOutput: true },
311
313
  ),
@@ -315,12 +317,16 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
315
317
  .split("\n")
316
318
  .filter(Boolean)
317
319
  .map((line) => {
318
- const [name, path, commit] = line.split("\t")
320
+ const [name, path, commit, parents, empty] = line.split("\t")
321
+ const parentCommits = parents?.split(",").filter(Boolean) ?? []
319
322
  return {
320
323
  name: name || null,
321
324
  path: path!,
322
325
  commit: commit || null,
323
326
  branch: null,
327
+ head:
328
+ parentCommits.length === 1 ? parentCommits[0]! : undefined,
329
+ dirty: empty === "false",
324
330
  }
325
331
  })
326
332
  }),
@@ -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
  }