@markjaquith/agency 2.57.0 → 2.58.1

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.
@@ -449,11 +449,7 @@ const inspectExecution = (
449
449
  : yield* backend.workspaceHead(checkoutPath)
450
450
  : null
451
451
  const dirty =
452
- exists && atPath
453
- ? atPath.dirty !== undefined
454
- ? atPath.dirty
455
- : yield* backend.workspaceDirty(checkoutPath)
456
- : null
452
+ exists && atPath ? yield* backend.workspaceDirty(checkoutPath) : null
457
453
  const expectedCommit =
458
454
  "branch" in checkout && context?.skipWritableRevisionResolution
459
455
  ? actualCommit
@@ -774,6 +770,81 @@ const jjWorkspaceName = (
774
770
  repo: string,
775
771
  ) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
776
772
 
773
+ // A process can stop after `jj workspace forget` but before checkout deletion.
774
+ // Only recover that residual when its repository and complete tree still match.
775
+ const inspectJjResidual = (
776
+ root: string,
777
+ repositoryPath: string,
778
+ workspacePath: string,
779
+ revision: string,
780
+ ) =>
781
+ Effect.gen(function* () {
782
+ const fs = yield* FileSystemService
783
+ const repoPointer = join(workspacePath, ".jj", "repo")
784
+ if (!(yield* fs.exists(repoPointer))) return "unowned" as const
785
+
786
+ const pointer = (yield* fs.readFile(repoPointer)).trim()
787
+ if (!pointer) return "unowned" as const
788
+ const linkedRepository = resolve(dirname(repoPointer), pointer)
789
+ const expectedRepository = join(repositoryPath, ".jj", "repo")
790
+ if (
791
+ !(yield* fs.exists(linkedRepository)) ||
792
+ (yield* fs.realPath(linkedRepository)) !==
793
+ (yield* fs.realPath(expectedRepository))
794
+ )
795
+ return "unowned" as const
796
+
797
+ const comparisonPath = join(
798
+ root,
799
+ `.agency-jj-residual-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
800
+ )
801
+ const comparisonName = basename(comparisonPath)
802
+ return yield* Effect.gen(function* () {
803
+ const created = yield* fs.runCommand(
804
+ [
805
+ "jj",
806
+ "-R",
807
+ repositoryPath,
808
+ "workspace",
809
+ "add",
810
+ "--name",
811
+ comparisonName,
812
+ "-r",
813
+ revision,
814
+ comparisonPath,
815
+ ],
816
+ { captureOutput: true },
817
+ )
818
+ if (created.exitCode !== 0) {
819
+ return yield* new WorktreeError({
820
+ message: `Cannot verify unregistered jj checkout ${workspacePath}: ${created.stderr}`,
821
+ })
822
+ }
823
+ const comparison = yield* fs.runCommand(
824
+ ["diff", "-qr", "-x", ".jj", workspacePath, comparisonPath],
825
+ { captureOutput: true },
826
+ )
827
+ if (comparison.exitCode > 1) {
828
+ return yield* new WorktreeError({
829
+ message: `Cannot verify unregistered jj checkout ${workspacePath}: ${comparison.stderr}`,
830
+ })
831
+ }
832
+ return comparison.exitCode === 0
833
+ ? ("clean" as const)
834
+ : ("modified" as const)
835
+ }).pipe(
836
+ Effect.ensuring(
837
+ Effect.gen(function* () {
838
+ yield* fs.runCommand(
839
+ ["jj", "-R", repositoryPath, "workspace", "forget", comparisonName],
840
+ { captureOutput: true },
841
+ )
842
+ yield* fs.deleteDirectory(comparisonPath)
843
+ }).pipe(Effect.ignore),
844
+ ),
845
+ )
846
+ })
847
+
777
848
  const buildInspectionContext = (
778
849
  startPath: string,
779
850
  skipWritableRevisionResolution: boolean,
@@ -1107,10 +1178,63 @@ const removeJj = (
1107
1178
  Effect.gen(function* () {
1108
1179
  const fs = yield* FileSystemService
1109
1180
  const versionControl = yield* VersionControlService
1181
+ const tasks = yield* TaskService
1182
+ const phases = yield* PhaseService
1110
1183
  const backend = yield* versionControl.forWorkbase(root)
1111
1184
  const inspection = yield* inspectExecution(taskId, phaseId, root)
1112
- const blocking = inspection.conflicts.filter(
1113
- (conflict) => conflict.kind !== "stale-registration",
1185
+ const task = yield* tasks.show(taskId, root)
1186
+ const execution =
1187
+ "phases" in task.data && phaseId
1188
+ ? (yield* phases.show(taskId, phaseId, root)).data
1189
+ : task.data
1190
+ const recoverable = new Map<string, string>()
1191
+ const modifiedResiduals = new Map<string, string>()
1192
+ for (const checkout of inspection.checkouts) {
1193
+ const recoveryRevision =
1194
+ checkout.expectedCommit ??
1195
+ (checkout.kind === "writable" && "base" in execution
1196
+ ? yield* backend.resolveRevision(
1197
+ join(root, "repos", checkout.repo),
1198
+ execution.base,
1199
+ )
1200
+ : null)
1201
+ if (
1202
+ !checkout.conflicts.some(
1203
+ (conflict) => conflict.kind === "unregistered-checkout",
1204
+ ) ||
1205
+ !recoveryRevision
1206
+ )
1207
+ continue
1208
+ const repositoryPath = join(root, "repos", checkout.repo)
1209
+ const residual = yield* inspectJjResidual(
1210
+ root,
1211
+ repositoryPath,
1212
+ checkout.path,
1213
+ recoveryRevision,
1214
+ )
1215
+ if (residual === "clean") recoverable.set(checkout.path, recoveryRevision)
1216
+ else if (residual === "modified")
1217
+ modifiedResiduals.set(checkout.path, recoveryRevision)
1218
+ }
1219
+ const blocking = inspection.checkouts.flatMap((checkout) =>
1220
+ checkout.conflicts
1221
+ .filter(
1222
+ (conflict) =>
1223
+ conflict.kind !== "stale-registration" &&
1224
+ !(
1225
+ conflict.kind === "unregistered-checkout" &&
1226
+ recoverable.has(checkout.path)
1227
+ ),
1228
+ )
1229
+ .map((conflict) =>
1230
+ conflict.kind === "unregistered-checkout" &&
1231
+ modifiedResiduals.has(checkout.path)
1232
+ ? {
1233
+ ...conflict,
1234
+ message: `Cannot remove unregistered jj checkout ${checkout.path}; its contents do not match expected revision '${modifiedResiduals.get(checkout.path)}'`,
1235
+ }
1236
+ : conflict,
1237
+ ),
1114
1238
  )
1115
1239
  if (blocking.length > 0) {
1116
1240
  return yield* new WorktreeError({
@@ -1125,6 +1249,7 @@ const removeJj = (
1125
1249
  workspaceName: string
1126
1250
  head: string
1127
1251
  branch: string | null
1252
+ registered: boolean
1128
1253
  }[] = []
1129
1254
  for (const checkout of inspection.checkouts) {
1130
1255
  if (checkout.dirty) {
@@ -1132,8 +1257,20 @@ const removeJj = (
1132
1257
  message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
1133
1258
  })
1134
1259
  }
1135
- if (!checkout.registeredPath) continue
1136
1260
  const repositoryPath = join(root, "repos", checkout.repo)
1261
+ if (!checkout.registeredPath) {
1262
+ const recoveryRevision = recoverable.get(checkout.path)
1263
+ if (!recoveryRevision) continue
1264
+ plans.push({
1265
+ repositoryPath,
1266
+ workspacePath: checkout.path,
1267
+ workspaceName: jjWorkspaceName(taskId, phaseId, checkout.repo),
1268
+ head: recoveryRevision,
1269
+ branch: checkout.actualBranch,
1270
+ registered: false,
1271
+ })
1272
+ continue
1273
+ }
1137
1274
  const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1138
1275
  (workspace) => workspace.path === checkout.registeredPath,
1139
1276
  )
@@ -1148,6 +1285,7 @@ const removeJj = (
1148
1285
  workspaceName: registered.name,
1149
1286
  head: checkout.actualCommit,
1150
1287
  branch: checkout.actualBranch,
1288
+ registered: true,
1151
1289
  })
1152
1290
  }
1153
1291
 
@@ -1166,11 +1304,15 @@ const removeJj = (
1166
1304
  const completed: typeof plans = []
1167
1305
  return yield* Effect.gen(function* () {
1168
1306
  for (const plan of plans) {
1169
- yield* backend.removeWorkspace({
1170
- repositoryPath: plan.repositoryPath,
1171
- workspacePath: plan.workspacePath,
1172
- workspaceName: plan.workspaceName,
1173
- })
1307
+ if (plan.registered) {
1308
+ yield* backend.removeWorkspace({
1309
+ repositoryPath: plan.repositoryPath,
1310
+ workspacePath: plan.workspacePath,
1311
+ workspaceName: plan.workspaceName,
1312
+ })
1313
+ } else {
1314
+ yield* fs.deleteDirectory(plan.workspacePath)
1315
+ }
1174
1316
  completed.push(plan)
1175
1317
  }
1176
1318
  yield* fs.deleteDirectoryIfEmpty(inspection.codePath)