@markjaquith/agency 3.12.0 → 3.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
@@ -184,8 +184,8 @@ existing Git repositories.
184
184
  Existing version 2 workbases without `repositories` remain valid. Run
185
185
  `agency repo setup` to preview deterministic adoption of legacy local aliases;
186
186
  `agency repo setup --apply` writes declarations only when a portable origin is
187
- unambiguous. Workbase configuration may also provide a custom writable-worktree
188
- creation command.
187
+ unambiguous. Workbase configuration may also provide custom writable-worktree
188
+ creation and removal commands.
189
189
 
190
190
  ### Custom Branch Names
191
191
 
@@ -299,6 +299,43 @@ The configured command applies only to the writable checkout.
299
299
  Supplemental read-only repositories remain detached Git worktrees at their
300
300
  declared refs so they do not acquire writable branches.
301
301
 
302
+ ### Custom Worktree Remove Command
303
+
304
+ Git workbases remove worktrees with `git worktree remove`. Set
305
+ `worktreeRemoveCommand` to an argv template when another tool should remove
306
+ writable worktrees:
307
+
308
+ ```json
309
+ {
310
+ "version": 2,
311
+ "worktreeRemoveCommand": [
312
+ "wt",
313
+ "-C",
314
+ "{repo}",
315
+ "-y",
316
+ "remove",
317
+ "{worktree}",
318
+ "--no-delete-branch",
319
+ "--foreground",
320
+ "--format",
321
+ "json"
322
+ ]
323
+ }
324
+ ```
325
+
326
+ It accepts the same placeholders and environment variables as
327
+ `worktreeCreateCommand`, and `{repo}` and `{worktree}` are required. Agency
328
+ invokes it directly without a shell whenever it removes an existing writable
329
+ checkout, including `agency worktree remove`, rebuilds, archives, and rollback
330
+ of a failed materialization. Agency still refuses dirty or mismatched checkouts
331
+ before running the command.
332
+
333
+ The command must remove the checkout synchronously, unregister it from Git, and
334
+ preserve the execution branch; Agency verifies all three afterward. Rollback of
335
+ a newly created checkout may delete the branch because Agency discards it
336
+ anyway. Reference checkouts and stale registrations are still removed or pruned
337
+ with Git.
338
+
302
339
  ### Post-checkout Commands
303
340
 
304
341
  Each repository declaration may provide a `postCheckoutCommand` argv
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.12.0",
3
+ "version": "3.13.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -172,6 +172,15 @@ export class DoctorService extends Effect.Service<DoctorService>()(
172
172
  ] as const,
173
173
  ]
174
174
  : []),
175
+ ...(config.worktreeRemoveCommand
176
+ ? [
177
+ [
178
+ "integration.worktree-remove",
179
+ config.worktreeRemoveCommand,
180
+ "Worktree remover",
181
+ ] as const,
182
+ ]
183
+ : []),
175
184
  ...Object.entries(config.repositories ?? {}).flatMap(
176
185
  ([alias, repository]) =>
177
186
  repository.postCheckoutCommand
@@ -20,7 +20,7 @@ import {
20
20
  type WorkbaseRegistry as WorkbaseRegistryData,
21
21
  type WorkbaseRegistration,
22
22
  } from "../workbase/schemas"
23
- import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
23
+ import { validateWorktreeCommand } from "../workbase/worktree-command"
24
24
  import { validatePostCheckoutCommand } from "../workbase/checkout-command"
25
25
  import { validateBranchNameCommand } from "../workbase/branch-name-template"
26
26
  import { validateAgents } from "../workbase/agent-command"
@@ -315,18 +315,21 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
315
315
  message: `Invalid workbase configuration in ${configPath}:\n${decoded.error}`,
316
316
  })
317
317
  }
318
- if (decoded.value.worktreeCreateCommand) {
318
+ for (const setting of [
319
+ "worktreeCreateCommand",
320
+ "worktreeRemoveCommand",
321
+ ] as const) {
322
+ const command = decoded.value[setting]
323
+ if (!command) continue
319
324
  try {
320
- validateWorktreeCreateCommand(
321
- decoded.value.worktreeCreateCommand,
322
- )
325
+ validateWorktreeCommand(setting, command)
323
326
  } catch (cause) {
324
327
  return yield* new WorkbaseConfigError({
325
328
  path: configPath,
326
329
  message:
327
330
  cause instanceof Error
328
331
  ? cause.message
329
- : "Invalid worktreeCreateCommand",
332
+ : `Invalid ${setting}`,
330
333
  })
331
334
  }
332
335
  }
@@ -5,7 +5,7 @@ import { WorkbaseService } from "./WorkbaseService"
5
5
  import { TaskService } from "./TaskService"
6
6
  import { PhaseService } from "./PhaseService"
7
7
  import {
8
- expandWorktreeCreateCommand,
8
+ expandWorktreeCommand,
9
9
  worktreeCommandEnvironment,
10
10
  } from "../workbase/worktree-command"
11
11
  import {
@@ -258,6 +258,105 @@ const runPostCheckoutHook = (options: {
258
258
  })
259
259
  })
