@clossys/launcher 0.3.1 → 0.4.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.
Files changed (285) hide show
  1. package/README.md +1324 -60
  2. package/contracts/conversation-contract.md +2 -2
  3. package/contracts/product-ci-workflow.yml +74 -0
  4. package/contracts/repository-inventory.json +53 -0
  5. package/dist/admission-fixture.d.ts +168 -0
  6. package/dist/admission-fixture.d.ts.map +1 -0
  7. package/dist/admission-fixture.js +453 -0
  8. package/dist/admission-fixture.js.map +1 -0
  9. package/dist/admission.d.ts +124 -0
  10. package/dist/admission.d.ts.map +1 -0
  11. package/dist/admission.js +799 -0
  12. package/dist/admission.js.map +1 -0
  13. package/dist/agents-guide.d.ts +9 -0
  14. package/dist/agents-guide.d.ts.map +1 -0
  15. package/dist/agents-guide.js +26 -0
  16. package/dist/agents-guide.js.map +1 -0
  17. package/dist/apply-command-options.check.d.ts +12 -0
  18. package/dist/apply-command-options.check.d.ts.map +1 -0
  19. package/dist/apply-command-options.check.js +20 -0
  20. package/dist/apply-command-options.check.js.map +1 -0
  21. package/dist/apply-plan-cli.d.ts +39 -1
  22. package/dist/apply-plan-cli.d.ts.map +1 -1
  23. package/dist/apply-plan-cli.js +432 -15
  24. package/dist/apply-plan-cli.js.map +1 -1
  25. package/dist/apply-plan.d.ts +46 -59
  26. package/dist/apply-plan.d.ts.map +1 -1
  27. package/dist/apply-plan.js +112 -97
  28. package/dist/apply-plan.js.map +1 -1
  29. package/dist/apply-step-fixture.d.ts +87 -0
  30. package/dist/apply-step-fixture.d.ts.map +1 -0
  31. package/dist/apply-step-fixture.js +199 -0
  32. package/dist/apply-step-fixture.js.map +1 -0
  33. package/dist/apply-store.d.ts +93 -0
  34. package/dist/apply-store.d.ts.map +1 -0
  35. package/dist/apply-store.js +625 -0
  36. package/dist/apply-store.js.map +1 -0
  37. package/dist/approval-sheet.d.ts +21 -0
  38. package/dist/approval-sheet.d.ts.map +1 -0
  39. package/dist/approval-sheet.js +157 -0
  40. package/dist/approval-sheet.js.map +1 -0
  41. package/dist/body-command.d.ts +42 -0
  42. package/dist/body-command.d.ts.map +1 -0
  43. package/dist/body-command.js +143 -0
  44. package/dist/body-command.js.map +1 -0
  45. package/dist/change-set-contract.d.ts +381 -0
  46. package/dist/change-set-contract.d.ts.map +1 -0
  47. package/dist/change-set-contract.js +738 -0
  48. package/dist/change-set-contract.js.map +1 -0
  49. package/dist/change-set-digest.d.ts +28 -0
  50. package/dist/change-set-digest.d.ts.map +1 -0
  51. package/dist/change-set-digest.js +65 -0
  52. package/dist/change-set-digest.js.map +1 -0
  53. package/dist/check-cli.d.ts.map +1 -1
  54. package/dist/check-cli.js +14 -3
  55. package/dist/check-cli.js.map +1 -1
  56. package/dist/cli.d.ts +17 -6
  57. package/dist/cli.d.ts.map +1 -1
  58. package/dist/cli.js +84 -23
  59. package/dist/cli.js.map +1 -1
  60. package/dist/core.d.ts +79 -22
  61. package/dist/core.d.ts.map +1 -1
  62. package/dist/core.js +843 -268
  63. package/dist/core.js.map +1 -1
  64. package/dist/dry-materialize.d.ts +63 -0
  65. package/dist/dry-materialize.d.ts.map +1 -0
  66. package/dist/dry-materialize.js +330 -0
  67. package/dist/dry-materialize.js.map +1 -0
  68. package/dist/generated/contract-schema.generated.d.ts +97 -0
  69. package/dist/generated/contract-schema.generated.d.ts.map +1 -0
  70. package/dist/generated/contract-schema.generated.js +496 -0
  71. package/dist/generated/contract-schema.generated.js.map +1 -0
  72. package/dist/generated/package-scope.generated.d.ts +6 -0
  73. package/dist/generated/package-scope.generated.d.ts.map +1 -0
  74. package/dist/generated/package-scope.generated.js +10 -0
  75. package/dist/generated/package-scope.generated.js.map +1 -0
  76. package/dist/generated/plan-contracts.generated.d.ts +3 -0
  77. package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
  78. package/dist/generated/plan-contracts.generated.js +2840 -0
  79. package/dist/generated/plan-contracts.generated.js.map +1 -0
  80. package/dist/host.d.ts.map +1 -1
  81. package/dist/host.js +11 -0
  82. package/dist/host.js.map +1 -1
  83. package/dist/identity.d.ts +15 -0
  84. package/dist/identity.d.ts.map +1 -0
  85. package/dist/identity.js +48 -0
  86. package/dist/identity.js.map +1 -0
  87. package/dist/index.d.ts +34 -5
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +19 -2
  90. package/dist/index.js.map +1 -1
  91. package/dist/inventory-adoption.d.ts +24 -5
  92. package/dist/inventory-adoption.d.ts.map +1 -1
  93. package/dist/inventory-adoption.js +70 -25
  94. package/dist/inventory-adoption.js.map +1 -1
  95. package/dist/inventory-choice.d.ts +40 -0
  96. package/dist/inventory-choice.d.ts.map +1 -0
  97. package/dist/inventory-choice.js +156 -0
  98. package/dist/inventory-choice.js.map +1 -0
  99. package/dist/inventory-contract.d.ts +89 -0
  100. package/dist/inventory-contract.d.ts.map +1 -0
  101. package/dist/inventory-contract.js +121 -0
  102. package/dist/inventory-contract.js.map +1 -0
  103. package/dist/key-editor.d.ts +30 -0
  104. package/dist/key-editor.d.ts.map +1 -0
  105. package/dist/key-editor.js +445 -0
  106. package/dist/key-editor.js.map +1 -0
  107. package/dist/ledger-contract.d.ts +187 -0
  108. package/dist/ledger-contract.d.ts.map +1 -0
  109. package/dist/ledger-contract.js +532 -0
  110. package/dist/ledger-contract.js.map +1 -0
  111. package/dist/ledger-trust.d.ts +90 -0
  112. package/dist/ledger-trust.d.ts.map +1 -0
  113. package/dist/ledger-trust.js +198 -0
  114. package/dist/ledger-trust.js.map +1 -0
  115. package/dist/lockfile-invariants.d.ts +48 -0
  116. package/dist/lockfile-invariants.d.ts.map +1 -0
  117. package/dist/lockfile-invariants.js +375 -0
  118. package/dist/lockfile-invariants.js.map +1 -0
  119. package/dist/lockfile-readers.d.ts +72 -0
  120. package/dist/lockfile-readers.d.ts.map +1 -0
  121. package/dist/lockfile-readers.js +713 -0
  122. package/dist/lockfile-readers.js.map +1 -0
  123. package/dist/lockfile-regen.d.ts +106 -0
  124. package/dist/lockfile-regen.d.ts.map +1 -0
  125. package/dist/lockfile-regen.js +760 -0
  126. package/dist/lockfile-regen.js.map +1 -0
  127. package/dist/lockfile-tool-env.d.ts +29 -0
  128. package/dist/lockfile-tool-env.d.ts.map +1 -0
  129. package/dist/lockfile-tool-env.js +111 -0
  130. package/dist/lockfile-tool-env.js.map +1 -0
  131. package/dist/materialize.d.ts +113 -0
  132. package/dist/materialize.d.ts.map +1 -0
  133. package/dist/materialize.js +840 -0
  134. package/dist/materialize.js.map +1 -0
  135. package/dist/observe-repository.d.ts +90 -0
  136. package/dist/observe-repository.d.ts.map +1 -0
  137. package/dist/observe-repository.js +1367 -0
  138. package/dist/observe-repository.js.map +1 -0
  139. package/dist/plan-bundle-setup-fixture.d.ts +68 -0
  140. package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
  141. package/dist/plan-bundle-setup-fixture.js +167 -0
  142. package/dist/plan-bundle-setup-fixture.js.map +1 -0
  143. package/dist/plan-bundle.d.ts +250 -0
  144. package/dist/plan-bundle.d.ts.map +1 -0
  145. package/dist/plan-bundle.js +827 -0
  146. package/dist/plan-bundle.js.map +1 -0
  147. package/dist/plan-command.d.ts +29 -0
  148. package/dist/plan-command.d.ts.map +1 -0
  149. package/dist/plan-command.js +493 -0
  150. package/dist/plan-command.js.map +1 -0
  151. package/dist/plan-contract.d.ts +153 -0
  152. package/dist/plan-contract.d.ts.map +1 -0
  153. package/dist/plan-contract.js +61 -0
  154. package/dist/plan-contract.js.map +1 -0
  155. package/dist/plan-digest.d.ts +25 -0
  156. package/dist/plan-digest.d.ts.map +1 -0
  157. package/dist/plan-digest.js +106 -0
  158. package/dist/plan-digest.js.map +1 -0
  159. package/dist/plan-rules.d.ts +23 -0
  160. package/dist/plan-rules.d.ts.map +1 -0
  161. package/dist/plan-rules.js +177 -0
  162. package/dist/plan-rules.js.map +1 -0
  163. package/dist/planned-bundle.d.ts +20 -0
  164. package/dist/planned-bundle.d.ts.map +1 -0
  165. package/dist/planned-bundle.js +191 -0
  166. package/dist/planned-bundle.js.map +1 -0
  167. package/dist/product-repository.d.ts +4 -0
  168. package/dist/product-repository.d.ts.map +1 -1
  169. package/dist/product-repository.js +9 -1
  170. package/dist/product-repository.js.map +1 -1
  171. package/dist/provenance-gate.d.ts +48 -0
  172. package/dist/provenance-gate.d.ts.map +1 -0
  173. package/dist/provenance-gate.js +324 -0
  174. package/dist/provenance-gate.js.map +1 -0
  175. package/dist/pull-request-body.d.ts +45 -0
  176. package/dist/pull-request-body.d.ts.map +1 -0
  177. package/dist/pull-request-body.js +232 -0
  178. package/dist/pull-request-body.js.map +1 -0
  179. package/dist/registry-snapshot.d.ts +141 -0
  180. package/dist/registry-snapshot.d.ts.map +1 -0
  181. package/dist/registry-snapshot.js +483 -0
  182. package/dist/registry-snapshot.js.map +1 -0
  183. package/dist/release-age-edit.d.ts +52 -0
  184. package/dist/release-age-edit.d.ts.map +1 -0
  185. package/dist/release-age-edit.js +413 -0
  186. package/dist/release-age-edit.js.map +1 -0
  187. package/dist/root-entries.d.ts +36 -0
  188. package/dist/root-entries.d.ts.map +1 -0
  189. package/dist/root-entries.js +80 -0
  190. package/dist/root-entries.js.map +1 -0
  191. package/dist/setup-template-scripts.d.ts +34 -0
  192. package/dist/setup-template-scripts.d.ts.map +1 -0
  193. package/dist/setup-template-scripts.js +557 -0
  194. package/dist/setup-template-scripts.js.map +1 -0
  195. package/dist/setup-templates.d.ts +54 -0
  196. package/dist/setup-templates.d.ts.map +1 -0
  197. package/dist/setup-templates.js +427 -0
  198. package/dist/setup-templates.js.map +1 -0
  199. package/dist/skills.d.ts +34 -1
  200. package/dist/skills.d.ts.map +1 -1
  201. package/dist/skills.js +129 -17
  202. package/dist/skills.js.map +1 -1
  203. package/dist/status.d.ts +63 -0
  204. package/dist/status.d.ts.map +1 -0
  205. package/dist/status.js +539 -0
  206. package/dist/status.js.map +1 -0
  207. package/dist/types.d.ts +151 -13
  208. package/dist/types.d.ts.map +1 -1
  209. package/package.json +4 -4
  210. package/skeleton/README.md +14 -9
  211. package/skeleton/package.json +2 -1
  212. package/skill/SKILL.md +23 -7
  213. package/skill-catalogue/advisor/SKILL.md +59 -6
  214. package/skill-catalogue/architect/SKILL.md +2 -2
  215. package/skill-catalogue/bouncer/SKILL.md +2 -2
  216. package/skill-catalogue/builder/SKILL.md +2 -2
  217. package/skill-catalogue/butler/SKILL.md +2 -2
  218. package/skill-catalogue/controller/SKILL.md +2 -2
  219. package/skill-catalogue/customer/SKILL.md +2 -2
  220. package/skill-catalogue/designer/SKILL.md +4 -2
  221. package/skill-catalogue/giver/SKILL.md +2 -2
  222. package/skill-catalogue/influencer/SKILL.md +2 -2
  223. package/skill-catalogue/inspector/SKILL.md +2 -2
  224. package/skill-catalogue/integrator/SKILL.md +2 -2
  225. package/skill-catalogue/keeper/SKILL.md +2 -2
  226. package/skill-catalogue/launcher/SKILL.md +23 -7
  227. package/skill-catalogue/locksmith/SKILL.md +2 -2
  228. package/skill-catalogue/messenger/SKILL.md +2 -2
  229. package/skill-catalogue/observer/SKILL.md +2 -2
  230. package/skill-catalogue/publisher/SKILL.md +2 -2
  231. package/skill-catalogue/starter/SKILL.md +3 -2
  232. package/skill-catalogue/strategist/SKILL.md +12 -4
  233. package/skill-catalogue/writer/SKILL.md +2 -2
  234. package/src/admission-fixture.ts +572 -0
  235. package/src/admission.ts +816 -0
  236. package/src/agents-guide.ts +29 -0
  237. package/src/apply-command-options.check.ts +27 -0
  238. package/src/apply-plan-cli.ts +454 -14
  239. package/src/apply-plan.ts +112 -124
  240. package/src/apply-step-fixture.ts +236 -0
  241. package/src/apply-store.ts +584 -0
  242. package/src/approval-sheet.ts +164 -0
  243. package/src/body-command.ts +162 -0
  244. package/src/change-set-contract.ts +937 -0
  245. package/src/change-set-digest.ts +70 -0
  246. package/src/check-cli.ts +14 -3
  247. package/src/cli.ts +90 -22
  248. package/src/core.ts +973 -275
  249. package/src/dry-materialize.ts +353 -0
  250. package/src/generated/contract-schema.generated.ts +520 -0
  251. package/src/generated/package-scope.generated.ts +10 -0
  252. package/src/generated/plan-contracts.generated.ts +2840 -0
  253. package/src/host.ts +10 -0
  254. package/src/identity.ts +51 -0
  255. package/src/index.ts +72 -3
  256. package/src/inventory-adoption.ts +107 -29
  257. package/src/inventory-choice.ts +172 -0
  258. package/src/inventory-contract.ts +166 -0
  259. package/src/key-editor.ts +446 -0
  260. package/src/ledger-contract.ts +637 -0
  261. package/src/ledger-trust.ts +267 -0
  262. package/src/lockfile-invariants.ts +421 -0
  263. package/src/lockfile-readers.ts +749 -0
  264. package/src/lockfile-regen.ts +851 -0
  265. package/src/lockfile-tool-env.ts +131 -0
  266. package/src/materialize.ts +886 -0
  267. package/src/observe-repository.ts +1365 -0
  268. package/src/plan-bundle-setup-fixture.ts +200 -0
  269. package/src/plan-bundle.ts +964 -0
  270. package/src/plan-command.ts +509 -0
  271. package/src/plan-contract.ts +179 -0
  272. package/src/plan-digest.ts +102 -0
  273. package/src/plan-rules.ts +188 -0
  274. package/src/planned-bundle.ts +211 -0
  275. package/src/product-repository.ts +10 -1
  276. package/src/provenance-gate.ts +352 -0
  277. package/src/pull-request-body.ts +261 -0
  278. package/src/registry-snapshot.ts +534 -0
  279. package/src/release-age-edit.ts +430 -0
  280. package/src/root-entries.ts +81 -0
  281. package/src/setup-template-scripts.ts +571 -0
  282. package/src/setup-templates.ts +471 -0
  283. package/src/skills.ts +161 -18
  284. package/src/status.ts +557 -0
  285. package/src/types.ts +148 -13
