@markjaquith/agency 3.12.0 → 3.14.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.14.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -138,6 +138,7 @@ Options:
138
138
 
139
139
  Sync may materialize declared repositories and unambiguous missing checkouts,
140
140
  adopt legacy repositories with portable origins, record a uniquely matched PR,
141
- and mark merged work done. Dirty, stale, or
141
+ adopt a recorded PR's retargeted base or renamed head branch, and mark merged
142
+ work done. Dirty, stale, or
142
143
  conflicting checkouts are always left unresolved.
143
144
  `
@@ -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
@@ -58,7 +58,12 @@ interface RegisteredWorktree {
58
58
  }
59
59
 
60
60
  interface SyncChange {
61
- readonly kind: "materialize-workspace" | "record-pr" | "mark-done"
61
+ readonly kind:
62
+ | "materialize-workspace"
63
+ | "adopt-pr-branch"
64
+ | "adopt-pr-base"
65
+ | "record-pr"
66
+ | "mark-done"
62
67
  readonly target: string
63
68
  readonly message: string
64
69
  readonly status: "planned" | "applied"
@@ -162,14 +167,34 @@ interface PullRequestQuery {
162
167
  | undefined
163
168
  }
164
169
 
170
+ const canonicalRecord = (record: PullRequestRecord | null) =>
171
+ record
172
+ ? JSON.stringify(
173
+ Object.entries(record)
174
+ .filter(([, value]) => value !== undefined)
175
+ .sort(([a], [b]) => a.localeCompare(b)),
176
+ )
177
+ : null
178
+
179
+ const samePullRequestRecord = (
180
+ current: PullRequestRecord,
181
+ existing: PullRequestRecord | null,
182
+ ) => canonicalRecord(current) === canonicalRecord(existing)
183
+
184
+ const belongsToWritableRepository = (
185
+ current: PullRequestRecord,
186
+ remoteRepository: string,
187
+ ) =>
188
+ current.headRepository?.toLowerCase() === remoteRepository.toLowerCase() &&
189
+ current.baseRepository?.toLowerCase() === current.repository.toLowerCase()
190
+
165
191
  const mergedPullRequestFromGitHub = (
166
192
  data: ExecutionData,
167
193
  query: PullRequestQuery | undefined,
168
194
  details: readonly PullRequestRecord[] | undefined,
169
195
  ) => {
170
196
  if (!query?.result || query.result.exitCode !== 0 || !details) return null
171
- const existing = data.pr ? normalizePullRequestRecord(data.pr) : null
172
- const matches = existing
197
+ const matches = data.pr
173
198
  ? details
174
199
  : details.filter(
175
200
  (item) =>
@@ -177,18 +202,10 @@ const mergedPullRequestFromGitHub = (
177
202
  )
178
203
  if (matches.length !== 1) return null
179
204
  const current = matches[0]!
180
- if (
181
- current.merged !== true ||
182
- current.headRepository?.toLowerCase() !==
183
- query.remoteRepository.toLowerCase() ||
184
- current.headBranch !== data.branch ||
185
- current.baseRepository?.toLowerCase() !==
186
- current.repository.toLowerCase() ||
187
- current.baseBranch !== data.base
188
- ) {
189
- return null
190
- }
191
- return current
205
+ return current.merged === true &&
206
+ belongsToWritableRepository(current, query.remoteRepository)
207
+ ? current
208
+ : null
192
209
  }
193
210
 
194
211
  const mutateExecution = (
@@ -650,6 +667,22 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
650
667
  { concurrency: 8 },
651
668
  )
652
669
 
670
+ const branchOwners = new Map<string, string>()
671
+ const branchKey = (repo: string, branch: string) =>
672
+ JSON.stringify([repo, branch])
673
+ for (const task of allTaskRecords) {
674
+ if ("repo" in task.data)
675
+ branchOwners.set(
676
+ branchKey(task.data.repo, task.data.branch),
677
+ `task:${task.id}`,
678
+ )
679
+ for (const phase of documents.phasesByTask.get(task.id) ?? [])
680
+ branchOwners.set(
681
+ branchKey(phase.data.repo, phase.data.branch),
682
+ `phase:${task.id}/${phase.id}`,
683
+ )
684
+ }
685
+
653
686
  for (const record of records.sort((a, b) =>
654
687
  a.key.localeCompare(b.key),
655
688
  )) {
@@ -662,6 +695,126 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
662
695
  const githubDetails = githubResponse?.ok
663
696
  ? githubResponse.details
664
697
  : undefined
698
+ let renamedFrom: string | null = null
699
+ let adoptionConflict = false
700
+ const recordedPr =
701
+ !config.delivery && data.pr ? githubDetails?.[0] : undefined
702
+ if (
703
+ query &&
704
+ recordedPr?.headBranch &&
705
+ recordedPr.baseBranch &&
706
+ belongsToWritableRepository(recordedPr, query.remoteRepository) &&
707
+ (recordedPr.headBranch !== data.branch ||
708
+ recordedPr.baseBranch !== data.base)
709
+ ) {
710
+ const previous = { branch: data.branch, base: data.base }
711
+ const adopted = {
712
+ branch: recordedPr.headBranch,
713
+ base: recordedPr.baseBranch,
714
+ }
715
+ const repositoryPath = join(root, "repos", data.repo)
716
+ let renameLocal = false
717
+ if (adopted.branch !== previous.branch) {
718
+ const owner = branchOwners.get(
719
+ branchKey(data.repo, adopted.branch),
720
+ )
721
+ const [previousLocal, adoptedLocal] = yield* Effect.all([
722
+ backend.resolveRevision(
723
+ repositoryPath,
724
+ `refs/heads/${previous.branch}`,
725
+ ),
726
+ backend.resolveRevision(
727
+ repositoryPath,
728
+ `refs/heads/${adopted.branch}`,
729
+ ),
730
+ ])
731
+ if (owner && owner !== record.key) {
732
+ adoptionConflict = true
733
+ unresolved.push({
734
+ kind: "pr-branch-conflict",
735
+ target: record.key,
736
+ message: `Recorded PR head '${adopted.branch}' is declared by '${owner}'`,
737
+ action:
738
+ "Correct the recorded PR URL or the other declaration",
739
+ })
740
+ } else if (previousLocal && adoptedLocal) {
741
+ adoptionConflict = true
742
+ unresolved.push({
743
+ kind: "pr-branch-conflict",
744
+ target: record.key,
745
+ message: `Cannot rename local branch '${previous.branch}' to recorded PR head '${adopted.branch}' because it already exists`,
746
+ action: `Reconcile or delete local branch '${adopted.branch}' manually`,
747
+ })
748
+ } else {
749
+ renameLocal = previousLocal !== null
750
+ }
751
+ }
752
+ if (!adoptionConflict && apply) {
753
+ const renameBranch = (from: string, to: string) =>
754
+ runExternal(["git", "branch", "-m", from, to], {
755
+ cwd: repositoryPath,
756
+ })
757
+ const renamed = renameLocal
758
+ ? yield* renameBranch(previous.branch, adopted.branch)
759
+ : null
760
+ if (renamed && renamed.exitCode !== 0) {
761
+ adoptionConflict = true
762
+ unresolved.push({
763
+ kind: "pr-branch-rename-failed",
764
+ target: record.key,
765
+ message: `Could not rename local branch '${previous.branch}' to '${adopted.branch}': ${commandErrorSummary(renamed.stderr, "git branch failed")}`,
766
+ action: "Rename the local branch manually",
767
+ })
768
+ } else {
769
+ const updated = yield* mutateExecution(root, record, revision, {
770
+ ...data,
771
+ ...adopted,
772
+ }).pipe(
773
+ Effect.tapError(() =>
774
+ renamed
775
+ ? renameBranch(adopted.branch, previous.branch)
776
+ : Effect.void,
777
+ ),
778
+ )
779
+ data = updated.data
780
+ revision = updated.revision
781
+ const cached = registeredByRepository.get(repositoryPath)
782
+ for (const [index, item] of (cached ?? []).entries()) {
783
+ if (item.branch === `refs/heads/${previous.branch}`)
784
+ cached![index] = {
785
+ ...item,
786
+ branch: `refs/heads/${adopted.branch}`,
787
+ }
788
+ }
789
+ }
790
+ } else if (!adoptionConflict) {
791
+ data = { ...data, ...adopted }
792
+ if (renameLocal) renamedFrom = previous.branch
793
+ }
794
+ if (!adoptionConflict) {
795
+ if (adopted.branch !== previous.branch) {
796
+ branchOwners.delete(branchKey(data.repo, previous.branch))
797
+ branchOwners.set(
798
+ branchKey(data.repo, adopted.branch),
799
+ record.key,
800
+ )
801
+ changes.push({
802
+ kind: "adopt-pr-branch",
803
+ target: record.key,
804
+ message: `Adopt pull request head '${adopted.branch}' (was '${previous.branch}')`,
805
+ status: apply ? "applied" : "planned",
806
+ })
807
+ }
808
+ if (adopted.base !== previous.base) {
809
+ changes.push({
810
+ kind: "adopt-pr-base",
811
+ target: record.key,
812
+ message: `Adopt pull request base '${adopted.base}' (was '${previous.base}')`,
813
+ status: apply ? "applied" : "planned",
814
+ })
815
+ }
816
+ }
817
+ }
665
818
  const remoteMergedPr = config.delivery
666
819
  ? null
667
820
  : mergedPullRequestFromGitHub(data, query, githubDetails)
@@ -838,7 +991,12 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
838
991
  action: "Review and preserve or discard local changes manually",
839
992
  })
840
993
  }
841
- if (atPath && branchRef && atPath.branch !== branchRef) {
994
+ if (
995
+ atPath &&
996
+ branchRef &&
997
+ atPath.branch !== branchRef &&
998
+ !(renamedFrom && atPath.branch === `refs/heads/${renamedFrom}`)
999
+ ) {
842
1000
  unresolved.push({
843
1001
  kind: "wrong-branch",
844
1002
  target: record.key,
@@ -1022,21 +1180,26 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1022
1180
  if (githubDetails) {
1023
1181
  current = githubDetails[0]!
1024
1182
  pr = current
1025
- if (
1026
- current.headRepository?.toLowerCase() !==
1027
- remoteRepository.toLowerCase() ||
1028
- current.headBranch !== data.branch ||
1029
- current.baseRepository?.toLowerCase() !==
1030
- current.repository.toLowerCase() ||
1031
- current.baseBranch !== data.base
1032
- ) {
1183
+ if (!belongsToWritableRepository(current, remoteRepository)) {
1033
1184
  prConflict = true
1034
1185
  unresolved.push({
1035
1186
  kind: "pr-repository-conflict",
1036
1187
  target: record.key,
1037
- message: `Recorded PR head does not match '${remoteRepository}:${data.branch}' or base '${current.repository}:${data.base}'`,
1188
+ message: `Recorded PR repositories do not match writable repository '${remoteRepository}' and base '${current.repository}'`,
1038
1189
  action: "Correct the declaration or recorded PR URL",
1039
1190
  })
1191
+ } else if (
1192
+ current.headBranch !== data.branch ||
1193
+ current.baseBranch !== data.base
1194
+ ) {
1195
+ prConflict = true
1196
+ if (!adoptionConflict)
1197
+ unresolved.push({
1198
+ kind: "pr-branch-conflict",
1199
+ target: record.key,
1200
+ message: `Recorded PR head '${current.headBranch ?? "unknown"}' or base '${current.baseBranch ?? "unknown"}' could not be adopted`,
1201
+ action: "Correct the declaration or recorded PR URL",
1202
+ })
1040
1203
  }
1041
1204
  }
1042
1205
  } else {
@@ -1092,7 +1255,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
1092
1255
  }
1093
1256
  }
1094
1257
 
1095
- if (current && JSON.stringify(current) !== JSON.stringify(existing)) {
1258
+ if (current && !samePullRequestRecord(current, existing)) {
1096
1259
  if (apply) {
1097
1260
  const recorded = yield* mutateExecution(root, record, revision, {
1098
1261
  ...data,
@@ -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) => {