260
260
 
261
+ const runWorktreeRemoveCommand = (options: {
262
+ readonly command: readonly string[]
263
+ readonly alias: string
264
+ readonly repositoryPath: string
265
+ readonly checkoutPath: string
266
+ readonly branch: string
267
+ readonly base: string
268
+ readonly preserveBranch: boolean
269
+ readonly forwardOutput: boolean
270
+ readonly verboseLog: (...args: unknown[]) => void
271
+ }) =>
272
+ Effect.gen(function* () {
273
+ const fs = yield* FileSystemService
274
+ const variables = {
275
+ repo: options.repositoryPath,
276
+ worktree: options.checkoutPath,
277
+ branch: options.branch,
278
+ base: options.base,
279
+ }
280
+ let args: string[]
281
+ try {
282
+ args = expandWorktreeCommand(
283
+ "worktreeRemoveCommand",
284
+ options.command,
285
+ variables,
286
+ )
287
+ } catch (cause) {
288
+ return yield* new WorktreeError({
289
+ message:
290
+ cause instanceof Error
291
+ ? cause.message
292
+ : "Invalid worktreeRemoveCommand",
293
+ })
294
+ }
295
+ const canonicalCheckoutPath = yield* fs.realPath(options.checkoutPath)
296
+ options.verboseLog(
297
+ `Running worktree remove command: ${formatCommand(args)}`,
298
+ )
299
+ const result = yield* fs.runCommand(args, {
300
+ cwd: options.repositoryPath,
301
+ captureOutput: true,
302
+ forwardOutput: options.forwardOutput,
303
+ env: worktreeCommandEnvironment(variables),
304
+ })
305
+ if (result.exitCode !== 0) {
306
+ return yield* new WorktreeError({
307
+ message: `Failed to remove worktree for '${options.alias}': ${result.stderr}`,
308
+ })
309
+ }
310
+ if (yield* fs.exists(options.checkoutPath)) {
311
+ return yield* new WorktreeError({
312
+ message: `Worktree remove command did not remove ${options.checkoutPath}`,
313
+ })
314
+ }
315
+ const listed = yield* fs.runCommand(
316
+ [
317
+ "git",
318
+ "-C",
319
+ options.repositoryPath,
320
+ "worktree",
321
+ "list",
322
+ "--porcelain",
323
+ "-z",
324
+ ],
325
+ { captureOutput: true },
326
+ )
327
+ if (
328
+ listed.exitCode !== 0 ||
329
+ parseWorktreeList(listed.stdout).some(
330
+ (worktree) =>
331
+ resolve(worktree.path) === canonicalCheckoutPath ||
332
+ resolve(worktree.path) === resolve(options.checkoutPath),
333
+ )
334
+ ) {
335
+ return yield* new WorktreeError({
336
+ message: `Worktree remove command left ${options.checkoutPath} registered as a Git worktree`,
337
+ })
338
+ }
339
+ if (options.preserveBranch) {
340
+ const branchExists = yield* fs.runCommand(
341
+ [
342
+ "git",
343
+ "-C",
344
+ options.repositoryPath,
345
+ "show-ref",
346
+ "--verify",
347
+ "--quiet",
348
+ `refs/heads/${options.branch}`,
349
+ ],
350
+ { captureOutput: true },
351
+ )
352
+ if (branchExists.exitCode !== 0) {
353
+ return yield* new WorktreeError({
354
+ message: `Worktree remove command deleted branch '${options.branch}' in repository '${options.alias}'; it must preserve the branch`,
355
+ })
356
+ }
357
+ }
358
+ })
359
+
261
360
  const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
262
361
 
263
362
  const originRef = (ref: string) =>
@@ -1083,12 +1182,16 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1083
1182
  }