package/src/status.ts ADDED
@@ -0,0 +1,557 @@
1
+ // `launcher-apply-plan status`: what a stored change set's pull request is doing, derived from read-only evidence (issue #1701,
2
+ // RFC apply-approved-plan sections 4.4, 6 and T5, decisions D6 and D14). It runs after the client's agent has pushed a branch and
3
+ // opened the pull request, and answers one question: is the pull request that carries this set's marker the pull request this
4
+ // set describes?
5
+ //
6
+ // It never changes anything. Three read-only `gh api` calls (S1: GET only, see createGhPorts) say who is asking, which pull
7
+ // requests are open, and where the default branch is; every other read is git plumbing over commits that are already in the
8
+ // clone (`ls-tree`, `cat-file`, `diff-tree`, `rev-parse`). It never fetches a pull request's head (a partial clone is refused before any object is read, and its own git calls and the
9
+ // preconditions' it shares with `verify` run with lazy fetch off and a time limit; the hub admission's git reads have no time limit), never checks anything out and
10
+ // never writes an index, a ref, a worktree or a file. (The one exception is verify's own precondition step, which fetches the
11
+ // default branch into its remote-tracking ref exactly as `verify` does; see runPreconditions.)
12
+ //
13
+ // This is a public-safety surface, so every ambiguity refuses:
14
+ //
15
+ // - S2. It prints a fixed state token, a fixed reason token and `#<n>` for a pull request number that is a safe integer, and
16
+ // nothing else: never a body, a title, a login, an author or repository id, a branch, a path or any tool output.
17
+ // - S3. A body that names the marker counts only when its author is the person running this and both its head and its base are
18
+ // the change set's own repository. Any other author, a fork, a deleted fork or a missing field is `foreign-marker`.
19
+ // - S4. A carriage return, a marker that is not the whole of line 1, or a body readChangeSetMarker reads as none is
20
+ // `marker-malformed`.
21
+ // - S5. A pull request with this set's digest is `proposed` only if its base is the default branch, its branch and title are the
22
+ // set's, its head commit is already in the clone, and verify's own checks (verifyPrepared) pass over that commit's tree,
23
+ // including the exact ledger bytes; otherwise `diverged`. A precondition that fails (the clone, the hub or the admission, not
24
+ // the pull request) and any object of the head or the tip that git cannot read are `indeterminate`, and are judged first. A base-commit
25
+ // read inside verify's checks that fails or times out (a base lockfile, for one) is taken as an absent file, so `diverged` can be the
26
+ // answer where `indeterminate` is the truer one. `proposed` does not check the
27
+ // head's ancestry to the base, and nothing in this unit does.
28
+ // - S6. A pull request with another digest this hub stored for the repository is `superseded`. A digest this hub never stored,
29
+ // two pull requests with one digest, 100 or more open pull requests (a full page of 100 cannot show that nothing lies beyond
30
+ // it), or a port that fails or answers malformed is `indeterminate`.
31
+ // - S7. `applied` means the default branch's tip is in the clone and holds every `after` and every key the set writes, whether
32
+ // the set was merged by a pull request or was already there; it needs no open pull request.
33
+ //
34
+ // - S8. This set's pull request is `proposed` only if `sha256:` and the hex SHA-256 of its body's UTF-8 bytes, exactly as the port
35
+ // returned it (no trim, no line-ending or final-LF change), equal the set's `bodySha256`; otherwise `diverged` (`body-mismatch`).
36
+ // A body that is not well-formed UTF-16 is `body-mismatch`.
37
+ // - S9. A set with no `bodySha256` is `indeterminate` (`body-unbound`), never `proposed` or `diverged`.
38
+ // - S10. The order: the preconditions, then `body-unbound`, then base, ref and title, then `body-mismatch`, then `head-not-local`
39
+ // and verify. The precedence of states is unchanged.
40
+ // - S11. Only this set's own row is hashed, never an older one; neither the stored hash nor the body's is printed or stored, and the
41
+ // S2 line, the exit codes and the three GET calls are as they were.
42
+ //
43
+ // Precedence: indeterminate, then diverged, then superseded, then proposed, then applied, then planned. `materialized` is
44
+ // verify's, `proved` and `held` and the drift classes are not observed here.
45
+
46
+ import { spawnSync } from "node:child_process";
47
+ import { createHash } from "node:crypto";
48
+ import { readdirSync } from "node:fs";
49
+ import { dirname, isAbsolute, join } from "node:path";
50
+ import { contentDigest, validateRepositoryChangeSet } from "./change-set-contract.js";
51
+ import type { RepositoryChangeSet } from "./change-set-contract.js";
52
+ import { changeSetDigest } from "./change-set-digest.js";
53
+ import type { ReadinessRunner } from "./admission.js";
54
+ import { valueAtJsonPointer } from "./key-editor.js";
55
+ import { GIT_TIMEOUT_ENV, checkChangeSetShape, resolveCloneRoot, runPreconditions, verifyPrepared } from "./materialize.js";
56
+ import type { ApplyStepResult, GitPathList, TreeEntry, TreeReader } from "./materialize.js";
57
+ import { readChangeSetMarker } from "./pull-request-body.js";
58
+
59
+ export type StatusState = "proposed" | "applied" | "planned" | "diverged" | "superseded" | "indeterminate";
60
+
61
+ /** The outcome: a state, a fixed reason token, and the numbers of the pull requests it is about. Never a value read from GitHub but those numbers. */
62
+ export interface StatusResult {
63
+ readonly exitCode: 0 | 1 | 2;
64
+ readonly state: StatusState;
65
+ readonly reason?: string;
66
+ readonly pullRequests?: readonly number[];
67
+ }
68
+
69
+ /** The three read-only questions status asks GitHub. Each may return a value or a promise of one, or throw; a throw is `port-failed`. */
70
+ export interface StatusPorts {
71
+ /** The node id of the account running this. */
72
+ readonly viewer: () => string | Promise<string>;
73
+ /** The open pull requests of the repository, as rows of `PullRequestRow`'s fields: only the first page of 100 is read, and a full page is refused. */
74
+ readonly openPullRequests: (repositoryId: string) => unknown | Promise<unknown>;
75
+ /** The commit id of the default branch's tip on the remote. */
76
+ readonly tip: (repositoryId: string, branch: string) => string | Promise<string>;
77
+ }
78
+
79
+ export interface StatusInput {
80
+ readonly clone: string;
81
+ readonly hub: string;
82
+ readonly set: RepositoryChangeSet;
83
+ /** Every change set the hub stored, in any repository: the ones for this repository name the digests that count as older. */
84
+ readonly heldChangeSets?: readonly RepositoryChangeSet[];
85
+ readonly now?: () => Date;
86
+ readonly runReadiness?: ReadinessRunner;
87
+ /** The GitHub ports; read-only `gh api` by default. */
88
+ readonly ports?: StatusPorts;
89
+ /** How long one git call may run before status gives up on it as `indeterminate`; 30 seconds by default. */
90
+ readonly gitTimeoutMs?: number;
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // shapes
95
+
96
+ /** The page size of the open pull request listing: a listing this long may have more behind it, so the answer is `indeterminate`. */
97
+ export const MAX_OPEN_PULL_REQUESTS = 100;
98
+
99
+ const GIT_TIMEOUT_MS = 30_000;
100
+
101
+ const TOKEN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
102
+ const MARKER_LINE = /^<!-- clossys-change-set: (sha256:[0-9a-f]{64}) -->$/u;
103
+ const MARKER_WORD = /clossys-change-set/iu;
104
+ const COMMIT = /^[0-9a-f]{40}$/u;
105
+ const NODE_ID = /^[A-Za-z0-9_=+/-]{1,200}$/u;
106
+ const REPOSITORY_ID = /^(?!\.{1,2}\/)[A-Za-z0-9._-]{1,100}\/(?!\.{1,2}$)[A-Za-z0-9._-]{1,100}$/u;
107
+ const BRANCH_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/u;
108
+
109
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
110
+ const isNodeId = (value: unknown): value is string => typeof value === "string" && NODE_ID.test(value);
111
+
112
+ /** A reason that is safe to print: a fixed token or nothing else. */
113
+ export function safeReason(reason: unknown): string {
114
+ return typeof reason === "string" && reason.length <= 64 && TOKEN.test(reason) ? reason : "refused";
115
+ }
116
+
117
+ const branchSafe = (branch: unknown): branch is string => typeof branch === "string" && branch.length <= 200 && branch.split("/").every((part) => BRANCH_SEGMENT.test(part));
118
+
119
+ const indeterminate = (reason: string, pullRequests?: readonly number[]): StatusResult =>
120
+ pullRequests === undefined ? { exitCode: 2, state: "indeterminate", reason: safeReason(reason) } : { exitCode: 2, state: "indeterminate", reason: safeReason(reason), pullRequests };
121
+ const diverged = (reason: string, pullRequests: readonly number[]): StatusResult => ({ exitCode: 1, state: "diverged", reason: safeReason(reason), pullRequests });
122
+
123
+ /** A verify step's outcome as a status: its exit code 1 is `diverged`, 2 is `indeterminate`; its reason is passed through only as a token. */
124
+ function fromStep(step: ApplyStepResult, pullRequest: number): StatusResult {
125
+ if (step.exitCode === 1) return diverged(step.reason ?? "refused", [pullRequest]);
126
+ return indeterminate(step.reason ?? "refused", [pullRequest]);
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // git, over commits only
131
+
132
+ /** Thrown when git cannot be run or does not answer in time: the answer is unknown, never "no". */
133
+ const GIT_UNAVAILABLE = "git-unavailable";
134
+
135
+ /**
136
+ * git over the clone, with lazy fetch off (S1): an object a partial clone does not hold is reported missing, never fetched from a
137
+ * promisor remote. Every call is bounded by `timeoutMs`; a call that cannot run or times out throws.
138
+ */
139
+ function runGit(cwd: string, args: readonly string[], timeoutMs: number): { status: number; stdout: Buffer } {
140
+ const env: NodeJS.ProcessEnv = { ...process.env };
141
+ for (const name of Object.keys(env)) if (name.startsWith("GIT_")) delete env[name];
142
+ env.GIT_LITERAL_PATHSPECS = "1";
143
+ env.GIT_TERMINAL_PROMPT = "0";
144
+ env.GIT_OPTIONAL_LOCKS = "0";
145
+ env.GIT_NO_LAZY_FETCH = "1";
146
+ env.GIT_CEILING_DIRECTORIES = dirname(cwd);
147
+ const run = spawnSync("git", ["--no-replace-objects", ...args], { cwd, env, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs, killSignal: "SIGKILL" });
148
+ if (run.error !== undefined || run.status === null || run.stdout === null || run.stdout === undefined) throw new Error(GIT_UNAVAILABLE);
149
+ return { status: run.status, stdout: run.stdout };
150
+ }
151
+
152
+ /** The turn the next withGitLimits waits for: the one before it, finished or failed. */
153
+ let limitsTurn: Promise<unknown> = Promise.resolve();
154
+
155
+ /**
156
+ * Runs `run` with lazy fetch off and a time limit in this process's environment too, for the reads the shared preconditions and
157
+ * verifyPrepared make with their own git helpers, which inherit it (verify sets neither). The variables belong to the process, so
158
+ * two calls in one process take turns: an overlapping call waits until the one before it has put the variables back, and each
159
+ * sees only its own limit. The same variable, set in a shell, also limits `verify` and `materialize`, which set none.
160
+ */
161
+ function withGitLimits<T>(timeoutMs: number, run: () => T | Promise<T>): Promise<T> {
162
+ const turn = limitsTurn.then(() => withGitLimitsNow(timeoutMs, run));
163
+ limitsTurn = turn.catch(() => undefined);
164
+ return turn;
165
+ }
166
+
167
+ async function withGitLimitsNow<T>(timeoutMs: number, run: () => T | Promise<T>): Promise<T> {
168
+ const saved = { lazy: process.env.GIT_NO_LAZY_FETCH, limit: process.env[GIT_TIMEOUT_ENV] };
169
+ process.env.GIT_NO_LAZY_FETCH = "1";
170
+ process.env[GIT_TIMEOUT_ENV] = String(timeoutMs);
171
+ try {
172
+ return await run();
173
+ } finally {
174
+ if (saved.lazy === undefined) delete process.env.GIT_NO_LAZY_FETCH;
175
+ else process.env.GIT_NO_LAZY_FETCH = saved.lazy;
176
+ if (saved.limit === undefined) delete process.env[GIT_TIMEOUT_ENV];
177
+ else process.env[GIT_TIMEOUT_ENV] = saved.limit;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Whether the clone is a partial (promisor) clone, whose missing objects git would fetch on demand. Status refuses one before it
183
+ * reads any object, whatever version of git it has: by the configuration that makes a clone partial (`extensions.partialclone`, a
184
+ * remote marked `promisor`, which every filtered clone sets, blobless or treeless, or a remote that records a `partialclonefilter`) and by a `.promisor` pack marker, in case the
185
+ * configuration was edited away. Throws when git cannot say.
186
+ */
187
+ function isPartialClone(root: string, timeoutMs: number): boolean {
188
+ // `-z` ends each entry with a NUL and puts a newline between key and value, so a remote name that holds a space cannot split the key.
189
+ const config = runGit(root, ["config", "-z", "--get-regexp", "^(extensions\\.partialclone|remote\\..*\\.(promisor|partialclonefilter))$"], timeoutMs);
190
+ if (config.status !== 0 && config.status !== 1) throw new Error(GIT_UNAVAILABLE);
191
+ for (const entry of config.stdout.toString("utf8").split("\0")) {
192
+ const newline = entry.indexOf("\n");
193
+ const key = (newline === -1 ? entry : entry.slice(0, newline)).toLowerCase();
194
+ const value = newline === -1 ? "" : entry.slice(newline + 1).trim().toLowerCase();
195
+ if (key === "extensions.partialclone" || key.endsWith(".partialclonefilter")) return true;
196
+ if (key.endsWith(".promisor") && !["false", "no", "off", "0"].includes(value)) return true;
197
+ }
198
+ const located = runGit(root, ["rev-parse", "--git-path", "objects/pack"], timeoutMs);
199
+ if (located.status !== 0) throw new Error(GIT_UNAVAILABLE);
200
+ const relative = located.stdout.toString("utf8").trim();
201
+ const packs = isAbsolute(relative) ? relative : join(root, relative);
202
+ try {
203
+ return readdirSync(packs).some((name) => name.endsWith(".promisor"));
204
+ } catch (cause) {
205
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return false;
206
+ throw cause;
207
+ }
208
+ }
209
+
210
+ /** Whether `commit` is a commit object already in the clone's object database. Throws when git cannot say. */
211
+ function commitIsLocal(root: string, commit: string, timeoutMs: number): boolean {
212
+ return COMMIT.test(commit) && runGit(root, ["cat-file", "-e", `${commit}^{commit}`], timeoutMs).status === 0;
213
+ }
214
+
215
+ interface TreeRecord {
216
+ readonly mode: string;
217
+ readonly type: string;
218
+ readonly oid: string;
219
+ }
220
+
221
+ /** A TreeReader over one commit's tree, and whether any read of it failed (a corrupt, missing or unreachable object, or a git that did not answer). */
222
+ interface CommitReader {
223
+ readonly reader: TreeReader;
224
+ readonly unreadable: () => boolean;
225
+ }
226
+
227
+ /**
228
+ * A TreeReader over one commit's tree: git plumbing only, so the working tree, the index and every ref stay as they were. A path
229
+ * behind a link cannot be reached by `ls-tree`, and a link above a path is reported by hasSymlinkAncestor. verifyPrepared answers a
230
+ * failed read as a mismatch, so `unreadable` records every failure for the caller to turn into `indeterminate`.
231
+ */
232
+ function commitReader(root: string, commit: string, timeoutMs: number): CommitReader {
233
+ let failed = false;
234
+ const fail = (): never => {
235
+ failed = true;
236
+ throw new Error("object-unreadable");
237
+ };
238
+ const git = (args: readonly string[]): { status: number; stdout: Buffer } => {
239
+ try {
240
+ return runGit(root, args, timeoutMs);
241
+ } catch {
242
+ return fail();
243
+ }
244
+ };
245
+ /** The one tree entry at `relPath` in the commit, or null when there is none. Throws when git cannot say. */
246
+ const treeRecord = (relPath: string): TreeRecord | null => {
247
+ const listed = git(["ls-tree", "-z", commit, "--", relPath]);
248
+ if (listed.status !== 0) return fail();
249
+ const records = listed.stdout.toString("utf8").split("\0").filter((record) => record !== "");
250
+ if (records.length === 0) return null;
251
+ if (records.length !== 1) return fail();
252
+ const record = records[0]!;
253
+ const tab = record.indexOf("\t");
254
+ if (tab === -1 || record.slice(tab + 1) !== relPath) return fail();
255
+ const [mode, type, oid, ...rest] = record.slice(0, tab).split(" ");
256
+ if (mode === undefined || type === undefined || oid === undefined || rest.length > 0 || !COMMIT.test(oid)) return fail();
257
+ return { mode, type, oid };
258
+ };
259
+ const blobBytes = (oid: string): Buffer => {
260
+ const blob = git(["cat-file", "blob", oid]);
261
+ if (blob.status !== 0) return fail();
262
+ return blob.stdout;
263
+ };
264
+ const reader: TreeReader = {
265
+ onBranch: () => true,
266
+ changedPaths(baseCommit): GitPathList {
267
+ const diff = git(["diff-tree", "-r", "-z", "--name-only", "--no-renames", baseCommit, commit]);
268
+ if (diff.status !== 0) return { ok: false };
269
+ return { ok: true, paths: diff.stdout.toString("utf8").split("\0").filter((path) => path !== "") };
270
+ },
271
+ dirtyPaths: () => ({ ok: true, paths: [] }),
272
+ hasSymlinkAncestor(relPath) {
273
+ const parts = relPath.split("/");
274
+ for (let length = 1; length < parts.length; length += 1) {
275
+ const record = treeRecord(parts.slice(0, length).join("/"));
276
+ if (record === null) return false;
277
+ if (record.mode === "120000") return true;
278
+ }
279
+ return false;
280
+ },
281
+ entry(relPath): TreeEntry | null {
282
+ const record = treeRecord(relPath);
283
+ if (record === null) return null;
284
+ if (record.type !== "blob") return { kind: "other" };
285
+ if (record.mode === "120000") return { kind: "symlink", target: blobBytes(record.oid).toString("utf8") };
286
+ if (record.mode === "100644") return { kind: "file", executable: false };
287
+ if (record.mode === "100755") return { kind: "file", executable: true };
288
+ return { kind: "other" };
289
+ },
290
+ bytes(relPath) {
291
+ const record = treeRecord(relPath);
292
+ if (record === null || record.type !== "blob" || (record.mode !== "100644" && record.mode !== "100755")) throw new Error("not-a-regular-file");
293
+ return blobBytes(record.oid);
294
+ },
295
+ };
296
+ return { reader, unreadable: () => failed };
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // the GitHub ports
301
+
302
+ /** Runs `gh` with these arguments; the exit status and standard output. Injected by tests. */
303
+ export type GhRun = (args: readonly string[]) => { readonly status: number | null; readonly stdout: string };
304
+
305
+ const runGh: GhRun = (args) => {
306
+ const run = spawnSync("gh", [...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 60_000, maxBuffer: 64 * 1024 * 1024 });
307
+ return { status: run.error === undefined ? run.status : null, stdout: typeof run.stdout === "string" ? run.stdout : "" };
308
+ };
309
+
310
+ const PULL_REQUEST_FIELDS =
311
+ "[.[] | {number: .number, title: .title, body: .body, author: .user.node_id, headSha: .head.sha, headRef: .head.ref, headRepo: .head.repo.node_id, baseRepo: .base.repo.node_id, baseRef: .base.ref}]";
312
+
313
+ /**
314
+ * The default ports: GET-only `gh api` (S1). Every call is `gh api --method GET <path> [--jq <filter>]`: no field, input or body
315
+ * option, no other method, and a path built only from a repository id and a branch that were matched against strict patterns
316
+ * first. `run` is injectable so a test can see every argument list.
317
+ */
318
+ export function createGhPorts(run: GhRun = runGh): StatusPorts {
319
+ const get = (path: string, jq: string): string => {
320
+ const result = run(["api", "--method", "GET", path, "--jq", jq]);
321
+ if (result.status !== 0) throw new Error("gh api failed");
322
+ return result.stdout.trim();
323
+ };
324
+ const repositoryPath = (id: string): string => {
325
+ if (!REPOSITORY_ID.test(id)) throw new Error("repository-invalid");
326
+ return `repos/${id}`;
327
+ };
328
+ return {
329
+ viewer: () => get("user", ".node_id"),
330
+ // One page of 100: a full page may have more behind it, and readRows refuses it rather than guess (S6).
331
+ openPullRequests(id) {
332
+ const parsed: unknown = JSON.parse(get(`${repositoryPath(id)}/pulls?state=open&per_page=${MAX_OPEN_PULL_REQUESTS}&page=1`, PULL_REQUEST_FIELDS));
333
+ if (!Array.isArray(parsed)) throw new Error("gh api answered something else");
334
+ return parsed;
335
+ },
336
+ tip(id, branch) {
337
+ if (!branchSafe(branch)) throw new Error("branch-invalid");
338
+ return get(`${repositoryPath(id)}/git/ref/heads/${branch}`, ".object.sha");
339
+ },
340
+ };
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // the pull requests
345
+
346
+ /** The fields status reads of an open pull request; each is checked before it is trusted. */
347
+ interface PullRequestRow {
348
+ readonly number: number;
349
+ readonly body: string | null;
350
+ readonly title: unknown;
351
+ readonly author: unknown;
352
+ readonly headRepo: unknown;
353
+ readonly baseRepo: unknown;
354
+ readonly headRef: unknown;
355
+ readonly headSha: unknown;
356
+ readonly baseRef: unknown;
357
+ }
358
+
359
+ function readRows(raw: unknown): PullRequestRow[] | "too-many-open" | "port-malformed" {
360
+ if (!Array.isArray(raw)) return "port-malformed";
361
+ if (raw.length >= MAX_OPEN_PULL_REQUESTS) return "too-many-open";
362
+ const rows: PullRequestRow[] = [];
363
+ for (const row of raw as unknown[]) {
364
+ if (!isRecord(row)) return "port-malformed";
365
+ const { number, body } = row;
366
+ if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 1) return "port-malformed";
367
+ if (body !== null && typeof body !== "string") return "port-malformed";
368
+ rows.push({ number, body, title: row.title, author: row.author, headRepo: row.headRepo, baseRepo: row.baseRepo, headRef: row.headRef, headSha: row.headSha, baseRef: row.baseRef });
369
+ }
370
+ return rows.sort((left, right) => left.number - right.number);
371
+ }
372
+
373
+ const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
374
+
375
+ /** Whether `body` is the body `bodySha256` names (S8): the hash of its UTF-8 bytes as they are, and a body no encoder would have to repair. */
376
+ function bodyHashes(body: string, bodySha256: string): boolean {
377
+ if (LONE_SURROGATE.test(body)) return false;
378
+ return `sha256:${createHash("sha256").update(Buffer.from(body, "utf8")).digest("hex")}` === bodySha256;
379
+ }
380
+
381
+ /** The digest on the first and only marker line of a body (S4), or null. */
382
+ function markerOf(body: string): string | null {
383
+ if (body.includes("\r")) return null;
384
+ const first = MARKER_LINE.exec(body.split("\n", 1)[0]!)?.[1];
385
+ if (first === undefined) return null;
386
+ return readChangeSetMarker(body) === first ? first : null;
387
+ }
388
+
389
+ // ---------------------------------------------------------------------------
390
+ // applied
391
+
392
+ /**
393
+ * Whether the tree holds every `after` and every key the set writes (S7), and writes at least one thing that was not already there,
394
+ * so a set that changes nothing of its own is never read as applied. Throws when the tree cannot be read.
395
+ */
396
+ function holdsTheSet(set: RepositoryChangeSet, reader: TreeReader): boolean {
397
+ let changes = false;
398
+ for (const file of set.files) {
399
+ if ("derived" in file) continue;
400
+ if (reader.hasSymlinkAncestor(file.path)) return false;
401
+ const entry = reader.entry(file.path);
402
+ if (file.after === null) {
403
+ if (entry !== null && entry.kind !== "other") return false;
404
+ } else {
405
+ if (entry === null) return false;
406
+ if (file.mode === "120000") {
407
+ if (entry.kind !== "symlink" || contentDigest(entry.target) !== file.after) return false;
408
+ } else if (entry.kind !== "file" || entry.executable || contentDigest(reader.bytes(file.path).toString("utf8")) !== file.after) return false;
409
+ }
410
+ if (file.before !== file.after) changes = true;
411
+ }
412
+ if (set.keys.length > 0) {
413
+ if (reader.hasSymlinkAncestor("package.json")) return false;
414
+ const manifest = reader.bytes("package.json").toString("utf8");
415
+ for (const key of set.keys) {
416
+ const at = valueAtJsonPointer(manifest, key.pointer);
417
+ if (key.after === null ? at.found : !at.found || at.value !== key.after) return false;
418
+ if (key.before !== key.after) changes = true;
419
+ }
420
+ }
421
+ return changes;
422
+ }
423
+
424
+ /**
425
+ * The one line status prints (S2): `launcher-apply-plan status: <state>[ (<reason>)][ #<n>...]`. The state comes from a closed
426
+ * set, the reason must match the fixed-token shape or is printed as `refused`, and a pull request is printed only as `#` and a
427
+ * safe positive integer. Nothing read from GitHub, the clone or the hub reaches it in any other form.
428
+ */
429
+ export function formatStatus(result: { readonly state: StatusState; readonly reason?: unknown; readonly pullRequests?: readonly number[] }): string {
430
+ const states: readonly string[] = ["proposed", "applied", "planned", "diverged", "superseded", "indeterminate"];
431
+ const state = states.includes(result.state) ? result.state : "indeterminate";
432
+ const reason = result.reason === undefined ? "" : ` (${safeReason(result.reason)})`;
433
+ const numbers = (result.pullRequests ?? []).filter((n) => Number.isSafeInteger(n) && n > 0).map((n) => ` #${n}`);
434
+ return `launcher-apply-plan status: ${state}${reason}${numbers.join("")}`;
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // status
439
+
440
+ async function observe(input: StatusInput): Promise<StatusResult> {
441
+ const { set } = input;
442
+ const resolved = resolveCloneRoot(input.clone);
443
+ if ("exitCode" in resolved) return indeterminate(resolved.reason ?? "missing-clone");
444
+ const { root } = resolved;
445
+ const shape = checkChangeSetShape(set);
446
+ if (shape !== null) return indeterminate(shape.reason ?? "change-set-invalid");
447
+ const id = set.repository.id;
448
+ const branch = set.repository.defaultBranch;
449
+ const nodeId = set.repository.nodeId;
450
+ if (changeSetDigest(set) !== set.changeSetDigest) return indeterminate("change-set-invalid");
451
+ if (!REPOSITORY_ID.test(id) || !branchSafe(branch) || !isNodeId(nodeId)) return indeterminate("repository-invalid");
452
+
453
+ // Before any read of an object: a partial clone would have git fetch what it lacks, from the network, with the operator's credentials.
454
+ const timeoutMs = input.gitTimeoutMs ?? GIT_TIMEOUT_MS;
455
+ if (isPartialClone(root, timeoutMs)) return indeterminate("partial-clone");
456
+
457
+ // The digests this hub stored for this repository: the ones a marker may name besides this set's own.
458
+ const held = (input.heldChangeSets ?? []).filter((entry) => entry.repository.id === id);
459
+ const known = new Set<string>();
460
+ for (const entry of held) if (validateRepositoryChangeSet(entry).valid && changeSetDigest(entry) === entry.changeSetDigest) known.add(entry.changeSetDigest);
461
+ known.delete(set.changeSetDigest);
462
+
463
+ const ports = input.ports ?? createGhPorts();
464
+ let viewer: string;
465
+ let raw: unknown;
466
+ try {
467
+ viewer = await ports.viewer();
468
+ raw = await ports.openPullRequests(id);
469
+ } catch {
470
+ return indeterminate("port-failed");
471
+ }
472
+ if (!isNodeId(viewer)) return indeterminate("port-malformed");
473
+ const rows = readRows(raw);
474
+ if (typeof rows === "string") return indeterminate(rows);
475
+
476
+ const older: number[] = [];
477
+ const seen = new Set<string>();
478
+ let current: PullRequestRow | null = null;
479
+ for (const row of rows) {
480
+ if (row.body === null || !MARKER_WORD.test(row.body)) continue;
481
+ // S3: a body that names the marker is trusted only from the person running this, between this repository and itself.
482
+ if (!isNodeId(row.author) || row.author !== viewer || row.headRepo !== nodeId || row.baseRepo !== nodeId) return indeterminate("foreign-marker");
483
+ // S4
484
+ const digest = markerOf(row.body);
485
+ if (digest === null) return indeterminate("marker-malformed");
486
+ // S6
487
+ if (seen.has(digest)) return indeterminate("duplicate-digest");
488
+ seen.add(digest);
489
+ if (digest === set.changeSetDigest) current = row;
490
+ else if (known.has(digest)) older.push(row.number);
491
+ else return indeterminate("unknown-digest");
492
+ }
493
+
494
+ let proposed: StatusResult | null = null;
495
+ if (current !== null) {
496
+ const number = current.number;
497
+ if (typeof current.baseRef !== "string" || typeof current.headRef !== "string" || typeof current.title !== "string" || typeof current.headSha !== "string") return indeterminate("port-malformed", [number]);
498
+ // The clone, the hub and the admission come first: a refusal there is about them, not about the pull request, so it is
499
+ // `indeterminate` whatever the pull request says.
500
+ const pre = await withGitLimits(timeoutMs, () => runPreconditions(input.clone, input.hub, set, input.heldChangeSets ?? [], { now: input.now, runReadiness: input.runReadiness }));
501
+ if ("exitCode" in pre) return indeterminate(pre.reason ?? "refused", [number]);
502
+ // S9
503
+ const bodySha256 = set.pullRequest.bodySha256;
504
+ if (bodySha256 === undefined) return indeterminate("body-unbound", [number]);
505
+ // S5
506
+ if (current.baseRef !== branch) return diverged("base-branch-mismatch", [number]);
507
+ if (current.headRef !== set.branch) return diverged("ref-mismatch", [number]);
508
+ if (current.title !== set.pullRequest.title) return diverged("title-mismatch", [number]);
509
+ // S8: `current.body` passed markerOf, so it is a string.
510
+ if (current.body === null || !bodyHashes(current.body, bodySha256)) return diverged("body-mismatch", [number]);
511
+ if (!commitIsLocal(root, current.headSha, timeoutMs)) return diverged("head-not-local", [number]);
512
+ const head = commitReader(root, current.headSha, timeoutMs);
513
+ let verified: ApplyStepResult | null = null;
514
+ try {
515
+ verified = await withGitLimits(timeoutMs, () => verifyPrepared(set, pre, head.reader));
516
+ } catch (cause) {
517
+ if (!head.unreadable()) throw cause;
518
+ }
519
+ // A read that failed may have been answered as a mismatch: an object git cannot read is unknown, not diverged.
520
+ if (verified === null || head.unreadable()) return indeterminate("object-unreadable", [number]);
521
+ if (verified.exitCode !== 0) return fromStep(verified, number);
522
+ proposed = { exitCode: 0, state: "proposed", pullRequests: [number] };
523
+ }
524
+ if (older.length > 0) return { exitCode: 2, state: "superseded", pullRequests: older };
525
+ if (proposed !== null) return proposed;
526
+
527
+ // S7: with no pull request of this set to report, the default branch's own tip decides applied or planned.
528
+ let tip: string;
529
+ try {
530
+ tip = await ports.tip(id, branch);
531
+ } catch {
532
+ return indeterminate("port-failed");
533
+ }
534
+ if (!COMMIT.test(tip)) return indeterminate("port-malformed");
535
+ if (!commitIsLocal(root, tip, timeoutMs)) return indeterminate("tip-not-local");
536
+ const tipReader = commitReader(root, tip, timeoutMs);
537
+ let holds: boolean;
538
+ try {
539
+ holds = holdsTheSet(set, tipReader.reader);
540
+ } catch {
541
+ return indeterminate("tip-unreadable");
542
+ }
543
+ if (tipReader.unreadable()) return indeterminate("tip-unreadable");
544
+ return holds ? { exitCode: 0, state: "applied" } : { exitCode: 2, state: "planned" };
545
+ }
546
+
547
+ /**
548
+ * The state of one stored change set's pull request. Never throws: anything unexpected is `indeterminate`. Reads the clone only
549
+ * through git plumbing over commits, and GitHub only through `input.ports`.
550
+ */
551
+ export async function statusRepository(input: StatusInput): Promise<StatusResult> {
552
+ try {
553
+ return await observe(input);
554
+ } catch {
555
+ return indeterminate("status-failed");
556
+ }
557
+ }