@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
|
|
188
|
-
creation
|
|
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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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 =
|
|
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
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
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
|
|
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",
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
35
|
+
export const expandWorktreeCommand = (
|
|
36
|
+
setting: WorktreeCommandSetting,
|
|
33
37
|
command: readonly string[],
|
|
34
38
|
variables: WorktreeCommandVariables,
|
|
35
39
|
): string[] => {
|
|
36
|
-
|
|
40
|
+
validateWorktreeCommand(setting, command)
|
|
37
41
|
|
|
38
42
|
return command.map((argument) =>
|
|
39
43
|
argument.replaceAll(/\{([^{}]+)\}/g, (match, placeholder: string) => {
|