1084
1183
  if (config.worktreeCreateCommand) {
1085
1184
  try {
1086
- expandWorktreeCreateCommand(config.worktreeCreateCommand, {
1087
- repo: repositoryPath,
1088
- worktree: checkoutPath,
1089
- branch: checkout.branch,
1090
- base: executionBase,
1091
- })
1185
+ expandWorktreeCommand(
1186
+ "worktreeCreateCommand",
1187
+ config.worktreeCreateCommand,
1188
+ {
1189
+ repo: repositoryPath,
1190
+ worktree: checkoutPath,
1191
+ branch: checkout.branch,
1192
+ base: executionBase,
1193
+ },
1194
+ )
1092
1195
  } catch (cause) {
1093
1196
  return yield* new WorktreeError({
1094
1197
  message:
@@ -1364,7 +1467,8 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1364
1467
  base: executionBase,
1365
1468
  }
1366
1469
  try {
1367
- args = expandWorktreeCreateCommand(
1470
+ args = expandWorktreeCommand(
1471
+ "worktreeCreateCommand",
1368
1472
  config.worktreeCreateCommand,
1369
1473
  variables,
1370
1474
  )
@@ -1781,18 +1885,37 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1781
1885
  !(yield* fs.isDirectory(checkoutPath))
1782
1886
  )
1783
1887
  continue
1784
- const removed = yield* fs.runCommand(
1785
- [
1786
- "git",
1787
- "-C",
1788
- join(root, "repos", checkout.repo),
1789
- "worktree",
1790
- "remove",
1791
- "--force",
1792
- checkoutPath,
1793
- ],
1794
- { captureOutput: true },
1795
- )
1888
+ const repositoryPath = join(root, "repos", checkout.repo)
1889
+ const removed =
1890
+ config.worktreeRemoveCommand && "branch" in checkout
1891
+ ? yield* runWorktreeRemoveCommand({
1892
+ command: config.worktreeRemoveCommand,
1893
+ alias: checkout.repo,
1894
+ repositoryPath,
1895
+ checkoutPath,
1896
+ branch: checkout.branch,
1897
+ base: executionBase,
1898
+ preserveBranch: false,
1899
+ forwardOutput: forwardCommandOutput,
1900
+ verboseLog,
1901
+ }).pipe(
1902
+ Effect.as({ exitCode: 0 }),
1903
+ Effect.catchAll(() =>
1904
+ Effect.succeed({ exitCode: 1 }),
1905
+ ),
1906
+ )
1907
+ : yield* fs.runCommand(
1908
+ [
1909
+ "git",
1910
+ "-C",
1911
+ repositoryPath,
1912
+ "worktree",
1913
+ "remove",
1914
+ "--force",
1915
+ checkoutPath,
1916
+ ],
1917
+ { captureOutput: true },
1918
+ )
1796
1919
  if (removed.exitCode === 0)
1797
1920
  rolledBack.push(`create-worktree ${checkout.repo}`)
1798
1921
  else manualRecovery.push(`Remove ${checkoutPath}`)
@@ -1821,6 +1944,24 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1821
1944
  ]),
1822
1945
  ).values()
1823
1946
  for (const branch of branchCandidates) {
1947
+ if (config.worktreeRemoveCommand) {
1948
+ const branchExists = yield* fs.runCommand(
1949
+ [
1950
+ "git",
1951
+ "-C",
1952
+ join(root, "repos", branch.repo),
1953
+ "show-ref",
1954
+ "--verify",
1955
+ "--quiet",
1956
+ `refs/heads/${branch.branch}`,
1957
+ ],
1958
+ { captureOutput: true },
1959
+ )
1960
+ if (branchExists.exitCode !== 0) {
1961
+ rolledBack.push(`create-branch ${branch.repo}`)
1962
+ continue
1963
+ }
1964
+ }
1824
1965
  const deleted = yield* fs.runCommand(
1825
1966
  [
1826
1967
  "git",
@@ -1881,7 +2022,10 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1881
2022
  const workbase = yield* WorkbaseService
1882
2023
  const tasks = yield* TaskService
1883
2024
  const phases = yield* PhaseService
1884
- const root = yield* workbase.discover(startPath)
2025
+ const { verboseLog } = createLoggers(options)
2026
+ const forwardCommandOutput =
2027
+ options.verbose === true && !options.silent && !options.json
2028
+ const { root, config } = yield* workbase.loadConfig(startPath)
1885
2029
  const removal = Effect.gen(function* () {
1886
2030
  const inspection = yield* inspectExecution(taskId, phaseId, root)
1887
2031
  const blockingConflicts = inspection.conflicts.filter(
@@ -1907,6 +2051,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1907
2051
  repo: string
1908
2052
  repos?: readonly RepositoryReference[]
1909
2053
  branch: string
2054
+ base: string
1910
2055
  }
1911
2056
  | { review: { repo: string; commit: string } }
1912
2057
  let codePath: string
@@ -1959,6 +2104,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1959
2104
  checkoutPath: string
1960
2105
  registeredPath: string
1961
2106
  checkoutExists: boolean
2107
+ writable: boolean
1962
2108
  head?: string
1963
2109
  branch?: string
1964
2110
  }[] = []
@@ -2115,6 +2261,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2115
2261
  checkoutPath,
2116
2262
  registeredPath: registered.path,
2117
2263
  checkoutExists,
2264
+ writable: "branch" in checkout,
2118
2265
  head: registered.head,
2119
2266
  branch: registered.branch?.replace(/^refs\/heads\//, ""),
2120
2267
  })
@@ -2188,6 +2335,34 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2188
2335
  })
2189
2336
  const removed = yield* Effect.gen(function* () {
2190
2337
  for (const plan of removalPlans) {
2338
+ if (
2339
+ config.worktreeRemoveCommand &&
2340
+ plan.writable &&
2341
+ plan.checkoutExists &&
2342
+ plan.branch &&
2343
+ !("review" in execution)
2344
+ ) {
2345
+ yield* runWorktreeRemoveCommand({
2346
+ command: config.worktreeRemoveCommand,
2347
+ alias: plan.alias,
2348
+ repositoryPath: plan.repositoryPath,
2349
+ checkoutPath: plan.checkoutPath,
2350
+ branch: plan.branch,
2351
+ base: execution.base,
2352
+ preserveBranch: true,
2353
+ forwardOutput: forwardCommandOutput,
2354
+ verboseLog,
2355
+ }).pipe(
2356
+ Effect.tapError(() =>
2357
+ Effect.gen(function* () {
2358
+ if (!(yield* fs.exists(plan.checkoutPath)))
2359
+ completed.push(plan)
2360
+ }),
2361
+ ),
2362
+ )
2363
+ completed.push(plan)
2364
+ continue
2365
+ }
2191
2366
  const command = plan.checkoutExists
2192
2367
  ? [
2193
2368
  "git",
@@ -109,6 +109,7 @@ export const WorkbaseConfig = Schema.Struct({
109
109
  chooserCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
110
110
  branchNameCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
111
111
  worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
112
+ worktreeRemoveCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
112
113
  agents: Schema.optional(
113
114
  Schema.Record({
114
115
  key: EntityId,
@@ -5,15 +5,20 @@ interface WorktreeCommandVariables {
5
5
  readonly base: string
6
6
  }
7
7
 
8
+ type WorktreeCommandSetting = "worktreeCreateCommand" | "worktreeRemoveCommand"
9
+
8
10
  const REQUIRED_PLACEHOLDERS = ["repo", "worktree"] as const
9
11
  const PLACEHOLDERS = new Set(["repo", "worktree", "branch", "base"])
10
12
 
11
- export const validateWorktreeCreateCommand = (command: readonly string[]) => {
13
+ export const validateWorktreeCommand = (
14
+ setting: WorktreeCommandSetting,
15
+ command: readonly string[],
16
+ ) => {
12
17
  const template = command.join("\u0000")
13
18
  for (const placeholder of REQUIRED_PLACEHOLDERS) {
14
19
  if (!template.includes(`{${placeholder}}`)) {
15
20
  throw new Error(
16
- `worktreeCreateCommand must include the {${placeholder}} placeholder`,
21
+ `${setting} must include the {${placeholder}} placeholder`,
17
22
  )
18
23
  }
19
24
  }
@@ -21,19 +26,18 @@ export const validateWorktreeCreateCommand = (command: readonly string[]) => {
21
26
  for (const match of argument.matchAll(/\{([^{}]+)\}/g)) {
22
27
  const placeholder = match[1]!
23
28
  if (!PLACEHOLDERS.has(placeholder)) {
24
- throw new Error(
25
- `Unknown worktreeCreateCommand placeholder: {${placeholder}}`,
26
- )
29
+ throw new Error(`Unknown ${setting} placeholder: {${placeholder}}`)
27
30
  }
28
31
  }
29
32
  }
30
33
  }
31
34
 
32
- export const expandWorktreeCreateCommand = (
35
+ export const expandWorktreeCommand = (
36
+ setting: WorktreeCommandSetting,
33
37
  command: readonly string[],
34
38
  variables: WorktreeCommandVariables,
35
39
  ): string[] => {
36
- validateWorktreeCreateCommand(command)
40
+ validateWorktreeCommand(setting, command)
37
41
 
38
42
  return command.map((argument) =>
39
43
  argument.replaceAll(/\{([^{}]+)\}/g, (match, placeholder: string) => {