@markjaquith/agency 3.2.0 → 3.2.2
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 +10 -2
- package/cli-main.ts +38 -0
- package/package.json +1 -1
- package/src/commands/context.test.ts +56 -0
- package/src/commands/push.test.ts +4 -2
- package/src/commands/push.ts +2 -0
- package/src/protocol.test.ts +24 -0
- package/src/protocol.ts +56 -4
- package/src/services/ContextService.ts +157 -200
- package/src/services/FileSystemService.ts +2 -0
- package/src/services/PushService.test.ts +110 -4
- package/src/services/PushService.ts +435 -85
- package/src/usage-log.test.ts +11 -1
- package/src/usage-log.ts +22 -4
- package/src/utils/process.test.ts +10 -0
- package/src/utils/process.ts +59 -6
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Data, Effect } from "effect"
|
|
1
|
+
import { Data, Effect, Either } from "effect"
|
|
2
2
|
import { ContextService } from "./ContextService"
|
|
3
3
|
import { FileSystemService } from "./FileSystemService"
|
|
4
4
|
import { PhaseService } from "./PhaseService"
|
|
@@ -6,8 +6,77 @@ import { TaskService } from "./TaskService"
|
|
|
6
6
|
import { WorkbaseService } from "./WorkbaseService"
|
|
7
7
|
import { parseGitCommits, type PushCommitMetadata } from "./push-validation"
|
|
8
8
|
|
|
9
|
+
type PushCategory =
|
|
10
|
+
| "precondition"
|
|
11
|
+
| "commit_validation"
|
|
12
|
+
| "remote_divergence"
|
|
13
|
+
| "hook_rejection"
|
|
14
|
+
| "authentication"
|
|
15
|
+
| "transport"
|
|
16
|
+
| "timeout"
|
|
17
|
+
| "ambiguous_publication"
|
|
18
|
+
| "git"
|
|
19
|
+
|
|
20
|
+
const categoryMetadata: Record<
|
|
21
|
+
PushCategory,
|
|
22
|
+
{ code: string; retryable: boolean; remediation: string }
|
|
23
|
+
> = {
|
|
24
|
+
precondition: {
|
|
25
|
+
code: "PUSH_PRECONDITION",
|
|
26
|
+
retryable: false,
|
|
27
|
+
remediation: "Resolve the local publication precondition and retry.",
|
|
28
|
+
},
|
|
29
|
+
commit_validation: {
|
|
30
|
+
code: "PUSH_COMMIT_VALIDATION",
|
|
31
|
+
retryable: false,
|
|
32
|
+
remediation: "Rewrite the reported outgoing commits and retry.",
|
|
33
|
+
},
|
|
34
|
+
remote_divergence: {
|
|
35
|
+
code: "PUSH_REMOTE_DIVERGENCE",
|
|
36
|
+
retryable: false,
|
|
37
|
+
remediation: "Rebase onto the remote delivery branch before retrying.",
|
|
38
|
+
},
|
|
39
|
+
hook_rejection: {
|
|
40
|
+
code: "PUSH_HOOK_REJECTED",
|
|
41
|
+
retryable: false,
|
|
42
|
+
remediation: "Resolve the hook failure and retry; hooks are not bypassed.",
|
|
43
|
+
},
|
|
44
|
+
authentication: {
|
|
45
|
+
code: "PUSH_AUTHENTICATION",
|
|
46
|
+
retryable: false,
|
|
47
|
+
remediation: "Authenticate Git for the configured remote and retry.",
|
|
48
|
+
},
|
|
49
|
+
transport: {
|
|
50
|
+
code: "PUSH_TRANSPORT",
|
|
51
|
+
retryable: true,
|
|
52
|
+
remediation: "Check remote connectivity and retry.",
|
|
53
|
+
},
|
|
54
|
+
timeout: {
|
|
55
|
+
code: "PUSH_TIMEOUT",
|
|
56
|
+
retryable: true,
|
|
57
|
+
remediation: "Check remote connectivity and retry the bounded operation.",
|
|
58
|
+
},
|
|
59
|
+
ambiguous_publication: {
|
|
60
|
+
code: "PUSH_OUTCOME_UNKNOWN",
|
|
61
|
+
retryable: false,
|
|
62
|
+
remediation:
|
|
63
|
+
"Inspect the exact remote delivery ref before retrying publication.",
|
|
64
|
+
},
|
|
65
|
+
git: {
|
|
66
|
+
code: "PUSH_GIT_ERROR",
|
|
67
|
+
retryable: false,
|
|
68
|
+
remediation: "Resolve the reported Git failure and retry.",
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
9
72
|
class PushError extends Data.TaggedError("PushError")<{
|
|
10
73
|
readonly message: string
|
|
74
|
+
readonly category: PushCategory
|
|
75
|
+
readonly stage: PushStage
|
|
76
|
+
readonly elapsedMs?: number
|
|
77
|
+
readonly protocolCode: string
|
|
78
|
+
readonly retryable: boolean
|
|
79
|
+
readonly remediation: string
|
|
11
80
|
}> {}
|
|
12
81
|
|
|
13
82
|
interface CommandResult {
|
|
@@ -28,46 +97,162 @@ interface PushResult {
|
|
|
28
97
|
readonly tip: string
|
|
29
98
|
}
|
|
30
99
|
|
|
31
|
-
type PushStage =
|
|
100
|
+
type PushStage =
|
|
101
|
+
| "context"
|
|
102
|
+
| "inspect"
|
|
103
|
+
| "validate"
|
|
104
|
+
| "fetch"
|
|
105
|
+
| "publish"
|
|
106
|
+
| "reconcile"
|
|
32
107
|
|
|
33
108
|
interface PushOptions {
|
|
34
109
|
readonly onProgress?: (stage: PushStage) => void
|
|
110
|
+
readonly fetchTimeoutMs?: number
|
|
111
|
+
readonly pushTimeoutMs?: number
|
|
112
|
+
readonly retryDelayMs?: number
|
|
113
|
+
readonly forwardOutput?: boolean
|
|
35
114
|
}
|
|
36
115
|
|
|
37
116
|
const validEmail = (email: string) => /^[^@\s]+@[^@\s]+$/.test(email)
|
|
38
117
|
|
|
118
|
+
const pushError = (
|
|
119
|
+
message: string,
|
|
120
|
+
category: PushCategory,
|
|
121
|
+
stage: PushStage,
|
|
122
|
+
elapsedMs?: number,
|
|
123
|
+
) =>
|
|
124
|
+
new PushError({
|
|
125
|
+
message,
|
|
126
|
+
category,
|
|
127
|
+
stage,
|
|
128
|
+
...(elapsedMs === undefined ? {} : { elapsedMs }),
|
|
129
|
+
protocolCode: categoryMetadata[category].code,
|
|
130
|
+
retryable: categoryMetadata[category].retryable,
|
|
131
|
+
remediation: categoryMetadata[category].remediation,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const processTimeout = (error: unknown): { elapsedMs?: number } | null => {
|
|
135
|
+
if (typeof error !== "object" || error === null) return null
|
|
136
|
+
if ("timedOut" in error && error.timedOut === true) {
|
|
137
|
+
return {
|
|
138
|
+
elapsedMs:
|
|
139
|
+
"elapsedMs" in error && typeof error.elapsedMs === "number"
|
|
140
|
+
? error.elapsedMs
|
|
141
|
+
: undefined,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return "cause" in error ? processTimeout(error.cause) : null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const classifyGitFailure = (
|
|
148
|
+
message: string,
|
|
149
|
+
stage: PushStage,
|
|
150
|
+
elapsedMs?: number,
|
|
151
|
+
) => {
|
|
152
|
+
const normalized = message.toLowerCase()
|
|
153
|
+
const category: PushCategory =
|
|
154
|
+
/authentication failed|permission denied|could not read username|terminal prompts disabled|credential/.test(
|
|
155
|
+
normalized,
|
|
156
|
+
)
|
|
157
|
+
? "authentication"
|
|
158
|
+
: /pre-push hook|hook declined|remote rejected/.test(normalized)
|
|
159
|
+
? "hook_rejection"
|
|
160
|
+
: /non-fast-forward|fetch first|stale info/.test(normalized)
|
|
161
|
+
? "remote_divergence"
|
|
162
|
+
: /could not resolve|connection|network|remote end hung up|unable to access|repository not found/.test(
|
|
163
|
+
normalized,
|
|
164
|
+
)
|
|
165
|
+
? "transport"
|
|
166
|
+
: "git"
|
|
167
|
+
return pushError(message, category, stage, elapsedMs)
|
|
168
|
+
}
|
|
169
|
+
|
|
39
170
|
const requireCommand = (
|
|
40
171
|
fs: FileSystemService,
|
|
41
172
|
args: readonly string[],
|
|
42
173
|
cwd: string,
|
|
43
174
|
label: string,
|
|
175
|
+
stage: PushStage,
|
|
176
|
+
options: {
|
|
177
|
+
readonly timeoutMs?: number
|
|
178
|
+
readonly env?: Record<string, string>
|
|
179
|
+
readonly forwardOutput?: boolean
|
|
180
|
+
} = {},
|
|
44
181
|
) =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
182
|
+
Effect.suspend(() => {
|
|
183
|
+
const startedAt = performance.now()
|
|
184
|
+
return fs
|
|
185
|
+
.runCommand(args, {
|
|
186
|
+
cwd,
|
|
187
|
+
captureOutput: true,
|
|
188
|
+
forwardOutput: options.forwardOutput,
|
|
189
|
+
env: options.env,
|
|
190
|
+
timeoutMs: options.timeoutMs,
|
|
191
|
+
})
|
|
192
|
+
.pipe(
|
|
193
|
+
Effect.mapError((error) => {
|
|
194
|
+
const timeout = processTimeout(error)
|
|
195
|
+
const elapsedMs =
|
|
196
|
+
timeout?.elapsedMs ?? Math.round(performance.now() - startedAt)
|
|
197
|
+
return timeout
|
|
198
|
+
? pushError(
|
|
199
|
+
`${label} timed out after ${elapsedMs} ms`,
|
|
200
|
+
"timeout",
|
|
201
|
+
stage,
|
|
202
|
+
elapsedMs,
|
|
203
|
+
)
|
|
204
|
+
: classifyGitFailure(`${label}: ${error.message}`, stage, elapsedMs)
|
|
205
|
+
}),
|
|
206
|
+
Effect.flatMap((result) =>
|
|
207
|
+
result.exitCode === 0
|
|
208
|
+
? Effect.succeed(result)
|
|
209
|
+
: Effect.fail(
|
|
210
|
+
classifyGitFailure(
|
|
211
|
+
`${label}: ${result.stderr.trim() || result.stdout.trim()}`,
|
|
212
|
+
stage,
|
|
213
|
+
Math.round(performance.now() - startedAt),
|
|
214
|
+
),
|
|
215
|
+
),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
})
|
|
56
219
|
|
|
57
220
|
const git = (
|
|
58
221
|
fs: FileSystemService,
|
|
59
222
|
cwd: string,
|
|
60
223
|
args: readonly string[],
|
|
61
224
|
label: string,
|
|
62
|
-
|
|
225
|
+
stage: PushStage,
|
|
226
|
+
options?: Parameters<typeof requireCommand>[5],
|
|
227
|
+
) => requireCommand(fs, ["git", ...args], cwd, label, stage, options)
|
|
63
228
|
|
|
64
|
-
const gitRevision = (
|
|
229
|
+
const gitRevision = (
|
|
230
|
+
fs: FileSystemService,
|
|
231
|
+
cwd: string,
|
|
232
|
+
revision: string,
|
|
233
|
+
stage: PushStage = "inspect",
|
|
234
|
+
) =>
|
|
65
235
|
fs
|
|
66
236
|
.runCommand(["git", "rev-parse", "--verify", `${revision}^{commit}`], {
|
|
67
237
|
cwd,
|
|
68
238
|
captureOutput: true,
|
|
239
|
+
timeoutMs: 10_000,
|
|
69
240
|
})
|
|
70
241
|
.pipe(
|
|
242
|
+
Effect.mapError((error) => {
|
|
243
|
+
const timeout = processTimeout(error)
|
|
244
|
+
return timeout
|
|
245
|
+
? pushError(
|
|
246
|
+
`Git revision inspection timed out after ${timeout.elapsedMs ?? 10_000} ms`,
|
|
247
|
+
"timeout",
|
|
248
|
+
stage,
|
|
249
|
+
timeout.elapsedMs,
|
|
250
|
+
)
|
|
251
|
+
: classifyGitFailure(
|
|
252
|
+
`Failed to inspect Git revision '${revision}': ${error.message}`,
|
|
253
|
+
stage,
|
|
254
|
+
)
|
|
255
|
+
}),
|
|
71
256
|
Effect.map((result) =>
|
|
72
257
|
result.exitCode === 0 ? result.stdout.trim() || null : null,
|
|
73
258
|
),
|
|
@@ -78,20 +263,37 @@ const gitAncestor = (
|
|
|
78
263
|
cwd: string,
|
|
79
264
|
ancestor: string,
|
|
80
265
|
descendant: string,
|
|
266
|
+
stage: PushStage,
|
|
81
267
|
) =>
|
|
82
268
|
fs
|
|
83
269
|
.runCommand(["git", "merge-base", "--is-ancestor", ancestor, descendant], {
|
|
84
270
|
cwd,
|
|
85
271
|
captureOutput: true,
|
|
272
|
+
timeoutMs: 10_000,
|
|
86
273
|
})
|
|
87
274
|
.pipe(
|
|
275
|
+
Effect.mapError((error) => {
|
|
276
|
+
const timeout = processTimeout(error)
|
|
277
|
+
return timeout
|
|
278
|
+
? pushError(
|
|
279
|
+
`Git ancestry inspection timed out after ${timeout.elapsedMs ?? 10_000} ms`,
|
|
280
|
+
"timeout",
|
|
281
|
+
stage,
|
|
282
|
+
timeout.elapsedMs,
|
|
283
|
+
)
|
|
284
|
+
: classifyGitFailure(
|
|
285
|
+
`Failed to inspect Git ancestry: ${error.message}`,
|
|
286
|
+
stage,
|
|
287
|
+
)
|
|
288
|
+
}),
|
|
88
289
|
Effect.flatMap((result) => {
|
|
89
290
|
if (result.exitCode === 0) return Effect.succeed(true)
|
|
90
291
|
if (result.exitCode === 1) return Effect.succeed(false)
|
|
91
292
|
return Effect.fail(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
293
|
+
classifyGitFailure(
|
|
294
|
+
`Failed to inspect Git ancestry: ${result.stderr.trim()}`,
|
|
295
|
+
stage,
|
|
296
|
+
),
|
|
95
297
|
)
|
|
96
298
|
}),
|
|
97
299
|
)
|
|
@@ -101,9 +303,11 @@ const validateGitCommits = (
|
|
|
101
303
|
base: string,
|
|
102
304
|
) => {
|
|
103
305
|
if (commits.length === 0) {
|
|
104
|
-
throw
|
|
105
|
-
|
|
106
|
-
|
|
306
|
+
throw pushError(
|
|
307
|
+
`No commits to publish after base '${base}'`,
|
|
308
|
+
"precondition",
|
|
309
|
+
"validate",
|
|
310
|
+
)
|
|
107
311
|
}
|
|
108
312
|
const issues: string[] = []
|
|
109
313
|
for (const commit of commits) {
|
|
@@ -118,66 +322,152 @@ const validateGitCommits = (
|
|
|
118
322
|
)
|
|
119
323
|
}
|
|
120
324
|
}
|
|
121
|
-
if (issues.length > 0)
|
|
325
|
+
if (issues.length > 0)
|
|
326
|
+
throw pushError(issues.join("\n"), "commit_validation", "validate")
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const positiveInteger = (value: string | undefined, fallback: number) => {
|
|
330
|
+
const parsed = Number.parseInt(value ?? "", 10)
|
|
331
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback
|
|
122
332
|
}
|
|
123
333
|
|
|
334
|
+
const gitEnvironment = (): Record<string, string> => ({
|
|
335
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
336
|
+
GCM_INTERACTIVE: "Never",
|
|
337
|
+
GIT_SSH_COMMAND:
|
|
338
|
+
process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes -o ConnectTimeout=15",
|
|
339
|
+
})
|
|
340
|
+
|
|
124
341
|
const publishGit = (
|
|
125
342
|
fs: FileSystemService,
|
|
126
343
|
checkout: string,
|
|
127
344
|
remote: string,
|
|
128
345
|
branch: string,
|
|
129
346
|
base: string,
|
|
130
|
-
|
|
347
|
+
options: PushOptions,
|
|
131
348
|
) =>
|
|
132
349
|
Effect.gen(function* () {
|
|
350
|
+
const onProgress = options.onProgress
|
|
351
|
+
const fetchTimeoutMs =
|
|
352
|
+
options.fetchTimeoutMs ??
|
|
353
|
+
positiveInteger(process.env.AGENCY_PUSH_FETCH_TIMEOUT_MS, 30_000)
|
|
354
|
+
const pushTimeoutMs =
|
|
355
|
+
options.pushTimeoutMs ??
|
|
356
|
+
positiveInteger(process.env.AGENCY_PUSH_TIMEOUT_MS, 120_000)
|
|
357
|
+
const retryDelayMs = options.retryDelayMs ?? 250
|
|
358
|
+
const env = gitEnvironment()
|
|
133
359
|
onProgress?.("inspect")
|
|
134
360
|
const currentBranch = yield* git(
|
|
135
361
|
fs,
|
|
136
362
|
checkout,
|
|
137
363
|
["symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
138
364
|
"Git checkout must be attached to the declared branch",
|
|
365
|
+
"inspect",
|
|
139
366
|
)
|
|
140
367
|
if (currentBranch.stdout.trim() !== branch) {
|
|
141
|
-
return yield*
|
|
142
|
-
|
|
143
|
-
|
|
368
|
+
return yield* pushError(
|
|
369
|
+
`Declared delivery branch '${branch}' does not match checked-out Git branch '${currentBranch.stdout.trim()}'`,
|
|
370
|
+
"precondition",
|
|
371
|
+
"inspect",
|
|
372
|
+
)
|
|
144
373
|
}
|
|
145
374
|
const status = yield* git(
|
|
146
375
|
fs,
|
|
147
376
|
checkout,
|
|
148
377
|
["status", "--porcelain=v1"],
|
|
149
378
|
"Failed to inspect Git status",
|
|
379
|
+
"inspect",
|
|
150
380
|
)
|
|
151
381
|
if (status.stdout.length > 0) {
|
|
152
|
-
return yield*
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
382
|
+
return yield* pushError(
|
|
383
|
+
"Cannot publish a dirty Git worktree; commit or discard changes first",
|
|
384
|
+
"precondition",
|
|
385
|
+
"inspect",
|
|
386
|
+
)
|
|
156
387
|
}
|
|
157
388
|
|
|
158
|
-
|
|
159
|
-
yield*
|
|
389
|
+
const initialTip = yield* gitRevision(fs, checkout, "HEAD")
|
|
390
|
+
const initialBase = yield* gitRevision(
|
|
160
391
|
fs,
|
|
161
392
|
checkout,
|
|
162
|
-
|
|
163
|
-
`Failed to fetch remote '${remote}'`,
|
|
393
|
+
`refs/remotes/${remote}/${base}`,
|
|
164
394
|
)
|
|
395
|
+
if (initialTip && initialBase) {
|
|
396
|
+
const cachedBaseIsAncestor = yield* gitAncestor(
|
|
397
|
+
fs,
|
|
398
|
+
checkout,
|
|
399
|
+
initialBase,
|
|
400
|
+
initialTip,
|
|
401
|
+
"validate",
|
|
402
|
+
)
|
|
403
|
+
if (cachedBaseIsAncestor) {
|
|
404
|
+
onProgress?.("validate")
|
|
405
|
+
const initialLog = yield* git(
|
|
406
|
+
fs,
|
|
407
|
+
checkout,
|
|
408
|
+
[
|
|
409
|
+
"log",
|
|
410
|
+
"--format=%H%x00%an%x00%ae%x00%B%x00%x1e",
|
|
411
|
+
`${initialBase}..${initialTip}`,
|
|
412
|
+
],
|
|
413
|
+
"Failed to inspect outgoing Git commits",
|
|
414
|
+
"validate",
|
|
415
|
+
)
|
|
416
|
+
yield* Effect.try({
|
|
417
|
+
try: () =>
|
|
418
|
+
validateGitCommits(parseGitCommits(initialLog.stdout), base),
|
|
419
|
+
catch: (cause) => cause as PushError,
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
onProgress?.("fetch")
|
|
425
|
+
const fetchRemote = (
|
|
426
|
+
attempt: number,
|
|
427
|
+
): Effect.Effect<CommandResult, PushError> =>
|
|
428
|
+
git(
|
|
429
|
+
fs,
|
|
430
|
+
checkout,
|
|
431
|
+
[
|
|
432
|
+
"fetch",
|
|
433
|
+
"--prune",
|
|
434
|
+
remote,
|
|
435
|
+
`+refs/heads/${base}:refs/remotes/${remote}/${base}`,
|
|
436
|
+
`+refs/heads/${branch}*:refs/remotes/${remote}/${branch}*`,
|
|
437
|
+
],
|
|
438
|
+
`Failed to fetch remote '${remote}'`,
|
|
439
|
+
"fetch",
|
|
440
|
+
{ timeoutMs: fetchTimeoutMs, env },
|
|
441
|
+
).pipe(
|
|
442
|
+
Effect.catchAll((error) =>
|
|
443
|
+
attempt < 2 && ["timeout", "transport"].includes(error.category)
|
|
444
|
+
? Effect.sleep(retryDelayMs + Math.floor(Math.random() * 100)).pipe(
|
|
445
|
+
Effect.flatMap(() => fetchRemote(attempt + 1)),
|
|
446
|
+
)
|
|
447
|
+
: Effect.fail(error),
|
|
448
|
+
),
|
|
449
|
+
)
|
|
450
|
+
yield* fetchRemote(1)
|
|
165
451
|
const [tip, baseRevision] = yield* Effect.all(
|
|
166
452
|
[
|
|
167
|
-
gitRevision(fs, checkout, "HEAD"),
|
|
168
|
-
gitRevision(fs, checkout, `refs/remotes/${remote}/${base}
|
|
453
|
+
gitRevision(fs, checkout, "HEAD", "validate"),
|
|
454
|
+
gitRevision(fs, checkout, `refs/remotes/${remote}/${base}`, "validate"),
|
|
169
455
|
],
|
|
170
456
|
{ concurrency: "unbounded" },
|
|
171
457
|
)
|
|
172
458
|
if (!tip || !baseRevision) {
|
|
173
|
-
return yield*
|
|
174
|
-
|
|
175
|
-
|
|
459
|
+
return yield* pushError(
|
|
460
|
+
`Declared base '${base}' was not found on remote '${remote}'`,
|
|
461
|
+
"precondition",
|
|
462
|
+
"validate",
|
|
463
|
+
)
|
|
176
464
|
}
|
|
177
|
-
if (!(yield* gitAncestor(fs, checkout, baseRevision, tip))) {
|
|
178
|
-
return yield*
|
|
179
|
-
|
|
180
|
-
|
|
465
|
+
if (!(yield* gitAncestor(fs, checkout, baseRevision, tip, "validate"))) {
|
|
466
|
+
return yield* pushError(
|
|
467
|
+
`Declared base '${base}' (${baseRevision}) is not an ancestor of Git HEAD (${tip})`,
|
|
468
|
+
"precondition",
|
|
469
|
+
"validate",
|
|
470
|
+
)
|
|
181
471
|
}
|
|
182
472
|
|
|
183
473
|
onProgress?.("validate")
|
|
@@ -190,6 +480,7 @@ const publishGit = (
|
|
|
190
480
|
`${baseRevision}..${tip}`,
|
|
191
481
|
],
|
|
192
482
|
"Failed to inspect outgoing Git commits",
|
|
483
|
+
"validate",
|
|
193
484
|
)
|
|
194
485
|
yield* Effect.try({
|
|
195
486
|
try: () => validateGitCommits(parseGitCommits(log.stdout), base),
|
|
@@ -200,11 +491,17 @@ const publishGit = (
|
|
|
200
491
|
fs,
|
|
201
492
|
checkout,
|
|
202
493
|
`refs/remotes/${remote}/${branch}`,
|
|
494
|
+
"validate",
|
|
203
495
|
)
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
496
|
+
if (
|
|
497
|
+
remoteTip &&
|
|
498
|
+
!(yield* gitAncestor(fs, checkout, remoteTip, tip, "validate"))
|
|
499
|
+
) {
|
|
500
|
+
return yield* pushError(
|
|
501
|
+
`Remote branch '${branch}' on '${remote}' is not an ancestor of Git HEAD; refusing a non-fast-forward update`,
|
|
502
|
+
"remote_divergence",
|
|
503
|
+
"validate",
|
|
504
|
+
)
|
|
208
505
|
}
|
|
209
506
|
|
|
210
507
|
onProgress?.("publish")
|
|
@@ -217,19 +514,58 @@ const publishGit = (
|
|
|
217
514
|
`+refs/heads/*:refs/remotes/${remote}/*`,
|
|
218
515
|
],
|
|
219
516
|
`Failed to configure remote '${remote}' tracking`,
|
|
517
|
+
"publish",
|
|
220
518
|
)
|
|
221
|
-
yield*
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
519
|
+
const pushed = yield* Effect.either(
|
|
520
|
+
git(
|
|
521
|
+
fs,
|
|
522
|
+
checkout,
|
|
523
|
+
[
|
|
524
|
+
"push",
|
|
525
|
+
"-u",
|
|
526
|
+
remote,
|
|
527
|
+
`HEAD:refs/heads/${branch}`,
|
|
528
|
+
`--force-if-includes`,
|
|
529
|
+
],
|
|
530
|
+
`Failed to push declared branch '${branch}'`,
|
|
531
|
+
"publish",
|
|
532
|
+
{
|
|
533
|
+
timeoutMs: pushTimeoutMs,
|
|
534
|
+
env,
|
|
535
|
+
forwardOutput: options.forwardOutput,
|
|
536
|
+
},
|
|
537
|
+
),
|
|
232
538
|
)
|
|
539
|
+
if (Either.isLeft(pushed)) {
|
|
540
|
+
onProgress?.("reconcile")
|
|
541
|
+
const reconciled = yield* Effect.either(
|
|
542
|
+
git(
|
|
543
|
+
fs,
|
|
544
|
+
checkout,
|
|
545
|
+
["ls-remote", "--heads", remote, `refs/heads/${branch}`],
|
|
546
|
+
`Failed to reconcile remote branch '${branch}'`,
|
|
547
|
+
"reconcile",
|
|
548
|
+
{ timeoutMs: fetchTimeoutMs, env },
|
|
549
|
+
),
|
|
550
|
+
)
|
|
551
|
+
if (Either.isLeft(reconciled)) {
|
|
552
|
+
return yield* pushError(
|
|
553
|
+
`Publication outcome is unknown after '${pushed.left.message}' and remote reconciliation also failed: ${reconciled.left.message}`,
|
|
554
|
+
"ambiguous_publication",
|
|
555
|
+
"reconcile",
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
const reconciledTip = reconciled.right.stdout.split(/\s+/, 1)[0] || null
|
|
559
|
+
if (reconciledTip === tip) return { tip }
|
|
560
|
+
if (reconciledTip !== remoteTip) {
|
|
561
|
+
return yield* pushError(
|
|
562
|
+
`Publication outcome is unknown: remote branch '${branch}' changed to ${reconciledTip ?? "a missing ref"} while publishing ${tip}`,
|
|
563
|
+
"ambiguous_publication",
|
|
564
|
+
"reconcile",
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
return yield* pushed.left
|
|
568
|
+
}
|
|
233
569
|
return { tip }
|
|
234
570
|
})
|
|
235
571
|
|
|
@@ -249,48 +585,58 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
|
249
585
|
compact: true,
|
|
250
586
|
})
|
|
251
587
|
if (!context.validation.valid) {
|
|
252
|
-
return yield*
|
|
253
|
-
|
|
254
|
-
|
|
588
|
+
return yield* pushError(
|
|
589
|
+
"Cannot publish from an invalid Agency workbase",
|
|
590
|
+
"precondition",
|
|
591
|
+
"context",
|
|
592
|
+
)
|
|
255
593
|
}
|
|
256
594
|
if (context.target.kind !== "task" && context.target.kind !== "phase") {
|
|
257
|
-
return yield*
|
|
258
|
-
|
|
259
|
-
|
|
595
|
+
return yield* pushError(
|
|
596
|
+
"agency push must run from an execution task or phase",
|
|
597
|
+
"precondition",
|
|
598
|
+
"context",
|
|
599
|
+
)
|
|
260
600
|
}
|
|
261
601
|
if (
|
|
262
602
|
context.authority.mode !== "execution" ||
|
|
263
603
|
!context.authority.writable
|
|
264
604
|
) {
|
|
265
|
-
return yield*
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
605
|
+
return yield* pushError(
|
|
606
|
+
"Current Agency target has no writable execution authority",
|
|
607
|
+
"precondition",
|
|
608
|
+
"context",
|
|
609
|
+
)
|
|
269
610
|
}
|
|
270
611
|
if (
|
|
271
612
|
!context.workspace?.writable?.materialized ||
|
|
272
613
|
!context.workspace.writable.registered
|
|
273
614
|
) {
|
|
274
|
-
return yield*
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
615
|
+
return yield* pushError(
|
|
616
|
+
"Current Agency writable checkout is not materialized and registered",
|
|
617
|
+
"precondition",
|
|
618
|
+
"context",
|
|
619
|
+
)
|
|
278
620
|
}
|
|
279
621
|
const blockers = context.graph.readiness.blockers.filter(
|
|
280
622
|
(blocker) =>
|
|
281
623
|
blocker.kind === "dependency" || blocker.kind === "validation",
|
|
282
624
|
)
|
|
283
625
|
if (blockers.length > 0) {
|
|
284
|
-
return yield*
|
|
285
|
-
|
|
286
|
-
|
|
626
|
+
return yield* pushError(
|
|
627
|
+
`Cannot publish blocked Agency work: ${blockers.map((blocker) => blocker.reason).join("; ")}`,
|
|
628
|
+
"precondition",
|
|
629
|
+
"context",
|
|
630
|
+
)
|
|
287
631
|
}
|
|
288
632
|
|
|
289
633
|
const taskId = context.target.taskId
|
|
290
634
|
if (!taskId) {
|
|
291
|
-
return yield*
|
|
292
|
-
|
|
293
|
-
|
|
635
|
+
return yield* pushError(
|
|
636
|
+
"Current Agency execution target has no task ID",
|
|
637
|
+
"precondition",
|
|
638
|
+
"context",
|
|
639
|
+
)
|
|
294
640
|
}
|
|
295
641
|
const phaseId =
|
|
296
642
|
context.target.kind === "phase" ? context.target.phaseId : undefined
|
|
@@ -303,14 +649,18 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
|
303
649
|
: null
|
|
304
650
|
: task.data
|
|
305
651
|
if (!execution || "review" in execution) {
|
|
306
|
-
return yield*
|
|
307
|
-
|
|
308
|
-
|
|
652
|
+
return yield* pushError(
|
|
653
|
+
"Current Agency target is not a delivery execution unit",
|
|
654
|
+
"precondition",
|
|
655
|
+
"context",
|
|
656
|
+
)
|
|
309
657
|
}
|
|
310
658
|
if (execution.status !== "working") {
|
|
311
|
-
return yield*
|
|
312
|
-
|
|
313
|
-
|
|
659
|
+
return yield* pushError(
|
|
660
|
+
`Cannot publish Agency work with status '${execution.status}'; status must be working`,
|
|
661
|
+
"precondition",
|
|
662
|
+
"context",
|
|
663
|
+
)
|
|
314
664
|
}
|
|
315
665
|
const checkout = context.authority.writable.checkoutPath
|
|
316
666
|
const { config } = yield* workbase.loadConfig(context.workbase.root)
|
|
@@ -321,7 +671,7 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
|
|
|
321
671
|
remote,
|
|
322
672
|
execution.branch,
|
|
323
673
|
execution.base,
|
|
324
|
-
options
|
|
674
|
+
options,
|
|
325
675
|
)
|
|
326
676
|
return {
|
|
327
677
|
vcs: "git",
|