@markjaquith/agency 2.4.0 → 2.6.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.
- package/README.md +56 -20
- package/cli.ts +43 -2
- package/package.json +1 -1
- package/skills/agency/SKILL.md +41 -5
- package/src/cli.test.ts +31 -3
- package/src/commands/archive.test.ts +69 -0
- package/src/commands/archive.ts +70 -0
- package/src/commands/init.test.ts +3 -0
- package/src/commands/phase.ts +23 -1
- package/src/commands/task-phase.test.ts +46 -1
- package/src/commands/task.test.ts +94 -0
- package/src/commands/task.ts +160 -17
- package/src/commands/validate.test.ts +65 -0
- package/src/commands/validate.ts +19 -5
- package/src/commands/work.test.ts +100 -34
- package/src/commands/work.ts +30 -10
- package/src/commands/workbase.test.ts +62 -0
- package/src/commands/workbase.ts +58 -0
- package/src/services/ArchiveService.test.ts +334 -0
- package/src/services/ArchiveService.ts +246 -0
- package/src/services/PhaseService.ts +28 -0
- package/src/services/TaskPhaseService.test.ts +79 -0
- package/src/services/TaskService.ts +31 -1
- package/src/services/WorkbaseService.test.ts +99 -1
- package/src/services/WorkbaseService.ts +101 -2
- package/src/services/WorktreeService.test.ts +150 -1
- package/src/services/WorktreeService.ts +123 -1
- package/src/test-utils.ts +2 -0
- package/src/utils/progress.test.ts +37 -0
- package/src/utils/progress.ts +36 -0
- package/src/workbase/AGENTS.md +3 -0
- package/src/workbase/opencode-file.ts +37 -0
- package/src/workbase/schemas.test.ts +114 -0
- package/src/workbase/schemas.ts +18 -2
- package/src/workbase/work-target.test.ts +16 -9
- package/src/workbase/work-target.ts +37 -6
- package/src/workbase/workbase-choice.ts +71 -0
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
TaskFrontmatter,
|
|
10
10
|
type RepositoryReference,
|
|
11
11
|
type TaskFrontmatter as TaskData,
|
|
12
|
+
WorkStatus,
|
|
12
13
|
} from "../workbase/schemas"
|
|
13
14
|
import {
|
|
14
15
|
formatMarkdownDocument,
|
|
@@ -28,7 +29,7 @@ interface TaskRecord {
|
|
|
28
29
|
|
|
29
30
|
export interface CreateTaskInput {
|
|
30
31
|
readonly id: string
|
|
31
|
-
readonly ticketUrl: string
|
|
32
|
+
readonly ticketUrl: string | null
|
|
32
33
|
readonly description?: string
|
|
33
34
|
readonly epic?: string
|
|
34
35
|
readonly multiPhase?: boolean
|
|
@@ -57,6 +58,13 @@ const decodeId = (id: string) => {
|
|
|
57
58
|
: Effect.succeed(result.right)
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
const decodeStatus = (status: string) => {
|
|
62
|
+
const result = Schema.decodeUnknownEither(WorkStatus)(status)
|
|
63
|
+
return Either.isLeft(result)
|
|
64
|
+
? Effect.fail(new TaskError({ message: `Invalid work status '${status}'` }))
|
|
65
|
+
: Effect.succeed(result.right)
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
61
69
|
sync: () => ({
|
|
62
70
|
create: (input: CreateTaskInput, startPath: string = process.cwd()) =>
|
|
@@ -188,5 +196,27 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
188
196
|
}
|
|
189
197
|
return record
|
|
190
198
|
}),
|
|
199
|
+
|
|
200
|
+
setStatus: (
|
|
201
|
+
id: string,
|
|
202
|
+
status: string,
|
|
203
|
+
startPath: string = process.cwd(),
|
|
204
|
+
) =>
|
|
205
|
+
Effect.gen(function* () {
|
|
206
|
+
const fs = yield* FileSystemService
|
|
207
|
+
const service = yield* TaskService
|
|
208
|
+
const validStatus = yield* decodeStatus(status)
|
|
209
|
+
const record = yield* service.show(id, startPath)
|
|
210
|
+
if ("phases" in record.data) {
|
|
211
|
+
return yield* new TaskError({
|
|
212
|
+
message: `Task '${id}' has multiple phases; set status on a phase instead`,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
const parsed = yield* parseFrontmatter(record.content, record.path)
|
|
216
|
+
const data = { ...record.data, status: validStatus }
|
|
217
|
+
const content = formatMarkdownDocument(data, parsed.body)
|
|
218
|
+
yield* fs.writeFile(record.path, content)
|
|
219
|
+
return { ...record, content, data } satisfies TaskRecord
|
|
220
|
+
}),
|
|
191
221
|
}),
|
|
192
222
|
}) {}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
3
|
import { createHash } from "node:crypto"
|
|
4
|
-
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { mkdir, realpath } from "node:fs/promises"
|
|
5
5
|
import { dirname, join } from "node:path"
|
|
6
6
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
7
7
|
import { managedWorkbaseAgents } from "../workbase/agents-file"
|
|
8
|
+
import { managedWorkbaseOpencode } from "../workbase/opencode-file"
|
|
8
9
|
import { WorkbaseService } from "./WorkbaseService"
|
|
9
10
|
|
|
10
11
|
const write = async (root: string, path: string, content: string) => {
|
|
@@ -18,6 +19,11 @@ const managedAgents = (body: string) => {
|
|
|
18
19
|
return `<!-- agency-managed: sha256=${checksum} -->\n\n${body}`
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
const managedOpencode = (body: string) => {
|
|
23
|
+
const checksum = createHash("sha256").update(body).digest("hex")
|
|
24
|
+
return `// agency-managed: sha256=${checksum}\n\n${body}`
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
describe("WorkbaseService", () => {
|
|
22
28
|
let root: string
|
|
23
29
|
|
|
@@ -45,6 +51,98 @@ describe("WorkbaseService", () => {
|
|
|
45
51
|
expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
|
|
46
52
|
managedWorkbaseAgents,
|
|
47
53
|
)
|
|
54
|
+
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
55
|
+
managedWorkbaseOpencode,
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("registers canonical workbase paths without duplicates", async () => {
|
|
60
|
+
const workbaseRoot = join(root, "workbase")
|
|
61
|
+
const nested = join(workbaseRoot, "nested")
|
|
62
|
+
const configDirectory = join(root, "config")
|
|
63
|
+
await write(workbaseRoot, "agency.json", '{"version":2}\n')
|
|
64
|
+
await mkdir(nested, { recursive: true })
|
|
65
|
+
|
|
66
|
+
const first = await runTestEffect(
|
|
67
|
+
WorkbaseService.pipe(
|
|
68
|
+
Effect.flatMap((service) => service.register(nested, configDirectory)),
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
await runTestEffect(
|
|
72
|
+
WorkbaseService.pipe(
|
|
73
|
+
Effect.flatMap((service) =>
|
|
74
|
+
service.register(workbaseRoot, configDirectory),
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
const registered = await runTestEffect(
|
|
79
|
+
WorkbaseService.pipe(
|
|
80
|
+
Effect.flatMap((service) => service.listRegistered(configDirectory)),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
expect(first).toBe(await realpath(workbaseRoot))
|
|
85
|
+
expect(registered).toEqual([first])
|
|
86
|
+
expect(
|
|
87
|
+
await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
|
|
88
|
+
).toEqual({ version: 1, workbases: [first] })
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("preserves an unmanaged workbase OpenCode config", async () => {
|
|
92
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
93
|
+
await write(root, ".opencode/opencode.jsonc", '{"model":"test/model"}\n')
|
|
94
|
+
|
|
95
|
+
await runTestEffect(
|
|
96
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
100
|
+
'{"model":"test/model"}\n',
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("does not override an existing JSON OpenCode config", async () => {
|
|
105
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
106
|
+
await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
|
|
107
|
+
|
|
108
|
+
await runTestEffect(
|
|
109
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
expect(
|
|
113
|
+
await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
|
|
114
|
+
).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("updates an unmodified managed workbase OpenCode config", async () => {
|
|
118
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
119
|
+
await write(
|
|
120
|
+
root,
|
|
121
|
+
".opencode/opencode.jsonc",
|
|
122
|
+
managedOpencode('{"references":{}}\n'),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
await runTestEffect(
|
|
126
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
130
|
+
managedWorkbaseOpencode,
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("preserves a modified managed workbase OpenCode config", async () => {
|
|
135
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
136
|
+
const content = `${managedOpencode('{"references":{}}\n')}\n// User edit\n`
|
|
137
|
+
await write(root, ".opencode/opencode.jsonc", content)
|
|
138
|
+
|
|
139
|
+
await runTestEffect(
|
|
140
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
144
|
+
content,
|
|
145
|
+
)
|
|
48
146
|
})
|
|
49
147
|
|
|
50
148
|
test("preserves an unmanaged workbase AGENTS.md", async () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Schema } from "@effect/schema"
|
|
2
2
|
import { TreeFormatter } from "@effect/schema"
|
|
3
3
|
import { Data, Effect, Either } from "effect"
|
|
4
|
+
import { homedir } from "node:os"
|
|
4
5
|
import { dirname, join, relative, resolve } from "node:path"
|
|
5
6
|
import { FileSystemService } from "./FileSystemService"
|
|
6
7
|
import { parseFrontmatter } from "../workbase/frontmatter"
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
PhaseFrontmatter,
|
|
10
11
|
TaskFrontmatter,
|
|
11
12
|
WorkbaseConfig,
|
|
13
|
+
WorkbaseRegistry,
|
|
12
14
|
type Dependency,
|
|
13
15
|
type EpicFrontmatter as EpicData,
|
|
14
16
|
type PhaseFrontmatter as PhaseData,
|
|
@@ -19,6 +21,10 @@ import {
|
|
|
19
21
|
canUpdateManagedWorkbaseAgents,
|
|
20
22
|
managedWorkbaseAgents,
|
|
21
23
|
} from "../workbase/agents-file"
|
|
24
|
+
import {
|
|
25
|
+
canUpdateManagedWorkbaseOpencode,
|
|
26
|
+
managedWorkbaseOpencode,
|
|
27
|
+
} from "../workbase/opencode-file"
|
|
22
28
|
|
|
23
29
|
class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
|
|
24
30
|
readonly message: string
|
|
@@ -30,6 +36,12 @@ class WorkbaseConfigError extends Data.TaggedError("WorkbaseConfigError")<{
|
|
|
30
36
|
readonly cause?: unknown
|
|
31
37
|
}> {}
|
|
32
38
|
|
|
39
|
+
class WorkbaseRegistryError extends Data.TaggedError("WorkbaseRegistryError")<{
|
|
40
|
+
readonly message: string
|
|
41
|
+
readonly path: string
|
|
42
|
+
readonly cause?: unknown
|
|
43
|
+
}> {}
|
|
44
|
+
|
|
33
45
|
interface ValidationIssue {
|
|
34
46
|
readonly path: string
|
|
35
47
|
readonly message: string
|
|
@@ -68,6 +80,45 @@ const decode = <S extends Schema.Schema.AnyNoContext>(
|
|
|
68
80
|
: { success: true, value: result.right }
|
|
69
81
|
}
|
|
70
82
|
|
|
83
|
+
const registryPath = (configDirectory?: string) =>
|
|
84
|
+
join(
|
|
85
|
+
configDirectory ||
|
|
86
|
+
process.env.XDG_CONFIG_HOME ||
|
|
87
|
+
join(homedir(), ".config"),
|
|
88
|
+
"agency",
|
|
89
|
+
"workbases.json",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const readRegistry = (configDirectory?: string) =>
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const fs = yield* FileSystemService
|
|
95
|
+
const path = registryPath(configDirectory)
|
|
96
|
+
if (!(yield* fs.exists(path))) {
|
|
97
|
+
return { path, registry: { version: 1, workbases: [] } as const }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const content = yield* fs.readFile(path)
|
|
101
|
+
let input: unknown
|
|
102
|
+
try {
|
|
103
|
+
input = JSON.parse(content)
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
return yield* new WorkbaseRegistryError({
|
|
106
|
+
path,
|
|
107
|
+
message: `Invalid JSON in workbase registry ${path}`,
|
|
108
|
+
cause,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const decoded = decode(WorkbaseRegistry, input)
|
|
113
|
+
if (!decoded.success) {
|
|
114
|
+
return yield* new WorkbaseRegistryError({
|
|
115
|
+
path,
|
|
116
|
+
message: `Invalid workbase registry in ${path}:\n${decoded.error}`,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
return { path, registry: decoded.value }
|
|
120
|
+
})
|
|
121
|
+
|
|
71
122
|
const ensureWorkbaseAgents = (root: string) =>
|
|
72
123
|
Effect.gen(function* () {
|
|
73
124
|
const fs = yield* FileSystemService
|
|
@@ -86,6 +137,32 @@ const ensureWorkbaseAgents = (root: string) =>
|
|
|
86
137
|
}
|
|
87
138
|
})
|
|
88
139
|
|
|
140
|
+
const ensureWorkbaseOpencode = (root: string) =>
|
|
141
|
+
Effect.gen(function* () {
|
|
142
|
+
const fs = yield* FileSystemService
|
|
143
|
+
const directory = join(root, ".opencode")
|
|
144
|
+
const path = join(directory, "opencode.jsonc")
|
|
145
|
+
if (!(yield* fs.exists(path))) {
|
|
146
|
+
if (yield* fs.exists(join(directory, "opencode.json"))) return
|
|
147
|
+
yield* fs.createDirectory(directory)
|
|
148
|
+
yield* fs.writeFile(path, managedWorkbaseOpencode)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const content = yield* fs.readFile(path)
|
|
153
|
+
if (
|
|
154
|
+
content !== managedWorkbaseOpencode &&
|
|
155
|
+
canUpdateManagedWorkbaseOpencode(content)
|
|
156
|
+
) {
|
|
157
|
+
yield* fs.writeFile(path, managedWorkbaseOpencode)
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const ensureWorkbaseAgentFiles = (root: string) =>
|
|
162
|
+
Effect.all([ensureWorkbaseAgents(root), ensureWorkbaseOpencode(root)], {
|
|
163
|
+
concurrency: "unbounded",
|
|
164
|
+
})
|
|
165
|
+
|
|
89
166
|
const findCycles = (nodes: readonly Dependency[]): readonly string[] => {
|
|
90
167
|
const dependencies = new Map(
|
|
91
168
|
nodes.map((node) => [node.id, [...(node.dependsOn ?? [])]]),
|
|
@@ -164,7 +241,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
164
241
|
`${existing}${prefix}${missing.join("\n")}\n`,
|
|
165
242
|
)
|
|
166
243
|
}
|
|
167
|
-
yield*
|
|
244
|
+
yield* ensureWorkbaseAgentFiles(root)
|
|
168
245
|
|
|
169
246
|
return root
|
|
170
247
|
}),
|
|
@@ -221,7 +298,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
221
298
|
})
|
|
222
299
|
}
|
|
223
300
|
}
|
|
224
|
-
yield*
|
|
301
|
+
yield* ensureWorkbaseAgentFiles(current)
|
|
225
302
|
return current
|
|
226
303
|
}
|
|
227
304
|
}
|
|
@@ -263,6 +340,28 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
263
340
|
return { root, config: decoded.value }
|
|
264
341
|
}),
|
|
265
342
|
|
|
343
|
+
register: (startPath: string, configDirectory?: string) =>
|
|
344
|
+
Effect.gen(function* () {
|
|
345
|
+
const service = yield* WorkbaseService
|
|
346
|
+
const fs = yield* FileSystemService
|
|
347
|
+
const discovered = yield* service.discover(startPath)
|
|
348
|
+
const root = yield* fs.realPath(discovered)
|
|
349
|
+
const { path, registry } = yield* readRegistry(configDirectory)
|
|
350
|
+
if (registry.workbases.includes(root)) return root
|
|
351
|
+
|
|
352
|
+
yield* fs.createDirectory(dirname(path))
|
|
353
|
+
yield* fs.writeJSON(path, {
|
|
354
|
+
version: 1,
|
|
355
|
+
workbases: [...registry.workbases, root],
|
|
356
|
+
})
|
|
357
|
+
return root
|
|
358
|
+
}),
|
|
359
|
+
|
|
360
|
+
listRegistered: (configDirectory?: string) =>
|
|
361
|
+
readRegistry(configDirectory).pipe(
|
|
362
|
+
Effect.map(({ registry }) => registry.workbases),
|
|
363
|
+
),
|
|
364
|
+
|
|
266
365
|
validate: (startPath: string = process.cwd()) =>
|
|
267
366
|
Effect.gen(function* () {
|
|
268
367
|
const service = yield* WorkbaseService
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdir } from "node:fs/promises"
|
|
3
|
+
import { mkdir, rm } from "node:fs/promises"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import {
|
|
6
6
|
captureLogs,
|
|
@@ -600,6 +600,155 @@ pr: null
|
|
|
600
600
|
).rejects.toThrow("is attached to branch 'main'")
|
|
601
601
|
})
|
|
602
602
|
|
|
603
|
+
test("removes worktrees without deleting branches", async () => {
|
|
604
|
+
await runTestEffect(
|
|
605
|
+
TaskService.pipe(
|
|
606
|
+
Effect.flatMap((service) =>
|
|
607
|
+
service.create(
|
|
608
|
+
{
|
|
609
|
+
id: "removable",
|
|
610
|
+
ticketUrl: "https://example.com/task",
|
|
611
|
+
repo: "agency",
|
|
612
|
+
repos: [{ repo: "effect", ref: "main" }],
|
|
613
|
+
branch: "task/removable",
|
|
614
|
+
base: "main",
|
|
615
|
+
},
|
|
616
|
+
root,
|
|
617
|
+
),
|
|
618
|
+
),
|
|
619
|
+
),
|
|
620
|
+
)
|
|
621
|
+
const workspace = await runTestEffect(
|
|
622
|
+
WorktreeService.pipe(
|
|
623
|
+
Effect.flatMap((service) =>
|
|
624
|
+
service.materialize("removable", undefined, root),
|
|
625
|
+
),
|
|
626
|
+
),
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
const removed = await runTestEffect(
|
|
630
|
+
WorktreeService.pipe(
|
|
631
|
+
Effect.flatMap((service) =>
|
|
632
|
+
service.remove("removable", undefined, root),
|
|
633
|
+
),
|
|
634
|
+
),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
expect(removed.sort()).toEqual(
|
|
638
|
+
[
|
|
639
|
+
join(workspace.codePath, "agency"),
|
|
640
|
+
join(workspace.codePath, "effect"),
|
|
641
|
+
].sort(),
|
|
642
|
+
)
|
|
643
|
+
expect(await Bun.file(workspace.codePath).exists()).toBe(false)
|
|
644
|
+
const branch = Bun.spawnSync([
|
|
645
|
+
"git",
|
|
646
|
+
"-C",
|
|
647
|
+
join(root, "repos/agency"),
|
|
648
|
+
"show-ref",
|
|
649
|
+
"--verify",
|
|
650
|
+
"refs/heads/task/removable",
|
|
651
|
+
])
|
|
652
|
+
expect(branch.exitCode).toBe(0)
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
test("refuses to remove a worktree with uncommitted changes", async () => {
|
|
656
|
+
await runTestEffect(
|
|
657
|
+
TaskService.pipe(
|
|
658
|
+
Effect.flatMap((service) =>
|
|
659
|
+
service.create(
|
|
660
|
+
{
|
|
661
|
+
id: "dirty",
|
|
662
|
+
ticketUrl: "https://example.com/task",
|
|
663
|
+
repo: "agency",
|
|
664
|
+
branch: "task/dirty",
|
|
665
|
+
base: "main",
|
|
666
|
+
},
|
|
667
|
+
root,
|
|
668
|
+
),
|
|
669
|
+
),
|
|
670
|
+
),
|
|
671
|
+
)
|
|
672
|
+
const workspace = await runTestEffect(
|
|
673
|
+
WorktreeService.pipe(
|
|
674
|
+
Effect.flatMap((service) =>
|
|
675
|
+
service.materialize("dirty", undefined, root),
|
|
676
|
+
),
|
|
677
|
+
),
|
|
678
|
+
)
|
|
679
|
+
await Bun.write(
|
|
680
|
+
join(workspace.writablePath, "uncommitted.txt"),
|
|
681
|
+
"keep me\n",
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
await expect(
|
|
685
|
+
runTestEffect(
|
|
686
|
+
WorktreeService.pipe(
|
|
687
|
+
Effect.flatMap((service) => service.remove("dirty", undefined, root)),
|
|
688
|
+
),
|
|
689
|
+
),
|
|
690
|
+
).rejects.toThrow("Failed to remove worktree for 'agency'")
|
|
691
|
+
expect(
|
|
692
|
+
await Bun.file(join(workspace.writablePath, "uncommitted.txt")).text(),
|
|
693
|
+
).toBe("keep me\n")
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
test("handles a missing checkout without deleting its branch", async () => {
|
|
697
|
+
await runTestEffect(
|
|
698
|
+
TaskService.pipe(
|
|
699
|
+
Effect.flatMap((service) =>
|
|
700
|
+
service.create(
|
|
701
|
+
{
|
|
702
|
+
id: "stale",
|
|
703
|
+
ticketUrl: "https://example.com/task",
|
|
704
|
+
repo: "agency",
|
|
705
|
+
branch: "task/stale",
|
|
706
|
+
base: "main",
|
|
707
|
+
},
|
|
708
|
+
root,
|
|
709
|
+
),
|
|
710
|
+
),
|
|
711
|
+
),
|
|
712
|
+
)
|
|
713
|
+
const workspace = await runTestEffect(
|
|
714
|
+
WorktreeService.pipe(
|
|
715
|
+
Effect.flatMap((service) =>
|
|
716
|
+
service.materialize("stale", undefined, root),
|
|
717
|
+
),
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
await rm(workspace.codePath, { recursive: true })
|
|
721
|
+
|
|
722
|
+
const removed = await runTestEffect(
|
|
723
|
+
WorktreeService.pipe(
|
|
724
|
+
Effect.flatMap((service) => service.remove("stale", undefined, root)),
|
|
725
|
+
),
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
expect(removed).toEqual([])
|
|
729
|
+
const worktrees = Bun.spawnSync([
|
|
730
|
+
"git",
|
|
731
|
+
"-C",
|
|
732
|
+
join(root, "repos/agency"),
|
|
733
|
+
"worktree",
|
|
734
|
+
"list",
|
|
735
|
+
"--porcelain",
|
|
736
|
+
])
|
|
737
|
+
expect(new TextDecoder().decode(worktrees.stdout)).not.toContain(
|
|
738
|
+
workspace.writablePath,
|
|
739
|
+
)
|
|
740
|
+
expect(
|
|
741
|
+
Bun.spawnSync([
|
|
742
|
+
"git",
|
|
743
|
+
"-C",
|
|
744
|
+
join(root, "repos/agency"),
|
|
745
|
+
"show-ref",
|
|
746
|
+
"--verify",
|
|
747
|
+
"refs/heads/task/stale",
|
|
748
|
+
]).exitCode,
|
|
749
|
+
).toBe(0)
|
|
750
|
+
})
|
|
751
|
+
|
|
603
752
|
test("supports Worktrunk as the configured command", async () => {
|
|
604
753
|
if (Bun.spawnSync(["which", "wt"], { stdout: "ignore" }).exitCode !== 0) {
|
|
605
754
|
return
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Data, Effect } from "effect"
|
|
2
|
-
import { dirname, join, resolve } from "node:path"
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path"
|
|
3
3
|
import { FileSystemService } from "./FileSystemService"
|
|
4
4
|
import { WorkbaseService } from "./WorkbaseService"
|
|
5
5
|
import { TaskService } from "./TaskService"
|
|
@@ -372,6 +372,128 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
372
372
|
repos: execution.repos ?? [],
|
|
373
373
|
} satisfies ExecutionWorkspace
|
|
374
374
|
}),
|
|
375
|
+
|
|
376
|
+
remove: (
|
|
377
|
+
taskId: string,
|
|
378
|
+
phaseId?: string,
|
|
379
|
+
startPath: string = process.cwd(),
|
|
380
|
+
) =>
|
|
381
|
+
Effect.gen(function* () {
|
|
382
|
+
const fs = yield* FileSystemService
|
|
383
|
+
const workbase = yield* WorkbaseService
|
|
384
|
+
const tasks = yield* TaskService
|
|
385
|
+
const phases = yield* PhaseService
|
|
386
|
+
const root = yield* workbase.discover(startPath)
|
|
387
|
+
const task = yield* tasks.show(taskId, root)
|
|
388
|
+
|
|
389
|
+
let execution: {
|
|
390
|
+
repo: string
|
|
391
|
+
repos?: readonly RepositoryReference[]
|
|
392
|
+
}
|
|
393
|
+
let codePath: string
|
|
394
|
+
if ("phases" in task.data) {
|
|
395
|
+
if (!phaseId) {
|
|
396
|
+
return yield* new WorktreeError({
|
|
397
|
+
message: `Task '${taskId}' has multiple phases; phase ID is required`,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
const phase = yield* phases.show(taskId, phaseId, root)
|
|
401
|
+
execution = phase.data
|
|
402
|
+
codePath = join(dirname(phase.path), "code")
|
|
403
|
+
} else {
|
|
404
|
+
if (phaseId) {
|
|
405
|
+
return yield* new WorktreeError({
|
|
406
|
+
message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
execution = task.data
|
|
410
|
+
codePath = join(dirname(task.path), "code")
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const codeDirectoryExists = yield* fs.isDirectory(codePath)
|
|
414
|
+
const removed: string[] = []
|
|
415
|
+
for (const alias of [
|
|
416
|
+
execution.repo,
|
|
417
|
+
...(execution.repos ?? []).map((reference) => reference.repo),
|
|
418
|
+
]) {
|
|
419
|
+
const repositoryPath = join(root, "repos", alias)
|
|
420
|
+
const checkoutPath = join(codePath, alias)
|
|
421
|
+
const listed = yield* fs.runCommand(
|
|
422
|
+
[
|
|
423
|
+
"git",
|
|
424
|
+
"-C",
|
|
425
|
+
repositoryPath,
|
|
426
|
+
"worktree",
|
|
427
|
+
"list",
|
|
428
|
+
"--porcelain",
|
|
429
|
+
"-z",
|
|
430
|
+
],
|
|
431
|
+
{ captureOutput: true },
|
|
432
|
+
)
|
|
433
|
+
if (listed.exitCode !== 0) {
|
|
434
|
+
return yield* new WorktreeError({
|
|
435
|
+
message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const checkoutExists = yield* fs.isDirectory(checkoutPath)
|
|
440
|
+
const canonicalCheckoutPath = checkoutExists
|
|
441
|
+
? yield* fs.realPath(checkoutPath)
|
|
442
|
+
: join(
|
|
443
|
+
yield* fs.realPath(dirname(codePath)),
|
|
444
|
+
basename(codePath),
|
|
445
|
+
alias,
|
|
446
|
+
)
|
|
447
|
+
let registeredPath: string | undefined
|
|
448
|
+
for (const worktree of parseWorktreeList(listed.stdout)) {
|
|
449
|
+
const worktreePath = (yield* fs.exists(worktree.path))
|
|
450
|
+
? yield* fs.realPath(worktree.path)
|
|
451
|
+
: resolve(worktree.path)
|
|
452
|
+
if (worktreePath === canonicalCheckoutPath) {
|
|
453
|
+
registeredPath = worktreePath
|
|
454
|
+
break
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (!registeredPath) {
|
|
458
|
+
if (checkoutExists) {
|
|
459
|
+
return yield* new WorktreeError({
|
|
460
|
+
message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const result = yield* fs.runCommand(
|
|
467
|
+
[
|
|
468
|
+
"git",
|
|
469
|
+
"-C",
|
|
470
|
+
repositoryPath,
|
|
471
|
+
"worktree",
|
|
472
|
+
"remove",
|
|
473
|
+
...(!checkoutExists ? ["--force"] : []),
|
|
474
|
+
checkoutExists ? checkoutPath : registeredPath,
|
|
475
|
+
],
|
|
476
|
+
{ captureOutput: true },
|
|
477
|
+
)
|
|
478
|
+
if (result.exitCode !== 0) {
|
|
479
|
+
return yield* new WorktreeError({
|
|
480
|
+
message: `Failed to remove worktree for '${alias}': ${result.stderr}`,
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
if (checkoutExists) removed.push(checkoutPath)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (codeDirectoryExists && (yield* fs.isDirectory(codePath))) {
|
|
487
|
+
const remaining = yield* fs.readDirectory(codePath)
|
|
488
|
+
if (remaining.length > 0) {
|
|
489
|
+
return yield* new WorktreeError({
|
|
490
|
+
message: `Cannot remove ${codePath}; it contains unmanaged entries: ${remaining.map((entry) => entry.name).join(", ")}`,
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
yield* fs.deleteDirectory(codePath)
|
|
494
|
+
}
|
|
495
|
+
return removed
|
|
496
|
+
}),
|
|
375
497
|
}),
|
|
376
498
|
},
|
|
377
499
|
) {}
|
package/src/test-utils.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { TaskService } from "./services/TaskService"
|
|
|
11
11
|
import { PhaseService } from "./services/PhaseService"
|
|
12
12
|
import { WorktreeService } from "./services/WorktreeService"
|
|
13
13
|
import { PullRequestService } from "./services/PullRequestService"
|
|
14
|
+
import { ArchiveService } from "./services/ArchiveService"
|
|
14
15
|
|
|
15
16
|
export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
|
|
16
17
|
|
|
@@ -26,6 +27,7 @@ const TestLayer = Layer.mergeAll(
|
|
|
26
27
|
PhaseService.Default,
|
|
27
28
|
WorktreeService.Default,
|
|
28
29
|
PullRequestService.Default,
|
|
30
|
+
ArchiveService.Default,
|
|
29
31
|
)
|
|
30
32
|
|
|
31
33
|
export async function runTestEffect<A, E>(
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { createProgress } from "./progress"
|
|
3
|
+
|
|
4
|
+
describe("progress", () => {
|
|
5
|
+
test("replaces an active TTY line with its completed state", () => {
|
|
6
|
+
const output: string[] = []
|
|
7
|
+
const progress = createProgress(
|
|
8
|
+
{},
|
|
9
|
+
{ isTTY: true, write: (text) => output.push(text) },
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
progress.start("Preparing workspace...")
|
|
13
|
+
progress.succeed("Workspace ready")
|
|
14
|
+
|
|
15
|
+
expect(output).toEqual([
|
|
16
|
+
"\r\x1b[2K\x1b[2m○\x1b[0m Preparing workspace...",
|
|
17
|
+
"\r\x1b[2K\x1b[32m✓\x1b[0m Workspace ready\n",
|
|
18
|
+
])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("stays quiet for silent or non-TTY output", () => {
|
|
22
|
+
const output: string[] = []
|
|
23
|
+
for (const [silent, isTTY] of [
|
|
24
|
+
[true, true],
|
|
25
|
+
[false, false],
|
|
26
|
+
] as const) {
|
|
27
|
+
const progress = createProgress(
|
|
28
|
+
{ silent },
|
|
29
|
+
{ isTTY, write: (text) => output.push(text) },
|
|
30
|
+
)
|
|
31
|
+
progress.start("Preparing workspace...")
|
|
32
|
+
progress.fail("Workspace preparation failed")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
expect(output).toEqual([])
|
|
36
|
+
})
|
|
37
|
+
})
|