@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
|
@@ -271,78 +271,57 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
271
271
|
|
|
272
272
|
if (relative(root, candidate) === "") {
|
|
273
273
|
const compact = !options.full
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
274
|
+
const validation = yield* provideContextServices(
|
|
275
|
+
workbase.validate(root, { includeDocuments: true }),
|
|
276
|
+
)
|
|
277
|
+
const projectDiscoveryDocument = <T>(
|
|
278
|
+
document: {
|
|
279
|
+
readonly id: string
|
|
280
|
+
readonly path: string
|
|
281
|
+
readonly content: string
|
|
282
|
+
readonly revision: string
|
|
283
|
+
readonly data: T
|
|
284
|
+
},
|
|
278
285
|
extra: Record<string, string> = {},
|
|
279
286
|
) =>
|
|
280
287
|
Effect.gen(function* () {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
288
|
+
const body = compact
|
|
289
|
+
? {}
|
|
290
|
+
: {
|
|
291
|
+
body: (yield* parseFrontmatter(
|
|
292
|
+
document.content,
|
|
293
|
+
document.path,
|
|
294
|
+
)).body,
|
|
295
|
+
}
|
|
289
296
|
return {
|
|
290
297
|
...extra,
|
|
291
|
-
id,
|
|
292
|
-
path,
|
|
293
|
-
sha256:
|
|
294
|
-
data:
|
|
295
|
-
...
|
|
298
|
+
id: document.id,
|
|
299
|
+
path: document.path,
|
|
300
|
+
sha256: document.revision,
|
|
301
|
+
data: document.data,
|
|
302
|
+
...body,
|
|
296
303
|
}
|
|
297
304
|
})
|
|
298
|
-
|
|
299
|
-
const epics
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
for (const entry of (yield* fs.readDirectory(taskRoot))
|
|
319
|
-
.filter((item) => item.isDirectory)
|
|
320
|
-
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
321
|
-
const document = yield* discover(
|
|
322
|
-
entry.name,
|
|
323
|
-
join(taskRoot, entry.name, "TASK.md"),
|
|
324
|
-
TaskFrontmatter,
|
|
325
|
-
)
|
|
326
|
-
if (document) tasks.push(document)
|
|
327
|
-
|
|
328
|
-
const phaseRoot = join(taskRoot, entry.name, "phases")
|
|
329
|
-
if (!(yield* fs.isDirectory(phaseRoot))) continue
|
|
330
|
-
for (const phaseEntry of (yield* fs.readDirectory(phaseRoot))
|
|
331
|
-
.filter((item) => item.isDirectory)
|
|
332
|
-
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
333
|
-
const phase = yield* discover(
|
|
334
|
-
phaseEntry.name,
|
|
335
|
-
join(phaseRoot, phaseEntry.name, "PHASE.md"),
|
|
336
|
-
PhaseFrontmatter,
|
|
337
|
-
{ taskId: entry.name },
|
|
338
|
-
)
|
|
339
|
-
if (phase) phases.push(phase)
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const validation = yield* provideContextServices(
|
|
345
|
-
workbase.validate(root),
|
|
305
|
+
const documents = validation.documents!
|
|
306
|
+
const epics = yield* Effect.all(
|
|
307
|
+
documents.epics.map((document) =>
|
|
308
|
+
projectDiscoveryDocument(document),
|
|
309
|
+
),
|
|
310
|
+
{ concurrency: "unbounded" },
|
|
311
|
+
)
|
|
312
|
+
const tasks = yield* Effect.all(
|
|
313
|
+
documents.tasks.map((document) =>
|
|
314
|
+
projectDiscoveryDocument(document),
|
|
315
|
+
),
|
|
316
|
+
{ concurrency: "unbounded" },
|
|
317
|
+
)
|
|
318
|
+
const phases = yield* Effect.all(
|
|
319
|
+
[...documents.phasesByTask].flatMap(([taskId, records]) =>
|
|
320
|
+
records.map((document) =>
|
|
321
|
+
projectDiscoveryDocument(document, { taskId }),
|
|
322
|
+
),
|
|
323
|
+
),
|
|
324
|
+
{ concurrency: "unbounded" },
|
|
346
325
|
)
|
|
347
326
|
return {
|
|
348
327
|
projection: compact ? "compact" : "complete",
|
|
@@ -556,104 +535,74 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
556
535
|
})
|
|
557
536
|
}
|
|
558
537
|
|
|
559
|
-
const
|
|
538
|
+
const validation = yield* provideContextServices(
|
|
539
|
+
workbase.validate(root, { includeDocuments: true }),
|
|
540
|
+
)
|
|
541
|
+
const validationDocuments = validation.documents!
|
|
542
|
+
const taskDocuments = new Map<string, Document<TaskData>>(
|
|
543
|
+
validationDocuments.tasks.map((document) => [
|
|
544
|
+
document.id,
|
|
545
|
+
{
|
|
546
|
+
id: document.id,
|
|
547
|
+
path: document.path,
|
|
548
|
+
sha256: document.revision,
|
|
549
|
+
data: document.data,
|
|
550
|
+
body: "",
|
|
551
|
+
},
|
|
552
|
+
]),
|
|
553
|
+
)
|
|
560
554
|
const phaseDocuments = new Map<string, Document<PhaseData>>()
|
|
561
|
-
for (const
|
|
562
|
-
join(root, "tasks"),
|
|
563
|
-
join(root, "archive", "tasks"),
|
|
564
|
-
]) {
|
|
565
|
-
if (!(yield* fs.isDirectory(taskRoot))) continue
|
|
566
|
-
const entries = (yield* fs.readDirectory(taskRoot))
|
|
567
|
-
.filter((entry) => entry.isDirectory)
|
|
568
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
569
|
-
const documents = yield* Effect.all(
|
|
570
|
-
entries.map((entry) =>
|
|
571
|
-
Effect.gen(function* () {
|
|
572
|
-
const path = join(taskRoot, entry.name, "TASK.md")
|
|
573
|
-
let taskDocument: Document<TaskData> | null = null
|
|
574
|
-
if (yield* fs.exists(path)) {
|
|
575
|
-
const content = yield* fs.readFile(path)
|
|
576
|
-
const parsed = yield* Effect.either(
|
|
577
|
-
parseFrontmatter(content, path),
|
|
578
|
-
)
|
|
579
|
-
if (Either.isRight(parsed)) {
|
|
580
|
-
const decoded = decode(TaskFrontmatter, parsed.right.data)
|
|
581
|
-
if (decoded.ok) {
|
|
582
|
-
taskDocument = {
|
|
583
|
-
id: entry.name,
|
|
584
|
-
path,
|
|
585
|
-
sha256: documentRevision(content),
|
|
586
|
-
data: decoded.value,
|
|
587
|
-
body: parsed.right.body,
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
const phasesPath = join(taskRoot, entry.name, "phases")
|
|
594
|
-
if (!(yield* fs.isDirectory(phasesPath))) {
|
|
595
|
-
return { taskDocument, phaseDocuments: [] }
|
|
596
|
-
}
|
|
597
|
-
const phaseEntries = (yield* fs.readDirectory(phasesPath))
|
|
598
|
-
.filter((item) => item.isDirectory)
|
|
599
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
600
|
-
const childDocuments = yield* Effect.all(
|
|
601
|
-
phaseEntries.map((phaseEntry) =>
|
|
602
|
-
Effect.gen(function* () {
|
|
603
|
-
const phasePath = join(
|
|
604
|
-
phasesPath,
|
|
605
|
-
phaseEntry.name,
|
|
606
|
-
"PHASE.md",
|
|
607
|
-
)
|
|
608
|
-
if (!(yield* fs.exists(phasePath))) return null
|
|
609
|
-
const content = yield* fs.readFile(phasePath)
|
|
610
|
-
const parsed = yield* Effect.either(
|
|
611
|
-
parseFrontmatter(content, phasePath),
|
|
612
|
-
)
|
|
613
|
-
if (Either.isLeft(parsed)) return null
|
|
614
|
-
const decoded = decode(
|
|
615
|
-
PhaseFrontmatter,
|
|
616
|
-
parsed.right.data,
|
|
617
|
-
)
|
|
618
|
-
if (!decoded.ok) return null
|
|
619
|
-
return [
|
|
620
|
-
`${entry.name}/${phaseEntry.name}`,
|
|
621
|
-
{
|
|
622
|
-
id: phaseEntry.name,
|
|
623
|
-
path: phasePath,
|
|
624
|
-
sha256: documentRevision(content),
|
|
625
|
-
data: decoded.value,
|
|
626
|
-
body: parsed.right.body,
|
|
627
|
-
},
|
|
628
|
-
] as const
|
|
629
|
-
}),
|
|
630
|
-
),
|
|
631
|
-
{ concurrency: "unbounded" },
|
|
632
|
-
)
|
|
633
|
-
return {
|
|
634
|
-
taskDocument,
|
|
635
|
-
phaseDocuments: childDocuments.filter(
|
|
636
|
-
(document): document is NonNullable<typeof document> =>
|
|
637
|
-
document !== null,
|
|
638
|
-
),
|
|
639
|
-
}
|
|
640
|
-
}),
|
|
641
|
-
),
|
|
642
|
-
{ concurrency: "unbounded" },
|
|
643
|
-
)
|
|
555
|
+
for (const [taskId, documents] of validationDocuments.phasesByTask) {
|
|
644
556
|
for (const document of documents) {
|
|
645
|
-
|
|
646
|
-
document.
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
557
|
+
phaseDocuments.set(`${taskId}/${document.id}`, {
|
|
558
|
+
id: document.id,
|
|
559
|
+
path: document.path,
|
|
560
|
+
sha256: document.revision,
|
|
561
|
+
data: document.data,
|
|
562
|
+
body: "",
|
|
563
|
+
})
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (task && !taskDocuments.has(task.id))
|
|
567
|
+
taskDocuments.set(task.id, task)
|
|
568
|
+
if (phase && target.taskId) {
|
|
569
|
+
phaseDocuments.set(`${target.taskId}/${phase.id}`, phase)
|
|
570
|
+
}
|
|
571
|
+
const relevantTaskIds = new Set([
|
|
572
|
+
...(target.taskId ? [target.taskId] : []),
|
|
573
|
+
...(epic?.data.tasks.map((child: Dependency) => child.id) ?? []),
|
|
574
|
+
])
|
|
575
|
+
for (const taskId of relevantTaskIds) {
|
|
576
|
+
let document = taskDocuments.get(taskId)
|
|
577
|
+
if (!document) {
|
|
578
|
+
const archived = yield* Effect.either(
|
|
579
|
+
readOptionalDocument(
|
|
580
|
+
taskId,
|
|
581
|
+
join(archivedTaskDirectory(root, taskId), "TASK.md"),
|
|
582
|
+
TaskFrontmatter,
|
|
583
|
+
),
|
|
584
|
+
)
|
|
585
|
+
document = Either.isRight(archived)
|
|
586
|
+
? (archived.right ?? undefined)
|
|
587
|
+
: undefined
|
|
588
|
+
if (document) taskDocuments.set(taskId, document)
|
|
589
|
+
}
|
|
590
|
+
if (!document || !("phases" in document.data)) continue
|
|
591
|
+
for (const child of document.data.phases) {
|
|
592
|
+
const key = `${taskId}/${child.id}`
|
|
593
|
+
if (phaseDocuments.has(key)) continue
|
|
594
|
+
const archived = yield* Effect.either(
|
|
595
|
+
readOptionalDocument(
|
|
596
|
+
child.id,
|
|
597
|
+
join(
|
|
598
|
+
archivedPhaseDirectory(root, taskId, child.id),
|
|
599
|
+
"PHASE.md",
|
|
600
|
+
),
|
|
601
|
+
PhaseFrontmatter,
|
|
602
|
+
),
|
|
603
|
+
)
|
|
604
|
+
if (Either.isRight(archived) && archived.right) {
|
|
605
|
+
phaseDocuments.set(key, archived.right)
|
|
657
606
|
}
|
|
658
607
|
}
|
|
659
608
|
}
|
|
@@ -720,9 +669,6 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
720
669
|
})
|
|
721
670
|
}
|
|
722
671
|
|
|
723
|
-
const validation = yield* provideContextServices(
|
|
724
|
-
workbase.validate(root),
|
|
725
|
-
)
|
|
726
672
|
const relevantPaths = new Set<string>(
|
|
727
673
|
[epic?.path, task?.path, phase?.path]
|
|
728
674
|
.filter((path): path is string => Boolean(path))
|
|
@@ -1017,9 +963,11 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1017
963
|
const codePath = join(entityDirectory, "code")
|
|
1018
964
|
const inspectionWarnings: string[] = []
|
|
1019
965
|
const repositories = new Map(
|
|
1020
|
-
|
|
1021
|
-
(
|
|
1022
|
-
|
|
966
|
+
reviewData
|
|
967
|
+
? (yield* provideContextServices(
|
|
968
|
+
repositoryService.list(root),
|
|
969
|
+
)).map((repository) => [repository.alias, repository])
|
|
970
|
+
: [],
|
|
1023
971
|
)
|
|
1024
972
|
|
|
1025
973
|
const inspectCheckout = (
|
|
@@ -1027,12 +975,18 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1027
975
|
checkoutPath: string,
|
|
1028
976
|
) =>
|
|
1029
977
|
Effect.gen(function* (): Generator<any, CheckoutInspection, any> {
|
|
1030
|
-
const materialized = yield*
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
978
|
+
const [materialized, listed, canonicalRoot] = yield* Effect.all(
|
|
979
|
+
[
|
|
980
|
+
fs.isDirectory(checkoutPath),
|
|
981
|
+
runGit(fs, repositoryPath, [
|
|
982
|
+
"worktree",
|
|
983
|
+
"list",
|
|
984
|
+
"--porcelain",
|
|
985
|
+
]),
|
|
986
|
+
fs.realPath(root),
|
|
987
|
+
],
|
|
988
|
+
{ concurrency: "unbounded" },
|
|
989
|
+
)
|
|
1036
990
|
if (listed === null) {
|
|
1037
991
|
inspectionWarnings.push(
|
|
1038
992
|
`Unable to inspect worktree registrations for ${repositoryPath}`,
|
|
@@ -1040,7 +994,7 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1040
994
|
}
|
|
1041
995
|
const listedPaths = worktreePaths(listed)
|
|
1042
996
|
const canonicalCheckoutPath = join(
|
|
1043
|
-
|
|
997
|
+
canonicalRoot,
|
|
1044
998
|
relative(root, checkoutPath),
|
|
1045
999
|
)
|
|
1046
1000
|
let registered =
|
|
@@ -1056,21 +1010,25 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1056
1010
|
dirty: null,
|
|
1057
1011
|
}
|
|
1058
1012
|
}
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1013
|
+
const [
|
|
1014
|
+
checkoutCommit,
|
|
1015
|
+
checkoutBranch,
|
|
1016
|
+
checkoutStatus,
|
|
1017
|
+
resolvedCheckoutPath,
|
|
1018
|
+
] = yield* Effect.all(
|
|
1019
|
+
[
|
|
1020
|
+
runGit(fs, checkoutPath, ["rev-parse", "HEAD"]),
|
|
1021
|
+
runGit(fs, checkoutPath, [
|
|
1022
|
+
"symbolic-ref",
|
|
1023
|
+
"--quiet",
|
|
1024
|
+
"--short",
|
|
1025
|
+
"HEAD",
|
|
1026
|
+
]),
|
|
1027
|
+
runGitText(fs, checkoutPath, ["status", "--porcelain"]),
|
|
1028
|
+
fs.realPath(checkoutPath),
|
|
1029
|
+
],
|
|
1030
|
+
{ concurrency: "unbounded" },
|
|
1031
|
+
)
|
|
1074
1032
|
registered = registered || listedPaths.has(resolvedCheckoutPath)
|
|
1075
1033
|
if (checkoutCommit === null) {
|
|
1076
1034
|
inspectionWarnings.push(
|
|
@@ -1094,17 +1052,16 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
1094
1052
|
const repositoryPath =
|
|
1095
1053
|
repository?.path ?? join(root, "repos", executionData.repo)
|
|
1096
1054
|
const checkoutPath = join(codePath, executionData.repo)
|
|
1097
|
-
const branchCommit = yield*
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
checkoutPath,
|
|
1055
|
+
const [branchCommit, baseCommit, checkout] = yield* Effect.all(
|
|
1056
|
+
[
|
|
1057
|
+
backend.resolveRevision(
|
|
1058
|
+
repositoryPath,
|
|
1059
|
+
executionData.branch,
|
|
1060
|
+
),
|
|
1061
|
+
backend.resolveRevision(repositoryPath, executionData.base),
|
|
1062
|
+
inspectCheckout(repositoryPath, checkoutPath),
|
|
1063
|
+
],
|
|
1064
|
+
{ concurrency: "unbounded" },
|
|
1108
1065
|
)
|
|
1109
1066
|
if (branchCommit === null) {
|
|
1110
1067
|
inspectionWarnings.push(
|
|
@@ -245,6 +245,7 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
|
|
|
245
245
|
readonly forwardOutput?: boolean
|
|
246
246
|
readonly passthrough?: boolean
|
|
247
247
|
readonly env?: Record<string, string>
|
|
248
|
+
readonly timeoutMs?: number
|
|
248
249
|
},
|
|
249
250
|
) =>
|
|
250
251
|
pipe(
|
|
@@ -264,6 +265,7 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
|
|
|
264
265
|
? "tee"
|
|
265
266
|
: "pipe",
|
|
266
267
|
env: options?.env,
|
|
268
|
+
timeoutMs: options?.timeoutMs,
|
|
267
269
|
}),
|
|
268
270
|
Effect.mapError(
|
|
269
271
|
(processError) =>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdir } from "node:fs/promises"
|
|
4
|
-
import { join } from "node:path"
|
|
3
|
+
import { chmod, mkdir } from "node:fs/promises"
|
|
4
|
+
import { join, resolve } from "node:path"
|
|
5
5
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { errorEnvelope } from "../protocol"
|
|
6
7
|
import { PushService } from "./PushService"
|
|
7
8
|
import { TaskService } from "./TaskService"
|
|
8
9
|
import { WorktreeService } from "./WorktreeService"
|
|
@@ -130,9 +131,18 @@ describe("PushService", () => {
|
|
|
130
131
|
}
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
const publish = (
|
|
134
|
+
const publish = (
|
|
135
|
+
taskPath: string,
|
|
136
|
+
options: {
|
|
137
|
+
fetchTimeoutMs?: number
|
|
138
|
+
pushTimeoutMs?: number
|
|
139
|
+
retryDelayMs?: number
|
|
140
|
+
} = {},
|
|
141
|
+
) =>
|
|
134
142
|
runTestEffect(
|
|
135
|
-
PushService.pipe(
|
|
143
|
+
PushService.pipe(
|
|
144
|
+
Effect.flatMap((service) => service.publish(taskPath, options)),
|
|
145
|
+
),
|
|
136
146
|
)
|
|
137
147
|
|
|
138
148
|
const remoteBranch = async (remote: string) =>
|
|
@@ -157,6 +167,17 @@ describe("PushService", () => {
|
|
|
157
167
|
)
|
|
158
168
|
}
|
|
159
169
|
|
|
170
|
+
const prePushHook = async (checkout: string) =>
|
|
171
|
+
resolve(
|
|
172
|
+
checkout,
|
|
173
|
+
(
|
|
174
|
+
await requireCommand(
|
|
175
|
+
["git", "rev-parse", "--git-path", "hooks/pre-push"],
|
|
176
|
+
checkout,
|
|
177
|
+
)
|
|
178
|
+
).stdout,
|
|
179
|
+
)
|
|
180
|
+
|
|
160
181
|
test("publishes a clean Git HEAD and establishes upstream tracking", async () => {
|
|
161
182
|
const fixture = await setup()
|
|
162
183
|
await configureAuthor(fixture.checkout)
|
|
@@ -299,4 +320,89 @@ describe("PushService", () => {
|
|
|
299
320
|
"has an invalid author",
|
|
300
321
|
)
|
|
301
322
|
})
|
|
323
|
+
|
|
324
|
+
test("bounds a stalled pre-push hook and confirms non-publication", async () => {
|
|
325
|
+
const fixture = await setup()
|
|
326
|
+
await configureAuthor(fixture.checkout)
|
|
327
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "timeout\n")
|
|
328
|
+
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
329
|
+
await requireCommand(
|
|
330
|
+
["git", "commit", "-m", "Add timed publication"],
|
|
331
|
+
fixture.checkout,
|
|
332
|
+
)
|
|
333
|
+
const hook = await prePushHook(fixture.checkout)
|
|
334
|
+
await Bun.write(hook, "#!/bin/sh\nsleep 30\n")
|
|
335
|
+
await chmod(hook, 0o755)
|
|
336
|
+
|
|
337
|
+
const startedAt = performance.now()
|
|
338
|
+
const failure = await publish(fixture.taskPath, {
|
|
339
|
+
pushTimeoutMs: 25,
|
|
340
|
+
}).catch((error) => error)
|
|
341
|
+
expect(performance.now() - startedAt).toBeLessThan(1_000)
|
|
342
|
+
expect(errorEnvelope(failure).error).toMatchObject({
|
|
343
|
+
code: "PUSH_TIMEOUT",
|
|
344
|
+
fields: { category: "timeout", stage: "publish" },
|
|
345
|
+
retryable: true,
|
|
346
|
+
})
|
|
347
|
+
await expect(remoteBranch(fixture.remote)).rejects.toThrow()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test("classifies hook rejection separately from transport failure", async () => {
|
|
351
|
+
const fixture = await setup()
|
|
352
|
+
await configureAuthor(fixture.checkout)
|
|
353
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "rejected\n")
|
|
354
|
+
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
355
|
+
await requireCommand(
|
|
356
|
+
["git", "commit", "-m", "Add rejected publication"],
|
|
357
|
+
fixture.checkout,
|
|
358
|
+
)
|
|
359
|
+
const hook = await prePushHook(fixture.checkout)
|
|
360
|
+
await Bun.write(
|
|
361
|
+
hook,
|
|
362
|
+
"#!/bin/sh\necho 'pre-push hook declined' >&2\nexit 1\n",
|
|
363
|
+
)
|
|
364
|
+
await chmod(hook, 0o755)
|
|
365
|
+
|
|
366
|
+
const failure = await publish(fixture.taskPath).catch((error) => error)
|
|
367
|
+
expect(errorEnvelope(failure).error).toMatchObject({
|
|
368
|
+
code: "PUSH_HOOK_REJECTED",
|
|
369
|
+
fields: { category: "hook_rejection", stage: "publish" },
|
|
370
|
+
retryable: false,
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
test("retries one transient fetch with non-interactive authentication", async () => {
|
|
375
|
+
const fixture = await setup()
|
|
376
|
+
await configureAuthor(fixture.checkout)
|
|
377
|
+
await Bun.write(join(fixture.checkout, "feature.txt"), "retried\n")
|
|
378
|
+
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
379
|
+
await requireCommand(
|
|
380
|
+
["git", "commit", "-m", "Add retried publication"],
|
|
381
|
+
fixture.checkout,
|
|
382
|
+
)
|
|
383
|
+
const attempts = join(fixture.root, "upload-pack-attempts")
|
|
384
|
+
const uploadPack = join(fixture.root, "upload-pack")
|
|
385
|
+
await Bun.write(
|
|
386
|
+
uploadPack,
|
|
387
|
+
`#!/bin/sh
|
|
388
|
+
echo x >> ${JSON.stringify(attempts)}
|
|
389
|
+
test "$GIT_TERMINAL_PROMPT" = 0 || exit 2
|
|
390
|
+
test "$GCM_INTERACTIVE" = Never || exit 2
|
|
391
|
+
if test "$(wc -l < ${JSON.stringify(attempts)})" -eq 1; then
|
|
392
|
+
echo 'Connection reset by peer' >&2
|
|
393
|
+
exit 1
|
|
394
|
+
fi
|
|
395
|
+
exec git-upload-pack "$@"
|
|
396
|
+
`,
|
|
397
|
+
)
|
|
398
|
+
await chmod(uploadPack, 0o755)
|
|
399
|
+
await requireCommand(
|
|
400
|
+
["git", "config", "remote.origin.uploadpack", uploadPack],
|
|
401
|
+
fixture.checkout,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
const result = await publish(fixture.taskPath, { retryDelayMs: 0 })
|
|
405
|
+
expect(result.tip).toBe(await remoteBranch(fixture.remote))
|
|
406
|
+
expect((await Bun.file(attempts).text()).trim().split("\n")).toHaveLength(2)
|
|
407
|
+
})
|
|
302
408
|
})
|