@markjaquith/agency 3.2.1 → 3.2.3
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/README.md +30 -60
- package/cli-main.ts +38 -72
- package/fixtures/protocol/orchestration-recipes.json +9 -34
- package/package.json +1 -4
- package/schemas/agency-graph-v1.schema.json +2 -31
- package/src/cli-parser.test.ts +13 -132
- package/src/cli-parser.ts +10 -101
- package/src/cli.test.ts +12 -111
- package/src/commands/act.ts +2 -6
- package/src/commands/push.test.ts +4 -2
- package/src/commands/push.ts +2 -0
- package/src/commands/sync.ts +3 -3
- package/src/commands/validate.ts +3 -1
- package/src/commands/work.test.ts +70 -8
- package/src/commands/work.ts +15 -8
- package/src/graph-schema.ts +0 -2
- package/src/protocol.test.ts +24 -25
- package/src/protocol.ts +56 -15
- package/src/readiness.test.ts +2 -2
- package/src/services/ArchiveBulkService.test.ts +0 -88
- package/src/services/ArchiveService.ts +0 -32
- package/src/services/FileSystemService.ts +2 -0
- package/src/services/GraphMutationService.ts +0 -26
- package/src/services/GraphService.test.ts +1 -1
- package/src/services/IntegrationService.test.ts +4 -4
- package/src/services/LifecycleTransaction.ts +5 -1
- package/src/services/PhaseService.ts +1 -8
- package/src/services/PushService.test.ts +110 -4
- package/src/services/PushService.ts +435 -85
- package/src/services/ReadinessService.test.ts +0 -34
- package/src/services/ReadinessService.ts +1 -20
- package/src/services/ReviewService.test.ts +0 -64
- package/src/services/ReviewService.ts +0 -5
- package/src/services/SyncService.test.ts +75 -88
- package/src/services/SyncService.ts +52 -123
- package/src/services/TaskPhaseService.test.ts +13 -1
- package/src/services/TaskService.ts +1 -7
- package/src/services/WorkbaseService.ts +0 -3
- package/src/services/WorktreeService.test.ts +192 -1
- package/src/services/WorktreeService.ts +169 -34
- package/src/test-utils.ts +0 -2
- package/src/usage-log.test.ts +11 -1
- package/src/usage-log.ts +22 -4
- package/src/utils/process.test.ts +10 -0
- package/src/utils/process.ts +59 -6
- package/src/workbase/AGENTS.md +9 -15
- package/src/workbase/agent-command.test.ts +0 -3
- package/src/workbase/agent-command.ts +0 -6
- package/src/workbase/document-revision.ts +0 -4
- package/src/workbase/schemas.test.ts +12 -25
- package/src/workbase/schemas.ts +0 -16
- package/src/commands/claim.ts +0 -122
- package/src/services/ClaimService.test.ts +0 -415
- package/src/services/ClaimService.ts +0 -608
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
import { Data, Effect, Either } from "effect"
|
|
2
2
|
import { dirname, join, resolve } from "node:path"
|
|
3
3
|
import type {
|
|
4
|
-
ClaimRecord,
|
|
5
4
|
PhaseFrontmatter,
|
|
6
5
|
RepositoryReference,
|
|
7
6
|
TaskFrontmatter,
|
|
8
7
|
WorkStatus,
|
|
9
8
|
PullRequestRecord,
|
|
10
9
|
} from "../workbase/schemas"
|
|
10
|
+
import { documentRevision } from "../workbase/document-revision"
|
|
11
|
+
import {
|
|
12
|
+
formatMarkdownDocument,
|
|
13
|
+
parseFrontmatterSync,
|
|
14
|
+
} from "../workbase/frontmatter"
|
|
11
15
|
import {
|
|
12
16
|
normalizePullRequestRecord,
|
|
13
17
|
parseOptionalPullRequestRecord,
|
|
14
18
|
recordFromGitHubJson,
|
|
15
19
|
resolveDeliveryCommand,
|
|
16
20
|
} from "../workbase/delivery-command"
|
|
17
|
-
import { ClaimService } from "./ClaimService"
|
|
18
21
|
import { FileSystemService } from "./FileSystemService"
|
|
22
|
+
import {
|
|
23
|
+
documentWriteStep,
|
|
24
|
+
runLifecycleTransaction,
|
|
25
|
+
} from "./LifecycleTransaction"
|
|
19
26
|
import { WorkbaseService } from "./WorkbaseService"
|
|
20
27
|
import { WorktreeService } from "./WorktreeService"
|
|
21
28
|
import {
|
|
@@ -37,6 +44,7 @@ interface ExecutionRecord {
|
|
|
37
44
|
readonly taskId: string
|
|
38
45
|
readonly phaseId?: string
|
|
39
46
|
readonly path: string
|
|
47
|
+
readonly content: string
|
|
40
48
|
readonly revision: string
|
|
41
49
|
readonly data: ExecutionData
|
|
42
50
|
}
|
|
@@ -49,11 +57,7 @@ interface RegisteredWorktree {
|
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
interface SyncChange {
|
|
52
|
-
readonly kind:
|
|
53
|
-
| "materialize-workspace"
|
|
54
|
-
| "release-stale-claim"
|
|
55
|
-
| "record-pr"
|
|
56
|
-
| "mark-done"
|
|
60
|
+
readonly kind: "materialize-workspace" | "record-pr" | "mark-done"
|
|
57
61
|
readonly target: string
|
|
58
62
|
readonly message: string
|
|
59
63
|
readonly status: "planned" | "applied"
|
|
@@ -84,7 +88,6 @@ interface ExecutionSyncState {
|
|
|
84
88
|
readonly status: WorkStatus
|
|
85
89
|
readonly branch: string | null
|
|
86
90
|
readonly base: string | null
|
|
87
|
-
readonly claim: ClaimRecord | null
|
|
88
91
|
readonly checkouts: readonly CheckoutState[]
|
|
89
92
|
readonly pr: Record<string, unknown>
|
|
90
93
|
readonly review?: {
|
|
@@ -140,11 +143,6 @@ const parseJson = <T>(value: string, fallback: T): T => {
|
|
|
140
143
|
}
|
|
141
144
|
}
|
|
142
145
|
|
|
143
|
-
const isExpired = (claim: ClaimRecord | undefined, now: Date) =>
|
|
144
|
-
claim?.state === "active" &&
|
|
145
|
-
claim.expiresAt !== undefined &&
|
|
146
|
-
Date.parse(claim.expiresAt) <= now.getTime()
|
|
147
|
-
|
|
148
146
|
const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
|
|
149
147
|
|
|
150
148
|
const originRef = (ref: string) =>
|
|
@@ -199,13 +197,32 @@ const mergedPullRequestFromGitHub = (
|
|
|
199
197
|
return current
|
|
200
198
|
}
|
|
201
199
|
|
|
200
|
+
const mutateExecution = (
|
|
201
|
+
root: string,
|
|
202
|
+
record: ExecutionRecord,
|
|
203
|
+
revision: string,
|
|
204
|
+
data: ExecutionData,
|
|
205
|
+
) => {
|
|
206
|
+
const parsed = parseFrontmatterSync(record.content, record.path)
|
|
207
|
+
const content = formatMarkdownDocument(data, parsed.body)
|
|
208
|
+
return runLifecycleTransaction({
|
|
209
|
+
root,
|
|
210
|
+
preconditions: [{ path: record.path, revision }],
|
|
211
|
+
steps: [documentWriteStep(root, [{ path: record.path, content }])],
|
|
212
|
+
}).pipe(
|
|
213
|
+
Effect.as({ data, revision: documentRevision(content) }),
|
|
214
|
+
Effect.catchTag("LifecycleTransactionError", (error) =>
|
|
215
|
+
Effect.fail(new SyncError({ message: error.message })),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
202
220
|
export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
203
221
|
sync: () => ({
|
|
204
222
|
reconcile: (
|
|
205
223
|
options: {
|
|
206
224
|
readonly cwd?: string
|
|
207
225
|
readonly apply?: boolean
|
|
208
|
-
readonly now?: Date
|
|
209
226
|
readonly onProgress?: (progress: SyncProgress) => void
|
|
210
227
|
readonly taskId?: string
|
|
211
228
|
readonly phaseId?: string
|
|
@@ -215,7 +232,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
215
232
|
const fs = yield* FileSystemService
|
|
216
233
|
const workbase = yield* WorkbaseService
|
|
217
234
|
const worktrees = yield* WorktreeService
|
|
218
|
-
const claims = yield* ClaimService
|
|
219
235
|
const repositories = yield* RepositoryService
|
|
220
236
|
const versionControl = yield* VersionControlService
|
|
221
237
|
const { root, config } = yield* workbase.loadConfig(options.cwd)
|
|
@@ -255,6 +271,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
255
271
|
taskId: task.id,
|
|
256
272
|
phaseId: phase.id,
|
|
257
273
|
path: phase.path,
|
|
274
|
+
content: phase.content,
|
|
258
275
|
revision: phase.revision,
|
|
259
276
|
data: phase.data,
|
|
260
277
|
})
|
|
@@ -264,6 +281,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
264
281
|
key: `task:${task.id}`,
|
|
265
282
|
taskId: task.id,
|
|
266
283
|
path: task.path,
|
|
284
|
+
content: task.content,
|
|
267
285
|
revision: task.revision,
|
|
268
286
|
data: task.data,
|
|
269
287
|
})
|
|
@@ -299,7 +317,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
299
317
|
})
|
|
300
318
|
|
|
301
319
|
const apply = options.apply === true
|
|
302
|
-
const now = options.now ?? new Date()
|
|
303
320
|
const changes: SyncChange[] = []
|
|
304
321
|
const warnings: SyncNotice[] = []
|
|
305
322
|
const unresolved: SyncNotice[] = []
|
|
@@ -500,8 +517,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
500
517
|
? null
|
|
501
518
|
: mergedPullRequestFromGitHub(data, query)
|
|
502
519
|
const skipCheckoutReconciliation =
|
|
503
|
-
Boolean(data.completion) ||
|
|
504
|
-
(remoteMergedPr !== null && data.claim?.state !== "active")
|
|
520
|
+
Boolean(data.completion) || remoteMergedPr !== null
|
|
505
521
|
let materialize = false
|
|
506
522
|
let workspaceConflict = false
|
|
507
523
|
const declared: readonly (
|
|
@@ -764,57 +780,12 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
764
780
|
})
|
|
765
781
|
}
|
|
766
782
|
|
|
767
|
-
if (
|
|
768
|
-
isExpired(data.claim, now) &&
|
|
769
|
-
(data.status === "working" || data.status === "delegated")
|
|
770
|
-
) {
|
|
771
|
-
const sessionId = data.claim!.sessionId
|
|
772
|
-
if (apply) {
|
|
773
|
-
const expired = yield* claims.expire(
|
|
774
|
-
{
|
|
775
|
-
taskId: record.taskId,
|
|
776
|
-
phaseId: record.phaseId,
|
|
777
|
-
revision,
|
|
778
|
-
now,
|
|
779
|
-
},
|
|
780
|
-
root,
|
|
781
|
-
)
|
|
782
|
-
data = expired.data as ExecutionData
|
|
783
|
-
revision = expired.revision
|
|
784
|
-
} else {
|
|
785
|
-
const claim: ClaimRecord = {
|
|
786
|
-
...data.claim!,
|
|
787
|
-
state: "released",
|
|
788
|
-
releasedAt: now.toISOString(),
|
|
789
|
-
}
|
|
790
|
-
data = { ...data, status: "open", claim }
|
|
791
|
-
}
|
|
792
|
-
changes.push({
|
|
793
|
-
kind: "release-stale-claim",
|
|
794
|
-
target: record.key,
|
|
795
|
-
message: `Release expired claim '${sessionId}'`,
|
|
796
|
-
status: apply ? "applied" : "planned",
|
|
797
|
-
})
|
|
798
|
-
} else if (
|
|
799
|
-
data.claim?.state === "active" &&
|
|
800
|
-
data.status !== "working" &&
|
|
801
|
-
data.status !== "delegated"
|
|
802
|
-
) {
|
|
803
|
-
unresolved.push({
|
|
804
|
-
kind: "claim-status-conflict",
|
|
805
|
-
target: record.key,
|
|
806
|
-
message: `Active claim conflicts with '${data.status}' status`,
|
|
807
|
-
action: "Release or finish the claim explicitly",
|
|
808
|
-
})
|
|
809
|
-
}
|
|
810
|
-
|
|
811
783
|
if (data.completion) {
|
|
812
784
|
executions.push({
|
|
813
785
|
target: record.key,
|
|
814
786
|
status: data.status,
|
|
815
787
|
branch: data.branch,
|
|
816
788
|
base: data.base,
|
|
817
|
-
claim: data.claim ?? null,
|
|
818
789
|
checkouts: checkoutStates,
|
|
819
790
|
pr: { url: null, state: "none" },
|
|
820
791
|
})
|
|
@@ -976,16 +947,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
976
947
|
|
|
977
948
|
if (current && JSON.stringify(current) !== JSON.stringify(existing)) {
|
|
978
949
|
if (apply) {
|
|
979
|
-
const recorded = yield*
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
pr: current,
|
|
985
|
-
},
|
|
986
|
-
root,
|
|
987
|
-
)
|
|
988
|
-
data = recorded.data as ExecutionData
|
|
950
|
+
const recorded = yield* mutateExecution(root, record, revision, {
|
|
951
|
+
...data,
|
|
952
|
+
pr: current,
|
|
953
|
+
})
|
|
954
|
+
data = recorded.data
|
|
989
955
|
revision = recorded.revision
|
|
990
956
|
}
|
|
991
957
|
changes.push({
|
|
@@ -1002,35 +968,20 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
1002
968
|
data.status !== "done" &&
|
|
1003
969
|
data.status !== "dropped"
|
|
1004
970
|
) {
|
|
1005
|
-
if (
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
message:
|
|
1010
|
-
"Pull request is merged while the execution unit remains claimed",
|
|
1011
|
-
action: "Finish or release the active claim",
|
|
1012
|
-
})
|
|
1013
|
-
} else {
|
|
1014
|
-
if (apply) {
|
|
1015
|
-
const completed = yield* claims.reconcile(
|
|
1016
|
-
{
|
|
1017
|
-
taskId: record.taskId,
|
|
1018
|
-
phaseId: record.phaseId,
|
|
1019
|
-
revision,
|
|
1020
|
-
status: "done",
|
|
1021
|
-
},
|
|
1022
|
-
root,
|
|
1023
|
-
)
|
|
1024
|
-
data = completed.data as ExecutionData
|
|
1025
|
-
revision = completed.revision
|
|
1026
|
-
}
|
|
1027
|
-
changes.push({
|
|
1028
|
-
kind: "mark-done",
|
|
1029
|
-
target: record.key,
|
|
1030
|
-
message: "Mark execution unit done from merged pull request",
|
|
1031
|
-
status: apply ? "applied" : "planned",
|
|
971
|
+
if (apply) {
|
|
972
|
+
const completed = yield* mutateExecution(root, record, revision, {
|
|
973
|
+
...data,
|
|
974
|
+
status: "done",
|
|
1032
975
|
})
|
|
976
|
+
data = completed.data
|
|
977
|
+
revision = completed.revision
|
|
1033
978
|
}
|
|
979
|
+
changes.push({
|
|
980
|
+
kind: "mark-done",
|
|
981
|
+
target: record.key,
|
|
982
|
+
message: "Mark execution unit done from merged pull request",
|
|
983
|
+
status: apply ? "applied" : "planned",
|
|
984
|
+
})
|
|
1034
985
|
}
|
|
1035
986
|
|
|
1036
987
|
executions.push({
|
|
@@ -1038,7 +989,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
1038
989
|
status: data.status,
|
|
1039
990
|
branch: data.branch,
|
|
1040
991
|
base: data.base,
|
|
1041
|
-
claim: data.claim ?? null,
|
|
1042
992
|
checkouts: checkoutStates,
|
|
1043
993
|
pr,
|
|
1044
994
|
})
|
|
@@ -1047,27 +997,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
1047
997
|
|
|
1048
998
|
for (const task of reviewRecords) {
|
|
1049
999
|
if (!("review" in task.data)) continue
|
|
1050
|
-
|
|
1051
|
-
let revision = task.revision
|
|
1052
|
-
if (
|
|
1053
|
-
isExpired(data.claim, now) &&
|
|
1054
|
-
(data.status === "working" || data.status === "delegated")
|
|
1055
|
-
) {
|
|
1056
|
-
if (apply) {
|
|
1057
|
-
const expired = yield* claims.expire(
|
|
1058
|
-
{ taskId: task.id, revision, now },
|
|
1059
|
-
root,
|
|
1060
|
-
)
|
|
1061
|
-
if ("review" in expired.data) data = expired.data
|
|
1062
|
-
revision = expired.revision
|
|
1063
|
-
}
|
|
1064
|
-
changes.push({
|
|
1065
|
-
kind: "release-stale-claim",
|
|
1066
|
-
target: `task:${task.id}`,
|
|
1067
|
-
message: `Release expired claim '${data.claim?.sessionId ?? "unknown"}'`,
|
|
1068
|
-
status: apply ? "applied" : "planned",
|
|
1069
|
-
})
|
|
1070
|
-
}
|
|
1000
|
+
const data = task.data
|
|
1071
1001
|
const inspection = yield* worktrees.inspect(task.id, undefined, root)
|
|
1072
1002
|
for (const conflict of inspection.conflicts) {
|
|
1073
1003
|
unresolved.push({
|
|
@@ -1118,7 +1048,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
1118
1048
|
status: data.status,
|
|
1119
1049
|
branch: null,
|
|
1120
1050
|
base: null,
|
|
1121
|
-
claim: data.claim ?? null,
|
|
1122
1051
|
checkouts: checkout
|
|
1123
1052
|
? [
|
|
1124
1053
|
{
|
|
@@ -615,7 +615,19 @@ describe("task and phase services", () => {
|
|
|
615
615
|
),
|
|
616
616
|
),
|
|
617
617
|
),
|
|
618
|
-
).rejects.toThrow("
|
|
618
|
+
).rejects.toThrow("Delegated status cannot be set directly")
|
|
619
|
+
await Bun.write(
|
|
620
|
+
createdTask.path,
|
|
621
|
+
workingTask.content.replace("status: working", "status: delegated"),
|
|
622
|
+
)
|
|
623
|
+
const reopenedDelegatedTask = await runTestEffect(
|
|
624
|
+
TaskService.pipe(
|
|
625
|
+
Effect.flatMap((service) =>
|
|
626
|
+
service.setStatus("single-status", "open", root),
|
|
627
|
+
),
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
expect(reopenedDelegatedTask.data.status).toBe("open")
|
|
619
631
|
await expect(
|
|
620
632
|
runTestEffect(
|
|
621
633
|
TaskService.pipe(
|
|
@@ -610,8 +610,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
610
610
|
const validStatus = yield* decodeStatus(status)
|
|
611
611
|
if (validStatus === "delegated") {
|
|
612
612
|
return yield* new TaskError({
|
|
613
|
-
message:
|
|
614
|
-
"Delegation requires explicit ownership; use 'agency claim'",
|
|
613
|
+
message: "Delegated status cannot be set directly",
|
|
615
614
|
})
|
|
616
615
|
}
|
|
617
616
|
const record = yield* service.show(id, startPath)
|
|
@@ -620,11 +619,6 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
620
619
|
message: `Task '${id}' has multiple phases; set status on a phase instead`,
|
|
621
620
|
})
|
|
622
621
|
}
|
|
623
|
-
if (record.data.claim?.state === "active") {
|
|
624
|
-
return yield* new TaskError({
|
|
625
|
-
message: `Task '${id}' has an active claim; use agency release or agency finish`,
|
|
626
|
-
})
|
|
627
|
-
}
|
|
628
622
|
if (nonPrCompletion && validStatus !== "done") {
|
|
629
623
|
return yield* new TaskError({
|
|
630
624
|
message: "Non-PR completion is valid only with a done status",
|
|
@@ -870,9 +870,6 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
870
870
|
"Non-PR completion cannot have a recorded pull request",
|
|
871
871
|
)
|
|
872
872
|
}
|
|
873
|
-
if (record.data.claim?.state === "active") {
|
|
874
|
-
issue(record.path, "Completed work cannot have an active claim")
|
|
875
|
-
}
|
|
876
873
|
}
|
|
877
874
|
|
|
878
875
|
for (const epic of epics.values()) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdir, realpath, rename, rm } from "node:fs/promises"
|
|
3
|
+
import { mkdir, realpath, rename, rm, symlink } from "node:fs/promises"
|
|
4
4
|
import { join, resolve } from "node:path"
|
|
5
5
|
import {
|
|
6
6
|
captureErrors,
|
|
@@ -1526,6 +1526,107 @@ pr: null
|
|
|
1526
1526
|
).toBe("keep me\n")
|
|
1527
1527
|
})
|
|
1528
1528
|
|
|
1529
|
+
test("refuses to remove worktrees for active work", async () => {
|
|
1530
|
+
await runTestEffect(
|
|
1531
|
+
TaskService.pipe(
|
|
1532
|
+
Effect.flatMap((service) =>
|
|
1533
|
+
service.create(
|
|
1534
|
+
{
|
|
1535
|
+
id: "active-removal",
|
|
1536
|
+
ticketUrl: null,
|
|
1537
|
+
repo: "agency",
|
|
1538
|
+
branch: "task/active-removal",
|
|
1539
|
+
base: "main",
|
|
1540
|
+
},
|
|
1541
|
+
root,
|
|
1542
|
+
),
|
|
1543
|
+
),
|
|
1544
|
+
),
|
|
1545
|
+
)
|
|
1546
|
+
const workspace = await runTestEffect(
|
|
1547
|
+
WorktreeService.pipe(
|
|
1548
|
+
Effect.flatMap((service) =>
|
|
1549
|
+
service.materialize("active-removal", undefined, root),
|
|
1550
|
+
),
|
|
1551
|
+
),
|
|
1552
|
+
)
|
|
1553
|
+
await runTestEffect(
|
|
1554
|
+
TaskService.pipe(
|
|
1555
|
+
Effect.flatMap((service) =>
|
|
1556
|
+
service.setStatus("active-removal", "working", root),
|
|
1557
|
+
),
|
|
1558
|
+
),
|
|
1559
|
+
)
|
|
1560
|
+
|
|
1561
|
+
await expect(
|
|
1562
|
+
runTestEffect(
|
|
1563
|
+
WorktreeService.pipe(
|
|
1564
|
+
Effect.flatMap((service) =>
|
|
1565
|
+
service.remove("active-removal", undefined, root),
|
|
1566
|
+
),
|
|
1567
|
+
),
|
|
1568
|
+
),
|
|
1569
|
+
).rejects.toThrow("active 'working' ownership")
|
|
1570
|
+
expect(
|
|
1571
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
1572
|
+
).toBe(true)
|
|
1573
|
+
})
|
|
1574
|
+
|
|
1575
|
+
test("restores earlier worktrees and preserves the Git failure diagnostic", async () => {
|
|
1576
|
+
await ensureEffectRepository()
|
|
1577
|
+
await runTestEffect(
|
|
1578
|
+
TaskService.pipe(
|
|
1579
|
+
Effect.flatMap((service) =>
|
|
1580
|
+
service.create(
|
|
1581
|
+
{
|
|
1582
|
+
id: "locked-removal",
|
|
1583
|
+
ticketUrl: null,
|
|
1584
|
+
repo: "agency",
|
|
1585
|
+
repos: [{ repo: "effect", ref: "main" }],
|
|
1586
|
+
branch: "task/locked-removal",
|
|
1587
|
+
base: "main",
|
|
1588
|
+
},
|
|
1589
|
+
root,
|
|
1590
|
+
),
|
|
1591
|
+
),
|
|
1592
|
+
),
|
|
1593
|
+
)
|
|
1594
|
+
const workspace = await runTestEffect(
|
|
1595
|
+
WorktreeService.pipe(
|
|
1596
|
+
Effect.flatMap((service) =>
|
|
1597
|
+
service.materialize("locked-removal", undefined, root),
|
|
1598
|
+
),
|
|
1599
|
+
),
|
|
1600
|
+
)
|
|
1601
|
+
const referencePath = join(workspace.codePath, "effect")
|
|
1602
|
+
await git([
|
|
1603
|
+
"-C",
|
|
1604
|
+
join(root, "repos/effect"),
|
|
1605
|
+
"worktree",
|
|
1606
|
+
"lock",
|
|
1607
|
+
referencePath,
|
|
1608
|
+
])
|
|
1609
|
+
|
|
1610
|
+
const failure = await runTestEffect(
|
|
1611
|
+
WorktreeService.pipe(
|
|
1612
|
+
Effect.flatMap((service) =>
|
|
1613
|
+
service.remove("locked-removal", undefined, root),
|
|
1614
|
+
),
|
|
1615
|
+
Effect.flip,
|
|
1616
|
+
),
|
|
1617
|
+
)
|
|
1618
|
+
|
|
1619
|
+
expect(failure.message).toContain("locked working tree")
|
|
1620
|
+
expect(failure).toMatchObject({
|
|
1621
|
+
completed: [workspace.writablePath!],
|
|
1622
|
+
rolledBack: [workspace.writablePath!],
|
|
1623
|
+
manualRecovery: [],
|
|
1624
|
+
})
|
|
1625
|
+
for (const checkout of [workspace.writablePath!, referencePath]) {
|
|
1626
|
+
expect(await Bun.file(join(checkout, "README.md")).exists()).toBe(true)
|
|
1627
|
+
}
|
|
1628
|
+
})
|
|
1629
|
+
|
|
1529
1630
|
test("handles a missing checkout without deleting its branch", async () => {
|
|
1530
1631
|
await runTestEffect(
|
|
1531
1632
|
TaskService.pipe(
|
|
@@ -1583,6 +1684,52 @@ pr: null
|
|
|
1583
1684
|
).toBe(0)
|
|
1584
1685
|
})
|
|
1585
1686
|
|
|
1687
|
+
test("does not prune unrelated stale worktree registrations", async () => {
|
|
1688
|
+
for (const id of ["stale-target", "stale-unrelated"]) {
|
|
1689
|
+
await runTestEffect(
|
|
1690
|
+
TaskService.pipe(
|
|
1691
|
+
Effect.flatMap((service) =>
|
|
1692
|
+
service.create(
|
|
1693
|
+
{
|
|
1694
|
+
id,
|
|
1695
|
+
ticketUrl: null,
|
|
1696
|
+
repo: "agency",
|
|
1697
|
+
branch: `task/${id}`,
|
|
1698
|
+
base: "main",
|
|
1699
|
+
},
|
|
1700
|
+
root,
|
|
1701
|
+
),
|
|
1702
|
+
),
|
|
1703
|
+
),
|
|
1704
|
+
)
|
|
1705
|
+
const workspace = await runTestEffect(
|
|
1706
|
+
WorktreeService.pipe(
|
|
1707
|
+
Effect.flatMap((service) => service.materialize(id, undefined, root)),
|
|
1708
|
+
),
|
|
1709
|
+
)
|
|
1710
|
+
await rm(workspace.codePath, { recursive: true })
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
await expect(
|
|
1714
|
+
runTestEffect(
|
|
1715
|
+
WorktreeService.pipe(
|
|
1716
|
+
Effect.flatMap((service) =>
|
|
1717
|
+
service.remove("stale-target", undefined, root),
|
|
1718
|
+
),
|
|
1719
|
+
),
|
|
1720
|
+
),
|
|
1721
|
+
).rejects.toThrow("would also remove unrelated stale registrations")
|
|
1722
|
+
const registered = await gitOutput([
|
|
1723
|
+
"-C",
|
|
1724
|
+
join(root, "repos/agency"),
|
|
1725
|
+
"worktree",
|
|
1726
|
+
"list",
|
|
1727
|
+
"--porcelain",
|
|
1728
|
+
])
|
|
1729
|
+
expect(registered).toContain("tasks/stale-target/code/agency")
|
|
1730
|
+
expect(registered).toContain("tasks/stale-unrelated/code/agency")
|
|
1731
|
+
})
|
|
1732
|
+
|
|
1586
1733
|
test("lists and inspects ownership, registration, commits, and dirtiness", async () => {
|
|
1587
1734
|
const repository = join(root, "repos/agency")
|
|
1588
1735
|
await git(["-C", repository, "branch", "task/inspected", "main"])
|
|
@@ -1712,6 +1859,50 @@ pr: null
|
|
|
1712
1859
|
expect(await Bun.file(unexpected).text()).toBe("keep me\n")
|
|
1713
1860
|
})
|
|
1714
1861
|
|
|
1862
|
+
test("does not follow a symlink named for an expected checkout", async () => {
|
|
1863
|
+
await runTestEffect(
|
|
1864
|
+
TaskService.pipe(
|
|
1865
|
+
Effect.flatMap((service) =>
|
|
1866
|
+
service.create(
|
|
1867
|
+
{
|
|
1868
|
+
id: "symlink-removal",
|
|
1869
|
+
ticketUrl: null,
|
|
1870
|
+
repo: "agency",
|
|
1871
|
+
branch: "task/symlink-removal",
|
|
1872
|
+
base: "main",
|
|
1873
|
+
},
|
|
1874
|
+
root,
|
|
1875
|
+
),
|
|
1876
|
+
),
|
|
1877
|
+
),
|
|
1878
|
+
)
|
|
1879
|
+
const repository = join(root, "repos/agency")
|
|
1880
|
+
await git(["-C", repository, "branch", "task/symlink-removal", "main"])
|
|
1881
|
+
const outside = join(root, "outside-symlink-removal")
|
|
1882
|
+
await git([
|
|
1883
|
+
"-C",
|
|
1884
|
+
repository,
|
|
1885
|
+
"worktree",
|
|
1886
|
+
"add",
|
|
1887
|
+
outside,
|
|
1888
|
+
"task/symlink-removal",
|
|
1889
|
+
])
|
|
1890
|
+
const codePath = join(root, "tasks/symlink-removal/code")
|
|
1891
|
+
await mkdir(codePath, { recursive: true })
|
|
1892
|
+
await symlink(outside, join(codePath, "agency"), "dir")
|
|
1893
|
+
|
|
1894
|
+
await expect(
|
|
1895
|
+
runTestEffect(
|
|
1896
|
+
WorktreeService.pipe(
|
|
1897
|
+
Effect.flatMap((service) =>
|
|
1898
|
+
service.remove("symlink-removal", undefined, root),
|
|
1899
|
+
),
|
|
1900
|
+
),
|
|
1901
|
+
),
|
|
1902
|
+
).rejects.toThrow("is a symbolic link")
|
|
1903
|
+
expect(await Bun.file(join(outside, "README.md")).exists()).toBe(true)
|
|
1904
|
+
})
|
|
1905
|
+
|
|
1715
1906
|
test("refuses removal when a branch has duplicate Agency owners", async () => {
|
|
1716
1907
|
await runTestEffect(
|
|
1717
1908
|
TaskService.pipe(
|