@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 +39 -2
- package/package.json +1 -1
- package/src/commands/sync.ts +2 -1
- package/src/services/DoctorService.ts +9 -0
- package/src/services/SyncService.ts +189 -26
- package/src/services/WorkbaseService.ts +9 -6
- package/src/services/WorktreeService.ts +196 -21
- package/src/workbase/schemas.ts +1 -0
- package/src/workbase/worktree-command.ts +11 -7
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
package/src/commands/sync.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
181
|
-
current.
|
|
182
|
-
current
|
|
183
|
-
|
|
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 (
|
|
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
|
|
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 &&
|
|
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 {
|
|
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) => {
|