@markjaquith/agency 2.54.4 → 2.56.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 +68 -1
- package/cli-main.ts +20 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +9 -0
- package/src/cli-parser.ts +10 -0
- package/src/cli.test.ts +1 -0
- package/src/commands/push.ts +25 -0
- package/src/protocol.ts +1 -0
- package/src/services/DoctorService.ts +12 -0
- package/src/services/IntegrationService.test.ts +2 -0
- package/src/services/PushService.test.ts +516 -0
- package/src/services/PushService.ts +576 -0
- package/src/services/WorkbaseService.test.ts +24 -0
- package/src/services/WorkbaseService.ts +18 -0
- package/src/services/WorktreeService.test.ts +383 -1
- package/src/services/WorktreeService.ts +232 -4
- package/src/test-utils.ts +2 -0
- package/src/workbase/AGENTS.md +5 -0
- package/src/workbase/checkout-command.test.ts +62 -0
- package/src/workbase/checkout-command.ts +66 -0
- package/src/workbase/schemas.test.ts +34 -0
- package/src/workbase/schemas.ts +1 -0
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import { ContextService } from "./ContextService"
|
|
3
|
+
import { FileSystemService } from "./FileSystemService"
|
|
4
|
+
import { PhaseService } from "./PhaseService"
|
|
5
|
+
import { TaskService } from "./TaskService"
|
|
6
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
7
|
+
|
|
8
|
+
class PushError extends Data.TaggedError("PushError")<{
|
|
9
|
+
readonly message: string
|
|
10
|
+
}> {}
|
|
11
|
+
|
|
12
|
+
interface CommandResult {
|
|
13
|
+
readonly exitCode: number
|
|
14
|
+
readonly stdout: string
|
|
15
|
+
readonly stderr: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CommitMetadata {
|
|
19
|
+
readonly commitId: string
|
|
20
|
+
readonly changeId?: string
|
|
21
|
+
readonly description: string
|
|
22
|
+
readonly empty: boolean
|
|
23
|
+
readonly authorName: string
|
|
24
|
+
readonly authorEmail: string
|
|
25
|
+
readonly conflict: boolean
|
|
26
|
+
readonly parents: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PushResult {
|
|
30
|
+
readonly vcs: "git" | "jj"
|
|
31
|
+
readonly taskId: string
|
|
32
|
+
readonly phaseId?: string
|
|
33
|
+
readonly branch: string
|
|
34
|
+
readonly base: string
|
|
35
|
+
readonly remote: string
|
|
36
|
+
readonly tip: string
|
|
37
|
+
readonly changeId?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const validEmail = (email: string) => /^[^@\s]+@[^@\s]+$/.test(email)
|
|
41
|
+
|
|
42
|
+
const requireCommand = (
|
|
43
|
+
fs: FileSystemService,
|
|
44
|
+
args: readonly string[],
|
|
45
|
+
cwd: string,
|
|
46
|
+
label: string,
|
|
47
|
+
) =>
|
|
48
|
+
fs.runCommand(args, { cwd, captureOutput: true }).pipe(
|
|
49
|
+
Effect.flatMap((result) =>
|
|
50
|
+
result.exitCode === 0
|
|
51
|
+
? Effect.succeed(result)
|
|
52
|
+
: Effect.fail(
|
|
53
|
+
new PushError({
|
|
54
|
+
message: `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const git = (
|
|
61
|
+
fs: FileSystemService,
|
|
62
|
+
cwd: string,
|
|
63
|
+
args: readonly string[],
|
|
64
|
+
label: string,
|
|
65
|
+
) => requireCommand(fs, ["git", ...args], cwd, label)
|
|
66
|
+
|
|
67
|
+
const gitRevision = (fs: FileSystemService, cwd: string, revision: string) =>
|
|
68
|
+
fs
|
|
69
|
+
.runCommand(["git", "rev-parse", "--verify", `${revision}^{commit}`], {
|
|
70
|
+
cwd,
|
|
71
|
+
captureOutput: true,
|
|
72
|
+
})
|
|
73
|
+
.pipe(
|
|
74
|
+
Effect.map((result) =>
|
|
75
|
+
result.exitCode === 0 ? result.stdout.trim() || null : null,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const gitAncestor = (
|
|
80
|
+
fs: FileSystemService,
|
|
81
|
+
cwd: string,
|
|
82
|
+
ancestor: string,
|
|
83
|
+
descendant: string,
|
|
84
|
+
) =>
|
|
85
|
+
fs
|
|
86
|
+
.runCommand(["git", "merge-base", "--is-ancestor", ancestor, descendant], {
|
|
87
|
+
cwd,
|
|
88
|
+
captureOutput: true,
|
|
89
|
+
})
|
|
90
|
+
.pipe(
|
|
91
|
+
Effect.flatMap((result) => {
|
|
92
|
+
if (result.exitCode === 0) return Effect.succeed(true)
|
|
93
|
+
if (result.exitCode === 1) return Effect.succeed(false)
|
|
94
|
+
return Effect.fail(
|
|
95
|
+
new PushError({
|
|
96
|
+
message: `Failed to inspect Git ancestry: ${result.stderr.trim()}`,
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const parseGitCommits = (output: string): readonly CommitMetadata[] =>
|
|
103
|
+
output
|
|
104
|
+
.split("\x1e")
|
|
105
|
+
.map((record) => record.replace(/^\n+|\n+$/g, ""))
|
|
106
|
+
.filter(Boolean)
|
|
107
|
+
.map((record) => {
|
|
108
|
+
const [
|
|
109
|
+
commitId = "",
|
|
110
|
+
authorName = "",
|
|
111
|
+
authorEmail = "",
|
|
112
|
+
description = "",
|
|
113
|
+
] = record.split("\0")
|
|
114
|
+
return {
|
|
115
|
+
commitId,
|
|
116
|
+
description,
|
|
117
|
+
empty: false,
|
|
118
|
+
authorName,
|
|
119
|
+
authorEmail,
|
|
120
|
+
conflict: false,
|
|
121
|
+
parents: [],
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const validateGitCommits = (
|
|
126
|
+
commits: readonly CommitMetadata[],
|
|
127
|
+
base: string,
|
|
128
|
+
) => {
|
|
129
|
+
if (commits.length === 0) {
|
|
130
|
+
throw new PushError({
|
|
131
|
+
message: `No commits to publish after base '${base}'`,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
const issues: string[] = []
|
|
135
|
+
for (const commit of commits) {
|
|
136
|
+
if (!commit.description.trim()) {
|
|
137
|
+
issues.push(
|
|
138
|
+
`Commit ${commit.commitId} has an empty message. Run: git rebase -i ${base}`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
if (!commit.authorName.trim() || !validEmail(commit.authorEmail.trim())) {
|
|
142
|
+
issues.push(
|
|
143
|
+
`Commit ${commit.commitId} has an invalid author. Run: git rebase -i ${base}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (issues.length > 0) throw new PushError({ message: issues.join("\n") })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const publishGit = (
|
|
151
|
+
fs: FileSystemService,
|
|
152
|
+
checkout: string,
|
|
153
|
+
remote: string,
|
|
154
|
+
branch: string,
|
|
155
|
+
base: string,
|
|
156
|
+
) =>
|
|
157
|
+
Effect.gen(function* () {
|
|
158
|
+
const currentBranch = yield* git(
|
|
159
|
+
fs,
|
|
160
|
+
checkout,
|
|
161
|
+
["symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
162
|
+
"Git checkout must be attached to the declared branch",
|
|
163
|
+
)
|
|
164
|
+
if (currentBranch.stdout.trim() !== branch) {
|
|
165
|
+
return yield* new PushError({
|
|
166
|
+
message: `Declared delivery branch '${branch}' does not match checked-out Git branch '${currentBranch.stdout.trim()}'`,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
const status = yield* git(
|
|
170
|
+
fs,
|
|
171
|
+
checkout,
|
|
172
|
+
["status", "--porcelain=v1"],
|
|
173
|
+
"Failed to inspect Git status",
|
|
174
|
+
)
|
|
175
|
+
if (status.stdout.length > 0) {
|
|
176
|
+
return yield* new PushError({
|
|
177
|
+
message:
|
|
178
|
+
"Cannot publish a dirty Git worktree; commit or discard changes first",
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
yield* git(
|
|
183
|
+
fs,
|
|
184
|
+
checkout,
|
|
185
|
+
["fetch", remote, `+refs/heads/*:refs/remotes/${remote}/*`],
|
|
186
|
+
`Failed to fetch remote '${remote}'`,
|
|
187
|
+
)
|
|
188
|
+
const tip = yield* gitRevision(fs, checkout, "HEAD")
|
|
189
|
+
const baseRevision = yield* gitRevision(
|
|
190
|
+
fs,
|
|
191
|
+
checkout,
|
|
192
|
+
`refs/remotes/${remote}/${base}`,
|
|
193
|
+
)
|
|
194
|
+
if (!tip || !baseRevision) {
|
|
195
|
+
return yield* new PushError({
|
|
196
|
+
message: `Declared base '${base}' was not found on remote '${remote}'`,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
if (!(yield* gitAncestor(fs, checkout, baseRevision, tip))) {
|
|
200
|
+
return yield* new PushError({
|
|
201
|
+
message: `Declared base '${base}' (${baseRevision}) is not an ancestor of Git HEAD (${tip})`,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const log = yield* git(
|
|
206
|
+
fs,
|
|
207
|
+
checkout,
|
|
208
|
+
[
|
|
209
|
+
"log",
|
|
210
|
+
"--format=%H%x00%an%x00%ae%x00%B%x00%x1e",
|
|
211
|
+
`${baseRevision}..${tip}`,
|
|
212
|
+
],
|
|
213
|
+
"Failed to inspect outgoing Git commits",
|
|
214
|
+
)
|
|
215
|
+
yield* Effect.try({
|
|
216
|
+
try: () => validateGitCommits(parseGitCommits(log.stdout), base),
|
|
217
|
+
catch: (cause) => cause as PushError,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const remoteTip = yield* gitRevision(
|
|
221
|
+
fs,
|
|
222
|
+
checkout,
|
|
223
|
+
`refs/remotes/${remote}/${branch}`,
|
|
224
|
+
)
|
|
225
|
+
if (remoteTip && !(yield* gitAncestor(fs, checkout, remoteTip, tip))) {
|
|
226
|
+
return yield* new PushError({
|
|
227
|
+
message: `Remote branch '${branch}' on '${remote}' is not an ancestor of Git HEAD; refusing a non-fast-forward update`,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
yield* git(
|
|
232
|
+
fs,
|
|
233
|
+
checkout,
|
|
234
|
+
["push", remote, `HEAD:refs/heads/${branch}`],
|
|
235
|
+
`Failed to push declared branch '${branch}'`,
|
|
236
|
+
)
|
|
237
|
+
yield* git(
|
|
238
|
+
fs,
|
|
239
|
+
checkout,
|
|
240
|
+
[
|
|
241
|
+
"config",
|
|
242
|
+
`remote.${remote}.fetch`,
|
|
243
|
+
`+refs/heads/*:refs/remotes/${remote}/*`,
|
|
244
|
+
],
|
|
245
|
+
`Failed to configure remote '${remote}' tracking`,
|
|
246
|
+
)
|
|
247
|
+
yield* git(
|
|
248
|
+
fs,
|
|
249
|
+
checkout,
|
|
250
|
+
[
|
|
251
|
+
"fetch",
|
|
252
|
+
remote,
|
|
253
|
+
`+refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
|
|
254
|
+
],
|
|
255
|
+
`Failed to refresh published branch '${branch}'`,
|
|
256
|
+
)
|
|
257
|
+
yield* git(
|
|
258
|
+
fs,
|
|
259
|
+
checkout,
|
|
260
|
+
["branch", "--set-upstream-to", `${remote}/${branch}`, branch],
|
|
261
|
+
`Failed to establish upstream tracking for branch '${branch}'`,
|
|
262
|
+
)
|
|
263
|
+
return { tip }
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const jjTemplate =
|
|
267
|
+
'"{\\"commitId\\":" ++ json(commit_id) ++ ",\\"changeId\\":" ++ json(change_id) ++ ",\\"description\\":" ++ json(description) ++ ",\\"empty\\":" ++ json(empty) ++ ",\\"authorName\\":" ++ json(author.name()) ++ ",\\"authorEmail\\":" ++ json(author.email()) ++ ",\\"conflict\\":" ++ json(conflict) ++ ",\\"parents\\":" ++ json(parents.map(|parent| parent.commit_id())) ++ "}\\n"'
|
|
268
|
+
|
|
269
|
+
const jjExact = (value: string) =>
|
|
270
|
+
value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')
|
|
271
|
+
|
|
272
|
+
const jj = (
|
|
273
|
+
fs: FileSystemService,
|
|
274
|
+
cwd: string,
|
|
275
|
+
args: readonly string[],
|
|
276
|
+
label: string,
|
|
277
|
+
) => requireCommand(fs, ["jj", "--no-pager", ...args], cwd, label)
|
|
278
|
+
|
|
279
|
+
const jjCommits = (fs: FileSystemService, cwd: string, revision: string) =>
|
|
280
|
+
jj(
|
|
281
|
+
fs,
|
|
282
|
+
cwd,
|
|
283
|
+
["log", "--no-graph", "-r", revision, "-T", jjTemplate],
|
|
284
|
+
"Failed to inspect jj changes",
|
|
285
|
+
).pipe(
|
|
286
|
+
Effect.map((result) =>
|
|
287
|
+
result.stdout
|
|
288
|
+
.split("\n")
|
|
289
|
+
.filter(Boolean)
|
|
290
|
+
.map((line) => JSON.parse(line) as CommitMetadata),
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
const jjRevision = (fs: FileSystemService, cwd: string, revision: string) =>
|
|
295
|
+
jjCommits(fs, cwd, revision).pipe(
|
|
296
|
+
Effect.map((commits) => {
|
|
297
|
+
if (commits.length === 0) return null
|
|
298
|
+
if (commits.length === 1) return commits[0]!
|
|
299
|
+
throw new PushError({
|
|
300
|
+
message: `Revision '${revision}' resolves to multiple jj commits`,
|
|
301
|
+
})
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
const optionalJjRevision = (
|
|
306
|
+
fs: FileSystemService,
|
|
307
|
+
cwd: string,
|
|
308
|
+
revision: string,
|
|
309
|
+
) =>
|
|
310
|
+
jjCommits(fs, cwd, revision).pipe(
|
|
311
|
+
Effect.catchTag("PushError", () => Effect.succeed([])),
|
|
312
|
+
Effect.map((commits) => {
|
|
313
|
+
if (commits.length === 0) return null
|
|
314
|
+
if (commits.length === 1) return commits[0]!
|
|
315
|
+
throw new PushError({
|
|
316
|
+
message: `Bookmark '${revision}' is conflicted or resolves to multiple commits`,
|
|
317
|
+
})
|
|
318
|
+
}),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
const jjAncestor = (
|
|
322
|
+
fs: FileSystemService,
|
|
323
|
+
cwd: string,
|
|
324
|
+
ancestor: string,
|
|
325
|
+
descendant: string,
|
|
326
|
+
) =>
|
|
327
|
+
jjCommits(fs, cwd, `${ancestor} & ::${descendant}`).pipe(
|
|
328
|
+
Effect.map((commits) => commits.length === 1),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
const validateJjCommits = (
|
|
332
|
+
commits: readonly CommitMetadata[],
|
|
333
|
+
base: string,
|
|
334
|
+
) => {
|
|
335
|
+
if (commits.length === 0) {
|
|
336
|
+
throw new PushError({
|
|
337
|
+
message: `No changes to publish after base '${base}'`,
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
const issues: string[] = []
|
|
341
|
+
for (const commit of commits) {
|
|
342
|
+
const id = commit.changeId!
|
|
343
|
+
if (commit.conflict) {
|
|
344
|
+
issues.push(`Change ${id} contains conflicts. Run: jj resolve -r ${id}`)
|
|
345
|
+
}
|
|
346
|
+
if (!commit.description.trim()) {
|
|
347
|
+
issues.push(`Change ${id} has no description. Run: jj describe -r ${id}`)
|
|
348
|
+
}
|
|
349
|
+
if (!commit.authorName.trim() || !validEmail(commit.authorEmail.trim())) {
|
|
350
|
+
issues.push(
|
|
351
|
+
`Change ${id} has an invalid author. Run: jj metaedit -r ${id} --author 'Name <email>'`,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (issues.length > 0) throw new PushError({ message: issues.join("\n") })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const publishJj = (
|
|
359
|
+
fs: FileSystemService,
|
|
360
|
+
checkout: string,
|
|
361
|
+
remote: string,
|
|
362
|
+
branch: string,
|
|
363
|
+
base: string,
|
|
364
|
+
) =>
|
|
365
|
+
Effect.gen(function* () {
|
|
366
|
+
yield* jj(
|
|
367
|
+
fs,
|
|
368
|
+
checkout,
|
|
369
|
+
["git", "fetch", "--remote", remote],
|
|
370
|
+
`Failed to fetch remote '${remote}'`,
|
|
371
|
+
)
|
|
372
|
+
const workingCopy = yield* jjRevision(fs, checkout, "@")
|
|
373
|
+
if (!workingCopy) {
|
|
374
|
+
return yield* new PushError({
|
|
375
|
+
message: "jj working copy commit was not found",
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
const canonicalPostCommit =
|
|
379
|
+
workingCopy.empty &&
|
|
380
|
+
!workingCopy.description.trim() &&
|
|
381
|
+
workingCopy.parents.length === 1
|
|
382
|
+
const tip = canonicalPostCommit
|
|
383
|
+
? yield* jjRevision(fs, checkout, "@-")
|
|
384
|
+
: workingCopy
|
|
385
|
+
if (!tip) {
|
|
386
|
+
return yield* new PushError({
|
|
387
|
+
message: "jj publication tip was not found",
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const baseBookmark = `${base}@${remote}`
|
|
392
|
+
const baseRevision = yield* optionalJjRevision(
|
|
393
|
+
fs,
|
|
394
|
+
checkout,
|
|
395
|
+
`remote_bookmarks(exact:"${jjExact(base)}", exact:"${jjExact(remote)}")`,
|
|
396
|
+
)
|
|
397
|
+
if (!baseRevision) {
|
|
398
|
+
return yield* new PushError({
|
|
399
|
+
message: `Declared base '${base}' was not found on remote '${remote}'`,
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
if (
|
|
403
|
+
!(yield* jjAncestor(fs, checkout, baseRevision.commitId, tip.commitId))
|
|
404
|
+
) {
|
|
405
|
+
return yield* new PushError({
|
|
406
|
+
message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId})`,
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const outgoing = yield* jjCommits(
|
|
411
|
+
fs,
|
|
412
|
+
checkout,
|
|
413
|
+
`${baseRevision.commitId}..${tip.commitId}`,
|
|
414
|
+
)
|
|
415
|
+
yield* Effect.try({
|
|
416
|
+
try: () => validateJjCommits(outgoing, base),
|
|
417
|
+
catch: (cause) => cause as PushError,
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
const localBookmark = yield* optionalJjRevision(
|
|
421
|
+
fs,
|
|
422
|
+
checkout,
|
|
423
|
+
`bookmarks(exact:"${jjExact(branch)}")`,
|
|
424
|
+
)
|
|
425
|
+
if (
|
|
426
|
+
localBookmark &&
|
|
427
|
+
!(yield* jjAncestor(fs, checkout, localBookmark.commitId, tip.commitId))
|
|
428
|
+
) {
|
|
429
|
+
return yield* new PushError({
|
|
430
|
+
message: `Local bookmark '${branch}' is not an ancestor of jj tip ${tip.changeId}; refusing to move it`,
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
const remoteBookmark = yield* optionalJjRevision(
|
|
434
|
+
fs,
|
|
435
|
+
checkout,
|
|
436
|
+
`remote_bookmarks(exact:"${jjExact(branch)}", exact:"${jjExact(remote)}")`,
|
|
437
|
+
)
|
|
438
|
+
if (
|
|
439
|
+
remoteBookmark &&
|
|
440
|
+
!(yield* jjAncestor(fs, checkout, remoteBookmark.commitId, tip.commitId))
|
|
441
|
+
) {
|
|
442
|
+
return yield* new PushError({
|
|
443
|
+
message: `Remote bookmark '${branch}@${remote}' is not an ancestor of jj tip ${tip.changeId}; refusing a non-fast-forward update`,
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
yield* jj(
|
|
448
|
+
fs,
|
|
449
|
+
checkout,
|
|
450
|
+
["bookmark", "set", branch, "-r", tip.commitId],
|
|
451
|
+
`Failed to set declared bookmark '${branch}'`,
|
|
452
|
+
)
|
|
453
|
+
yield* jj(
|
|
454
|
+
fs,
|
|
455
|
+
checkout,
|
|
456
|
+
["git", "push", "--remote", remote, "--bookmark", branch],
|
|
457
|
+
`Failed to push declared bookmark '${branch}'`,
|
|
458
|
+
)
|
|
459
|
+
yield* jj(
|
|
460
|
+
fs,
|
|
461
|
+
checkout,
|
|
462
|
+
["bookmark", "track", `${branch}@${remote}`],
|
|
463
|
+
`Failed to track remote bookmark '${branch}@${remote}'`,
|
|
464
|
+
)
|
|
465
|
+
return { tip: tip.commitId, changeId: tip.changeId }
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
469
|
+
sync: () => ({
|
|
470
|
+
publish: (startPath: string = process.cwd()) =>
|
|
471
|
+
Effect.gen(function* () {
|
|
472
|
+
const contexts = yield* ContextService
|
|
473
|
+
const fs = yield* FileSystemService
|
|
474
|
+
const tasks = yield* TaskService
|
|
475
|
+
const phases = yield* PhaseService
|
|
476
|
+
const workbase = yield* WorkbaseService
|
|
477
|
+
const context = yield* contexts.get({
|
|
478
|
+
cwd: startPath,
|
|
479
|
+
target: ".",
|
|
480
|
+
compact: true,
|
|
481
|
+
})
|
|
482
|
+
if (!context.validation.valid) {
|
|
483
|
+
return yield* new PushError({
|
|
484
|
+
message: "Cannot publish from an invalid Agency workbase",
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
if (context.target.kind !== "task" && context.target.kind !== "phase") {
|
|
488
|
+
return yield* new PushError({
|
|
489
|
+
message: "agency push must run from an execution task or phase",
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
if (
|
|
493
|
+
context.authority.mode !== "execution" ||
|
|
494
|
+
!context.authority.writable
|
|
495
|
+
) {
|
|
496
|
+
return yield* new PushError({
|
|
497
|
+
message:
|
|
498
|
+
"Current Agency target has no writable execution authority",
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
if (
|
|
502
|
+
!context.workspace?.writable?.materialized ||
|
|
503
|
+
!context.workspace.writable.registered
|
|
504
|
+
) {
|
|
505
|
+
return yield* new PushError({
|
|
506
|
+
message:
|
|
507
|
+
"Current Agency writable checkout is not materialized and registered",
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
const blockers = context.graph.readiness.blockers.filter(
|
|
511
|
+
(blocker) =>
|
|
512
|
+
blocker.kind === "dependency" || blocker.kind === "validation",
|
|
513
|
+
)
|
|
514
|
+
if (blockers.length > 0) {
|
|
515
|
+
return yield* new PushError({
|
|
516
|
+
message: `Cannot publish blocked Agency work: ${blockers.map((blocker) => blocker.reason).join("; ")}`,
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const taskId = context.target.taskId
|
|
521
|
+
if (!taskId) {
|
|
522
|
+
return yield* new PushError({
|
|
523
|
+
message: "Current Agency execution target has no task ID",
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
const phaseId =
|
|
527
|
+
context.target.kind === "phase" ? context.target.phaseId : undefined
|
|
528
|
+
const task = yield* tasks.show(taskId, context.workbase.root)
|
|
529
|
+
const execution =
|
|
530
|
+
"phases" in task.data
|
|
531
|
+
? phaseId
|
|
532
|
+
? (yield* phases.show(taskId, phaseId, context.workbase.root))
|
|
533
|
+
.data
|
|
534
|
+
: null
|
|
535
|
+
: task.data
|
|
536
|
+
if (!execution || "review" in execution) {
|
|
537
|
+
return yield* new PushError({
|
|
538
|
+
message: "Current Agency target is not a delivery execution unit",
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
if (execution.status !== "working") {
|
|
542
|
+
return yield* new PushError({
|
|
543
|
+
message: `Cannot publish Agency work with status '${execution.status}'; status must be working`,
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
const checkout = context.authority.writable.checkoutPath
|
|
547
|
+
const { config } = yield* workbase.loadConfig(context.workbase.root)
|
|
548
|
+
const remote = config.delivery?.remote ?? "origin"
|
|
549
|
+
const published =
|
|
550
|
+
context.workbase.vcs === "jj"
|
|
551
|
+
? yield* publishJj(
|
|
552
|
+
fs,
|
|
553
|
+
checkout,
|
|
554
|
+
remote,
|
|
555
|
+
execution.branch,
|
|
556
|
+
execution.base,
|
|
557
|
+
)
|
|
558
|
+
: yield* publishGit(
|
|
559
|
+
fs,
|
|
560
|
+
checkout,
|
|
561
|
+
remote,
|
|
562
|
+
execution.branch,
|
|
563
|
+
execution.base,
|
|
564
|
+
)
|
|
565
|
+
return {
|
|
566
|
+
vcs: context.workbase.vcs,
|
|
567
|
+
taskId,
|
|
568
|
+
...(phaseId ? { phaseId } : {}),
|
|
569
|
+
branch: execution.branch,
|
|
570
|
+
base: execution.base,
|
|
571
|
+
remote,
|
|
572
|
+
...published,
|
|
573
|
+
} satisfies PushResult
|
|
574
|
+
}),
|
|
575
|
+
}),
|
|
576
|
+
}) {}
|
|
@@ -371,6 +371,30 @@ status: done
|
|
|
371
371
|
).rejects.toThrow("{worktree}")
|
|
372
372
|
})
|
|
373
373
|
|
|
374
|
+
test("rejects an unknown post-checkout command placeholder", async () => {
|
|
375
|
+
await write(
|
|
376
|
+
root,
|
|
377
|
+
"agency.json",
|
|
378
|
+
JSON.stringify({
|
|
379
|
+
version: 2,
|
|
380
|
+
repositories: {
|
|
381
|
+
agency: {
|
|
382
|
+
remote: "https://example.com/agency.git",
|
|
383
|
+
postCheckoutCommand: ["tool", "{unknown}"],
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
}),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
await expect(
|
|
390
|
+
runTestEffect(
|
|
391
|
+
WorkbaseService.pipe(
|
|
392
|
+
Effect.flatMap((service) => service.discover(root)),
|
|
393
|
+
),
|
|
394
|
+
),
|
|
395
|
+
).rejects.toThrow("Repository 'agency'")
|
|
396
|
+
})
|
|
397
|
+
|
|
374
398
|
test("rejects an unknown runner command placeholder", async () => {
|
|
375
399
|
await write(
|
|
376
400
|
root,
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type WorkbaseRegistration,
|
|
21
21
|
} from "../workbase/schemas"
|
|
22
22
|
import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
|
|
23
|
+
import { validatePostCheckoutCommand } from "../workbase/checkout-command"
|
|
23
24
|
import { validateRunners } from "../workbase/runner-command"
|
|
24
25
|
import { findDependencyCycles } from "../workbase/dependency-graph"
|
|
25
26
|
import { validateDelivery } from "../workbase/delivery-command"
|
|
@@ -274,6 +275,23 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
274
275
|
})
|
|
275
276
|
}
|
|
276
277
|
}
|
|
278
|
+
for (const [alias, repository] of Object.entries(
|
|
279
|
+
decoded.value.repositories ?? {},
|
|
280
|
+
)) {
|
|
281
|
+
if (!repository.postCheckoutCommand) continue
|
|
282
|
+
try {
|
|
283
|
+
validatePostCheckoutCommand(repository.postCheckoutCommand)
|
|
284
|
+
} catch (cause) {
|
|
285
|
+
return yield* new WorkbaseConfigError({
|
|
286
|
+
path: configPath,
|
|
287
|
+
message: `Repository '${alias}': ${
|
|
288
|
+
cause instanceof Error
|
|
289
|
+
? cause.message
|
|
290
|
+
: "Invalid postCheckoutCommand"
|
|
291
|
+
}`,
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
}
|
|
277
295
|
try {
|
|
278
296
|
validateRunners(decoded.value.runners)
|
|
279
297
|
validateDelivery(decoded.value.delivery)
|