devflow-kit 3.7.0 → 3.9.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 (205) hide show
  1. package/CHANGELOG.md +479 -1
  2. package/README.md +15 -15
  3. package/dist/agents/code.md +6 -8
  4. package/dist/agents/design.md +1 -1
  5. package/dist/agents/fix.md +7 -8
  6. package/dist/agents/git.md +3 -3
  7. package/dist/agents/harden.md +1 -1
  8. package/dist/agents/refactor.md +2 -4
  9. package/dist/agents/review.md +4 -4
  10. package/dist/agents/skim.md +2 -2
  11. package/dist/agents/triage.md +9 -2
  12. package/dist/cli/commands/capture.js +1 -1
  13. package/dist/cli/commands/curation-move-io.js +102 -0
  14. package/dist/cli/commands/curation-move-step.js +374 -0
  15. package/dist/cli/commands/curation.js +808 -0
  16. package/dist/cli/commands/gate-level-prompts.js +428 -0
  17. package/dist/cli/commands/gates.js +35 -11
  18. package/dist/cli/commands/init-seed.js +11 -8
  19. package/dist/cli/commands/init.js +244 -57
  20. package/dist/cli/commands/memory.js +60 -13
  21. package/dist/cli/commands/prompt-io.js +37 -0
  22. package/dist/cli/commands/uninstall.js +157 -17
  23. package/dist/cli.js +2 -2
  24. package/dist/commands/bug-analysis.md +25 -36
  25. package/dist/commands/code-review.md +40 -44
  26. package/dist/commands/debug.md +5 -5
  27. package/dist/commands/dynamic-build.md +26 -25
  28. package/dist/commands/dynamic-plan.md +5 -5
  29. package/dist/commands/dynamic-profile.md +5 -5
  30. package/dist/commands/dynamic-tickets.md +5 -5
  31. package/dist/commands/explore.md +6 -6
  32. package/dist/commands/implement.md +53 -82
  33. package/dist/commands/plan.md +6 -6
  34. package/dist/commands/release.md +5 -5
  35. package/dist/commands/research.md +6 -6
  36. package/dist/commands/resolve.md +39 -41
  37. package/dist/commands/self-review.md +5 -13
  38. package/dist/core/area-pause-notice.js +44 -0
  39. package/dist/core/assets.js +20 -20
  40. package/dist/core/curate-tuning-config.js +5 -6
  41. package/dist/core/curation-move-notice.js +84 -0
  42. package/dist/core/curation-move-skip.js +35 -0
  43. package/dist/core/curation-move.js +35 -0
  44. package/dist/core/curation-queue-cleanup.js +135 -0
  45. package/dist/core/curation-store.js +99 -0
  46. package/dist/core/{learning-variants.js → curation-variants.js} +65 -41
  47. package/dist/core/echo-value.js +11 -5
  48. package/dist/core/evidence-policy.js +11 -9
  49. package/dist/core/feature-switch.js +95 -19
  50. package/dist/core/fs-atomic.js +1 -1
  51. package/dist/core/gate-level.js +10 -2
  52. package/dist/core/git.js +16 -14
  53. package/dist/core/ledger-root.js +82 -22
  54. package/dist/core/linked-path.js +4 -4
  55. package/dist/core/manifest-lock.js +238 -0
  56. package/dist/core/manifest.js +58 -14
  57. package/dist/core/migrations.js +1 -13
  58. package/dist/core/observations.js +2 -2
  59. package/dist/core/plugins.js +19 -19
  60. package/dist/core/pool.js +26 -0
  61. package/dist/core/project-paths.js +103 -34
  62. package/dist/core/queue-drain.js +2 -2
  63. package/dist/core/tracked-areas.js +159 -0
  64. package/dist/core/tracker.js +1 -1
  65. package/dist/{learning-off → curation-off}/agents/code.md +6 -8
  66. package/dist/{learning-off → curation-off}/agents/fix.md +7 -8
  67. package/dist/{learning-off → curation-off}/agents/refactor.md +2 -4
  68. package/dist/{learning-off → curation-off}/agents/review.md +3 -3
  69. package/dist/{learning-off → curation-off}/agents/triage.md +8 -1
  70. package/dist/{learning-off → curation-off}/commands/bug-analysis.md +20 -31
  71. package/dist/{learning-off → curation-off}/commands/code-review.md +37 -41
  72. package/dist/{learning-off → curation-off}/commands/debug.md +2 -2
  73. package/dist/{learning-off → curation-off}/commands/dynamic-build.md +23 -22
  74. package/dist/{learning-off → curation-off}/commands/explore.md +2 -2
  75. package/dist/{learning-off → curation-off}/commands/implement.md +50 -79
  76. package/dist/{learning-off → curation-off}/commands/plan.md +2 -2
  77. package/dist/{learning-off → curation-off}/commands/resolve.md +36 -38
  78. package/dist/{learning-off → curation-off}/commands/self-review.md +2 -10
  79. package/dist/hud/components/{learning-counts.js → curation-counts.js} +63 -17
  80. package/dist/hud/config.js +1 -1
  81. package/dist/hud/index.js +6 -6
  82. package/dist/hud/render.js +3 -3
  83. package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
  84. package/dist/skills/git/references/pr/post-review-summary.md +12 -11
  85. package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
  86. package/dist/skills/git/references/pr/validate-branch.md +8 -5
  87. package/dist/skills/git/references/publication-gate.md +6 -7
  88. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +13 -5
  89. package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
  90. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +13 -5
  91. package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
  92. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +13 -5
  93. package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
  94. package/dist/targets/claude-code/{learning-install.js → curation-install.js} +85 -65
  95. package/dist/targets/claude-code/installer.js +15 -15
  96. package/dist/targets/claude-code/language-stamp.js +5 -5
  97. package/dist/targets/claude-code/post-install.js +2 -2
  98. package/dist/targets/claude-code/tracker-servers.js +1 -1
  99. package/package.json +3 -2
  100. package/src/assets/agents/_partials/_code.mds +11 -11
  101. package/src/assets/agents/code.mds +6 -6
  102. package/src/assets/agents/curate.md +8 -8
  103. package/src/assets/agents/debug.mds +6 -6
  104. package/src/assets/agents/design.mds +18 -18
  105. package/src/assets/agents/evaluate.md +6 -12
  106. package/src/assets/agents/fix.mds +7 -6
  107. package/src/assets/agents/git.mds +3 -3
  108. package/src/assets/agents/harden.mds +7 -7
  109. package/src/assets/agents/knowledge.mds +23 -23
  110. package/src/assets/agents/refactor.mds +2 -2
  111. package/src/assets/agents/research.mds +15 -15
  112. package/src/assets/agents/review.mds +43 -43
  113. package/src/assets/agents/skim.mds +11 -11
  114. package/src/assets/agents/triage.mds +29 -22
  115. package/src/assets/agents/validate.md +30 -50
  116. package/src/assets/commands/_partials/_decisions.mds +22 -20
  117. package/src/assets/commands/_partials/_engine.mds +8 -8
  118. package/src/assets/commands/_partials/_knowledge.mds +29 -15
  119. package/src/assets/commands/_partials/_preamble.mds +4 -4
  120. package/src/assets/commands/_partials/_settings.mds +3 -3
  121. package/src/assets/commands/_partials/_wave.mds +7 -7
  122. package/src/assets/commands/bug-analysis.mds +37 -48
  123. package/src/assets/commands/code-review.mds +53 -57
  124. package/src/assets/commands/debug.mds +14 -14
  125. package/src/assets/commands/dynamic-build.mds +38 -37
  126. package/src/assets/commands/dynamic-plan.mds +22 -22
  127. package/src/assets/commands/dynamic-profile.mds +2 -2
  128. package/src/assets/commands/dynamic-tickets.mds +8 -8
  129. package/src/assets/commands/explore.mds +20 -20
  130. package/src/assets/commands/implement.mds +77 -98
  131. package/src/assets/commands/plan.mds +27 -27
  132. package/src/assets/commands/release.mds +12 -12
  133. package/src/assets/commands/research.mds +19 -19
  134. package/src/assets/commands/resolve.mds +66 -60
  135. package/src/assets/commands/self-review.mds +20 -20
  136. package/src/assets/mds/git/_ops.mds +9 -2
  137. package/src/assets/mds/git/_ops_pr.mds +21 -11
  138. package/src/assets/mds/git/_ops_steps.mds +20 -4
  139. package/src/assets/mds/git/_pr.mds +21 -18
  140. package/src/assets/mds/git/_references.mds +6 -7
  141. package/src/assets/mds/tracker/_github.mds +1 -4
  142. package/src/assets/mds/tracker/_jira.mds +1 -4
  143. package/src/assets/mds/tracker/_linear.mds +1 -4
  144. package/src/assets/rules/quality.md +1 -0
  145. package/src/assets/scripts/ci-wait.cjs +389 -92
  146. package/src/assets/scripts/hooks/background-memory-update +81 -3
  147. package/src/assets/scripts/hooks/capture-prompt +12 -12
  148. package/src/assets/scripts/hooks/capture-question +11 -11
  149. package/src/assets/scripts/hooks/capture-turn +12 -12
  150. package/src/assets/scripts/hooks/{learning-lock → curation-lock} +2 -2
  151. package/src/assets/scripts/hooks/curation-move +293 -0
  152. package/src/assets/scripts/hooks/ensure-devflow-init +45 -13
  153. package/src/assets/scripts/hooks/ensure-proxy +4 -4
  154. package/src/assets/scripts/hooks/ensure-root-gitignore +4 -4
  155. package/src/assets/scripts/hooks/git-marker +81 -0
  156. package/src/assets/scripts/hooks/json-helper.cjs +176 -51
  157. package/src/assets/scripts/hooks/json-parse +1 -1
  158. package/src/assets/scripts/hooks/lib/curation-move.cjs +1582 -0
  159. package/src/assets/scripts/hooks/lib/{learning-store.cjs → curation-store.cjs} +308 -122
  160. package/src/assets/scripts/hooks/lib/decisions-format.cjs +3 -3
  161. package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +24 -7
  162. package/src/assets/scripts/hooks/lib/project-paths.cjs +132 -39
  163. package/src/assets/scripts/hooks/lib/render-decisions.cjs +42 -22
  164. package/src/assets/scripts/hooks/lib/tracked-areas.cjs +452 -0
  165. package/src/assets/scripts/hooks/memory-permission +10 -2
  166. package/src/assets/scripts/hooks/memory-worker +12 -1
  167. package/src/assets/scripts/hooks/pre-compact-memory +17 -16
  168. package/src/assets/scripts/hooks/queue-append +444 -102
  169. package/src/assets/scripts/hooks/resolve-project-root +2 -2
  170. package/src/assets/scripts/hooks/session-start-context +198 -72
  171. package/src/assets/scripts/hooks/session-start-memory +42 -43
  172. package/src/assets/scripts/lib/project-config.cjs +12 -5
  173. package/src/assets/scripts/pr-evidence.cjs +5 -2
  174. package/src/assets/scripts/resolve-base.cjs +1043 -0
  175. package/src/assets/scripts/resolve-settings.cjs +283 -45
  176. package/src/assets/scripts/verify-evidence.cjs +307 -75
  177. package/src/assets/skills/agent-references/SKILL.md +11 -2
  178. package/src/assets/skills/agent-references/references/commands/implement-levels.md +29 -0
  179. package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +6 -4
  180. package/src/assets/skills/agent-references/references/commands/resolve-levels.md +25 -0
  181. package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
  182. package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
  183. package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
  184. package/src/assets/skills/apply-decisions/SKILL.md +1 -1
  185. package/src/assets/skills/docs-framework/SKILL.md +7 -7
  186. package/src/assets/skills/docs-framework/references/patterns.md +3 -4
  187. package/src/assets/skills/quality-gates/SKILL.md +1 -1
  188. package/src/assets/skills/worktree-support/references/roots.md +2 -2
  189. package/templates/lint-pack/.lint-pack.conf +86 -0
  190. package/templates/lint-pack/README.md +190 -0
  191. package/templates/lint-pack/lint-pack.sh +905 -0
  192. package/dist/cli/commands/learning.js +0 -554
  193. package/dist/core/learning-queue-cleanup.js +0 -27
  194. package/dist/core/learning-store.js +0 -61
  195. /package/dist/{learning-off → curation-off}/agents/debug.md +0 -0
  196. /package/dist/{learning-off → curation-off}/agents/design.md +0 -0
  197. /package/dist/{learning-off → curation-off}/agents/harden.md +0 -0
  198. /package/dist/{learning-off → curation-off}/agents/knowledge.md +0 -0
  199. /package/dist/{learning-off → curation-off}/agents/research.md +0 -0
  200. /package/dist/{learning-off → curation-off}/agents/skim.md +0 -0
  201. /package/dist/{learning-off → curation-off}/commands/dynamic-plan.md +0 -0
  202. /package/dist/{learning-off → curation-off}/commands/dynamic-profile.md +0 -0
  203. /package/dist/{learning-off → curation-off}/commands/dynamic-tickets.md +0 -0
  204. /package/dist/{learning-off → curation-off}/commands/release.md +0 -0
  205. /package/dist/{learning-off → curation-off}/commands/research.md +0 -0
