@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -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: "agency work [<directory-or-task-id> | --epic <epic-id>]",
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: "agency work [<directory-or-task-id> | --epic <epic-id>]",
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: 1,
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
 
@@ -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 expect(
117
- runTestEffect(
118
- WorktreeService.pipe(
119
- Effect.flatMap((service) =>
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
- ).resolves.toMatchObject({ repo: "agency" })
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
- "git",
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.realPath(codePath)
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) continue
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 createBranch = yield* fs.runCommand(
264
- [
265
- "git",
266
- "-C",
267
- repositoryPath,
268
- "branch",
269
- checkout.branch,
270
- execution.base,
271
- ],
272
- { captureOutput: true },
273
- )
274
- if (createBranch.exitCode !== 0) {
275
- return yield* new WorktreeError({
276
- message: `Failed to create branch '${checkout.branch}': ${createBranch.stderr}`,
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 result = yield* fs.runCommand(
364
- [
365
- "git",
366
- "-C",
367
- repositoryPath,
368
- "worktree",
369
- "add",
370
- "--detach",
371
- checkoutPath,
372
- commit,
373
- ],
374
- { captureOutput: true },
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
 
@@ -5,6 +5,7 @@ export interface BaseCommandOptions {
5
5
  readonly silent?: boolean
6
6
  readonly verbose?: boolean
7
7
  readonly json?: boolean
8
+ readonly dryRun?: boolean
8
9
  /** Whether this invocation may open prompts or selectors. */
9
10
  readonly inputAllowed?: boolean
10
11
  /**