@markjaquith/agency 2.12.0 → 2.13.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 +7 -0
- package/cli.ts +6 -3
- package/package.json +1 -1
- package/src/cli-parser.test.ts +16 -0
- package/src/cli-parser.ts +29 -4
- package/src/cli.test.ts +70 -0
- package/src/commands/work.test.ts +39 -1
- package/src/commands/work.ts +60 -0
- package/src/services/WorktreeService.test.ts +82 -7
- package/src/services/WorktreeService.ts +199 -41
- package/src/utils/command.ts +1 -0
package/README.md
CHANGED
|
@@ -412,6 +412,7 @@ before moving files, refuses dirty worktrees, and preserves branches.
|
|
|
412
412
|
|
|
413
413
|
```text
|
|
414
414
|
agency work [<directory> | --epic <epic-id>] [--opencode | --claude]
|
|
415
|
+
agency work prepare [target] [--dry-run] [--json]
|
|
415
416
|
agency pr create <task-id> [phase-id] [--draft] [--json]
|
|
416
417
|
```
|
|
417
418
|
|
|
@@ -421,6 +422,12 @@ workbase, Agency first presents the registered workbases, then the selected
|
|
|
421
422
|
workbase's hierarchy. If `fzf` is not installed, Agency prints the available
|
|
422
423
|
choices and asks for an explicit directory.
|
|
423
424
|
|
|
425
|
+
`agency work prepare` resolves an execution unit and creates or reuses its
|
|
426
|
+
writable and reference worktrees without launching an agent or changing status.
|
|
427
|
+
Its JSON result includes document and checkout paths, resolved commits, actions,
|
|
428
|
+
and Git operations. Use `--dry-run` to report planned fetch, branch, and worktree
|
|
429
|
+
changes without applying them.
|
|
430
|
+
|
|
424
431
|
Epic and multi-phase task targets launch orchestration agents beside their
|
|
425
432
|
documents. Single-phase tasks and phases fetch repositories, create or reuse
|
|
426
433
|
worktrees under `code/`, and launch an execution agent in the writable checkout
|
package/cli.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { parseCli } from "./src/cli-parser"
|
|
|
5
5
|
import { init, help as initHelp } from "./src/commands/init"
|
|
6
6
|
import { task, help as taskHelp } from "./src/commands/task"
|
|
7
7
|
import { pr, help as prHelp } from "./src/commands/pr"
|
|
8
|
-
import { work, help as workHelp } from "./src/commands/work"
|
|
8
|
+
import { work, workPrepare, help as workHelp } from "./src/commands/work"
|
|
9
9
|
import { status, help as statusHelp } from "./src/commands/status"
|
|
10
10
|
import { validate, help as validateHelp } from "./src/commands/validate"
|
|
11
11
|
import { context, help as contextHelp } from "./src/commands/context"
|
|
@@ -274,10 +274,13 @@ const commands: Record<string, Command> = {
|
|
|
274
274
|
console.log(workHelp)
|
|
275
275
|
return
|
|
276
276
|
}
|
|
277
|
+
const preparing = args[0] === "prepare"
|
|
277
278
|
await runCommand(
|
|
278
|
-
work({
|
|
279
|
-
directory: args[0],
|
|
279
|
+
(preparing ? workPrepare : work)({
|
|
280
|
+
directory: args[preparing ? 1 : 0],
|
|
280
281
|
epicId: options.epic,
|
|
282
|
+
json: options.json,
|
|
283
|
+
dryRun: options["dry-run"],
|
|
281
284
|
silent: options.silent,
|
|
282
285
|
verbose: options.verbose,
|
|
283
286
|
opencode: options.opencode,
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -155,6 +155,22 @@ describe("strict CLI parsing", () => {
|
|
|
155
155
|
expectUsageError(["status", "--compact"], "agency status")
|
|
156
156
|
})
|
|
157
157
|
|
|
158
|
+
test("accepts work preparation options only for the prepare subcommand", () => {
|
|
159
|
+
expect(
|
|
160
|
+
parseCli(["work", "prepare", "tasks/example", "--dry-run", "--json"]),
|
|
161
|
+
).toMatchObject({
|
|
162
|
+
commandName: "work",
|
|
163
|
+
args: ["prepare", "tasks/example"],
|
|
164
|
+
values: { "dry-run": true, json: true },
|
|
165
|
+
})
|
|
166
|
+
expect(() => parseCli(["work", "example", "--dry-run"])).toThrow(
|
|
167
|
+
"only valid with 'agency work prepare'",
|
|
168
|
+
)
|
|
169
|
+
expect(() => parseCli(["work", "prepare", "--opencode"])).toThrow(
|
|
170
|
+
"cannot be combined",
|
|
171
|
+
)
|
|
172
|
+
})
|
|
173
|
+
|
|
158
174
|
test("accepts repeatable graph filters and rejects output conflicts", () => {
|
|
159
175
|
expect(
|
|
160
176
|
parseCli([
|
package/src/cli-parser.ts
CHANGED
|
@@ -283,18 +283,22 @@ const commands = {
|
|
|
283
283
|
},
|
|
284
284
|
},
|
|
285
285
|
work: {
|
|
286
|
-
usage:
|
|
286
|
+
usage:
|
|
287
|
+
"agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
|
|
287
288
|
options: {
|
|
288
289
|
...commonOptions,
|
|
290
|
+
json: { type: "boolean" },
|
|
291
|
+
"dry-run": { type: "boolean" },
|
|
289
292
|
epic: { type: "string" },
|
|
290
293
|
opencode: { type: "boolean" },
|
|
291
294
|
claude: { type: "boolean" },
|
|
292
295
|
},
|
|
293
296
|
command: {
|
|
294
|
-
usage:
|
|
297
|
+
usage:
|
|
298
|
+
"agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
|
|
295
299
|
minArgs: 0,
|
|
296
|
-
maxArgs:
|
|
297
|
-
options: ["epic", "opencode", "claude"],
|
|
300
|
+
maxArgs: 2,
|
|
301
|
+
options: ["json", "dry-run", "epic", "opencode", "claude"],
|
|
298
302
|
conflicts: [
|
|
299
303
|
["opencode", "claude"],
|
|
300
304
|
["epic", "$positional"],
|
|
@@ -649,6 +653,27 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
649
653
|
if (commandName === "graph") {
|
|
650
654
|
validateGraphOptions(parsed.values, spec)
|
|
651
655
|
}
|
|
656
|
+
if (commandName === "work") {
|
|
657
|
+
const preparing = commandPositionals[0] === "prepare"
|
|
658
|
+
if (
|
|
659
|
+
(!preparing && commandPositionals.length > 1) ||
|
|
660
|
+
(preparing &&
|
|
661
|
+
(parsed.values.epic || parsed.values.opencode || parsed.values.claude))
|
|
662
|
+
) {
|
|
663
|
+
throw usageError(
|
|
664
|
+
preparing
|
|
665
|
+
? "Work preparation cannot be combined with agent or epic options."
|
|
666
|
+
: "The work command accepts at most one target.",
|
|
667
|
+
spec.usage,
|
|
668
|
+
)
|
|
669
|
+
}
|
|
670
|
+
if (!preparing && (parsed.values.json || parsed.values["dry-run"])) {
|
|
671
|
+
throw usageError(
|
|
672
|
+
"Options '--json' and '--dry-run' are only valid with 'agency work prepare'.",
|
|
673
|
+
spec.usage,
|
|
674
|
+
)
|
|
675
|
+
}
|
|
676
|
+
}
|
|
652
677
|
|
|
653
678
|
return {
|
|
654
679
|
commandName: commandName as keyof typeof commands,
|
package/src/cli.test.ts
CHANGED
|
@@ -301,6 +301,76 @@ status: open
|
|
|
301
301
|
})
|
|
302
302
|
})
|
|
303
303
|
|
|
304
|
+
test("prepares a workspace and reports a non-mutating dry-run", async () => {
|
|
305
|
+
const parent = await createTempDir()
|
|
306
|
+
tempDirs.push(parent)
|
|
307
|
+
const root = join(parent, "workbase")
|
|
308
|
+
const source = join(parent, "source")
|
|
309
|
+
expect(
|
|
310
|
+
Bun.spawnSync(["git", "init", "--initial-branch=main", source]).exitCode,
|
|
311
|
+
).toBe(0)
|
|
312
|
+
await Bun.write(join(source, "README.md"), "example\n")
|
|
313
|
+
for (const args of [
|
|
314
|
+
["config", "user.email", "test@example.com"],
|
|
315
|
+
["config", "user.name", "Test"],
|
|
316
|
+
["add", "README.md"],
|
|
317
|
+
["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
|
|
318
|
+
]) {
|
|
319
|
+
expect(Bun.spawnSync(["git", "-C", source, ...args]).exitCode).toBe(0)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
parseJson(await runCli(["init", root, "--json"], parent))
|
|
323
|
+
parseJson(await runCli(["repo", "link", "agency", source, "--json"], root))
|
|
324
|
+
parseJson(
|
|
325
|
+
await runCli(
|
|
326
|
+
[
|
|
327
|
+
"task",
|
|
328
|
+
"create",
|
|
329
|
+
"example",
|
|
330
|
+
"--repo",
|
|
331
|
+
"agency",
|
|
332
|
+
"--branch",
|
|
333
|
+
"feat/example",
|
|
334
|
+
"--base",
|
|
335
|
+
"main",
|
|
336
|
+
"--json",
|
|
337
|
+
],
|
|
338
|
+
root,
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
const planned = parseJson(
|
|
343
|
+
await runCli(["work", "prepare", "example", "--dry-run", "--json"], root),
|
|
344
|
+
)
|
|
345
|
+
const workbaseRoot = await realpath(root)
|
|
346
|
+
expect(planned).toMatchObject({
|
|
347
|
+
dryRun: true,
|
|
348
|
+
taskPath: join(workbaseRoot, "tasks/example/TASK.md"),
|
|
349
|
+
phasePath: null,
|
|
350
|
+
checkouts: [
|
|
351
|
+
{
|
|
352
|
+
repo: "agency",
|
|
353
|
+
kind: "writable",
|
|
354
|
+
action: "created",
|
|
355
|
+
resolvedCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
})
|
|
359
|
+
await expect(access(join(root, "tasks/example/code"))).rejects.toThrow()
|
|
360
|
+
|
|
361
|
+
const prepared = parseJson(
|
|
362
|
+
await runCli(["work", "prepare", "example", "--json"], root),
|
|
363
|
+
)
|
|
364
|
+
expect(prepared).toMatchObject({
|
|
365
|
+
dryRun: false,
|
|
366
|
+
checkouts: [{ action: "created", kind: "writable" }],
|
|
367
|
+
})
|
|
368
|
+
const task = parseJson(
|
|
369
|
+
await runCli(["task", "show", "example", "--json"], root),
|
|
370
|
+
)
|
|
371
|
+
expect(task.data.status).toBe("open")
|
|
372
|
+
})
|
|
373
|
+
|
|
304
374
|
test("envelopes help and version output in machine mode", async () => {
|
|
305
375
|
const help = await runCli(["status", "--help", "--json"])
|
|
306
376
|
expect(parseJson(help)).toContain("Usage: agency status")
|
|
@@ -7,7 +7,7 @@ import { TaskService } from "../services/TaskService"
|
|
|
7
7
|
import { PhaseService } from "../services/PhaseService"
|
|
8
8
|
import { WorktreeService } from "../services/WorktreeService"
|
|
9
9
|
import { captureErrors, captureLogs } from "../test-utils"
|
|
10
|
-
import { work } from "./work"
|
|
10
|
+
import { work, workPrepare } from "./work"
|
|
11
11
|
import type { PickWorkTarget } from "../workbase/work-target"
|
|
12
12
|
import type { PickWorkbase } from "../workbase/workbase-choice"
|
|
13
13
|
import type { Progress } from "../utils/progress"
|
|
@@ -24,6 +24,9 @@ const singlePhaseWorkspace: ExecutionWorkspace = {
|
|
|
24
24
|
writablePath: "/workbase/tasks/example/code/agency",
|
|
25
25
|
repo: "agency",
|
|
26
26
|
repos: [],
|
|
27
|
+
dryRun: false,
|
|
28
|
+
checkouts: [],
|
|
29
|
+
operations: [],
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const multiPhaseWorkspace: ExecutionWorkspace = {
|
|
@@ -34,6 +37,9 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
|
|
|
34
37
|
writablePath: "/workbase/tasks/example/phases/implementation/code/agency",
|
|
35
38
|
repo: "agency",
|
|
36
39
|
repos: [],
|
|
40
|
+
dryRun: false,
|
|
41
|
+
checkouts: [],
|
|
42
|
+
operations: [],
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
interface HarnessOptions {
|
|
@@ -167,6 +173,16 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
167
173
|
Effect.provideService(PhaseService, phases as never),
|
|
168
174
|
) as Effect.Effect<void, unknown, never>,
|
|
169
175
|
)
|
|
176
|
+
const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
|
|
177
|
+
Effect.runPromise(
|
|
178
|
+
workPrepare(commandOptions).pipe(
|
|
179
|
+
Effect.provideService(WorktreeService, worktrees as never),
|
|
180
|
+
Effect.provideService(FileSystemService, fs as never),
|
|
181
|
+
Effect.provideService(WorkbaseService, workbase as never),
|
|
182
|
+
Effect.provideService(TaskService, tasks as never),
|
|
183
|
+
Effect.provideService(PhaseService, phases as never),
|
|
184
|
+
) as Effect.Effect<void, unknown, never>,
|
|
185
|
+
)
|
|
170
186
|
|
|
171
187
|
return {
|
|
172
188
|
events,
|
|
@@ -177,10 +193,32 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
177
193
|
shownTasks,
|
|
178
194
|
progressUpdates,
|
|
179
195
|
run,
|
|
196
|
+
runPrepare,
|
|
180
197
|
}
|
|
181
198
|
}
|
|
182
199
|
|
|
183
200
|
describe("work command", () => {
|
|
201
|
+
test("prepares without launching or changing lifecycle status", async () => {
|
|
202
|
+
const harness = createHarness({ existingDirectories: [] })
|
|
203
|
+
|
|
204
|
+
await captureLogs(() =>
|
|
205
|
+
harness.runPrepare({
|
|
206
|
+
cwd: "/workbase",
|
|
207
|
+
directory: "example",
|
|
208
|
+
json: true,
|
|
209
|
+
dryRun: true,
|
|
210
|
+
}),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
expect(harness.events).toEqual(["materialize"])
|
|
214
|
+
expect(harness.launches).toEqual([])
|
|
215
|
+
expect(harness.statusUpdates).toEqual([])
|
|
216
|
+
expect(harness.materializeOptions[0]).toMatchObject({
|
|
217
|
+
json: true,
|
|
218
|
+
dryRun: true,
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
184
222
|
test("launches an epic agent from an epic directory", async () => {
|
|
185
223
|
const harness = createHarness()
|
|
186
224
|
|
package/src/commands/work.ts
CHANGED
|
@@ -243,14 +243,74 @@ export const work = (
|
|
|
243
243
|
launch(cli, [cli, ...args], launchPath)
|
|
244
244
|
})
|
|
245
245
|
|
|
246
|
+
export const workPrepare = (options: WorkOptions = {}) =>
|
|
247
|
+
Effect.gen(function* () {
|
|
248
|
+
const fs = yield* FileSystemService
|
|
249
|
+
const workbase = yield* WorkbaseService
|
|
250
|
+
const tasks = yield* TaskService
|
|
251
|
+
const phases = yield* PhaseService
|
|
252
|
+
const worktrees = yield* WorktreeService
|
|
253
|
+
const { log } = createLoggers(options)
|
|
254
|
+
const cwd = options.cwd ?? process.cwd()
|
|
255
|
+
const targetPath = options.directory ? resolve(cwd, options.directory) : cwd
|
|
256
|
+
const isDirectory = yield* fs.isDirectory(targetPath)
|
|
257
|
+
const root = yield* workbase.discover(isDirectory ? targetPath : cwd)
|
|
258
|
+
|
|
259
|
+
let taskId: string | undefined
|
|
260
|
+
let phaseId: string | undefined
|
|
261
|
+
if (options.directory && !isDirectory) {
|
|
262
|
+
const task = yield* tasks.show(options.directory, root)
|
|
263
|
+
taskId = task.id
|
|
264
|
+
} else {
|
|
265
|
+
const path = relative(root, targetPath)
|
|
266
|
+
const parts =
|
|
267
|
+
!path || isAbsolute(path) || path.startsWith(`..${sep}`)
|
|
268
|
+
? []
|
|
269
|
+
: path.split(sep)
|
|
270
|
+
if (parts[0] === "tasks" && parts[1]) {
|
|
271
|
+
const task = yield* tasks.show(parts[1], root)
|
|
272
|
+
taskId = task.id
|
|
273
|
+
if (parts[2] === "phases" && parts[3]) {
|
|
274
|
+
const phase = yield* phases.show(task.id, parts[3], root)
|
|
275
|
+
phaseId = phase.id
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!taskId) {
|
|
281
|
+
return yield* Effect.fail(
|
|
282
|
+
new Error(
|
|
283
|
+
"Work preparation requires a task ID or a path inside an execution unit",
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const workspace = yield* worktrees.materialize(taskId, phaseId, root, {
|
|
289
|
+
...options,
|
|
290
|
+
dryRun: options.dryRun,
|
|
291
|
+
})
|
|
292
|
+
if (options.json) {
|
|
293
|
+
log(JSON.stringify(workspace, null, 2))
|
|
294
|
+
} else {
|
|
295
|
+
log(
|
|
296
|
+
`${workspace.dryRun ? "Workspace plan" : "Workspace ready"}: ${workspace.writablePath}`,
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
246
301
|
export const help = `
|
|
247
302
|
Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
|
|
303
|
+
agency work prepare [target] [--dry-run] [--json]
|
|
248
304
|
|
|
249
305
|
Launch an agent for an epic, task, or phase. With no directory, select one
|
|
250
306
|
with fzf. A positional argument resolves as a directory first, then as a task
|
|
251
307
|
ID. Use '.' for the current directory. Outside a workbase, select a registered
|
|
252
308
|
workbase first.
|
|
253
309
|
|
|
310
|
+
The prepare subcommand resolves and materializes an execution workspace without
|
|
311
|
+
launching an agent or changing lifecycle status. --dry-run reports planned Git
|
|
312
|
+
changes without fetching, creating branches, or creating worktrees.
|
|
313
|
+
|
|
254
314
|
Options:
|
|
255
315
|
--epic <id> Work on an epic
|
|
256
316
|
--opencode Require OpenCode
|
|
@@ -113,15 +113,17 @@ describe("WorktreeService", () => {
|
|
|
113
113
|
["remote", "set-url", "origin", join(root, "missing")],
|
|
114
114
|
join(root, "repos/agency"),
|
|
115
115
|
)
|
|
116
|
-
await
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
service.materialize("existing", undefined, root),
|
|
121
|
-
),
|
|
116
|
+
const reused = await runTestEffect(
|
|
117
|
+
WorktreeService.pipe(
|
|
118
|
+
Effect.flatMap((service) =>
|
|
119
|
+
service.materialize("existing", undefined, root),
|
|
122
120
|
),
|
|
123
121
|
),
|
|
124
|
-
)
|
|
122
|
+
)
|
|
123
|
+
expect(reused.checkouts).toEqual([
|
|
124
|
+
expect.objectContaining({ repo: "agency", action: "reused" }),
|
|
125
|
+
])
|
|
126
|
+
expect(reused.operations).toEqual([])
|
|
125
127
|
})
|
|
126
128
|
|
|
127
129
|
test("reuses an immutable reference checkout without fetching", async () => {
|
|
@@ -771,6 +773,79 @@ pr: null
|
|
|
771
773
|
"refs/heads/task/removable",
|
|
772
774
|
])
|
|
773
775
|
expect(branch.exitCode).toBe(0)
|
|
776
|
+
expect(workspace.checkouts).toEqual([
|
|
777
|
+
expect.objectContaining({
|
|
778
|
+
repo: "agency",
|
|
779
|
+
kind: "writable",
|
|
780
|
+
action: "created",
|
|
781
|
+
resolvedCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
|
|
782
|
+
}),
|
|
783
|
+
expect.objectContaining({
|
|
784
|
+
repo: "effect",
|
|
785
|
+
kind: "reference",
|
|
786
|
+
action: "created",
|
|
787
|
+
resolvedCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
|
|
788
|
+
}),
|
|
789
|
+
])
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
test("reports dry-run fetch and worktree changes without mutating", async () => {
|
|
793
|
+
await runTestEffect(
|
|
794
|
+
TaskService.pipe(
|
|
795
|
+
Effect.flatMap((service) =>
|
|
796
|
+
service.create(
|
|
797
|
+
{
|
|
798
|
+
id: "planned",
|
|
799
|
+
ticketUrl: "https://example.com/task",
|
|
800
|
+
repo: "agency",
|
|
801
|
+
repos: [{ repo: "effect", ref: "main" }],
|
|
802
|
+
branch: "task/planned",
|
|
803
|
+
base: "main",
|
|
804
|
+
},
|
|
805
|
+
root,
|
|
806
|
+
),
|
|
807
|
+
),
|
|
808
|
+
),
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
const workspace = await runTestEffect(
|
|
812
|
+
WorktreeService.pipe(
|
|
813
|
+
Effect.flatMap((service) =>
|
|
814
|
+
service.materialize("planned", undefined, root, { dryRun: true }),
|
|
815
|
+
),
|
|
816
|
+
),
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
expect(workspace.dryRun).toBe(true)
|
|
820
|
+
expect(
|
|
821
|
+
workspace.checkouts.every(({ resolvedCommit }) => resolvedCommit),
|
|
822
|
+
).toBe(true)
|
|
823
|
+
expect(
|
|
824
|
+
workspace.checkouts.map(({ repo, action }) => ({ repo, action })),
|
|
825
|
+
).toEqual([
|
|
826
|
+
{ repo: "agency", action: "created" },
|
|
827
|
+
{ repo: "effect", action: "created" },
|
|
828
|
+
])
|
|
829
|
+
expect(workspace.operations).toEqual(
|
|
830
|
+
expect.arrayContaining([
|
|
831
|
+
expect.objectContaining({ action: "fetch", status: "planned" }),
|
|
832
|
+
expect.objectContaining({
|
|
833
|
+
action: "create-worktree",
|
|
834
|
+
status: "planned",
|
|
835
|
+
}),
|
|
836
|
+
]),
|
|
837
|
+
)
|
|
838
|
+
expect(await Bun.file(workspace.codePath).exists()).toBe(false)
|
|
839
|
+
expect(
|
|
840
|
+
Bun.spawnSync([
|
|
841
|
+
"git",
|
|
842
|
+
"-C",
|
|
843
|
+
join(root, "repos/agency"),
|
|
844
|
+
"show-ref",
|
|
845
|
+
"--verify",
|
|
846
|
+
"refs/heads/task/planned",
|
|
847
|
+
]).exitCode,
|
|
848
|
+
).not.toBe(0)
|
|
774
849
|
})
|
|
775
850
|
|
|
776
851
|
test("refuses to remove a worktree with uncommitted changes", async () => {
|
|
@@ -16,6 +16,22 @@ class WorktreeError extends Data.TaggedError("WorktreeError")<{
|
|
|
16
16
|
readonly message: string
|
|
17
17
|
}> {}
|
|
18
18
|
|
|
19
|
+
interface WorkspaceOperation {
|
|
20
|
+
readonly action: "fetch" | "create-branch" | "create-worktree"
|
|
21
|
+
readonly repo: string
|
|
22
|
+
readonly command: readonly string[]
|
|
23
|
+
readonly status: "planned" | "completed"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface WorkspaceCheckout {
|
|
27
|
+
readonly repo: string
|
|
28
|
+
readonly kind: "writable" | "reference"
|
|
29
|
+
readonly path: string
|
|
30
|
+
readonly requestedRef: string
|
|
31
|
+
readonly resolvedCommit: string | null
|
|
32
|
+
readonly action: "created" | "reused"
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
interface ExecutionWorkspace {
|
|
20
36
|
readonly root: string
|
|
21
37
|
readonly taskPath: string
|
|
@@ -24,6 +40,9 @@ interface ExecutionWorkspace {
|
|
|
24
40
|
readonly writablePath: string
|
|
25
41
|
readonly repo: string
|
|
26
42
|
readonly repos: readonly RepositoryReference[]
|
|
43
|
+
readonly dryRun: boolean
|
|
44
|
+
readonly checkouts: readonly WorkspaceCheckout[]
|
|
45
|
+
readonly operations: readonly WorkspaceOperation[]
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
interface GitWorktree {
|
|
@@ -121,7 +140,9 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
121
140
|
codePath = join(dirname(task.path), "code")
|
|
122
141
|
}
|
|
123
142
|
|
|
124
|
-
yield* fs.createDirectory(codePath)
|
|
143
|
+
if (!options.dryRun) yield* fs.createDirectory(codePath)
|
|
144
|
+
const operations: WorkspaceOperation[] = []
|
|
145
|
+
const checkoutReports: WorkspaceCheckout[] = []
|
|
125
146
|
const checkouts: readonly (
|
|
126
147
|
| { readonly repo: string; readonly branch: string }
|
|
127
148
|
| RepositoryReference
|
|
@@ -146,23 +167,38 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
146
167
|
{ captureOutput: true },
|
|
147
168
|
)
|
|
148
169
|
if (remote.exitCode !== 0) return false
|
|
170
|
+
const command = [
|
|
171
|
+
"git",
|
|
172
|
+
"-C",
|
|
173
|
+
repositoryPath,
|
|
174
|
+
"fetch",
|
|
175
|
+
"origin",
|
|
176
|
+
...(ref ? [ref] : []),
|
|
177
|
+
]
|
|
178
|
+
if (options.dryRun) {
|
|
179
|
+
operations.push({
|
|
180
|
+
action: "fetch",
|
|
181
|
+
repo: alias,
|
|
182
|
+
command,
|
|
183
|
+
status: "planned",
|
|
184
|
+
})
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
149
187
|
|
|
150
|
-
const fetch = yield* fs.runCommand(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"-C",
|
|
154
|
-
repositoryPath,
|
|
155
|
-
"fetch",
|
|
156
|
-
"origin",
|
|
157
|
-
...(ref ? [ref] : []),
|
|
158
|
-
],
|
|
159
|
-
{ captureOutput: true },
|
|
160
|
-
)
|
|
188
|
+
const fetch = yield* fs.runCommand(command, {
|
|
189
|
+
captureOutput: true,
|
|
190
|
+
})
|
|
161
191
|
if (fetch.exitCode !== 0) {
|
|
162
192
|
return yield* new WorktreeError({
|
|
163
193
|
message: `Failed to fetch '${alias}': ${fetch.stderr}`,
|
|
164
194
|
})
|
|
165
195
|
}
|
|
196
|
+
operations.push({
|
|
197
|
+
action: "fetch",
|
|
198
|
+
repo: alias,
|
|
199
|
+
command,
|
|
200
|
+
status: "completed",
|
|
201
|
+
})
|
|
166
202
|
return true
|
|
167
203
|
})
|
|
168
204
|
|
|
@@ -183,7 +219,9 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
183
219
|
message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
|
|
184
220
|
})
|
|
185
221
|
}
|
|
186
|
-
const canonicalCodePath = yield* fs.
|
|
222
|
+
const canonicalCodePath = (yield* fs.exists(codePath))
|
|
223
|
+
? yield* fs.realPath(codePath)
|
|
224
|
+
: resolve(codePath)
|
|
187
225
|
const canonicalCheckoutPath = join(canonicalCodePath, alias)
|
|
188
226
|
const worktrees: GitWorktree[] = []
|
|
189
227
|
for (const worktree of parseWorktreeList(listed.stdout)) {
|
|
@@ -212,7 +250,17 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
212
250
|
})
|
|
213
251
|
}
|
|
214
252
|
if (yield* fs.isDirectory(checkoutPath)) {
|
|
215
|
-
if (registeredAtPath?.branch === branchRef)
|
|
253
|
+
if (registeredAtPath?.branch === branchRef) {
|
|
254
|
+
checkoutReports.push({
|
|
255
|
+
repo: alias,
|
|
256
|
+
kind: "writable",
|
|
257
|
+
path: checkoutPath,
|
|
258
|
+
requestedRef: checkout.branch,
|
|
259
|
+
resolvedCommit: registeredAtPath.head ?? null,
|
|
260
|
+
action: "reused",
|
|
261
|
+
})
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
216
264
|
return yield* new WorktreeError({
|
|
217
265
|
message: `Existing checkout ${checkoutPath} is not registered to branch '${checkout.branch}'`,
|
|
218
266
|
})
|
|
@@ -260,21 +308,29 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
260
308
|
{ captureOutput: true },
|
|
261
309
|
)
|
|
262
310
|
if (branchExists.exitCode !== 0) {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
311
|
+
const command = [
|
|
312
|
+
"git",
|
|
313
|
+
"-C",
|
|
314
|
+
repositoryPath,
|
|
315
|
+
"branch",
|
|
316
|
+
checkout.branch,
|
|
317
|
+
execution.base,
|
|
318
|
+
]
|
|
319
|
+
operations.push({
|
|
320
|
+
action: "create-branch",
|
|
321
|
+
repo: alias,
|
|
322
|
+
command,
|
|
323
|
+
status: options.dryRun ? "planned" : "completed",
|
|
324
|
+
})
|
|
325
|
+
if (!options.dryRun) {
|
|
326
|
+
const createBranch = yield* fs.runCommand(command, {
|
|
327
|
+
captureOutput: true,
|
|
277
328
|
})
|
|
329
|
+
if (createBranch.exitCode !== 0) {
|
|
330
|
+
return yield* new WorktreeError({
|
|
331
|
+
message: `Failed to create branch '${checkout.branch}': ${createBranch.stderr}`,
|
|
332
|
+
})
|
|
333
|
+
}
|
|
278
334
|
}
|
|
279
335
|
}
|
|
280
336
|
args = [
|
|
@@ -287,6 +343,48 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
287
343
|
checkout.branch,
|
|
288
344
|
]
|
|
289
345
|
}
|
|
346
|
+
if (options.dryRun) {
|
|
347
|
+
operations.push({
|
|
348
|
+
action: "create-worktree",
|
|
349
|
+
repo: alias,
|
|
350
|
+
command: args,
|
|
351
|
+
status: "planned",
|
|
352
|
+
})
|
|
353
|
+
let resolved = yield* fs.runCommand(
|
|
354
|
+
[
|
|
355
|
+
"git",
|
|
356
|
+
"-C",
|
|
357
|
+
repositoryPath,
|
|
358
|
+
"rev-parse",
|
|
359
|
+
"--verify",
|
|
360
|
+
`${checkout.branch}^{commit}`,
|
|
361
|
+
],
|
|
362
|
+
{ captureOutput: true },
|
|
363
|
+
)
|
|
364
|
+
if (resolved.exitCode !== 0) {
|
|
365
|
+
resolved = yield* fs.runCommand(
|
|
366
|
+
[
|
|
367
|
+
"git",
|
|
368
|
+
"-C",
|
|
369
|
+
repositoryPath,
|
|
370
|
+
"rev-parse",
|
|
371
|
+
"--verify",
|
|
372
|
+
`${execution.base}^{commit}`,
|
|
373
|
+
],
|
|
374
|
+
{ captureOutput: true },
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
checkoutReports.push({
|
|
378
|
+
repo: alias,
|
|
379
|
+
kind: "writable",
|
|
380
|
+
path: checkoutPath,
|
|
381
|
+
requestedRef: checkout.branch,
|
|
382
|
+
resolvedCommit:
|
|
383
|
+
resolved.exitCode === 0 ? resolved.stdout.trim() : null,
|
|
384
|
+
action: "created",
|
|
385
|
+
})
|
|
386
|
+
continue
|
|
387
|
+
}
|
|
290
388
|
|
|
291
389
|
if (config.worktreeCreateCommand) {
|
|
292
390
|
verboseLog(`Running worktree command: ${formatCommand(args)}`)
|
|
@@ -308,6 +406,24 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
308
406
|
message: `Worktree command did not create ${checkoutPath}`,
|
|
309
407
|
})
|
|
310
408
|
}
|
|
409
|
+
operations.push({
|
|
410
|
+
action: "create-worktree",
|
|
411
|
+
repo: alias,
|
|
412
|
+
command: args,
|
|
413
|
+
status: "completed",
|
|
414
|
+
})
|
|
415
|
+
const head = yield* fs.runCommand(
|
|
416
|
+
["git", "-C", checkoutPath, "rev-parse", "HEAD"],
|
|
417
|
+
{ captureOutput: true },
|
|
418
|
+
)
|
|
419
|
+
checkoutReports.push({
|
|
420
|
+
repo: alias,
|
|
421
|
+
kind: "writable",
|
|
422
|
+
path: checkoutPath,
|
|
423
|
+
requestedRef: checkout.branch,
|
|
424
|
+
resolvedCommit: head.exitCode === 0 ? head.stdout.trim() : null,
|
|
425
|
+
action: "created",
|
|
426
|
+
})
|
|
311
427
|
} else {
|
|
312
428
|
const fetched = isCommitId(checkout.ref)
|
|
313
429
|
? false
|
|
@@ -349,6 +465,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
349
465
|
currentHead.exitCode === 0 &&
|
|
350
466
|
currentHead.stdout.trim() === commit
|
|
351
467
|
) {
|
|
468
|
+
checkoutReports.push({
|
|
469
|
+
repo: alias,
|
|
470
|
+
kind: "reference",
|
|
471
|
+
path: checkoutPath,
|
|
472
|
+
requestedRef: checkout.ref,
|
|
473
|
+
resolvedCommit: commit,
|
|
474
|
+
action: "reused",
|
|
475
|
+
})
|
|
352
476
|
continue
|
|
353
477
|
}
|
|
354
478
|
return yield* new WorktreeError({
|
|
@@ -360,24 +484,55 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
360
484
|
message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
|
|
361
485
|
})
|
|
362
486
|
}
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
{
|
|
375
|
-
|
|
487
|
+
const command = [
|
|
488
|
+
"git",
|
|
489
|
+
"-C",
|
|
490
|
+
repositoryPath,
|
|
491
|
+
"worktree",
|
|
492
|
+
"add",
|
|
493
|
+
"--detach",
|
|
494
|
+
checkoutPath,
|
|
495
|
+
commit,
|
|
496
|
+
]
|
|
497
|
+
if (options.dryRun) {
|
|
498
|
+
operations.push({
|
|
499
|
+
action: "create-worktree",
|
|
500
|
+
repo: alias,
|
|
501
|
+
command,
|
|
502
|
+
status: "planned",
|
|
503
|
+
})
|
|
504
|
+
checkoutReports.push({
|
|
505
|
+
repo: alias,
|
|
506
|
+
kind: "reference",
|
|
507
|
+
path: checkoutPath,
|
|
508
|
+
requestedRef: checkout.ref,
|
|
509
|
+
resolvedCommit: commit,
|
|
510
|
+
action: "created",
|
|
511
|
+
})
|
|
512
|
+
continue
|
|
513
|
+
}
|
|
514
|
+
const result = yield* fs.runCommand(command, {
|
|
515
|
+
captureOutput: true,
|
|
516
|
+
})
|
|
376
517
|
if (result.exitCode !== 0) {
|
|
377
518
|
return yield* new WorktreeError({
|
|
378
519
|
message: `Failed to create worktree for '${alias}': ${result.stderr}`,
|
|
379
520
|
})
|
|
380
521
|
}
|
|
522
|
+
operations.push({
|
|
523
|
+
action: "create-worktree",
|
|
524
|
+
repo: alias,
|
|
525
|
+
command,
|
|
526
|
+
status: "completed",
|
|
527
|
+
})
|
|
528
|
+
checkoutReports.push({
|
|
529
|
+
repo: alias,
|
|
530
|
+
kind: "reference",
|
|
531
|
+
path: checkoutPath,
|
|
532
|
+
requestedRef: checkout.ref,
|
|
533
|
+
resolvedCommit: commit,
|
|
534
|
+
action: "created",
|
|
535
|
+
})
|
|
381
536
|
}
|
|
382
537
|
}
|
|
383
538
|
|
|
@@ -389,6 +544,9 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
389
544
|
writablePath: join(codePath, execution.repo),
|
|
390
545
|
repo: execution.repo,
|
|
391
546
|
repos: execution.repos ?? [],
|
|
547
|
+
dryRun: options.dryRun === true,
|
|
548
|
+
checkouts: checkoutReports,
|
|
549
|
+
operations,
|
|
392
550
|
} satisfies ExecutionWorkspace
|
|
393
551
|
}),
|
|
394
552
|
|
package/src/utils/command.ts
CHANGED