@@ -0,0 +1,1043 @@
1
+ #!/usr/bin/env node
2
+ // src/assets/scripts/resolve-base.cjs
3
+ //
4
+ // The base for a branch diff (#495: use a fresh base for branch diffs). A branch diff
5
+ // that reads the local base branch lists the files of every commit the local branch is
6
+ // missing from the remote. This script fetches the base once, picks the tip to diff
7
+ // against by one stated rule, and prints the fork point of that tip and HEAD. Installed
8
+ // beside ci-wait.cjs under ~/.devflow/scripts/.
9
+ //
10
+ // Usage, from the worktree (the caller changes into it first):
11
+ // node resolve-base.cjs <local|pr> --name-stdin [--cross <true|false>] < name-file
12
+ // local the no-PR rule: origin's tip is chosen only when the local fork point
13
+ // is an ancestor of origin's fork point
14
+ // pr the PR rule: origin's tip whenever it resolves (fresh, or cached after a
15
+ // failed fetch), otherwise the local tip
16
+ // --name-stdin the base branch name is read from standard input (see below)
17
+ // --cross true for a cross-repository PR: no fetch, the local tip, REASON fork-origin
18
+ // node resolve-base.cjs <local|pr> <name> [--cross <true|false>]
19
+ // The same run with the name as an argument. This form is for in-process tests only: no
20
+ // prompt uses it, and tests/guards/base-ref-guard.test.ts forbids it in prompts. A follow-up
21
+ // issue tracks its removal. This form never reads standard input. A positional name next to
22
+ // --name-stdin gives REASON usage, and nothing is read.
23
+ //
24
+ // Standard input (--name-stdin): the name is one line of standard input, from a file or a
25
+ // pipe. Exactly one trailing LF or CRLF is removed, and nothing else is trimmed. A name is at
26
+ // most 256 bytes. The loop makes repeated reads of descriptor 0 and reads up to 257 bytes: the
27
+ // one byte past the cap tells an over-long input from a name at the cap, so a name is never cut
28
+ // short. A terminal is never read. Empty input (zero bytes), unreadable input and terminal input
29
+ // give REASON resolver-unavailable. A lone line end leaves an empty name, which the strict class
30
+ // refuses with REASON invalid-name. Input over the cap, or holding a NUL, a CR, a non-ASCII byte
31
+ // or a second line, also gives REASON invalid-name. A non-blocking descriptor with no input yet
32
+ // is waited for inside RUN_BUDGET_MS. A read of a blocking pipe returns only when input arrives
33
+ // or the writer closes the pipe, and RUN_BUDGET_MS does not bound that read, so a caller passes
34
+ // a file (`< "$F"`) or a pipe whose writer ends. Every stdin error maps to a REASON in
35
+ // REASON_NOTES.
36
+ //
37
+ // stdout is exactly one line, ending in a newline, exit 0 on every handled path:
38
+ // BASE_REF=<40hex|64hex|none> TIP=<origin|local|none> FETCH=<ok|failed|skipped> REASON=<code> NOTE=<none|info|degraded|stop>
39
+ // BASE_REF the merge base of the chosen tip and HEAD, verified as a commit; none when
40
+ // there is no usable base
41
+ // TIP the tip that won; none when no tip was used
42
+ // FETCH ok only when the fetch exited 0; failed when it ran and failed; skipped
43
+ // when it did not run. A ref that resolves proves the ref exists, not that
44
+ // it is fresh, so FETCH is taken from the fetch exit status alone.
45
+ // REASON one code from REASON_NOTES; NOTE is derived from it (none, info, degraded,
46
+ // stop). The output is checked against LINE_RE and against that table before
47
+ // it is written. Anything that fails the check prints FALLBACK_LINE.
48
+ // stderr stays empty. The name is never written to stdout or stderr. When stdout cannot
49
+ // be written, the script exits 3 with nothing on stderr; every other path exits 0 with
50
+ // one line.
51
+ //
52
+ // Check order: the read of the name (--name-stdin only), then the strict name class
53
+ // (STRICT_NAME_RE), then `git check-ref-format`, then
54
+ // the origin URL, then the cross-repository flag, then the fetch (after the read of
55
+ // core.sshCommand), then the tip of each ref, then the merge base of each tip with HEAD,
56
+ // then the ancestry check (the no-PR rule only), then the choice of tip, then the
57
+ // verification of BASE_REF. A name that fails either name check makes no further git call.
58
+ //
59
+ // Design constraints (binding):
60
+ // - Plumbing: every decision here is a fixed comparison of git answers. The ancestry
61
+ // checks are deterministic, and the keep-local policy is a rule, not a judgement.
62
+ // - Keep-local policy: in the no-PR rule a local base that holds commits origin lacks
63
+ // (local-ahead or diverged) stays the base. The branch was cut from it, so those
64
+ // commits are the branch's own context. Only a local fork point that is an ancestor
65
+ // of origin's moves the base to origin.
66
+ // - Tri-state ancestry (`merge-base --is-ancestor`): exit 0 is true, exit 1 is false,
67
+ // any other exit is unknown. Unknown is never read as false. It gives no-merge-base.
68
+ // A call that ends with no exit of its own is covered by the next point.
69
+ // - A local git call that gives no answer (a timeout, a signal kill, a git that does not
70
+ // start) gives resolver-unavailable, at every call site: the format check, the origin
71
+ // URL, the core.sshCommand read, the tip of each ref, the merge base of each tip, the
72
+ // ancestry check and the verification. A call that exits on its own is an answer, and
73
+ // its result maps by its own rule: a merge base that exits non-zero or is not a commit
74
+ // ID is no merge base for that tip, and the choice gives no-merge-base when it needs the
75
+ // tip; an exit that the call site does not accept gives resolver-unavailable, except at
76
+ // the core.sshCommand read, where an exit other than 0 or 1 counts as configured, so a
77
+ // command is never replaced on a guess.
78
+ // - The fetch is one call with a fixed argv: it writes only refs/remotes/origin/<name>
79
+ // (an empty --refmap= keeps the configured fetch refspecs out of the update), writes no
80
+ // FETCH_HEAD, follows no tags, and carries no --filter or --depth. The
81
+ // repository's hook, fsmonitor and upload-pack settings do not apply to it. An HTTP
82
+ // transfer under 1 byte/s for FETCH_STALL_SECONDS ends by itself (http.lowSpeedLimit and
83
+ // http.lowSpeedTime). The caller's GIT_HTTP_LOW_SPEED_LIMIT and GIT_HTTP_LOW_SPEED_TIME
84
+ // variables and a URL-specific http.<url>.lowSpeedLimit or http.<url>.lowSpeedTime key
85
+ // take precedence over these two options, and an SSH transfer has no such bound.
86
+ // Credential prompts are off: GIT_TERMINAL_PROMPT is 0, GIT_ASKPASS
87
+ // and SSH_ASKPASS are empty, so git runs no askpass program, and the default SSH command
88
+ // carries BatchMode=yes.
89
+ // BatchMode and ConnectTimeout are added only when no SSH command is configured
90
+ // (GIT_SSH_COMMAND, GIT_SSH or core.sshCommand); a configured command is never
91
+ // replaced, and host-key checks are never weakened.
92
+ // - The fetch is bounded at FETCH_TIMEOUT_MS. A fetch that does not exit on its own is
93
+ // followed by a kill of its whole process group (POSIX), so no helper survives. The kill
94
+ // is SIGKILL, so git cannot remove the lock of the ref it was updating. A lock file
95
+ // left at refs/remotes/origin/<name>.lock is read by every later run as fetch-failed,
96
+ // until the user deletes it. This is rare: git holds the lock only for the short ref
97
+ // update at the end of the fetch. The fetch runs in its own session. If the resolver is
98
+ // interrupted before its timer fires, the stall bound in the argv still ends an HTTP
99
+ // fetch that gets no answer, where the precedence above leaves it in force.
100
+ // - One run budget, RUN_BUDGET_MS, stays below the 120 s Bash tool limit. Each call takes
101
+ // the smaller of its own bound and the time left in the budget. A local call with no
102
+ // time left is not started and gives resolver-unavailable; the fetch with no time left
103
+ // gives timeout.
104
+ // - No retry, including on a held ref lock. A failed fetch with a cached origin ref
105
+ // applies the rule to the cached ref (REASON fetch-failed or timeout, unverified).
106
+ // - Git floor: 2.29 (the flag set) is asserted, not probed. An unknown option exits
107
+ // non-zero and reads as fetch-failed.
108
+ // - Every subprocess is `git`, started with an argv array and `shell: false`, stdin
109
+ // ignored, a timeout and a maxBuffer. Every git call carries `-c core.fsmonitor=false`.
110
+ // The script's own standard input is read only for --name-stdin, and only by the bounded
111
+ // loop in readNameFromStdin.
112
+ // - Nothing here calls process.exit(); the boundary sets process.exitCode.
113
+ // - Helpers (the fetch, the ancestry check, the commit check, the child environment)
114
+ // follow verify-evidence.cjs and resolve-evidence-policy.cjs. They are copied, not
115
+ // imported, so those scripts stay untouched.
116
+
117
+ 'use strict';
118
+
119
+ const childProcess = require('child_process');
120
+ const fs = require('fs');
121
+ const tty = require('tty');
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Closed vocabularies and bounds
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * The strict base-name class, one literal. Every prompt that checks a base name copies its
129
+ * `source` exactly, and a test holds each copy equal to this export. The one deliberate
130
+ * variant is the dynamic-build helper's BRANCH_NAME: a ticket branch is local, so the helper
131
+ * holds this class less the `origin/` refusal, and a test holds that relation. In words: 1 to
132
+ * 200 ASCII letters, digits and `._/+@,#-`; not starting with `-`, `#` or `origin/`; no `..`;
133
+ * not the whole name `@`, `HEAD`, `FETCH_HEAD`, `ORIG_HEAD` or `MERGE_HEAD`.
134
+ */
135
+ const STRICT_NAME_RE = /^(?![-#]|origin\/|(?:@|(?:FETCH_|ORIG_|MERGE_)?HEAD)$)(?!.*\.\.)[A-Za-z0-9._\/+@,#-]{1,200}$/;
136
+
137
+ /** REASON to NOTE: the only written copy of the table. */
138
+ const REASON_NOTES = Object.freeze({
139
+ 'fresh': 'none',
140
+ 'kept-local-ahead': 'none',
141
+ 'no-remote-branch': 'none',
142
+ 'fork-origin': 'info',
143
+ 'no-origin': 'info',
144
+ 'fetch-failed': 'degraded',
145
+ 'timeout': 'degraded',
146
+ 'invalid-name': 'stop',
147
+ 'no-merge-base': 'stop',
148
+ 'no-ref': 'stop',
149
+ 'usage': 'stop',
150
+ 'resolver-unavailable': 'stop',
151
+ });
152
+
153
+ /**
154
+ * The output grammar: anchored, closed alternations only, named groups for readers.
155
+ * Prompts never copy it; the script re-checks its own line against it before printing.
156
+ */
157
+ const LINE_RE = /^BASE_REF=(?<baseRef>[0-9a-f]{40}|[0-9a-f]{64}|none) TIP=(?<tip>origin|local|none) FETCH=(?<fetch>ok|failed|skipped) REASON=(?<reason>fresh|kept-local-ahead|no-remote-branch|fork-origin|no-origin|fetch-failed|timeout|invalid-name|no-merge-base|no-ref|usage|resolver-unavailable) NOTE=(?<note>none|info|degraded|stop)$/;
158
+
159
+ /** The line every unhandled failure prints. A constant, so the boundary can always compose it. */
160
+ const FALLBACK_LINE = 'BASE_REF=none TIP=none FETCH=skipped REASON=resolver-unavailable NOTE=stop';
161
+
162
+ /** The FETCH value a reason implies, where it implies one. A freshness claim needs an ok fetch. */
163
+ const FETCH_IMPLIED = Object.freeze({
164
+ 'fresh': 'ok',
165
+ 'kept-local-ahead': 'ok',
166
+ 'no-remote-branch': 'failed',
167
+ 'fetch-failed': 'failed',
168
+ 'timeout': 'failed',
169
+ 'fork-origin': 'skipped',
170
+ 'no-origin': 'skipped',
171
+ 'invalid-name': 'skipped',
172
+ 'usage': 'skipped',
173
+ });
174
+
175
+ const HEX_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
176
+
177
+ /** The wall-clock backstop for the one fetch. */
178
+ const FETCH_TIMEOUT_MS = 45000;
179
+ /**
180
+ * The git-side stall bound of the fetch, in seconds: an HTTP transfer under 1 byte/s for this
181
+ * long ends by itself. It equals the fetch bound, so while the resolver runs its own timer
182
+ * ends a stalled fetch first and the reason stays `timeout`.
183
+ */
184
+ const FETCH_STALL_SECONDS = FETCH_TIMEOUT_MS / 1000;
185
+ /**
186
+ * The bound of every other git call, which is local and short. The bound counts the launch
187
+ * of git. The launch of node stalled 14 to 16 s on a loaded macOS machine, and git launches
188
+ * did not stall. 30 s keeps about twice that stall and stays below FETCH_TIMEOUT_MS.
189
+ */
190
+ const LOCAL_TIMEOUT_MS = 30000;
191
+ /**
192
+ * The budget of one whole run. The prompts call this script under the 120 s Bash tool
193
+ * limit, and 100 s leaves the rest for the launch of node.
194
+ */
195
+ const RUN_BUDGET_MS = 100000;
196
+ /**
197
+ * The most bytes of a name read from standard input. The strict class admits 200, and the rest
198
+ * is room for the line end. One byte more than this is read, to tell a name at the cap from
199
+ * input over it.
200
+ */
201
+ const STDIN_CAP_BYTES = 256;
202
+ /** How long one wait for input sleeps when descriptor 0 has no input yet. */
203
+ const STDIN_WAIT_MS = 10;
204
+ /** The most waits in one read of standard input; their total never passes RUN_BUDGET_MS. */
205
+ const STDIN_MAX_WAITS = RUN_BUDGET_MS / STDIN_WAIT_MS;
206
+ /** What Atomics.wait sleeps on: nothing notifies it, so each wait runs its full time. */
207
+ const STDIN_WAIT_CELL = new Int32Array(new SharedArrayBuffer(4));
208
+ /** rev-parse, merge-base and config print one short line. */
209
+ const LINE_MAX_BUFFER = 4096;
210
+ /** The fetch's stderr is read for one phrase and discarded. */
211
+ const FETCH_MAX_BUFFER = 1048576;
212
+
213
+ /** Added to the fetch child only when no SSH command is configured. */
214
+ const SSH_DEFAULT_COMMAND = 'ssh -o BatchMode=yes -o ConnectTimeout=5';
215
+ /** git's message (LC_ALL=C) when the remote has no such branch; exit 128. */
216
+ const MISSING_BRANCH_TEXT = "couldn't find remote ref";
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // The name, the arguments
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * @param {unknown} name
224
+ * @returns {boolean}
225
+ */
226
+ function isStrictName(name) {
227
+ return typeof name === 'string' && STRICT_NAME_RE.test(name);
228
+ }
229
+
230
+ /**
231
+ * @typedef {{ kind: 'ok', mode: 'local' | 'pr', name: string, cross: boolean }
232
+ * | { kind: 'stdin', mode: 'local' | 'pr', cross: boolean }
233
+ * | { kind: 'usage' }} ParsedArgs
234
+ */
235
+
236
+ /** The one argument that takes the name from standard input; it stands where the name would. */
237
+ const STDIN_FLAG = '--name-stdin';
238
+
239
+ /**
240
+ * `<local|pr> --name-stdin [--cross <true|false>]` (the name comes from standard input) or
241
+ * `<local|pr> <name> [--cross <true|false>]`. The name is any string here: the later checks
242
+ * decide about it, and a usage result never carries it. `--name-stdin` is read only at the
243
+ * name slot, so a positional name next to it is a usage result.
244
+ *
245
+ * @param {readonly string[]} args argv without the node binary and the script path
246
+ * @returns {ParsedArgs}
247
+ */
248
+ function parseArgs(args) {
249
+ if (!Array.isArray(args) || (args.length !== 2 && args.length !== 4)) return { kind: 'usage' };
250
+ const mode = args[0];
251
+ if (mode !== 'local' && mode !== 'pr') return { kind: 'usage' };
252
+ let cross = false;
253
+ if (args.length === 4) {
254
+ if (args[2] !== '--cross' || (args[3] !== 'true' && args[3] !== 'false')) return { kind: 'usage' };
255
+ cross = args[3] === 'true';
256
+ }
257
+ if (args[1] === STDIN_FLAG) return { kind: 'stdin', mode, cross };
258
+ return { kind: 'ok', mode, name: String(args[1]), cross };
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // The line
263
+ // ---------------------------------------------------------------------------
264
+
265
+ /**
266
+ * @typedef {{ baseRef: string, tip: 'origin' | 'local' | 'none', fetch: 'ok' | 'failed' | 'skipped', reason: string }} Selection
267
+ */
268
+
269
+ /**
270
+ * @param {Selection} r
271
+ * @returns {string}
272
+ */
273
+ function formatLine(r) {
274
+ return 'BASE_REF=' + r.baseRef + ' TIP=' + r.tip + ' FETCH=' + r.fetch + ' REASON=' + r.reason
275
+ + ' NOTE=' + REASON_NOTES[r.reason];
276
+ }
277
+
278
+ /**
279
+ * The gate on a composed line: the grammar, then the facts the grammar cannot see. NOTE
280
+ * follows REASON; BASE_REF and TIP are none together and exactly when NOTE is stop; a
281
+ * reason that implies a fetch state is printed with that state.
282
+ *
283
+ * @param {unknown} line
284
+ * @returns {boolean}
285
+ */
286
+ function checkLine(line) {
287
+ if (typeof line !== 'string') return false;
288
+ const m = line.match(LINE_RE);
289
+ if (m === null || m.groups === undefined) return false;
290
+ const { baseRef, tip, fetch, reason, note } = m.groups;
291
+ if (REASON_NOTES[reason] !== note) return false;
292
+ if ((baseRef === 'none') !== (note === 'stop')) return false;
293
+ if ((tip === 'none') !== (baseRef === 'none')) return false;
294
+ const implied = FETCH_IMPLIED[reason];
295
+ return implied === undefined || implied === fetch;
296
+ }
297
+
298
+ /**
299
+ * @param {string} reason
300
+ * @param {'ok' | 'failed' | 'skipped'} [fetch]
301
+ * @returns {Selection}
302
+ */
303
+ function stopWith(reason, fetch = 'skipped') {
304
+ return { baseRef: 'none', tip: 'none', fetch, reason };
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // The choice of tip (pure)
309
+ // ---------------------------------------------------------------------------
310
+
311
+ /**
312
+ * @typedef {{ base: string | null }} Side the fork point of one tip and HEAD; null when none
313
+ * @typedef {{
314
+ * mode: 'local' | 'pr',
315
+ * skip: null | 'no-origin' | 'fork-origin',
316
+ * fetch: { state: 'ok' | 'failed' | 'skipped', reason: null | 'fetch-failed' | 'timeout' | 'no-remote-branch' },
317
+ * local: Side | null,
318
+ * remote: Side | null,
319
+ * localBehind: boolean | null,
320
+ * }} Facts
321
+ * local / remote: null when that ref is absent. localBehind: whether the local fork point
322
+ * is an ancestor of origin's (true, false, or null when it cannot be told).
323
+ */
324
+
325
+ /**
326
+ * The fork points of both refs, or null when a ref is absent or has no fork point. Both the
327
+ * ancestry question and the no-PR choice need them.
328
+ *
329
+ * @param {Side | null} local
330
+ * @param {Side | null} remote
331
+ * @returns {{ local: string, remote: string } | null}
332
+ */
333
+ function forkPointsOf(local, remote) {
334
+ if (local === null || remote === null || local.base === null || remote.base === null) return null;
335
+ return { local: local.base, remote: remote.base };
336
+ }
337
+
338
+ /**
339
+ * Whether the remote reported that it has no such branch. A cached origin ref is then
340
+ * ignored: it is not read, and the choice does not use it.
341
+ *
342
+ * @param {FetchOutcome} fetch
343
+ * @returns {boolean}
344
+ */
345
+ function remoteLacksBranch(fetch) {
346
+ return fetch.reason === 'no-remote-branch';
347
+ }
348
+
349
+ /**
350
+ * Choose the tip from observed facts. No I/O.
351
+ *
352
+ * pr: origin whenever it resolves, otherwise local. local: origin only when the local fork
353
+ * point is an ancestor of origin's; a local ref alone or an origin ref alone is used as is.
354
+ * When the remote reported no such branch, a cached origin ref is ignored.
355
+ *
356
+ * @param {Facts} f
357
+ * @returns {Selection}
358
+ */
359
+ function select(f) {
360
+ const fetch = f.fetch.state;
361
+ const remote = remoteLacksBranch(f.fetch) ? null : f.remote;
362
+ const local = f.local;
363
+ if (local === null && remote === null) return stopWith('no-ref', fetch);
364
+
365
+ /** @type {'origin' | 'local'} */
366
+ let tip;
367
+ if (local === null) tip = 'origin';
368
+ else if (remote === null) tip = 'local';
369
+ else if (f.mode === 'pr') tip = 'origin';
370
+ else {
371
+ if (forkPointsOf(local, remote) === null || f.localBehind === null) return stopWith('no-merge-base', fetch);
372
+ tip = f.localBehind ? 'origin' : 'local';
373
+ }
374
+
375
+ // The branches above leave the chosen side present.
376
+ const side = /** @type {Side} */ (tip === 'origin' ? remote : local);
377
+ if (side.base === null) return stopWith('no-merge-base', fetch);
378
+ return { baseRef: side.base, tip, fetch, reason: successReason(f, tip) };
379
+ }
380
+
381
+ /**
382
+ * @param {Facts} f
383
+ * @param {'origin' | 'local'} tip
384
+ * @returns {string}
385
+ */
386
+ function successReason(f, tip) {
387
+ if (f.skip !== null) return f.skip;
388
+ if (f.fetch.state === 'failed' && f.fetch.reason !== null) return f.fetch.reason;
389
+ return tip === 'origin' ? 'fresh' : 'kept-local-ahead';
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Subprocesses: argv arrays, no shell, every call bounded
394
+ // ---------------------------------------------------------------------------
395
+
396
+ /**
397
+ * @typedef {{ status: number | null, stdout?: unknown, stderr?: unknown, error?: { code?: unknown } | null, pid?: number }} RawResult
398
+ * @typedef {(file: string, args: string[], opts: Record<string, unknown>) => RawResult} SpawnFn
399
+ * @typedef {{ isatty: () => boolean, read: (buf: Buffer, offset: number, length: number) => number, wait: (ms: number) => void }} StdinPort
400
+ * @typedef {{ spawn: SpawnFn, env: NodeJS.ProcessEnv, parentEnv: NodeJS.ProcessEnv, cwd: string, fetchTimeoutMs: number, platform: string,
401
+ * now: () => number, deadline: number, stdin: StdinPort }} Context
402
+ * fetchTimeoutMs: the bound of the fetch. now: a monotonic clock in ms. deadline: the end of the run budget on that clock.
403
+ * stdin: descriptor 0, as three operations: the terminal check, one read (which throws with a code, EAGAIN among them), one wait.
404
+ * @typedef {{ ok: boolean, answered: boolean, status: number | null, errorCode: string | null, stdout: string, stderr: string, pid: number }} CallResult
405
+ * @typedef {{ env: NodeJS.ProcessEnv, timeoutMs: number, maxBuffer: number, grouped: boolean }} CallOptions
406
+ * grouped: run as the leader of its own process group, killed with SIGKILL on a timeout
407
+ */
408
+
409
+ /**
410
+ * The production spawn. The shell is off whatever the options say.
411
+ *
412
+ * @type {SpawnFn}
413
+ */
414
+ function defaultSpawn(file, args, opts) {
415
+ return childProcess.spawnSync(file, [...args], Object.assign({}, opts, { shell: false }));
416
+ }
417
+
418
+ /** @param {unknown} value @returns {string} */
419
+ function asText(value) {
420
+ if (typeof value === 'string') return value;
421
+ return Buffer.isBuffer(value) ? value.toString('utf8') : '';
422
+ }
423
+
424
+ /**
425
+ * The environment every git call gets: the caller's, with every credential prompt off and
426
+ * the message language fixed. An empty GIT_ASKPASS makes git skip core.askPass and
427
+ * SSH_ASKPASS too, so it runs no askpass program, and the terminal prompt is off.
428
+ *
429
+ * @param {NodeJS.ProcessEnv} base
430
+ * @returns {NodeJS.ProcessEnv}
431
+ */
432
+ function childEnv(base) {
433
+ return Object.assign({}, base, {
434
+ GIT_TERMINAL_PROMPT: '0',
435
+ GCM_INTERACTIVE: 'never',
436
+ GIT_ASKPASS: '',
437
+ SSH_ASKPASS: '',
438
+ SSH_ASKPASS_REQUIRE: 'never',
439
+ LC_ALL: 'C',
440
+ });
441
+ }
442
+
443
+ /**
444
+ * Descriptor 0 itself: never /dev/stdin and never the size that fstat reports, because a pipe
445
+ * or a socket reports only what is buffered so far. The read takes the current file position.
446
+ *
447
+ * @type {StdinPort}
448
+ */
449
+ const DEFAULT_STDIN = Object.freeze({
450
+ isatty: () => tty.isatty(0),
451
+ read: (buf, offset, length) => fs.readSync(0, buf, offset, length, null),
452
+ wait: ms => { Atomics.wait(STDIN_WAIT_CELL, 0, 0, ms); },
453
+ });
454
+
455
+ /**
456
+ * @param {unknown} injected a test's replacement of any of the three operations
457
+ * @returns {StdinPort}
458
+ */
459
+ function stdinPortOf(injected) {
460
+ const d = /** @type {Partial<StdinPort>} */ (injected !== null && typeof injected === 'object' ? injected : {});
461
+ return {
462
+ isatty: typeof d.isatty === 'function' ? d.isatty : DEFAULT_STDIN.isatty,
463
+ read: typeof d.read === 'function' ? d.read : DEFAULT_STDIN.read,
464
+ wait: typeof d.wait === 'function' ? d.wait : DEFAULT_STDIN.wait,
465
+ };
466
+ }
467
+
468
+ /** @returns {number} milliseconds on a clock that never goes back */
469
+ function monotonicMs() {
470
+ const [seconds, nanoseconds] = process.hrtime();
471
+ return seconds * 1e3 + nanoseconds / 1e6;
472
+ }
473
+
474
+ /**
475
+ * @param {{ spawn?: unknown, env?: unknown, cwd?: unknown, fetchTimeoutMs?: unknown, platform?: unknown, now?: unknown, stdin?: unknown }} [deps]
476
+ * @returns {Context}
477
+ */
478
+ function makeContext(deps) {
479
+ const d = deps || {};
480
+ const now = typeof d.now === 'function' ? /** @type {() => number} */ (d.now) : monotonicMs;
481
+ const parentEnv = d.env !== null && typeof d.env === 'object' ? /** @type {NodeJS.ProcessEnv} */ (d.env) : process.env;
482
+ const injected = typeof d.fetchTimeoutMs === 'number' && Number.isInteger(d.fetchTimeoutMs) && d.fetchTimeoutMs > 0 ? d.fetchTimeoutMs : FETCH_TIMEOUT_MS;
483
+ return {
484
+ spawn: typeof d.spawn === 'function' ? /** @type {SpawnFn} */ (d.spawn) : defaultSpawn,
485
+ env: childEnv(parentEnv),
486
+ parentEnv,
487
+ cwd: typeof d.cwd === 'string' && d.cwd !== '' ? d.cwd : process.cwd(),
488
+ fetchTimeoutMs: Math.min(injected, FETCH_TIMEOUT_MS),
489
+ platform: typeof d.platform === 'string' ? d.platform : process.platform,
490
+ now,
491
+ deadline: now() + RUN_BUDGET_MS,
492
+ stdin: stdinPortOf(d.stdin),
493
+ };
494
+ }
495
+
496
+ /**
497
+ * One bounded subprocess call, normalized. `answered` is true when the process ran to
498
+ * an exit of its own: a call that never started, timed out, was killed by a signal or
499
+ * overflowed did not answer. The timeout is the smaller of the call's own bound and the time
500
+ * left in the run budget; with no time left the call is not started and counts as timed out.
501
+ * The argv array is a fresh copy, so a spawn cannot mutate a caller's constant.
502
+ *
503
+ * @param {Context} ctx
504
+ * @param {string} file
505
+ * @param {readonly string[]} args
506
+ * @param {CallOptions} opts
507
+ * @returns {CallResult}
508
+ */
509
+ function runCall(ctx, file, args, opts) {
510
+ const left = ctx.deadline - ctx.now();
511
+ if (!(left > 0)) {
512
+ return { ok: false, answered: false, status: null, errorCode: 'ETIMEDOUT', stdout: '', stderr: '', pid: 0 };
513
+ }
514
+ const res = ctx.spawn(file, [...args], Object.assign({
515
+ cwd: ctx.cwd,
516
+ env: opts.env,
517
+ stdio: ['ignore', 'pipe', 'pipe'],
518
+ timeout: Math.max(1, Math.min(opts.timeoutMs, Math.floor(left))),
519
+ maxBuffer: opts.maxBuffer,
520
+ windowsHide: true,
521
+ shell: false,
522
+ }, opts.grouped ? { detached: ctx.platform !== 'win32', killSignal: 'SIGKILL' } : {}));
523
+ const raw = res !== null && typeof res === 'object' ? res : /** @type {RawResult} */ ({ status: null });
524
+ const errorCode = raw.error ? String(raw.error.code || 'EUNKNOWN') : null;
525
+ const status = typeof raw.status === 'number' ? raw.status : null;
526
+ return {
527
+ ok: errorCode === null && status === 0,
528
+ answered: errorCode === null && status !== null,
529
+ status,
530
+ errorCode,
531
+ stdout: asText(raw.stdout),
532
+ stderr: asText(raw.stderr),
533
+ pid: typeof raw.pid === 'number' && Number.isInteger(raw.pid) ? raw.pid : 0,
534
+ };
535
+ }
536
+
537
+ /**
538
+ * A local git call. The override keeps a repository's fsmonitor command from running.
539
+ *
540
+ * @param {Context} ctx
541
+ * @param {readonly string[]} args
542
+ * @returns {CallResult}
543
+ */
544
+ function git(ctx, args) {
545
+ return runCall(ctx, 'git', ['-c', 'core.fsmonitor=false', ...args], {
546
+ env: ctx.env, timeoutMs: LOCAL_TIMEOUT_MS, maxBuffer: LINE_MAX_BUFFER, grouped: false,
547
+ });
548
+ }
549
+
550
+ /**
551
+ * Kill every process of a group. ESRCH (nothing left) and any other refusal are ignored.
552
+ * A pid of 0 or 1 would reach the caller's own group or init, so it is never signalled.
553
+ *
554
+ * @param {number} pid the group leader
555
+ * @param {string} platform
556
+ * @returns {void}
557
+ */
558
+ function killGroup(pid, platform) {
559
+ if (platform === 'win32' || !Number.isInteger(pid) || pid <= 1) return;
560
+ try {
561
+ process.kill(-pid, 'SIGKILL');
562
+ } catch (_) {
563
+ // the group is already gone
564
+ }
565
+ }
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // The fetch
569
+ // ---------------------------------------------------------------------------
570
+
571
+ /**
572
+ * The exact fetch argv. The null device switches off the repository's hooks, fsmonitor is
573
+ * off, and `--upload-pack` replaces the repository's configured program. The two http.lowSpeed
574
+ * options end a stalled HTTP transfer after FETCH_STALL_SECONDS. The empty `--refmap=`
575
+ * keeps the configured remote.origin.fetch refspecs out of the update, so the one refspec
576
+ * given is the only ref written. Never --filter, never --depth. The name has passed the
577
+ * strict class; this sink holds that precondition.
578
+ *
579
+ * @param {string} name
580
+ * @param {string} [platform]
581
+ * @returns {string[]}
582
+ */
583
+ function fetchArgv(name, platform) {
584
+ if (!isStrictName(name)) throw new Error('resolve-base: unchecked name');
585
+ const nullDevice = (platform === undefined ? process.platform : platform) === 'win32' ? 'NUL' : '/dev/null';
586
+ return [
587
+ '-c', 'core.fsmonitor=false',
588
+ '-c', 'core.hooksPath=' + nullDevice,
589
+ '-c', 'http.lowSpeedLimit=1', '-c', 'http.lowSpeedTime=' + FETCH_STALL_SECONDS,
590
+ 'fetch', '--no-tags', '--no-write-fetch-head', '--no-recurse-submodules', '--no-auto-maintenance',
591
+ '--no-show-forced-updates', '--quiet', '--refmap=', '--upload-pack=git-upload-pack',
592
+ 'origin', '+refs/heads/' + name + ':refs/remotes/origin/' + name,
593
+ ];
594
+ }
595
+
596
+ /**
597
+ * Whether any SSH command is configured: GIT_SSH_COMMAND or GIT_SSH in the caller's
598
+ * environment, or core.sshCommand in the repository's configuration. An answer that cannot
599
+ * be read counts as configured, so a command is never replaced on a guess. No command
600
+ * string is parsed. null when the configuration read gave no answer at all.
601
+ *
602
+ * @param {Context} ctx
603
+ * @returns {boolean | null}
604
+ */
605
+ function sshCommandConfigured(ctx) {
606
+ if (ctx.parentEnv.GIT_SSH_COMMAND !== undefined || ctx.parentEnv.GIT_SSH !== undefined) return true;
607
+ const r = git(ctx, ['config', '--get', 'core.sshCommand']);
608
+ if (!r.answered) return null;
609
+ return r.status !== 1;
610
+ }
611
+
612
+ /**
613
+ * @typedef {{ state: 'ok' | 'failed' | 'skipped', reason: null | 'fetch-failed' | 'timeout' | 'no-remote-branch' }} FetchOutcome
614
+ */
615
+
616
+ /**
617
+ * Fetch the base branch once. Exit 0 is ok. A timeout is `timeout`. Exit 128 with git's
618
+ * missing-ref text (LC_ALL=C) is `no-remote-branch`. Any other failure is `fetch-failed`.
619
+ * The captured stderr is read for that one phrase and dropped. A fetch that did not exit
620
+ * on its own is followed by a kill of its process group.
621
+ *
622
+ * @param {Context} ctx
623
+ * @param {string} name
624
+ * @param {boolean} sshConfigured whether an SSH command is configured, so none is added
625
+ * @returns {FetchOutcome}
626
+ */
627
+ function fetchBase(ctx, name, sshConfigured) {
628
+ const env = Object.assign({}, ctx.env);
629
+ if (!sshConfigured) env.GIT_SSH_COMMAND = SSH_DEFAULT_COMMAND;
630
+ const r = runCall(ctx, 'git', fetchArgv(name, ctx.platform), {
631
+ env, timeoutMs: ctx.fetchTimeoutMs, maxBuffer: FETCH_MAX_BUFFER, grouped: true,
632
+ });
633
+ if (!r.answered) killGroup(r.pid, ctx.platform);
634
+ if (r.ok) return { state: 'ok', reason: null };
635
+ if (r.errorCode === 'ETIMEDOUT') return { state: 'failed', reason: 'timeout' };
636
+ if (r.status === 128 && r.stderr.includes(MISSING_BRANCH_TEXT)) return { state: 'failed', reason: 'no-remote-branch' };
637
+ return { state: 'failed', reason: 'fetch-failed' };
638
+ }
639
+
640
+ // ---------------------------------------------------------------------------
641
+ // Git facts
642
+ // ---------------------------------------------------------------------------
643
+
644
+ /** @param {string} text @returns {string} */
645
+ function lineOf(text) {
646
+ return text.replace(/\r?\n$/, '');
647
+ }
648
+
649
+ /**
650
+ * @typedef {{ kind: 'sha', sha: string } | { kind: 'absent' } | { kind: 'unknown' }} Tip
651
+ */
652
+
653
+ /**
654
+ * `rev-parse --verify --quiet <ref>^{commit}`: the commit a full ref names (exit 0), an
655
+ * absent ref (exit 1), or unknown for anything else. Full refs only, so a tag or a
656
+ * pseudo-ref of the same name is never read.
657
+ *
658
+ * @param {Context} ctx
659
+ * @param {string} fullRef
660
+ * @returns {Tip}
661
+ */
662
+ function tipOf(ctx, fullRef) {
663
+ const r = git(ctx, ['rev-parse', '--verify', '--quiet', fullRef + '^{commit}']);
664
+ if (!r.answered) return { kind: 'unknown' };
665
+ if (r.status === 1) return { kind: 'absent' };
666
+ const sha = r.status === 0 ? lineOf(r.stdout) : '';
667
+ return HEX_RE.test(sha) ? { kind: 'sha', sha } : { kind: 'unknown' };
668
+ }
669
+
670
+ /**
671
+ * The merge base of a commit and HEAD. It must exit 0 with 40 or 64 hex on stdout;
672
+ * any other answer, such as unrelated or truncated history, is no merge base. A call that
673
+ * gives no answer is unknown.
674
+ *
675
+ * @param {Context} ctx
676
+ * @param {string} sha
677
+ * @returns {{ kind: 'ok', base: string | null } | { kind: 'unknown' }}
678
+ */
679
+ function mergeBaseOf(ctx, sha) {
680
+ const r = git(ctx, ['merge-base', sha, 'HEAD']);
681
+ if (!r.answered) return { kind: 'unknown' };
682
+ const out = r.status === 0 ? lineOf(r.stdout) : '';
683
+ return { kind: 'ok', base: HEX_RE.test(out) ? out : null };
684
+ }
685
+
686
+ /**
687
+ * Tri-state ancestry: true (exit 0), false (exit 1), null for any other exit. A call that
688
+ * gives no answer is unknown.
689
+ *
690
+ * @param {Context} ctx
691
+ * @param {string} older
692
+ * @param {string} newer
693
+ * @returns {{ kind: 'ok', ancestor: boolean | null } | { kind: 'unknown' }}
694
+ */
695
+ function isAncestor(ctx, older, newer) {
696
+ const r = git(ctx, ['merge-base', '--is-ancestor', older, newer]);
697
+ if (!r.answered) return { kind: 'unknown' };
698
+ if (r.status === 0) return { kind: 'ok', ancestor: true };
699
+ if (r.status === 1) return { kind: 'ok', ancestor: false };
700
+ return { kind: 'ok', ancestor: null };
701
+ }
702
+
703
+ /**
704
+ * `rev-parse --verify --quiet <sha>^{commit}` names the same commit back.
705
+ *
706
+ * @param {Context} ctx
707
+ * @param {string} sha
708
+ * @returns {boolean}
709
+ */
710
+ function commitResolves(ctx, sha) {
711
+ const r = git(ctx, ['rev-parse', '--verify', '--quiet', sha + '^{commit}']);
712
+ return r.ok && lineOf(r.stdout) === sha;
713
+ }
714
+
715
+ /**
716
+ * The two name checks: the strict class, then `git check-ref-format`. A name that fails the
717
+ * class makes no git call.
718
+ *
719
+ * @param {Context} ctx
720
+ * @param {string} name
721
+ * @returns {string | null} the stop reason, or null when the name passes both
722
+ */
723
+ function checkName(ctx, name) {
724
+ if (!isStrictName(name)) return 'invalid-name';
725
+ const format = git(ctx, ['check-ref-format', 'refs/heads/' + name]);
726
+ if (!format.answered) return 'resolver-unavailable';
727
+ if (format.status === 1) return 'invalid-name';
728
+ return format.status === 0 ? null : 'resolver-unavailable';
729
+ }
730
+
731
+ /**
732
+ * Whether the fetch is skipped: no origin URL (a missing key or an empty value), or a
733
+ * cross-repository PR. An unanswered call, or an exit other than 0 or 1, is unknown.
734
+ *
735
+ * @param {Context} ctx
736
+ * @param {boolean} cross
737
+ * @returns {{ kind: 'ok', skip: null | 'no-origin' | 'fork-origin' } | { kind: 'unknown' }}
738
+ */
739
+ function originSkip(ctx, cross) {
740
+ const origin = git(ctx, ['config', '--get', 'remote.origin.url']);
741
+ if (!origin.answered) return { kind: 'unknown' };
742
+ if (origin.status === 1 || (origin.status === 0 && lineOf(origin.stdout) === '')) return { kind: 'ok', skip: 'no-origin' };
743
+ if (origin.status !== 0) return { kind: 'unknown' };
744
+ return { kind: 'ok', skip: cross ? 'fork-origin' : null };
745
+ }
746
+
747
+ /**
748
+ * The fork point of one tip and HEAD: no side for an absent ref, otherwise the merge base.
749
+ *
750
+ * @param {Context} ctx
751
+ * @param {Tip} tip a tip that is not unknown
752
+ * @returns {{ kind: 'ok', side: Side | null } | { kind: 'unknown' }}
753
+ */
754
+ function sideOf(ctx, tip) {
755
+ if (tip.kind !== 'sha') return { kind: 'ok', side: null };
756
+ const m = mergeBaseOf(ctx, tip.sha);
757
+ return m.kind === 'unknown' ? m : { kind: 'ok', side: { base: m.base } };
758
+ }
759
+
760
+ /**
761
+ * The fork point of each ref and HEAD: the tip of each ref first, then the merge base of
762
+ * each tip. The origin ref is read only when `readRemote`. A side is null when its ref is
763
+ * absent.
764
+ *
765
+ * @param {Context} ctx
766
+ * @param {string} name
767
+ * @param {boolean} readRemote
768
+ * @returns {{ kind: 'ok', local: Side | null, remote: Side | null } | { kind: 'unknown' }}
769
+ */
770
+ function sidesOf(ctx, name, readRemote) {
771
+ const localTip = tipOf(ctx, 'refs/heads/' + name);
772
+ if (localTip.kind === 'unknown') return { kind: 'unknown' };
773
+ /** @type {Tip} */
774
+ let remoteTip = { kind: 'absent' };
775
+ if (readRemote) {
776
+ remoteTip = tipOf(ctx, 'refs/remotes/origin/' + name);
777
+ if (remoteTip.kind === 'unknown') return { kind: 'unknown' };
778
+ }
779
+ const local = sideOf(ctx, localTip);
780
+ if (local.kind === 'unknown') return local;
781
+ const remote = sideOf(ctx, remoteTip);
782
+ if (remote.kind === 'unknown') return remote;
783
+ return { kind: 'ok', local: local.side, remote: remote.side };
784
+ }
785
+
786
+ /**
787
+ * Whether the local fork point is an ancestor of origin's. Asked only in the no-PR rule,
788
+ * and only when both sides have a fork point; null otherwise.
789
+ *
790
+ * @param {Context} ctx
791
+ * @param {'local' | 'pr'} mode
792
+ * @param {Side | null} local
793
+ * @param {Side | null} remote
794
+ * @returns {{ kind: 'ok', behind: boolean | null } | { kind: 'unknown' }}
795
+ */
796
+ function localBehindOf(ctx, mode, local, remote) {
797
+ const points = mode === 'local' ? forkPointsOf(local, remote) : null;
798
+ if (points === null) return { kind: 'ok', behind: null };
799
+ if (points.local === points.remote) return { kind: 'ok', behind: true };
800
+ const ancestry = isAncestor(ctx, points.local, points.remote);
801
+ return ancestry.kind === 'unknown' ? ancestry : { kind: 'ok', behind: ancestry.ancestor };
802
+ }
803
+
804
+ // ---------------------------------------------------------------------------
805
+ // The name on standard input
806
+ // ---------------------------------------------------------------------------
807
+
808
+ /**
809
+ * @typedef {{ kind: 'ok', name: string } | { kind: 'refused', reason: 'invalid-name' | 'resolver-unavailable' }} StdinName
810
+ */
811
+
812
+ /** @type {StdinName} */
813
+ const STDIN_UNAVAILABLE = Object.freeze({ kind: 'refused', reason: 'resolver-unavailable' });
814
+ /** @type {StdinName} */
815
+ const STDIN_INVALID = Object.freeze({ kind: 'refused', reason: 'invalid-name' });
816
+
817
+ /**
818
+ * After a failed read, decide whether the loop may read again, and wait first when it may. Only
819
+ * an EAGAIN (a non-blocking descriptor with no input yet) is waited for. The wait count and the
820
+ * run deadline each end the wait, and a wait that throws ends it too. One wait sleeps at most
821
+ * STDIN_WAIT_MS and never past the deadline.
822
+ *
823
+ * @param {Context} ctx
824
+ * @param {unknown} err what the failed read threw
825
+ * @param {number} waits the waits made so far in this read of the name
826
+ * @returns {boolean} true when one wait ran and the loop reads again; false when the read is over
827
+ */
828
+ function waitAfterFailedRead(ctx, err, waits) {
829
+ const code = err !== null && typeof err === 'object' ? /** @type {{ code?: unknown }} */ (err).code : undefined;
830
+ const left = ctx.deadline - ctx.now();
831
+ if (code !== 'EAGAIN' || waits >= STDIN_MAX_WAITS || !(left > 0)) return false;
832
+ try {
833
+ ctx.stdin.wait(Math.max(1, Math.min(STDIN_WAIT_MS, Math.floor(left))));
834
+ } catch (_) {
835
+ return false;
836
+ }
837
+ return true;
838
+ }
839
+
840
+ /**
841
+ * Remove exactly one trailing LF or CRLF; nothing else is trimmed.
842
+ *
843
+ * @param {string} text
844
+ * @returns {string}
845
+ */
846
+ function withoutLineEnd(text) {
847
+ if (text.endsWith('\r\n')) return text.slice(0, -2);
848
+ return text.endsWith('\n') ? text.slice(0, -1) : text;
849
+ }
850
+
851
+ /**
852
+ * Read the base name from descriptor 0: one bounded loop, no throw. A terminal is refused
853
+ * without a read. The loop asks for at most STDIN_CAP_BYTES + 1 bytes in all, so input over the
854
+ * cap is told from input at it and is never cut short. A read that fails with EAGAIN waits and
855
+ * reads again (waitAfterFailedRead), and every wait counts inside the run budget. Each pass takes
856
+ * at least one byte, ends the loop, or spends one of the STDIN_MAX_WAITS waits, so the loop has a
857
+ * fixed upper bound. One read of a blocking pipe returns only when input arrives or the writer
858
+ * closes the pipe; the callers pass a file.
859
+ *
860
+ * Empty input and an unreadable descriptor give resolver-unavailable. Input over the cap gives
861
+ * invalid-name. Exactly one trailing LF or CRLF is removed (withoutLineEnd) and nothing is
862
+ * trimmed. The bytes are decoded one byte to one character, so any byte outside the strict class
863
+ * (a NUL, a CR, a byte of 0x80 or more, a second line) reaches the class check as a character it
864
+ * refuses.
865
+ *
866
+ * @param {Context} ctx
867
+ * @returns {StdinName}
868
+ */
869
+ function readNameFromStdin(ctx) {
870
+ const port = ctx.stdin;
871
+ let terminal;
872
+ try {
873
+ terminal = port.isatty();
874
+ } catch (_) {
875
+ return STDIN_UNAVAILABLE;
876
+ }
877
+ if (terminal !== false) return STDIN_UNAVAILABLE;
878
+
879
+ const limit = STDIN_CAP_BYTES + 1;
880
+ const buf = Buffer.alloc(limit);
881
+ let total = 0;
882
+ let waits = 0;
883
+ while (total < limit) {
884
+ let got;
885
+ try {
886
+ got = port.read(buf, total, limit - total);
887
+ } catch (err) {
888
+ if (!waitAfterFailedRead(ctx, err, waits)) return STDIN_UNAVAILABLE;
889
+ waits += 1;
890
+ continue;
891
+ }
892
+ if (!Number.isInteger(got) || got < 0 || got > limit - total) return STDIN_UNAVAILABLE;
893
+ if (got === 0) break;
894
+ total += got;
895
+ }
896
+ if (total === 0) return STDIN_UNAVAILABLE;
897
+ if (total > STDIN_CAP_BYTES) return STDIN_INVALID;
898
+
899
+ return { kind: 'ok', name: withoutLineEnd(buf.toString('latin1', 0, total)) };
900
+ }
901
+
902
+ // ---------------------------------------------------------------------------
903
+ // resolveBase
904
+ // ---------------------------------------------------------------------------
905
+
906
+ /**
907
+ * @typedef {{ mode: 'local' | 'pr', name: string, cross: boolean }} Request
908
+ */
909
+
910
+ /**
911
+ * Run the checks, the fetch, the choice and the verification for one request, in the order
912
+ * the header states. Every path returns a Selection. A local git call that gives no answer is
913
+ * resolver-unavailable at every call site; the fetch has its own outcomes (fetch-failed,
914
+ * timeout, no-remote-branch). An answered call that cannot be used maps by its call site, as
915
+ * the header states. The ancestry check runs only in the no-PR rule.
916
+ *
917
+ * @param {Request} req
918
+ * @param {Context} ctx
919
+ * @returns {Selection}
920
+ */
921
+ function resolveBase(req, ctx) {
922
+ const name = req.name;
923
+ /** @type {'ok' | 'failed' | 'skipped'} */
924
+ let fetchState = 'skipped';
925
+ /** @param {string} reason @returns {Selection} */
926
+ const stop = reason => stopWith(reason, fetchState);
927
+
928
+ const refused = checkName(ctx, name);
929
+ if (refused !== null) return stop(refused);
930
+
931
+ const origin = originSkip(ctx, req.cross);
932
+ if (origin.kind === 'unknown') return stop('resolver-unavailable');
933
+ const skip = origin.skip;
934
+
935
+ /** @type {FetchOutcome} */
936
+ let fetch = { state: 'skipped', reason: null };
937
+ if (skip === null) {
938
+ const sshConfigured = sshCommandConfigured(ctx);
939
+ if (sshConfigured === null) return stop('resolver-unavailable');
940
+ fetch = fetchBase(ctx, name, sshConfigured);
941
+ fetchState = fetch.state;
942
+ }
943
+
944
+ const sides = sidesOf(ctx, name, skip === null && !remoteLacksBranch(fetch));
945
+ if (sides.kind === 'unknown') return stop('resolver-unavailable');
946
+ const behind = localBehindOf(ctx, req.mode, sides.local, sides.remote);
947
+ if (behind.kind === 'unknown') return stop('resolver-unavailable');
948
+
949
+ const chosen = select({ mode: req.mode, skip, fetch, local: sides.local, remote: sides.remote, localBehind: behind.behind });
950
+ if (chosen.baseRef !== 'none' && !commitResolves(ctx, chosen.baseRef)) return stop('resolver-unavailable');
951
+ return chosen;
952
+ }
953
+
954
+ // ---------------------------------------------------------------------------
955
+ // main and the top-level boundary
956
+ // ---------------------------------------------------------------------------
957
+
958
+ /**
959
+ * @typedef {{ spawn?: SpawnFn, env?: NodeJS.ProcessEnv, cwd?: string, fetchTimeoutMs?: number, platform?: string,
960
+ * now?: () => number, stdin?: Partial<StdinPort>, formatLine?: (r: Selection) => string }} MainDeps injected by tests only
961
+ */
962
+
963
+ /**
964
+ * The selection for parsed arguments. A usage result makes no call and reads nothing. The
965
+ * argument form never reads standard input. The standard-input form reads the name first, and
966
+ * a refused read stops here, before any git call.
967
+ *
968
+ * @param {ParsedArgs} parsed
969
+ * @param {MainDeps} deps
970
+ * @returns {Selection}
971
+ */
972
+ function selectionFor(parsed, deps) {
973
+ if (parsed.kind === 'usage') return stopWith('usage');
974
+ const ctx = makeContext(deps);
975
+ if (parsed.kind === 'ok') return resolveBase({ mode: parsed.mode, name: parsed.name, cross: parsed.cross }, ctx);
976
+ const read = readNameFromStdin(ctx);
977
+ if (read.kind === 'refused') return stopWith(read.reason);
978
+ return resolveBase({ mode: parsed.mode, name: read.name, cross: parsed.cross }, ctx);
979
+ }
980
+
981
+ /**
982
+ * @param {readonly string[]} argv process.argv
983
+ * @param {MainDeps} [deps]
984
+ * @returns {{ code: number, line: string }}
985
+ */
986
+ function main(argv, deps) {
987
+ const d = deps || {};
988
+ let line = FALLBACK_LINE;
989
+ try {
990
+ const selection = selectionFor(parseArgs(Array.isArray(argv) ? argv.slice(2) : []), d);
991
+ const compose = typeof d.formatLine === 'function' ? d.formatLine : formatLine;
992
+ const composed = compose(selection);
993
+ if (checkLine(composed)) line = composed;
994
+ } catch (_) {
995
+ line = FALLBACK_LINE;
996
+ }
997
+ return { code: 0, line };
998
+ }
999
+
1000
+ /**
1001
+ * The ONLY stdout write and exit-code assignment. A write that fails (a closed pipe, an
1002
+ * unwritable descriptor, a stdout that cannot be opened) sets exit 3, whether it fails
1003
+ * synchronously, through the write callback, or as a stream 'error' event. Nothing is
1004
+ * written to stderr on any of these paths.
1005
+ *
1006
+ * @param {{ code: number, line: string }} outcome
1007
+ * @returns {void}
1008
+ */
1009
+ function emit(outcome) {
1010
+ process.exitCode = outcome.code;
1011
+ const writeFailed = () => { process.exitCode = 3; };
1012
+ try {
1013
+ process.stdout.on('error', writeFailed);
1014
+ process.stdout.write(outcome.line + '\n', err => { if (err) writeFailed(); });
1015
+ } catch (_) {
1016
+ writeFailed();
1017
+ }
1018
+ }
1019
+
1020
+ if (require.main === module) {
1021
+ emit(main(process.argv));
1022
+ }
1023
+
1024
+ // ---------------------------------------------------------------------------
1025
+ // Exports: the tests and the prompt-copy checks are the consumers
1026
+ // ---------------------------------------------------------------------------
1027
+
1028
+ module.exports = Object.freeze({
1029
+ STRICT_NAME_RE,
1030
+ LINE_RE,
1031
+ FALLBACK_LINE,
1032
+ REASON_NOTES,
1033
+ FETCH_TIMEOUT_MS,
1034
+ LOCAL_TIMEOUT_MS,
1035
+ RUN_BUDGET_MS,
1036
+ isStrictName,
1037
+ parseArgs,
1038
+ select,
1039
+ formatLine,
1040
+ checkLine,
1041
+ fetchArgv,
1042
+ main,
1043
+ });