@markjaquith/agency 2.23.0 → 2.24.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.
@@ -13,6 +13,7 @@ import {
13
13
  parseFrontmatter,
14
14
  } from "../workbase/frontmatter"
15
15
  import { documentRevision } from "../workbase/document-revision"
16
+ import { archivedEpicDirectory } from "../workbase/archive"
16
17
 
17
18
  class EpicError extends Data.TaggedError("EpicError")<{
18
19
  readonly message: string
@@ -73,6 +74,11 @@ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
73
74
  message: `Epic '${validId}' already exists`,
74
75
  })
75
76
  }
77
+ if (yield* fs.exists(archivedEpicDirectory(root, validId))) {
78
+ return yield* new EpicError({
79
+ message: `Epic '${validId}' is archived; restore it before reusing this ID`,
80
+ })
81
+ }
76
82
 
77
83
  for (const { repo: alias } of data.repos) {
78
84
  if (!(yield* fs.exists(join(root, "repos", alias)))) {
@@ -17,6 +17,7 @@ import {
17
17
  } from "../workbase/frontmatter"
18
18
  import { canTransitionStatus } from "../readiness"
19
19
  import { documentRevision } from "../workbase/document-revision"
20
+ import { archivedPhaseDirectory } from "../workbase/archive"
20
21
 
21
22
  class PhaseError extends Data.TaggedError("PhaseError")<{
22
23
  readonly message: string
@@ -114,6 +115,11 @@ export class PhaseService extends Effect.Service<PhaseService>()(
114
115
  message: `Phase '${id}' already exists on task '${taskId}'`,
115
116
  })
116
117
  }
118
+ if (yield* fs.exists(archivedPhaseDirectory(root, taskId, id))) {
119
+ return yield* new PhaseError({
120
+ message: `Phase '${id}' on task '${taskId}' is archived; restore it before reusing this ID`,
121
+ })
122
+ }
117
123
  const knownPhases = new Set(
118
124
  isMultiPhase
119
125
  ? task.data.phases.map((phase) => phase.id)
@@ -17,6 +17,7 @@ import {
17
17
  } from "../workbase/frontmatter"
18
18
  import { canTransitionStatus } from "../readiness"
19
19
  import { documentRevision } from "../workbase/document-revision"
20
+ import { archivedTaskDirectory } from "../workbase/archive"
20
21
 
21
22
  class TaskError extends Data.TaggedError("TaskError")<{
22
23
  readonly message: string
@@ -85,6 +86,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
85
86
  message: `Task '${id}' already exists`,
86
87
  })
87
88
  }
89
+ if (yield* fs.exists(archivedTaskDirectory(root, id))) {
90
+ return yield* new TaskError({
91
+ message: `Task '${id}' is archived; restore it before reusing this ID`,
92
+ })
93
+ }
88
94
 
89
95
  let data: TaskData
90
96
  if (input.multiPhase) {
@@ -88,6 +88,8 @@ interface MaterializeOptions extends BaseCommandOptions {
88
88
  readonly force?: boolean
89
89
  }
90
90
 
91
+ interface RemoveOptions extends BaseCommandOptions {}
92
+
91
93
  export class WorktreeService extends Effect.Service<WorktreeService>()(
92
94
  "WorktreeService",
93
95
  {
@@ -558,6 +560,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
558
560
  taskId: string,
559
561
  phaseId?: string,
560
562
  startPath: string = process.cwd(),
563
+ options: RemoveOptions = {},
561
564
  ) =>
562
565
  Effect.gen(function* () {
563
566
  const fs = yield* FileSystemService
@@ -592,11 +595,22 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
592
595
  }
593
596
 
594
597
  const codeDirectoryExists = yield* fs.isDirectory(codePath)
595
- const removed: string[] = []
596
- for (const alias of [
598
+ const aliases = [
597
599
  execution.repo,
598
600
  ...(execution.repos ?? []).map((reference) => reference.repo),
599
- ]) {
601
+ ]
602
+ if (codeDirectoryExists) {
603
+ const unmanaged = (yield* fs.readDirectory(codePath)).filter(
604
+ (entry) => !aliases.includes(entry.name),
605
+ )
606
+ if (unmanaged.length > 0) {
607
+ return yield* new WorktreeError({
608
+ message: `Cannot remove ${codePath}; it contains unmanaged entries: ${unmanaged.map((entry) => entry.name).join(", ")}`,
609
+ })
610
+ }
611
+ }
612
+ const removed: string[] = []
613
+ for (const alias of aliases) {
600
614
  const repositoryPath = join(root, "repos", alias)
601
615
  const checkoutPath = join(codePath, alias)
602
616
  const listed = yield* fs.runCommand(
@@ -643,6 +657,19 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
643
657
  }
644
658
  continue
645
659
  }
660
+ if (checkoutExists) {
661
+ const status = yield* fs.runCommand(
662
+ ["git", "-C", checkoutPath, "status", "--porcelain"],
663
+ { captureOutput: true },
664
+ )
665
+ if (status.exitCode !== 0 || status.stdout.trim()) {
666
+ return yield* new WorktreeError({
667
+ message: `Failed to remove worktree for '${alias}': worktree has uncommitted changes`,
668
+ })
669
+ }
670
+ removed.push(checkoutPath)
671
+ }
672
+ if (options.dryRun) continue
646
673
 
647
674
  const result = yield* fs.runCommand(
648
675
  [
@@ -661,10 +688,13 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
661
688
  message: `Failed to remove worktree for '${alias}': ${result.stderr}`,
662
689
  })
663
690
  }
664
- if (checkoutExists) removed.push(checkoutPath)
665
691
  }
666
692
 
667
- if (codeDirectoryExists && (yield* fs.isDirectory(codePath))) {
693
+ if (
694
+ !options.dryRun &&
695
+ codeDirectoryExists &&
696
+ (yield* fs.isDirectory(codePath))
697
+ ) {
668
698
  const remaining = yield* fs.readDirectory(codePath)
669
699
  if (remaining.length > 0) {
670
700
  return yield* new WorktreeError({
@@ -0,0 +1,18 @@
1
+ import { join } from "node:path"
2
+
3
+ const lifecycleManifestName = ".agency-lifecycle.json"
4
+
5
+ export const archivedEpicDirectory = (root: string, id: string) =>
6
+ join(root, "archive", "epics", id)
7
+
8
+ export const archivedTaskDirectory = (root: string, id: string) =>
9
+ join(root, "archive", "tasks", id)
10
+
11
+ export const archivedPhaseDirectory = (
12
+ root: string,
13
+ taskId: string,
14
+ id: string,
15
+ ) => join(archivedTaskDirectory(root, taskId), "phases", id)
16
+
17
+ export const lifecycleManifestPath = (directory: string) =>
18
+ join(directory, lifecycleManifestName)