session-orchestrator 3.20.0 → 3.22.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
|
@@ -41,6 +41,19 @@
|
|
|
41
41
|
* for values that bypass the source strip (e.g. a `--repo` override). See
|
|
42
42
|
* `stripUrlCredentials` / `userinfoIsCredential`.
|
|
43
43
|
*
|
|
44
|
+
* Remote-resolution core (#1039): since the `-R` spec is only ONE of several
|
|
45
|
+
* questions a caller asks about a repo's remotes, the file now carries a shared
|
|
46
|
+
* core below the frozen `-R`/`--hostname` exports — one primitive
|
|
47
|
+
* ({@link listRemotes}, a single `git remote -v` spawn) and three projections
|
|
48
|
+
* ({@link resolvePreferredRemote}, {@link detectVcsFamily},
|
|
49
|
+
* {@link resolveBaselineRange}). Every one of them returns a DISCRIMINATED
|
|
50
|
+
* result carrying a {@link REMOTE_RESOLUTION_REASONS} reason instead of a
|
|
51
|
+
* `T | null`, because `null` folds "no remote configured" onto "the query
|
|
52
|
+
* failed" — a fold that currently scores a fail-open 2/2 in
|
|
53
|
+
* `harness-audit/categories/category6.mjs`. `resolveRepoSpec` /
|
|
54
|
+
* `resolveRepoHost` keep their `string|undefined` contract verbatim and are now
|
|
55
|
+
* thin projections of that core.
|
|
56
|
+
*
|
|
44
57
|
* Lifted out of `scripts/archive-closed-prds.mjs::defaultGlabRepo` (that
|
|
45
58
|
* script's docblock described this exact problem months before #839 was
|
|
46
59
|
* filed) into a shared `scripts/lib/` module so
|
|
@@ -158,12 +171,23 @@ export function redactUrlCredentials(text) {
|
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
/**
|
|
161
|
-
* Default git
|
|
162
|
-
* Never throws — returns `{ ok:false, stdout:'', stderr }` on any
|
|
163
|
-
* (missing remote, not a git repo, git not on PATH, ...).
|
|
174
|
+
* Default git runner: `git <gitArgs>`.
|
|
175
|
+
* Never throws — returns `{ ok:false, stdout:'', stderr, status, code }` on any
|
|
176
|
+
* failure (missing remote, not a git repo, git not on PATH, ...).
|
|
177
|
+
*
|
|
178
|
+
* `status` (process exit code) and `code` (spawn errno, e.g. `'ENOENT'`) were
|
|
179
|
+
* added for {@link listRemotes}'s failure taxonomy: git's own exit codes are
|
|
180
|
+
* the ONLY signal that distinguishes "this is not a git repository" (128) from
|
|
181
|
+
* "git is not installed" (spawn ENOENT) from "there are simply no remotes"
|
|
182
|
+
* (exit 0, empty stdout). Folding those three onto one falsy value is the
|
|
183
|
+
* defect class #1039 was filed against — see {@link REMOTE_RESOLUTION_REASONS}.
|
|
184
|
+
*
|
|
185
|
+
* Both fields are OPTIONAL in the `gitRun` DI contract: an injected test stub
|
|
186
|
+
* that returns only `{ ok, stdout, stderr }` still works, and its failures
|
|
187
|
+
* classify as the generic `'git-error'`.
|
|
164
188
|
*
|
|
165
189
|
* @param {string[]} gitArgs
|
|
166
|
-
* @returns {
|
|
190
|
+
* @returns {GitRunResult}
|
|
167
191
|
*/
|
|
168
192
|
function defaultGitRun(gitArgs) {
|
|
169
193
|
try {
|
|
@@ -171,11 +195,17 @@ function defaultGitRun(gitArgs) {
|
|
|
171
195
|
encoding: 'utf8',
|
|
172
196
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
173
197
|
});
|
|
174
|
-
return { ok: true, stdout: String(stdout ?? ''), stderr: '' };
|
|
198
|
+
return { ok: true, stdout: String(stdout ?? ''), stderr: '', status: 0 };
|
|
175
199
|
} catch (err) {
|
|
176
200
|
const stderr =
|
|
177
201
|
err && err.stderr ? String(err.stderr) : err && err.message ? String(err.message) : 'unknown error';
|
|
178
|
-
return {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
stdout: '',
|
|
205
|
+
stderr,
|
|
206
|
+
status: err && typeof err.status === 'number' ? err.status : undefined,
|
|
207
|
+
code: err && typeof err.code === 'string' ? err.code : undefined,
|
|
208
|
+
};
|
|
179
209
|
}
|
|
180
210
|
}
|
|
181
211
|
|
|
@@ -197,17 +227,56 @@ const WRONG_FAMILY_HOST = {
|
|
|
197
227
|
github: 'gitlab.com',
|
|
198
228
|
};
|
|
199
229
|
|
|
230
|
+
/** URI schemes that can name a supported Git remote. */
|
|
231
|
+
const REMOTE_URI_PROTOCOLS = new Set(['http:', 'https:', 'ssh:']);
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Parse a URI-style remote only when it uses one of this module's supported
|
|
235
|
+
* protocols. The `URL` parser makes hostname/port handling consistent between
|
|
236
|
+
* HTTP(S) and URI-style SSH while scp-style SSH stays a separate grammar.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} url
|
|
239
|
+
* @returns {URL|null}
|
|
240
|
+
*/
|
|
241
|
+
function parseRemoteUri(url) {
|
|
242
|
+
if (typeof url !== 'string' || !/^(?:https?|ssh):\/\//i.test(url)) return null;
|
|
243
|
+
try {
|
|
244
|
+
const parsed = new URL(url);
|
|
245
|
+
return REMOTE_URI_PROTOCOLS.has(parsed.protocol) ? parsed : null;
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
200
251
|
/**
|
|
201
|
-
* Extract the
|
|
202
|
-
* (`https://host/owner/repo.git`)
|
|
203
|
-
*
|
|
252
|
+
* Extract the operational host from a git remote URL, handling HTTPS
|
|
253
|
+
* (`https://host/owner/repo.git`), scp-style SSH (`git@host:owner/repo.git`),
|
|
254
|
+
* and URI-style SSH (`ssh://git@host/owner/repo.git`) forms. A non-default
|
|
255
|
+
* URI port is preserved because callers may need it to address a self-hosted
|
|
256
|
+
* instance. Returns `null` for an unrecognized shape (never throws).
|
|
204
257
|
*
|
|
205
258
|
* @param {string} url
|
|
206
259
|
* @returns {string|null}
|
|
207
260
|
*/
|
|
208
261
|
function extractHost(url) {
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
262
|
+
const parsed = parseRemoteUri(url);
|
|
263
|
+
if (parsed !== null) return parsed.host.toLowerCase() || null;
|
|
264
|
+
const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
|
|
265
|
+
if (sshMatch) return sshMatch[1].toLowerCase();
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Extract a bare hostname for VCS-family comparisons. This deliberately drops
|
|
271
|
+
* a URI port: `github.com:443` is still the public GitHub host, while the
|
|
272
|
+
* operational `extractHost()` value retains a non-default self-hosted port.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} url
|
|
275
|
+
* @returns {string|null}
|
|
276
|
+
*/
|
|
277
|
+
function extractHostname(url) {
|
|
278
|
+
const parsed = parseRemoteUri(url);
|
|
279
|
+
if (parsed !== null) return parsed.hostname.toLowerCase() || null;
|
|
211
280
|
const sshMatch = /^[^@\s]+@([^:\s]+):/i.exec(url);
|
|
212
281
|
if (sshMatch) return sshMatch[1].toLowerCase();
|
|
213
282
|
return null;
|
|
@@ -261,26 +330,29 @@ function normalizeGithubSpec(url) {
|
|
|
261
330
|
* see {@link stripUrlCredentials}. A credential-free URL is unchanged
|
|
262
331
|
* (byte-identical), so #839/#872 behaviour is preserved.
|
|
263
332
|
*
|
|
333
|
+
* Since #1039 this is a THIN projection of {@link resolvePreferredRemote} and
|
|
334
|
+
* performs no git call of its own. Two reasons the delegation is load-bearing:
|
|
335
|
+
*
|
|
336
|
+
* 1. **One credential-strip source (#907, CWE-214).** The userinfo strip now
|
|
337
|
+
* lives in {@link listRemotes}, at the single point every remote URL in
|
|
338
|
+
* this module enters from. A second code path *around* that source would
|
|
339
|
+
* re-open the leak — which is exactly why this function must not read a
|
|
340
|
+
* remote URL itself.
|
|
341
|
+
* 2. **One git call instead of N.** The former implementation ran one
|
|
342
|
+
* `git remote get-url <name>` spawn PER preference entry, on the
|
|
343
|
+
* session-start hot path. `listRemotes` runs `git remote -v` exactly once.
|
|
344
|
+
*
|
|
264
345
|
* @param {{
|
|
265
346
|
* repoRoot?: string,
|
|
266
347
|
* vcs?: 'gitlab' | 'github',
|
|
267
|
-
* gitRun?:
|
|
348
|
+
* gitRun?: GitRun
|
|
268
349
|
* }} [opts]
|
|
269
350
|
* @returns {string|undefined}
|
|
270
351
|
*/
|
|
271
352
|
function resolveRawRemoteUrl({ repoRoot, vcs = 'gitlab', gitRun = defaultGitRun } = {}) {
|
|
272
353
|
const vcsResolved = vcs === 'github' ? 'github' : 'gitlab';
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
for (const remote of REMOTE_PREFERENCE[vcsResolved]) {
|
|
277
|
-
const { ok, stdout } = gitRun(['-C', root, 'remote', 'get-url', remote]);
|
|
278
|
-
const url = ok ? stripUrlCredentials(stdout.trim()) : '';
|
|
279
|
-
if (!url) continue;
|
|
280
|
-
if (extractHost(url) === wrongFamilyHost) continue;
|
|
281
|
-
return url;
|
|
282
|
-
}
|
|
283
|
-
return undefined;
|
|
354
|
+
const resolved = resolvePreferredRemote({ repoRoot, vcs: vcsResolved, gitRun });
|
|
355
|
+
return resolved.ok ? resolved.url : undefined;
|
|
284
356
|
}
|
|
285
357
|
|
|
286
358
|
/**
|
|
@@ -306,10 +378,22 @@ function resolveRawRemoteUrl({ repoRoot, vcs = 'gitlab', gitRun = defaultGitRun
|
|
|
306
378
|
* argv-boundary guard ({@link isUnsafeForArgv}), which this function applies
|
|
307
379
|
* to the FINAL spec value (post `normalizeGithubSpec`, when applicable).
|
|
308
380
|
*
|
|
381
|
+
* **CHANGELOG-worthy behaviour change (#1039, operator-approved.)** This
|
|
382
|
+
* function inherits the SOLE-REMOTE FALLBACK from the shared core (see
|
|
383
|
+
* {@link resolvePreferredRemote}): a repo whose only remote is named something
|
|
384
|
+
* else (`upstream` in a fork, `gl` in a hand-configured clone) now resolves to
|
|
385
|
+
* that remote instead of returning `undefined`. The fallback fires EXCLUSIVELY
|
|
386
|
+
* where `undefined` was returned before — it can never redirect an
|
|
387
|
+
* already-resolving repo to a DIFFERENT target, because the preference order
|
|
388
|
+
* ({@link REMOTE_PREFERENCE}) is still consulted first and is byte-identical to
|
|
389
|
+
* the pre-#1039 list. The cross-family guard applies to the fallback candidate
|
|
390
|
+
* too, so a lone `github.com` remote under `vcs:'gitlab'` still yields
|
|
391
|
+
* `undefined` rather than a spec `glab` is guaranteed to reject.
|
|
392
|
+
*
|
|
309
393
|
* @param {{
|
|
310
394
|
* repoRoot?: string,
|
|
311
395
|
* vcs?: 'gitlab' | 'github',
|
|
312
|
-
* gitRun?:
|
|
396
|
+
* gitRun?: GitRun
|
|
313
397
|
* }} [opts]
|
|
314
398
|
* @returns {string|undefined}
|
|
315
399
|
*/
|
|
@@ -322,12 +406,12 @@ export function resolveRepoSpec({ repoRoot, vcs = 'gitlab', gitRun = defaultGitR
|
|
|
322
406
|
}
|
|
323
407
|
|
|
324
408
|
/**
|
|
325
|
-
* Resolve the
|
|
326
|
-
* `glab api --hostname`/`gh api --hostname
|
|
327
|
-
* CLIs does NOT accept `-R`/`--repo` (it has no
|
|
328
|
-
* `--hostname`
|
|
329
|
-
* host-pinning counterpart to `resolveRepoSpec` for those api-only
|
|
330
|
-
* sites.
|
|
409
|
+
* Resolve the operational host of the matching remote, preserving a
|
|
410
|
+
* non-default self-hosted port for `glab api --hostname`/`gh api --hostname`.
|
|
411
|
+
* The `api` subcommand of both CLIs does NOT accept `-R`/`--repo` (it has no
|
|
412
|
+
* repo concept), only `--hostname` to pin which instance the request targets.
|
|
413
|
+
* This is the host-pinning counterpart to `resolveRepoSpec` for those api-only
|
|
414
|
+
* call sites.
|
|
331
415
|
*
|
|
332
416
|
* Applies the identical remote-preference-order + cross-family-guard
|
|
333
417
|
* resolution as `resolveRepoSpec`, just returning the host instead of the
|
|
@@ -348,6 +432,136 @@ export function resolveRepoHost({ repoRoot, vcs, gitRun } = {}) {
|
|
|
348
432
|
return isUnsafeForArgv(host) ? undefined : host;
|
|
349
433
|
}
|
|
350
434
|
|
|
435
|
+
/** Project-path characters that cannot name a GitLab namespace/project. */
|
|
436
|
+
// eslint-disable-next-line no-control-regex -- validate every C0/C1 control before encoding a GitLab API path
|
|
437
|
+
const UNSAFE_PROJECT_PATH_CHARS_RE = /[\\\x00-\x1f\x7f-\x9f?#]/;
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Decode a transport project path exactly once, then validate its canonical
|
|
441
|
+
* namespace/project shape. A percent sign surviving the one decode is rejected
|
|
442
|
+
* because `%252e%252e` is indistinguishable from a literal encoded escape; do
|
|
443
|
+
* not normalize that ambiguity into an API target.
|
|
444
|
+
*
|
|
445
|
+
* @param {string} rawProjectPath
|
|
446
|
+
* @param {{ uriPath: boolean }} opts
|
|
447
|
+
* @returns {string|undefined}
|
|
448
|
+
*/
|
|
449
|
+
function normalizeGitlabProjectPath(rawProjectPath, { uriPath }) {
|
|
450
|
+
if (typeof rawProjectPath !== 'string') return undefined;
|
|
451
|
+
|
|
452
|
+
let projectPath = rawProjectPath;
|
|
453
|
+
if (uriPath) {
|
|
454
|
+
// URI syntax supplies one separator before the path. More than one is a
|
|
455
|
+
// path segment, not syntax to trim away.
|
|
456
|
+
if (!projectPath.startsWith('/') || projectPath.startsWith('//')) return undefined;
|
|
457
|
+
projectPath = projectPath.slice(1);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (
|
|
461
|
+
projectPath === '' ||
|
|
462
|
+
projectPath.startsWith('/') ||
|
|
463
|
+
projectPath.endsWith('/') ||
|
|
464
|
+
UNSAFE_PROJECT_PATH_CHARS_RE.test(projectPath)
|
|
465
|
+
) {
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let decodedProjectPath;
|
|
470
|
+
try {
|
|
471
|
+
decodedProjectPath = decodeURIComponent(projectPath);
|
|
472
|
+
} catch {
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (
|
|
477
|
+
decodedProjectPath.includes('%') ||
|
|
478
|
+
UNSAFE_PROJECT_PATH_CHARS_RE.test(decodedProjectPath) ||
|
|
479
|
+
isUnsafeForArgv(decodedProjectPath)
|
|
480
|
+
) {
|
|
481
|
+
return undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const withoutGitSuffix = decodedProjectPath.replace(/\.git$/i, '');
|
|
485
|
+
const segments = withoutGitSuffix.split('/');
|
|
486
|
+
if (
|
|
487
|
+
withoutGitSuffix === '' ||
|
|
488
|
+
withoutGitSuffix.startsWith('/') ||
|
|
489
|
+
withoutGitSuffix.endsWith('/') ||
|
|
490
|
+
segments.length < 2 ||
|
|
491
|
+
segments.some((segment) => segment === '' || segment === '.' || segment === '..')
|
|
492
|
+
) {
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return withoutGitSuffix;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Extract an operational GitLab host and the raw (not URL-normalized) project
|
|
501
|
+
* path. URI parsing owns authority/port validation, while the raw path keeps
|
|
502
|
+
* dot and percent-encoded traversal visible to {@link normalizeGitlabProjectPath}
|
|
503
|
+
* before WHATWG URL normalization could erase it.
|
|
504
|
+
*
|
|
505
|
+
* @param {string} url
|
|
506
|
+
* @returns {{ host: string, rawProjectPath: string, uriPath: boolean }|undefined}
|
|
507
|
+
*/
|
|
508
|
+
function extractGitlabProjectTargetParts(url) {
|
|
509
|
+
const scpMatch = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(url);
|
|
510
|
+
if (scpMatch) {
|
|
511
|
+
return { host: scpMatch[1], rawProjectPath: scpMatch[2], uriPath: false };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const parsed = parseRemoteUri(url);
|
|
515
|
+
if (
|
|
516
|
+
parsed === null ||
|
|
517
|
+
parsed.search !== '' ||
|
|
518
|
+
parsed.hash !== '' ||
|
|
519
|
+
((parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
|
520
|
+
(parsed.username !== '' || parsed.password !== ''))
|
|
521
|
+
) {
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// `parsed.pathname` is intentionally NOT used: the URL parser resolves
|
|
526
|
+
// `.`/`..` before this boundary can reject them. The regex reads only the
|
|
527
|
+
// path from an already-parsed, supported URI.
|
|
528
|
+
const rawPathMatch = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?(?:[?#].*)?$/i.exec(url);
|
|
529
|
+
if (rawPathMatch === null) return undefined;
|
|
530
|
+
return { host: parsed.host, rawProjectPath: rawPathMatch[1] ?? '', uriPath: true };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Derive the API target for a GitLab project from the same sanitized,
|
|
535
|
+
* preference-selected remote that powers {@link resolveRepoSpec}. GitLab's REST
|
|
536
|
+
* API takes a URL-encoded `namespace/project` path rather than a remote URL or
|
|
537
|
+
* numeric project ID, so this projection removes the ambient project-metadata
|
|
538
|
+
* lookup from callers that need to pin both project and host.
|
|
539
|
+
*
|
|
540
|
+
* Supports HTTPS, scp-style SSH, and `ssh://` remotes. Credential stripping
|
|
541
|
+
* happens upstream in {@link listRemotes}; this helper returns only an
|
|
542
|
+
* operational host (including a non-default self-hosted port) and a once-only
|
|
543
|
+
* encoded project path, never a remote URL or HTTP userinfo. An SSH login such
|
|
544
|
+
* as `git@host` is transport identity, not project-path userinfo.
|
|
545
|
+
*
|
|
546
|
+
* @param {{ repoRoot?: string, gitRun?: GitRun }} [opts]
|
|
547
|
+
* @returns {{ host: string, encodedProjectPath: string }|undefined}
|
|
548
|
+
*/
|
|
549
|
+
export function resolveGitlabProjectTarget({ repoRoot, gitRun = defaultGitRun } = {}) {
|
|
550
|
+
const url = resolveRawRemoteUrl({ repoRoot, vcs: 'gitlab', gitRun });
|
|
551
|
+
if (!url) return undefined;
|
|
552
|
+
|
|
553
|
+
const parts = extractGitlabProjectTargetParts(url);
|
|
554
|
+
if (!parts || isUnsafeForArgv(parts.host)) return undefined;
|
|
555
|
+
|
|
556
|
+
const projectPath = normalizeGitlabProjectPath(parts.rawProjectPath, { uriPath: parts.uriPath });
|
|
557
|
+
if (!projectPath) return undefined;
|
|
558
|
+
|
|
559
|
+
return {
|
|
560
|
+
host: parts.host.toLowerCase(),
|
|
561
|
+
encodedProjectPath: encodeURIComponent(projectPath),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
351
565
|
/**
|
|
352
566
|
* @deprecated Back-compat alias for `archive-closed-prds.mjs`'s original
|
|
353
567
|
* `defaultGlabRepo(repoRoot, gitRunFn)` positional signature (gitlab-only).
|
|
@@ -360,3 +574,439 @@ export function resolveRepoHost({ repoRoot, vcs, gitRun } = {}) {
|
|
|
360
574
|
export function defaultGlabRepo(repoRoot, gitRunFn) {
|
|
361
575
|
return resolveRepoSpec({ repoRoot, vcs: 'gitlab', gitRun: gitRunFn });
|
|
362
576
|
}
|
|
577
|
+
|
|
578
|
+
/* ------------------------------------------------------------------------ *
|
|
579
|
+
* #1039 — remote resolution core: one primitive, three projections.
|
|
580
|
+
*
|
|
581
|
+
* Four probes across the repo resolved their git remote as the hard-coded
|
|
582
|
+
* literal `origin` and were therefore BLIND in every repo whose remotes are
|
|
583
|
+
* named `gitlab`/`github` (this repo's own shape: `github` + `origin`). The
|
|
584
|
+
* shared core below replaces that literal.
|
|
585
|
+
*
|
|
586
|
+
* The contract's load-bearing decision is that a resolution result is NEVER
|
|
587
|
+
* `T | null`. `null` folds "no remote is configured" (a legitimate, benign
|
|
588
|
+
* repo state) onto "the query failed" (a broken tool or a non-repo), and that
|
|
589
|
+
* fold is LIVE in this codebase: `scripts/lib/harness-audit/categories/
|
|
590
|
+
* category6.mjs:145-155` awards 2 of 2 points with the message "no github
|
|
591
|
+
* mirror remote configured — skipped" when its `git remote` call returns
|
|
592
|
+
* `null` — which it also does outside a git repo, and when git is not on PATH.
|
|
593
|
+
* A fail-open scoring 100%.
|
|
594
|
+
*
|
|
595
|
+
* So every projection returns a DISCRIMINATED result carrying a reason from
|
|
596
|
+
* {@link REMOTE_RESOLUTION_REASONS}, and {@link isQueryFailure} is the single
|
|
597
|
+
* predicate that separates "I could not ask" from "I asked, the answer is no".
|
|
598
|
+
* ------------------------------------------------------------------------ */
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Every reason a remote resolution can fail, frozen so consumers can switch on
|
|
602
|
+
* it exhaustively. The list splits into two classes — the split, not the
|
|
603
|
+
* individual strings, is the point:
|
|
604
|
+
*
|
|
605
|
+
* QUERY FAILURE (the question could not be asked; see {@link isQueryFailure})
|
|
606
|
+
* - `not-a-git-repo` git exited 128 — the path is not inside a work tree.
|
|
607
|
+
* - `git-unavailable` the spawn failed with ENOENT — git is not on PATH.
|
|
608
|
+
* - `git-error` any other non-zero exit, or an injected `gitRun`
|
|
609
|
+
* stub that reported failure without an exit code.
|
|
610
|
+
*
|
|
611
|
+
* ABSENCE (the question was answered; the answer is "no remote for you")
|
|
612
|
+
* - `no-remotes` git exited 0 with an empty remote list — a fresh
|
|
613
|
+
* `git init`, or a clone-less work tree. BENIGN.
|
|
614
|
+
* - `no-matching-remote` >= 2 remotes exist and none matches the requested
|
|
615
|
+
* preference order. Deliberately NOT a guess: picking
|
|
616
|
+
* arbitrarily here means querying the WRONG project
|
|
617
|
+
* successfully, which is worse than not querying.
|
|
618
|
+
* - `unsafe-value` the chosen remote's name or URL carries whitespace /
|
|
619
|
+
* a C0 control character and must not reach an argv
|
|
620
|
+
* position ({@link isUnsafeForArgv}).
|
|
621
|
+
*
|
|
622
|
+
* @type {readonly RemoteResolutionReason[]}
|
|
623
|
+
*/
|
|
624
|
+
export const REMOTE_RESOLUTION_REASONS = Object.freeze([
|
|
625
|
+
'not-a-git-repo',
|
|
626
|
+
'git-unavailable',
|
|
627
|
+
'git-error',
|
|
628
|
+
'no-remotes',
|
|
629
|
+
'no-matching-remote',
|
|
630
|
+
'unsafe-value',
|
|
631
|
+
]);
|
|
632
|
+
|
|
633
|
+
/** @type {ReadonlySet<string>} */
|
|
634
|
+
const QUERY_FAILURE_REASONS = new Set(['not-a-git-repo', 'git-unavailable', 'git-error']);
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* `true` when `reason` means the question could not be ASKED (broken tool, no
|
|
638
|
+
* repo), `false` when it means the question was answered in the negative (no
|
|
639
|
+
* remote configured, no match, unsafe value).
|
|
640
|
+
*
|
|
641
|
+
* Consumers MUST branch on this rather than on truthiness: a query failure is a
|
|
642
|
+
* degraded measurement and should be surfaced (WARN / skip-with-reason), while
|
|
643
|
+
* an absence is a real, reportable repo state. Treating them alike is the
|
|
644
|
+
* category6.mjs fail-open documented above.
|
|
645
|
+
*
|
|
646
|
+
* An unknown / absent reason returns `false` — fail-safe toward "this is a real
|
|
647
|
+
* answer", so a future reason added to {@link REMOTE_RESOLUTION_REASONS}
|
|
648
|
+
* without updating this predicate never silently masks a genuine finding as a
|
|
649
|
+
* tooling glitch.
|
|
650
|
+
*
|
|
651
|
+
* @param {RemoteResolutionReason|string|undefined} reason
|
|
652
|
+
* @returns {boolean}
|
|
653
|
+
*/
|
|
654
|
+
export function isQueryFailure(reason) {
|
|
655
|
+
return typeof reason === 'string' && QUERY_FAILURE_REASONS.has(reason);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Classify a failed {@link GitRunResult} into a query-failure reason.
|
|
660
|
+
*
|
|
661
|
+
* Named ceiling (BV-004): exit 128 is mapped to `not-a-git-repo` because that
|
|
662
|
+
* is what `git remote -v` returns for "not a git repository", and this module
|
|
663
|
+
* only ever runs read-only remote/ref plumbing where 128 has no other common
|
|
664
|
+
* cause. It is NOT a general git-exit-code taxonomy — revisit if a caller
|
|
665
|
+
* starts routing write commands (`git push`, `git fetch`) through `gitRun`,
|
|
666
|
+
* where 128 also covers auth and network fatals.
|
|
667
|
+
*
|
|
668
|
+
* @param {GitRunResult} res
|
|
669
|
+
* @returns {RemoteResolutionReason}
|
|
670
|
+
*/
|
|
671
|
+
function classifyGitFailure(res) {
|
|
672
|
+
if (res && res.code === 'ENOENT') return 'git-unavailable';
|
|
673
|
+
if (res && res.status === 128) return 'not-a-git-repo';
|
|
674
|
+
return 'git-error';
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* One `git remote -v` output line: `<name>\t<url> (fetch|push)`.
|
|
679
|
+
*
|
|
680
|
+
* `(.*?)` is lazy with an anchored tail, so a URL containing a space (a
|
|
681
|
+
* corrupted `.git/config`, the argv-boundary guard's realistic source) is
|
|
682
|
+
* captured whole rather than truncated at the space. A line that does not match
|
|
683
|
+
* this shape at all is DROPPED — see {@link listRemotes}.
|
|
684
|
+
*/
|
|
685
|
+
const REMOTE_V_LINE_RE = /^(\S+)\s+(.*?)\s+\((fetch|push)\)$/;
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* THE PRIMITIVE. Enumerate the repo's git remotes in ONE `git remote -v` spawn.
|
|
689
|
+
*
|
|
690
|
+
* Contract:
|
|
691
|
+
* - `{ ok: true, remotes: [{ name, url }] }` — the list, in git's own output
|
|
692
|
+
* order (alphabetical by remote name). **`remotes: []` is a VALID `ok:true`
|
|
693
|
+
* result** and means "this repo has no remotes", never "the query failed".
|
|
694
|
+
* Conflating the two is the defect this whole module section exists for.
|
|
695
|
+
* - `{ ok: false, reason, stderr }` — the query itself failed; `reason`
|
|
696
|
+
* always satisfies {@link isQueryFailure}.
|
|
697
|
+
*
|
|
698
|
+
* Only FETCH URLs are reported, one entry per remote name (first fetch line
|
|
699
|
+
* wins). Push URLs are a separate `remote.<name>.pushurl` concept that no
|
|
700
|
+
* `-R`/`--repo`/baseline-range consumer in this repo wants.
|
|
701
|
+
*
|
|
702
|
+
* Credential safety (#907, CWE-214): every URL passes through
|
|
703
|
+
* {@link stripUrlCredentials} HERE, at the single point remote URLs enter this
|
|
704
|
+
* module. Every other function in the file — including `resolveRepoSpec` and
|
|
705
|
+
* `resolveRepoHost` — reads its URLs from this function's output, so there is
|
|
706
|
+
* exactly ONE strip source and no path around it.
|
|
707
|
+
*
|
|
708
|
+
* Unparseable lines are dropped silently rather than failing the call: git
|
|
709
|
+
* cannot emit them, so their only source is a corrupted config or an embedded
|
|
710
|
+
* newline, and in both cases the remaining well-formed remotes are still the
|
|
711
|
+
* best available answer. A repo whose EVERY line is unparseable therefore
|
|
712
|
+
* reports `no-remotes` (absence), which is correct — nothing usable was found,
|
|
713
|
+
* and git did answer.
|
|
714
|
+
*
|
|
715
|
+
* @param {{ repoRoot?: string, gitRun?: GitRun }} [opts]
|
|
716
|
+
* @returns {{ ok: true, remotes: GitRemote[] }
|
|
717
|
+
* | { ok: false, reason: RemoteResolutionReason, stderr: string }}
|
|
718
|
+
*/
|
|
719
|
+
export function listRemotes({ repoRoot, gitRun = defaultGitRun } = {}) {
|
|
720
|
+
const root = repoRoot ?? process.cwd();
|
|
721
|
+
const res = gitRun(['-C', root, 'remote', '-v']) ?? { ok: false, stdout: '', stderr: '' };
|
|
722
|
+
|
|
723
|
+
if (!res.ok) {
|
|
724
|
+
return { ok: false, reason: classifyGitFailure(res), stderr: String(res.stderr ?? '') };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** @type {GitRemote[]} */
|
|
728
|
+
const remotes = [];
|
|
729
|
+
const seen = new Set();
|
|
730
|
+
|
|
731
|
+
for (const rawLine of String(res.stdout ?? '').split('\n')) {
|
|
732
|
+
const line = rawLine.replace(/\r$/, '');
|
|
733
|
+
const match = REMOTE_V_LINE_RE.exec(line);
|
|
734
|
+
if (match === null) continue;
|
|
735
|
+
const [, name, rawUrl, direction] = match;
|
|
736
|
+
if (direction !== 'fetch') continue;
|
|
737
|
+
if (seen.has(name)) continue;
|
|
738
|
+
const url = stripUrlCredentials(rawUrl).trim();
|
|
739
|
+
if (url === '') continue;
|
|
740
|
+
seen.add(name);
|
|
741
|
+
remotes.push({ name, url });
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return { ok: true, remotes };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Remote-name preference order when NO `vcs` is supplied.
|
|
749
|
+
*
|
|
750
|
+
* **Operator decision (#1039) — deliberately DIFFERENT from
|
|
751
|
+
* {@link REMOTE_PREFERENCE}, do not "fix" the divergence.** The vcs-pinned
|
|
752
|
+
* lists put the platform-named remote first because the caller has already
|
|
753
|
+
* declared which platform it is talking to. The vcs-less order puts `origin`
|
|
754
|
+
* first because its callers (baseline ranges, family detection, vault-note
|
|
755
|
+
* namespacing) derive an IDENTITY from the answer, and identity must not move.
|
|
756
|
+
*
|
|
757
|
+
* Concretely, in THIS repo (`origin` → `…/infrastructure/session-orchestrator`,
|
|
758
|
+
* `github` → `…/Kanevry/session-orchestrator`), a `gitlab`-first order would
|
|
759
|
+
* re-namespace every existing vault note from `infrastructure/…` to
|
|
760
|
+
* `Kanevry/…`. Silent mass-rename of historical notes is not an improvement.
|
|
761
|
+
*/
|
|
762
|
+
const VCS_LESS_PREFERENCE = Object.freeze(['origin', 'gitlab', 'github']);
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Pick the one remote a repo-scoped command should target.
|
|
766
|
+
*
|
|
767
|
+
* Resolution, in order:
|
|
768
|
+
* 1. **Preference.** With `vcs` set: `REMOTE_PREFERENCE[vcs]`
|
|
769
|
+
* (`['<vcs>', 'origin']`, byte-identical to the pre-#1039 list, because
|
|
770
|
+
* 13 production importers of `resolveRepoSpec` inherit their `-R` target
|
|
771
|
+
* from it — a reordering would silently switch every one of them in a repo
|
|
772
|
+
* that has both remotes). With `vcs` omitted: {@link VCS_LESS_PREFERENCE}.
|
|
773
|
+
* 2. **Cross-family guard** (only when `vcs` is set): a candidate whose host
|
|
774
|
+
* is the OTHER platform's well-known public host is SKIPPED and resolution
|
|
775
|
+
* continues with the next preference entry. Passing `github.com` to
|
|
776
|
+
* `glab -R` is a guaranteed hard failure — strictly worse than resolving
|
|
777
|
+
* nothing. Unchanged from #839.
|
|
778
|
+
* 3. **Sole-remote fallback.** Exactly one remote configured and no
|
|
779
|
+
* preference hit → use it, `via:'sole-remote'`. This is what makes a fork
|
|
780
|
+
* (`upstream`) or a hand-named clone (`gl`) resolvable at all. The
|
|
781
|
+
* cross-family guard still applies to this candidate.
|
|
782
|
+
* 4. Otherwise `{ ok:false }` with `no-remotes` (nothing configured) or
|
|
783
|
+
* `no-matching-remote` (>= 2 remotes, none matched). With two or more
|
|
784
|
+
* candidates and no preference signal there is no non-arbitrary pick, and
|
|
785
|
+
* guessing means successfully querying the WRONG project — the failure
|
|
786
|
+
* mode is a silent wrong answer, not an error. The full `remotes` list
|
|
787
|
+
* rides along so the caller can surface the ambiguity to the operator.
|
|
788
|
+
*
|
|
789
|
+
* The argv-boundary guard runs AFTER the candidate is chosen, and an unsafe
|
|
790
|
+
* candidate ENDS resolution with `unsafe-value` rather than falling through to
|
|
791
|
+
* the next preference entry — preserving pre-#1039 behaviour, where an unsafe
|
|
792
|
+
* value likewise produced `undefined` and no retry.
|
|
793
|
+
*
|
|
794
|
+
* @param {{ repoRoot?: string, vcs?: 'gitlab'|'github', gitRun?: GitRun }} [opts]
|
|
795
|
+
* @returns {{ ok: true, name: string, url: string, via: 'preference'|'sole-remote' }
|
|
796
|
+
* | { ok: false, reason: RemoteResolutionReason, remotes?: GitRemote[], stderr?: string }}
|
|
797
|
+
*/
|
|
798
|
+
export function resolvePreferredRemote({ repoRoot, vcs, gitRun = defaultGitRun } = {}) {
|
|
799
|
+
const listed = listRemotes({ repoRoot, gitRun });
|
|
800
|
+
if (!listed.ok) return { ok: false, reason: listed.reason, stderr: listed.stderr };
|
|
801
|
+
|
|
802
|
+
const { remotes } = listed;
|
|
803
|
+
if (remotes.length === 0) return { ok: false, reason: 'no-remotes', remotes };
|
|
804
|
+
|
|
805
|
+
const vcsPinned = vcs === 'github' || vcs === 'gitlab' ? vcs : null;
|
|
806
|
+
const order = vcsPinned ? REMOTE_PREFERENCE[vcsPinned] : VCS_LESS_PREFERENCE;
|
|
807
|
+
const wrongFamilyHost = vcsPinned ? WRONG_FAMILY_HOST[vcsPinned] : null;
|
|
808
|
+
const isWrongFamily = (url) => wrongFamilyHost !== null && extractHostname(url) === wrongFamilyHost;
|
|
809
|
+
|
|
810
|
+
/** @param {GitRemote} remote @param {'preference'|'sole-remote'} via */
|
|
811
|
+
const accept = (remote, via) =>
|
|
812
|
+
isUnsafeForArgv(remote.url) || isUnsafeForArgv(remote.name)
|
|
813
|
+
? { ok: false, reason: /** @type {RemoteResolutionReason} */ ('unsafe-value'), remotes }
|
|
814
|
+
: { ok: true, name: remote.name, url: remote.url, via };
|
|
815
|
+
|
|
816
|
+
for (const name of order) {
|
|
817
|
+
const candidate = remotes.find((remote) => remote.name === name);
|
|
818
|
+
if (candidate === undefined) continue;
|
|
819
|
+
if (isWrongFamily(candidate.url)) continue;
|
|
820
|
+
return accept(candidate, 'preference');
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (remotes.length === 1 && !isWrongFamily(remotes[0].url)) {
|
|
824
|
+
return accept(remotes[0], 'sole-remote');
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return { ok: false, reason: 'no-matching-remote', remotes };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Classify a single remote into a VCS family from its URL host, then its name.
|
|
832
|
+
*
|
|
833
|
+
* Host rule: `github.com` or any `github.*` host → github; `gitlab.com` or any
|
|
834
|
+
* `gitlab.*` host → gitlab. The `github.*` half is what keeps GitHub Enterprise
|
|
835
|
+
* (`github.example.com`) out of the gitlab bucket — a `url.includes('github.com')`
|
|
836
|
+
* test classifies it as gitlab and points `glab` at a GitHub instance.
|
|
837
|
+
*
|
|
838
|
+
* @param {GitRemote} remote
|
|
839
|
+
* @returns {{ family: 'gitlab'|'github', via: 'host-match'|'remote-name' }|null}
|
|
840
|
+
*/
|
|
841
|
+
function classifyRemoteFamily(remote) {
|
|
842
|
+
const host = extractHostname(remote.url);
|
|
843
|
+
if (host !== null) {
|
|
844
|
+
if (host === 'github.com' || host.startsWith('github.')) return { family: 'github', via: 'host-match' };
|
|
845
|
+
if (host === 'gitlab.com' || host.startsWith('gitlab.')) return { family: 'gitlab', via: 'host-match' };
|
|
846
|
+
}
|
|
847
|
+
if (remote.name === 'github') return { family: 'github', via: 'remote-name' };
|
|
848
|
+
if (remote.name === 'gitlab') return { family: 'gitlab', via: 'remote-name' };
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Decide which VCS family a repo belongs to, from its remotes — the projection
|
|
854
|
+
* that replaces "assume gitlab because the config says so".
|
|
855
|
+
*
|
|
856
|
+
* Precedence per remote: URL host, then remote name (see
|
|
857
|
+
* {@link classifyRemoteFamily}). Among classified remotes the representative is
|
|
858
|
+
* picked by {@link VCS_LESS_PREFERENCE}, then by git's own listing order — so
|
|
859
|
+
* in this repo (`github` → github.com, `origin` → gitlab.…) the answer is
|
|
860
|
+
* `gitlab` via `origin`, not `github` via the alphabetically-first remote.
|
|
861
|
+
*
|
|
862
|
+
* When NO remote classifies, `via:'default'` + `vcs:'gitlab'` preserves today's
|
|
863
|
+
* behaviour (every `resolveRepoSpec` caller already defaults to gitlab) —
|
|
864
|
+
* provided a representative remote can be named at all. When it cannot (>= 2
|
|
865
|
+
* unclassifiable remotes), the call fails with `no-matching-remote` rather than
|
|
866
|
+
* inventing one: naming the wrong remote is what #1039 is about.
|
|
867
|
+
*
|
|
868
|
+
* `ambiguous` is `true` when two or more remotes classify into DIFFERENT
|
|
869
|
+
* families — the ordinary GitLab-primary / GitHub-mirror shape. It is a signal
|
|
870
|
+
* for the caller to disclose the choice, not an error: `vcs` is still the
|
|
871
|
+
* preference-ordered answer, and `alternatives` names the remotes that would
|
|
872
|
+
* have said otherwise.
|
|
873
|
+
*
|
|
874
|
+
* @param {{ repoRoot?: string, gitRun?: GitRun }} [opts]
|
|
875
|
+
* @returns {{ ok: true, vcs: 'gitlab'|'github', name: string, url: string,
|
|
876
|
+
* via: 'host-match'|'remote-name'|'default', ambiguous: boolean,
|
|
877
|
+
* alternatives: string[] }
|
|
878
|
+
* | { ok: false, reason: RemoteResolutionReason, remotes?: GitRemote[], stderr?: string }}
|
|
879
|
+
*/
|
|
880
|
+
export function detectVcsFamily({ repoRoot, gitRun = defaultGitRun } = {}) {
|
|
881
|
+
const listed = listRemotes({ repoRoot, gitRun });
|
|
882
|
+
if (!listed.ok) return { ok: false, reason: listed.reason, stderr: listed.stderr };
|
|
883
|
+
|
|
884
|
+
const { remotes } = listed;
|
|
885
|
+
if (remotes.length === 0) return { ok: false, reason: 'no-remotes', remotes };
|
|
886
|
+
|
|
887
|
+
const classified = remotes
|
|
888
|
+
.map((remote) => ({ remote, verdict: classifyRemoteFamily(remote) }))
|
|
889
|
+
.filter((entry) => entry.verdict !== null);
|
|
890
|
+
|
|
891
|
+
if (classified.length === 0) {
|
|
892
|
+
// No family signal anywhere. Fall back to today's implicit default
|
|
893
|
+
// (gitlab), but only if a representative remote can be NAMED — the
|
|
894
|
+
// preferred-remote resolution below refuses to guess among >= 2.
|
|
895
|
+
const preferred = resolvePreferredRemote({ repoRoot, gitRun });
|
|
896
|
+
if (!preferred.ok) return preferred;
|
|
897
|
+
return {
|
|
898
|
+
ok: true,
|
|
899
|
+
vcs: 'gitlab',
|
|
900
|
+
name: preferred.name,
|
|
901
|
+
url: preferred.url,
|
|
902
|
+
via: 'default',
|
|
903
|
+
ambiguous: false,
|
|
904
|
+
alternatives: [],
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const chosen =
|
|
909
|
+
VCS_LESS_PREFERENCE.map((name) => classified.find((entry) => entry.remote.name === name)).find(
|
|
910
|
+
(entry) => entry !== undefined,
|
|
911
|
+
) ?? classified[0];
|
|
912
|
+
|
|
913
|
+
const alternatives = classified
|
|
914
|
+
.filter((entry) => entry.verdict.family !== chosen.verdict.family)
|
|
915
|
+
.map((entry) => entry.remote.name);
|
|
916
|
+
|
|
917
|
+
return {
|
|
918
|
+
ok: true,
|
|
919
|
+
vcs: chosen.verdict.family,
|
|
920
|
+
name: chosen.remote.name,
|
|
921
|
+
url: chosen.remote.url,
|
|
922
|
+
via: chosen.verdict.via,
|
|
923
|
+
ambiguous: alternatives.length > 0,
|
|
924
|
+
alternatives,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Resolve the three-dot diff range a session-drift / scope measurement should
|
|
930
|
+
* run against — the projection that replaces the hard-coded literal
|
|
931
|
+
* `'origin/main...HEAD'` (live at `scripts/lib/scope-baseline.mjs:519`, which is
|
|
932
|
+
* silently inert in any repo whose remote is not named `origin` or whose default
|
|
933
|
+
* branch is not `main`).
|
|
934
|
+
*
|
|
935
|
+
* Chain, first hit wins:
|
|
936
|
+
* 1. {@link resolvePreferredRemote} (vcs-less) → the remote `R`.
|
|
937
|
+
* 2. `git symbolic-ref --short refs/remotes/<R>/HEAD` → `via:'remote-head'`.
|
|
938
|
+
* Only populated by an explicit `git remote set-head -a`, so it is the
|
|
939
|
+
* most authoritative and the least often present.
|
|
940
|
+
* 3. `git rev-parse --verify --quiet refs/remotes/<R>/main`, then `…/master`
|
|
941
|
+
* → `via:'remote-default-branch'`. Covers the freshly-pushed repo where
|
|
942
|
+
* nobody ever ran `set-head`.
|
|
943
|
+
* 4. `refs/heads/main`, then `refs/heads/master` → `via:'local-default-branch'`,
|
|
944
|
+
* gated on `allowLocalFallback` (default `true`). A local branch is a
|
|
945
|
+
* weaker baseline than a tracking ref — it does not know what the remote
|
|
946
|
+
* has — so a caller that needs a remote-anchored measurement passes
|
|
947
|
+
* `allowLocalFallback:false` and gets `no-tracking-ref` instead.
|
|
948
|
+
* 5. `{ ok:false, reason:'no-tracking-ref' }`, or `'unborn-head'` when the
|
|
949
|
+
* repo has no commit at all (probed only on this path, so the happy path
|
|
950
|
+
* costs nothing).
|
|
951
|
+
*
|
|
952
|
+
* **No root-commit fallback, by operator decision.** Diffing against the first
|
|
953
|
+
* commit of the repository yields a ratio over the ENTIRE history, which is not
|
|
954
|
+
* a session-drift measurement — it is a number that looks like one. An honest
|
|
955
|
+
* `no-tracking-ref` lets the caller skip with a reason.
|
|
956
|
+
*
|
|
957
|
+
* The range is always three-dot (`<base>...HEAD`, merge-base relative),
|
|
958
|
+
* identical to the semantics of the literal it replaces.
|
|
959
|
+
*
|
|
960
|
+
* @param {{ repoRoot?: string, gitRun?: GitRun, allowLocalFallback?: boolean }} [opts]
|
|
961
|
+
* @returns {{ ok: true, range: string, base: string, remote: string,
|
|
962
|
+
* via: 'remote-head'|'remote-default-branch'|'local-default-branch' }
|
|
963
|
+
* | { ok: false, reason: RemoteResolutionReason|'no-tracking-ref'|'unborn-head',
|
|
964
|
+
* remotes?: GitRemote[], stderr?: string }}
|
|
965
|
+
*/
|
|
966
|
+
export function resolveBaselineRange({ repoRoot, gitRun = defaultGitRun, allowLocalFallback = true } = {}) {
|
|
967
|
+
const preferred = resolvePreferredRemote({ repoRoot, gitRun });
|
|
968
|
+
if (!preferred.ok) return preferred;
|
|
969
|
+
|
|
970
|
+
const root = repoRoot ?? process.cwd();
|
|
971
|
+
const remote = preferred.name;
|
|
972
|
+
const run = (args) => gitRun(['-C', root, ...args]) ?? { ok: false, stdout: '', stderr: '' };
|
|
973
|
+
const done = (base, via) => ({ ok: /** @type {true} */ (true), range: `${base}...HEAD`, base, remote, via });
|
|
974
|
+
|
|
975
|
+
const head = run(['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`]);
|
|
976
|
+
const headRef = head.ok ? head.stdout.trim() : '';
|
|
977
|
+
if (headRef !== '' && !isUnsafeForArgv(headRef)) return done(headRef, 'remote-head');
|
|
978
|
+
|
|
979
|
+
for (const branch of ['main', 'master']) {
|
|
980
|
+
const verified = run(['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${branch}`]);
|
|
981
|
+
if (verified.ok && verified.stdout.trim() !== '') return done(`${remote}/${branch}`, 'remote-default-branch');
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
if (allowLocalFallback) {
|
|
985
|
+
for (const branch of ['main', 'master']) {
|
|
986
|
+
const verified = run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
987
|
+
if (verified.ok && verified.stdout.trim() !== '') return done(branch, 'local-default-branch');
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const headCommit = run(['rev-parse', '--verify', '--quiet', 'HEAD']);
|
|
992
|
+
const unborn = !headCommit.ok || headCommit.stdout.trim() === '';
|
|
993
|
+
return { ok: false, reason: unborn ? 'unborn-head' : 'no-tracking-ref' };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* @typedef {'not-a-git-repo'|'git-unavailable'|'git-error'|'no-remotes'|'no-matching-remote'|'unsafe-value'} RemoteResolutionReason
|
|
998
|
+
*/
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* @typedef {{ name: string, url: string }} GitRemote
|
|
1002
|
+
*/
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* @typedef {{ ok: boolean, stdout: string, stderr: string, status?: number, code?: string }} GitRunResult
|
|
1006
|
+
*/
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Injectable git runner. `status`/`code` are OPTIONAL — a stub that omits them
|
|
1010
|
+
* still works; its failures classify as the generic `git-error`.
|
|
1011
|
+
* @typedef {(args: string[]) => GitRunResult} GitRun
|
|
1012
|
+
*/
|