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.
- package/CHANGELOG.md +479 -1
- package/README.md +15 -15
- package/dist/agents/code.md +6 -8
- package/dist/agents/design.md +1 -1
- package/dist/agents/fix.md +7 -8
- package/dist/agents/git.md +3 -3
- package/dist/agents/harden.md +1 -1
- package/dist/agents/refactor.md +2 -4
- package/dist/agents/review.md +4 -4
- package/dist/agents/skim.md +2 -2
- package/dist/agents/triage.md +9 -2
- package/dist/cli/commands/capture.js +1 -1
- package/dist/cli/commands/curation-move-io.js +102 -0
- package/dist/cli/commands/curation-move-step.js +374 -0
- package/dist/cli/commands/curation.js +808 -0
- package/dist/cli/commands/gate-level-prompts.js +428 -0
- package/dist/cli/commands/gates.js +35 -11
- package/dist/cli/commands/init-seed.js +11 -8
- package/dist/cli/commands/init.js +244 -57
- package/dist/cli/commands/memory.js +60 -13
- package/dist/cli/commands/prompt-io.js +37 -0
- package/dist/cli/commands/uninstall.js +157 -17
- package/dist/cli.js +2 -2
- package/dist/commands/bug-analysis.md +25 -36
- package/dist/commands/code-review.md +40 -44
- package/dist/commands/debug.md +5 -5
- package/dist/commands/dynamic-build.md +26 -25
- package/dist/commands/dynamic-plan.md +5 -5
- package/dist/commands/dynamic-profile.md +5 -5
- package/dist/commands/dynamic-tickets.md +5 -5
- package/dist/commands/explore.md +6 -6
- package/dist/commands/implement.md +53 -82
- package/dist/commands/plan.md +6 -6
- package/dist/commands/release.md +5 -5
- package/dist/commands/research.md +6 -6
- package/dist/commands/resolve.md +39 -41
- package/dist/commands/self-review.md +5 -13
- package/dist/core/area-pause-notice.js +44 -0
- package/dist/core/assets.js +20 -20
- package/dist/core/curate-tuning-config.js +5 -6
- package/dist/core/curation-move-notice.js +84 -0
- package/dist/core/curation-move-skip.js +35 -0
- package/dist/core/curation-move.js +35 -0
- package/dist/core/curation-queue-cleanup.js +135 -0
- package/dist/core/curation-store.js +99 -0
- package/dist/core/{learning-variants.js → curation-variants.js} +65 -41
- package/dist/core/echo-value.js +11 -5
- package/dist/core/evidence-policy.js +11 -9
- package/dist/core/feature-switch.js +95 -19
- package/dist/core/fs-atomic.js +1 -1
- package/dist/core/gate-level.js +10 -2
- package/dist/core/git.js +16 -14
- package/dist/core/ledger-root.js +82 -22
- package/dist/core/linked-path.js +4 -4
- package/dist/core/manifest-lock.js +238 -0
- package/dist/core/manifest.js +58 -14
- package/dist/core/migrations.js +1 -13
- package/dist/core/observations.js +2 -2
- package/dist/core/plugins.js +19 -19
- package/dist/core/pool.js +26 -0
- package/dist/core/project-paths.js +103 -34
- package/dist/core/queue-drain.js +2 -2
- package/dist/core/tracked-areas.js +159 -0
- package/dist/core/tracker.js +1 -1
- package/dist/{learning-off → curation-off}/agents/code.md +6 -8
- package/dist/{learning-off → curation-off}/agents/fix.md +7 -8
- package/dist/{learning-off → curation-off}/agents/refactor.md +2 -4
- package/dist/{learning-off → curation-off}/agents/review.md +3 -3
- package/dist/{learning-off → curation-off}/agents/triage.md +8 -1
- package/dist/{learning-off → curation-off}/commands/bug-analysis.md +20 -31
- package/dist/{learning-off → curation-off}/commands/code-review.md +37 -41
- package/dist/{learning-off → curation-off}/commands/debug.md +2 -2
- package/dist/{learning-off → curation-off}/commands/dynamic-build.md +23 -22
- package/dist/{learning-off → curation-off}/commands/explore.md +2 -2
- package/dist/{learning-off → curation-off}/commands/implement.md +50 -79
- package/dist/{learning-off → curation-off}/commands/plan.md +2 -2
- package/dist/{learning-off → curation-off}/commands/resolve.md +36 -38
- package/dist/{learning-off → curation-off}/commands/self-review.md +2 -10
- package/dist/hud/components/{learning-counts.js → curation-counts.js} +63 -17
- package/dist/hud/config.js +1 -1
- package/dist/hud/index.js +6 -6
- package/dist/hud/render.js +3 -3
- package/dist/skills/git/references/pr/post-resolution-summary.md +12 -11
- package/dist/skills/git/references/pr/post-review-summary.md +12 -11
- package/dist/skills/git/references/pr/update-pr-evidence.md +2 -2
- package/dist/skills/git/references/pr/validate-branch.md +8 -5
- package/dist/skills/git/references/publication-gate.md +6 -7
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +13 -5
- package/dist/skills/git/references/tracker/github/manage-debt.md +6 -2
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +13 -5
- package/dist/skills/git/references/tracker/jira/manage-debt.md +6 -2
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +13 -5
- package/dist/skills/git/references/tracker/linear/manage-debt.md +6 -2
- package/dist/targets/claude-code/{learning-install.js → curation-install.js} +85 -65
- package/dist/targets/claude-code/installer.js +15 -15
- package/dist/targets/claude-code/language-stamp.js +5 -5
- package/dist/targets/claude-code/post-install.js +2 -2
- package/dist/targets/claude-code/tracker-servers.js +1 -1
- package/package.json +3 -2
- package/src/assets/agents/_partials/_code.mds +11 -11
- package/src/assets/agents/code.mds +6 -6
- package/src/assets/agents/curate.md +8 -8
- package/src/assets/agents/debug.mds +6 -6
- package/src/assets/agents/design.mds +18 -18
- package/src/assets/agents/evaluate.md +6 -12
- package/src/assets/agents/fix.mds +7 -6
- package/src/assets/agents/git.mds +3 -3
- package/src/assets/agents/harden.mds +7 -7
- package/src/assets/agents/knowledge.mds +23 -23
- package/src/assets/agents/refactor.mds +2 -2
- package/src/assets/agents/research.mds +15 -15
- package/src/assets/agents/review.mds +43 -43
- package/src/assets/agents/skim.mds +11 -11
- package/src/assets/agents/triage.mds +29 -22
- package/src/assets/agents/validate.md +30 -50
- package/src/assets/commands/_partials/_decisions.mds +22 -20
- package/src/assets/commands/_partials/_engine.mds +8 -8
- package/src/assets/commands/_partials/_knowledge.mds +29 -15
- package/src/assets/commands/_partials/_preamble.mds +4 -4
- package/src/assets/commands/_partials/_settings.mds +3 -3
- package/src/assets/commands/_partials/_wave.mds +7 -7
- package/src/assets/commands/bug-analysis.mds +37 -48
- package/src/assets/commands/code-review.mds +53 -57
- package/src/assets/commands/debug.mds +14 -14
- package/src/assets/commands/dynamic-build.mds +38 -37
- package/src/assets/commands/dynamic-plan.mds +22 -22
- package/src/assets/commands/dynamic-profile.mds +2 -2
- package/src/assets/commands/dynamic-tickets.mds +8 -8
- package/src/assets/commands/explore.mds +20 -20
- package/src/assets/commands/implement.mds +77 -98
- package/src/assets/commands/plan.mds +27 -27
- package/src/assets/commands/release.mds +12 -12
- package/src/assets/commands/research.mds +19 -19
- package/src/assets/commands/resolve.mds +66 -60
- package/src/assets/commands/self-review.mds +20 -20
- package/src/assets/mds/git/_ops.mds +9 -2
- package/src/assets/mds/git/_ops_pr.mds +21 -11
- package/src/assets/mds/git/_ops_steps.mds +20 -4
- package/src/assets/mds/git/_pr.mds +21 -18
- package/src/assets/mds/git/_references.mds +6 -7
- package/src/assets/mds/tracker/_github.mds +1 -4
- package/src/assets/mds/tracker/_jira.mds +1 -4
- package/src/assets/mds/tracker/_linear.mds +1 -4
- package/src/assets/rules/quality.md +1 -0
- package/src/assets/scripts/ci-wait.cjs +389 -92
- package/src/assets/scripts/hooks/background-memory-update +81 -3
- package/src/assets/scripts/hooks/capture-prompt +12 -12
- package/src/assets/scripts/hooks/capture-question +11 -11
- package/src/assets/scripts/hooks/capture-turn +12 -12
- package/src/assets/scripts/hooks/{learning-lock → curation-lock} +2 -2
- package/src/assets/scripts/hooks/curation-move +293 -0
- package/src/assets/scripts/hooks/ensure-devflow-init +45 -13
- package/src/assets/scripts/hooks/ensure-proxy +4 -4
- package/src/assets/scripts/hooks/ensure-root-gitignore +4 -4
- package/src/assets/scripts/hooks/git-marker +81 -0
- package/src/assets/scripts/hooks/json-helper.cjs +176 -51
- package/src/assets/scripts/hooks/json-parse +1 -1
- package/src/assets/scripts/hooks/lib/curation-move.cjs +1582 -0
- package/src/assets/scripts/hooks/lib/{learning-store.cjs → curation-store.cjs} +308 -122
- package/src/assets/scripts/hooks/lib/decisions-format.cjs +3 -3
- package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +24 -7
- package/src/assets/scripts/hooks/lib/project-paths.cjs +132 -39
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +42 -22
- package/src/assets/scripts/hooks/lib/tracked-areas.cjs +452 -0
- package/src/assets/scripts/hooks/memory-permission +10 -2
- package/src/assets/scripts/hooks/memory-worker +12 -1
- package/src/assets/scripts/hooks/pre-compact-memory +17 -16
- package/src/assets/scripts/hooks/queue-append +444 -102
- package/src/assets/scripts/hooks/resolve-project-root +2 -2
- package/src/assets/scripts/hooks/session-start-context +198 -72
- package/src/assets/scripts/hooks/session-start-memory +42 -43
- package/src/assets/scripts/lib/project-config.cjs +12 -5
- package/src/assets/scripts/pr-evidence.cjs +5 -2
- package/src/assets/scripts/resolve-base.cjs +1043 -0
- package/src/assets/scripts/resolve-settings.cjs +283 -45
- package/src/assets/scripts/verify-evidence.cjs +307 -75
- package/src/assets/skills/agent-references/SKILL.md +11 -2
- package/src/assets/skills/agent-references/references/commands/implement-levels.md +29 -0
- package/src/assets/skills/agent-references/references/commands/implement-revalidate.md +6 -4
- package/src/assets/skills/agent-references/references/commands/resolve-levels.md +25 -0
- package/src/assets/skills/agent-references/references/synthesize/review.md +4 -4
- package/src/assets/skills/agent-references/references/triage/severity-tiers.md +43 -0
- package/src/assets/skills/agent-references/references/validate/passed-alone.md +14 -0
- package/src/assets/skills/apply-decisions/SKILL.md +1 -1
- package/src/assets/skills/docs-framework/SKILL.md +7 -7
- package/src/assets/skills/docs-framework/references/patterns.md +3 -4
- package/src/assets/skills/quality-gates/SKILL.md +1 -1
- package/src/assets/skills/worktree-support/references/roots.md +2 -2
- package/templates/lint-pack/.lint-pack.conf +86 -0
- package/templates/lint-pack/README.md +190 -0
- package/templates/lint-pack/lint-pack.sh +905 -0
- package/dist/cli/commands/learning.js +0 -554
- package/dist/core/learning-queue-cleanup.js +0 -27
- package/dist/core/learning-store.js +0 -61
- /package/dist/{learning-off → curation-off}/agents/debug.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/design.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/harden.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/knowledge.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/research.md +0 -0
- /package/dist/{learning-off → curation-off}/agents/skim.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/dynamic-plan.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/dynamic-profile.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/dynamic-tickets.md +0 -0
- /package/dist/{learning-off → curation-off}/commands/release.md +0 -0
- /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
|
+
});
|