javi-forge 1.11.0 → 1.13.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/dist/commands/ci.d.ts +24 -1
- package/dist/commands/ci.js +150 -10
- package/dist/lib/git-diff.d.ts +29 -0
- package/dist/lib/git-diff.js +132 -0
- package/dist/ui/CI.js +2 -0
- package/package.json +1 -1
package/dist/commands/ci.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type CIGateConfig } from "../lib/ci-config.js";
|
|
1
2
|
import type { Stack } from "../types/index.js";
|
|
2
3
|
export type CIMode = "full" | "quick" | "shell" | "detect";
|
|
3
4
|
export interface CIOptions {
|
|
@@ -16,7 +17,7 @@ export interface CIOptions {
|
|
|
16
17
|
/** Explicit single-stack override (--stack). Insufficient for hybrid repos */
|
|
17
18
|
stack?: string;
|
|
18
19
|
}
|
|
19
|
-
export type CIStepStatus = "pending" | "running" | "done" | "error" | "skipped";
|
|
20
|
+
export type CIStepStatus = "pending" | "running" | "done" | "error" | "skipped" | "warning";
|
|
20
21
|
export interface CIStep {
|
|
21
22
|
id: string;
|
|
22
23
|
label: string;
|
|
@@ -60,6 +61,8 @@ export type RunnerSource = "auto" | "config" | "stack-override";
|
|
|
60
61
|
export interface ResolvedRunners {
|
|
61
62
|
readonly source: RunnerSource;
|
|
62
63
|
readonly runners: readonly ResolvedRunner[];
|
|
64
|
+
/** Declared quality gates (version 2 only); empty for auto/stack-override. */
|
|
65
|
+
readonly gates: readonly CIGateConfig[];
|
|
63
66
|
}
|
|
64
67
|
export interface ResolveRunnerOptions {
|
|
65
68
|
/** Explicit config path (--config). Wins over default discovery */
|
|
@@ -77,6 +80,26 @@ export interface ResolveRunnerOptions {
|
|
|
77
80
|
*/
|
|
78
81
|
export declare function resolveCIRunners(projectDir: string, options?: ResolveRunnerOptions): Promise<ResolvedRunners>;
|
|
79
82
|
export declare function runCI(options: CIOptions, onStep: CIStepCallback): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Execute a single gate command HOST-NATIVE via `bash -c`, at the repo root,
|
|
85
|
+
* with the provided env MAP. Modeled on `runSemgrep`/`runGhagga` (a spawned
|
|
86
|
+
* process, NOT `runStep`'s Docker branch — gates have no runner or image).
|
|
87
|
+
*
|
|
88
|
+
* RESOLVES the child exit code (it does NOT throw on a non-zero exit) so the
|
|
89
|
+
* collector and the JSON `exitCode` field are populatable. A spawn error (e.g.
|
|
90
|
+
* `bash` missing) rejects, and the collector treats that as a failure.
|
|
91
|
+
*
|
|
92
|
+
* SIGNAL DEATH IS A FAILURE, NEVER A FALSE-GREEN: when the child is terminated
|
|
93
|
+
* by a signal (OOM kill, SIGSEGV/SIGABRT, external SIGTERM) `close` reports a
|
|
94
|
+
* NULL code. Mapping that to 0 would report a signal-killed blocking gate as
|
|
95
|
+
* `done` and let the build PASS — the exact false-green this phase exists to
|
|
96
|
+
* eliminate. A null code therefore resolves to a NON-ZERO code using the shell
|
|
97
|
+
* convention `128 + <signal number>` when the signal is resolvable, else 1.
|
|
98
|
+
*
|
|
99
|
+
* Env values arrive as discrete map entries — never string-spliced into the
|
|
100
|
+
* `bash -c` command — so metacharacters in a value cannot break out of the shell.
|
|
101
|
+
*/
|
|
102
|
+
export declare function runGateNative(cmd: string, cwd: string, env: Record<string, string>): Promise<number>;
|
|
80
103
|
/**
|
|
81
104
|
* Classification of an existing `.git/hooks/<name>` before anything is written
|
|
82
105
|
* (design D6). Every state has exactly one write policy, so no hook is ever
|
package/dist/commands/ci.js
CHANGED
|
@@ -2,10 +2,11 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
4
|
import fsp from "node:fs/promises";
|
|
5
|
+
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import fs from "fs-extra";
|
|
7
8
|
import { HOOK_ASSETS_DIR } from "../constants.js";
|
|
8
|
-
import { CI_STACKS, findCIConfig, loadCIConfig, } from "../lib/ci-config.js";
|
|
9
|
+
import { CI_STACKS, findCIConfig, GATE_MODE, loadCIConfig, } from "../lib/ci-config.js";
|
|
9
10
|
import { refreshContextDir } from "../lib/context.js";
|
|
10
11
|
import { ensureImage, isDockerAvailable, openShell, runInContainer, } from "../lib/docker.js";
|
|
11
12
|
import { execFileAsync } from "../lib/exec.js";
|
|
@@ -148,8 +149,12 @@ function freezeRunner(runner) {
|
|
|
148
149
|
requiredTools: Object.freeze([...runner.requiredTools]),
|
|
149
150
|
});
|
|
150
151
|
}
|
|
151
|
-
function freezeRunners(source, runners) {
|
|
152
|
-
return Object.freeze({
|
|
152
|
+
function freezeRunners(source, runners, gates = []) {
|
|
153
|
+
return Object.freeze({
|
|
154
|
+
source,
|
|
155
|
+
runners: Object.freeze(runners),
|
|
156
|
+
gates: Object.freeze([...gates]),
|
|
157
|
+
});
|
|
153
158
|
}
|
|
154
159
|
/** Build-tool heuristic for a known stack in a given directory. */
|
|
155
160
|
async function detectBuildTool(stack, dir) {
|
|
@@ -247,7 +252,7 @@ export async function resolveCIRunners(projectDir, options = {}) {
|
|
|
247
252
|
for (const runnerConfig of ciConfig.runners) {
|
|
248
253
|
runners.push(await resolveConfiguredRunner(projectDir, runnerConfig));
|
|
249
254
|
}
|
|
250
|
-
return freezeRunners("config", runners);
|
|
255
|
+
return freezeRunners("config", runners, ciConfig.gates ?? []);
|
|
251
256
|
}
|
|
252
257
|
// Zero-config default: single auto-detected runner (unchanged behavior).
|
|
253
258
|
const info = await detectCIStack(projectDir);
|
|
@@ -288,11 +293,12 @@ function report(onStep, id, label, status, detail) {
|
|
|
288
293
|
/**
|
|
289
294
|
* Detect-step label: legacy format for auto, explicit otherwise.
|
|
290
295
|
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* (`
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
+
* RUNNER COUNT: the `auto` and `stack-override` paths each yield exactly one
|
|
297
|
+
* runner, so their branches deref `runners[0]` safely. A gates-only v2 config
|
|
298
|
+
* (version 2, `runners` omitted) reaches here with ZERO runners on the `config`
|
|
299
|
+
* source; that branch never dereferences `runners[0]` — it only reads
|
|
300
|
+
* `runners.length` and maps over the (possibly empty) list — so an empty list
|
|
301
|
+
* is handled without a fallback.
|
|
296
302
|
*/
|
|
297
303
|
function describeRunners(resolved) {
|
|
298
304
|
const first = resolved.runners[0];
|
|
@@ -324,9 +330,24 @@ export async function runCI(options, onStep) {
|
|
|
324
330
|
report(onStep, stepDetect, "Detecting stack", "error", String(e));
|
|
325
331
|
throw e;
|
|
326
332
|
}
|
|
333
|
+
// ── Gates-only v2 repo (zero runners) ──────────────────────────────────────
|
|
334
|
+
// A `version: 2` config MAY declare `gates:` with NO `runners`. Such a repo
|
|
335
|
+
// has no stack to detect, no image to build, and no runner loop, so the
|
|
336
|
+
// runner prologue below (which dereferences `resolved.runners[0]`) must be
|
|
337
|
+
// skipped entirely. `detect`/`shell` have nothing to target → named error;
|
|
338
|
+
// `full`/`quick` jump straight to the gate phase and return.
|
|
339
|
+
if (resolved.runners.length === 0) {
|
|
340
|
+
if (mode === "detect" || mode === "shell") {
|
|
341
|
+
const detail = "no runners resolved — nothing to detect or shell into";
|
|
342
|
+
report(onStep, mode, `${mode} mode`, "error", detail);
|
|
343
|
+
throw new Error(`no runners resolved — ${mode} mode requires at least one runner`);
|
|
344
|
+
}
|
|
345
|
+
await runGates(resolved.gates, projectDir, onStep);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
327
348
|
// Legacy single-runner view for the zero-config auto path. Keeping this
|
|
328
349
|
// shape guarantees single-stack repositories behave exactly as before.
|
|
329
|
-
// `runners[0]` is always present —
|
|
350
|
+
// `runners[0]` is always present here — the zero-runner case returned above.
|
|
330
351
|
// The command lists CAN be empty, so those keep their `?? null`.
|
|
331
352
|
const primary = resolved.runners[0];
|
|
332
353
|
const stackInfo = {
|
|
@@ -487,6 +508,14 @@ export async function runCI(options, onStep) {
|
|
|
487
508
|
report(onStep, stepGhagga, "GHAGGA review", "skipped", "ghagga not installed");
|
|
488
509
|
}
|
|
489
510
|
}
|
|
511
|
+
// ── Gate phase (full || quick — skipped in detect/shell) ────────────────────
|
|
512
|
+
// Gates run on every REAL CI run, including `--quick` (the pre-push path where
|
|
513
|
+
// a blocking gate matters most). `detect`/`shell` return earlier, so mode is
|
|
514
|
+
// already `full` or `quick` here; the guard makes the contract explicit.
|
|
515
|
+
// `runGates` no-ops on an empty gate list (a v1 repo carries none).
|
|
516
|
+
if (mode === "full" || mode === "quick") {
|
|
517
|
+
await runGates(resolved.gates, projectDir, onStep);
|
|
518
|
+
}
|
|
490
519
|
}
|
|
491
520
|
// =============================================================================
|
|
492
521
|
// Step runners
|
|
@@ -716,6 +745,117 @@ async function runGhagga(projectDir) {
|
|
|
716
745
|
});
|
|
717
746
|
}
|
|
718
747
|
// =============================================================================
|
|
748
|
+
// Gate phase (version 2) — host-native, repo-level
|
|
749
|
+
// =============================================================================
|
|
750
|
+
/** Drop `undefined` entries so `process.env` fits the spawn env-map contract. */
|
|
751
|
+
function filterDefinedEnv(env) {
|
|
752
|
+
const out = {};
|
|
753
|
+
for (const [key, value] of Object.entries(env)) {
|
|
754
|
+
if (value !== undefined)
|
|
755
|
+
out[key] = value;
|
|
756
|
+
}
|
|
757
|
+
return out;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Execute a single gate command HOST-NATIVE via `bash -c`, at the repo root,
|
|
761
|
+
* with the provided env MAP. Modeled on `runSemgrep`/`runGhagga` (a spawned
|
|
762
|
+
* process, NOT `runStep`'s Docker branch — gates have no runner or image).
|
|
763
|
+
*
|
|
764
|
+
* RESOLVES the child exit code (it does NOT throw on a non-zero exit) so the
|
|
765
|
+
* collector and the JSON `exitCode` field are populatable. A spawn error (e.g.
|
|
766
|
+
* `bash` missing) rejects, and the collector treats that as a failure.
|
|
767
|
+
*
|
|
768
|
+
* SIGNAL DEATH IS A FAILURE, NEVER A FALSE-GREEN: when the child is terminated
|
|
769
|
+
* by a signal (OOM kill, SIGSEGV/SIGABRT, external SIGTERM) `close` reports a
|
|
770
|
+
* NULL code. Mapping that to 0 would report a signal-killed blocking gate as
|
|
771
|
+
* `done` and let the build PASS — the exact false-green this phase exists to
|
|
772
|
+
* eliminate. A null code therefore resolves to a NON-ZERO code using the shell
|
|
773
|
+
* convention `128 + <signal number>` when the signal is resolvable, else 1.
|
|
774
|
+
*
|
|
775
|
+
* Env values arrive as discrete map entries — never string-spliced into the
|
|
776
|
+
* `bash -c` command — so metacharacters in a value cannot break out of the shell.
|
|
777
|
+
*/
|
|
778
|
+
export async function runGateNative(cmd, cwd, env) {
|
|
779
|
+
return await new Promise((resolve, reject) => {
|
|
780
|
+
const proc = spawn("bash", ["-c", cmd], { cwd, env, stdio: "inherit" });
|
|
781
|
+
proc.on("close", (code, signal) => {
|
|
782
|
+
if (code !== null) {
|
|
783
|
+
resolve(code);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
// Signal death: map to a non-zero code so the collector records a
|
|
787
|
+
// blocking failure. `128 + signum` mirrors the shell; fall back to 1
|
|
788
|
+
// when the signal name is not resolvable.
|
|
789
|
+
const signum = signal ? os.constants.signals[signal] : undefined;
|
|
790
|
+
resolve(signum !== undefined ? 128 + signum : 1);
|
|
791
|
+
});
|
|
792
|
+
proc.on("error", reject);
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Repo-level gate phase. Each gate runs host-native via `runGateNative` at the
|
|
797
|
+
* repo root. Outcome semantics:
|
|
798
|
+
* - exit 0 → `done`
|
|
799
|
+
* - non-zero/spawn error, blocking → `error`, gate id recorded (NOT re-thrown)
|
|
800
|
+
* - non-zero/spawn error, informative → `warning`, build never fails
|
|
801
|
+
*
|
|
802
|
+
* A blocking failure does NOT abort the phase — every gate reports its own
|
|
803
|
+
* status first. After the loop, if ANY blocking gate failed, ONE aggregate
|
|
804
|
+
* error is thrown so a single blocking failure never hides a later gate's
|
|
805
|
+
* result (the top-level catch yields exit 1). Informative failures never
|
|
806
|
+
* contribute to the accumulator, so the exit code stays 0.
|
|
807
|
+
*
|
|
808
|
+
* Multi-command gates run in order and STOP at the first non-zero exit
|
|
809
|
+
* (fail-fast, matching the runner precedent); that first code is reported.
|
|
810
|
+
*
|
|
811
|
+
* Slice 3 wires `mode` and `scope: all` gates with the engine-injected env
|
|
812
|
+
* (`CI=true`). `scope: changed` wiring, `baseline`, and gate `env` injection
|
|
813
|
+
* land in slice 4.
|
|
814
|
+
*/
|
|
815
|
+
async function runGates(gates, projectDir, onStep) {
|
|
816
|
+
if (gates.length === 0)
|
|
817
|
+
return;
|
|
818
|
+
const blockingFailures = [];
|
|
819
|
+
// Engine-injected keys only in slice 3; gate `env` + JAVI_FORGE_CHANGED_FILES
|
|
820
|
+
// are added in slice 4.
|
|
821
|
+
const baseEnv = {
|
|
822
|
+
...filterDefinedEnv(process.env),
|
|
823
|
+
CI: "true",
|
|
824
|
+
};
|
|
825
|
+
for (const gate of gates) {
|
|
826
|
+
const stepId = `gate:${gate.id}`;
|
|
827
|
+
const label = `Gate: ${gate.id}`;
|
|
828
|
+
report(onStep, stepId, label, "running");
|
|
829
|
+
let exitCode = 0;
|
|
830
|
+
let spawnError;
|
|
831
|
+
try {
|
|
832
|
+
for (const cmd of gate.run) {
|
|
833
|
+
exitCode = await runGateNative(cmd, projectDir, baseEnv);
|
|
834
|
+
if (exitCode !== 0)
|
|
835
|
+
break; // fail-fast: skip the remaining commands
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
catch (e) {
|
|
839
|
+
spawnError = e;
|
|
840
|
+
}
|
|
841
|
+
if (spawnError === undefined && exitCode === 0) {
|
|
842
|
+
report(onStep, stepId, `${label} passed`, "done");
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const detail = spawnError !== undefined ? String(spawnError) : `exit ${exitCode}`;
|
|
846
|
+
if (gate.mode === GATE_MODE.BLOCKING) {
|
|
847
|
+
blockingFailures.push(gate.id);
|
|
848
|
+
report(onStep, stepId, `${label} failed`, "error", detail);
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
report(onStep, stepId, `${label} failed (informative)`, "warning", detail);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (blockingFailures.length > 0) {
|
|
855
|
+
throw new Error(`blocking gate(s) failed: ${blockingFailures.join(", ")}`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// =============================================================================
|
|
719
859
|
// CI Hooks Installation
|
|
720
860
|
// =============================================================================
|
|
721
861
|
/**
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the base ref to diff HEAD against, forge-agnostic. Precedence:
|
|
3
|
+
* 1. `$CI_MERGE_REQUEST_DIFF_BASE_SHA` (GitLab MR) when non-empty.
|
|
4
|
+
* 2. `$CI_COMMIT_BEFORE_SHA` (GitLab push) when non-empty AND not the
|
|
5
|
+
* all-zeros new-branch sentinel.
|
|
6
|
+
* 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD`.
|
|
7
|
+
* 4. Local fallback: `git merge-base <candidate> HEAD` over
|
|
8
|
+
* `origin/main`, `origin/master`, `main`, `master` — first that resolves.
|
|
9
|
+
* 5. Nothing resolves → `null` (caller loud-degrades).
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveBaseRef(env: Record<string, string | undefined>, cwd: string): Promise<string | null>;
|
|
12
|
+
/**
|
|
13
|
+
* The union (deduped) of files changed relative to `base`:
|
|
14
|
+
* - committed: `git diff --name-only --diff-filter=ACMR <base>...HEAD`
|
|
15
|
+
* (three-dot; ACMR keeps Added/Copied/Modified/Renamed, drops deletions)
|
|
16
|
+
* - unstaged: `git diff --name-only`
|
|
17
|
+
* - staged: `git diff --name-only --cached`
|
|
18
|
+
*
|
|
19
|
+
* Invoked as `execFileAsync("git", [...], { cwd })` — an argv array, never a
|
|
20
|
+
* shell string.
|
|
21
|
+
*
|
|
22
|
+
* THROWS if any git invocation fails. A base sha absent from local history
|
|
23
|
+
* (CI shallow clone / bad object) makes the committed diff error; that failure
|
|
24
|
+
* MUST propagate so the caller can skip the scope:changed gate with a named
|
|
25
|
+
* warning. It MUST NOT be swallowed into an empty set — an empty set means
|
|
26
|
+
* "no changed files" and would silently pass a scope:changed gate.
|
|
27
|
+
*/
|
|
28
|
+
export declare function changedFiles(base: string, cwd: string): Promise<string[]>;
|
|
29
|
+
//# sourceMappingURL=git-diff.d.ts.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { execFileAsync } from "./exec.js";
|
|
2
|
+
/**
|
|
3
|
+
* Forge-agnostic changed-file diff engine for `scope: changed` gates.
|
|
4
|
+
*
|
|
5
|
+
* Two injectable functions:
|
|
6
|
+
* - {@link resolveBaseRef} — resolve the base commit to diff HEAD against,
|
|
7
|
+
* following a forge-agnostic precedence chain (GitLab MR / GitLab push /
|
|
8
|
+
* GitHub PR / local merge-base). Returns `null` when nothing resolves so the
|
|
9
|
+
* caller can loud-degrade (skip the scope:changed gate with a named warning).
|
|
10
|
+
* - {@link changedFiles} — the union of committed (Added/Copied/Modified/Renamed,
|
|
11
|
+
* deletions dropped), unstaged, and staged changes. THROWS on a git failure
|
|
12
|
+
* (e.g. a base sha absent from local history under a CI shallow clone) so the
|
|
13
|
+
* caller can skip-with-warning; it MUST NOT swallow the failure into an empty
|
|
14
|
+
* set (that would look like "no changes" and silently pass a scope gate).
|
|
15
|
+
*
|
|
16
|
+
* This module is UNWIRED: nothing in the run path imports it yet. The gate
|
|
17
|
+
* phase consumes it in a later slice.
|
|
18
|
+
*/
|
|
19
|
+
/** The all-zeros sha git emits for a brand-new branch's "before" ref. */
|
|
20
|
+
const NEW_BRANCH_SENTINEL = "0".repeat(40);
|
|
21
|
+
/**
|
|
22
|
+
* Local base-ref candidates, tried in order. The first whose `git merge-base
|
|
23
|
+
* <candidate> HEAD` resolves wins.
|
|
24
|
+
*/
|
|
25
|
+
const LOCAL_BASE_CANDIDATES = [
|
|
26
|
+
"origin/main",
|
|
27
|
+
"origin/master",
|
|
28
|
+
"main",
|
|
29
|
+
"master",
|
|
30
|
+
];
|
|
31
|
+
function isNonEmpty(value) {
|
|
32
|
+
return typeof value === "string" && value.length > 0;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Compute `git merge-base <ref> HEAD` in `cwd`, returning the resolved sha or
|
|
36
|
+
* `null` when the ref does not exist / has no common ancestor.
|
|
37
|
+
*/
|
|
38
|
+
async function tryMergeBase(ref, cwd) {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout } = await execFileAsync("git", ["merge-base", ref, "HEAD"], {
|
|
41
|
+
cwd,
|
|
42
|
+
});
|
|
43
|
+
const sha = stdout.trim();
|
|
44
|
+
return sha.length > 0 ? sha : null;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the base ref to diff HEAD against, forge-agnostic. Precedence:
|
|
52
|
+
* 1. `$CI_MERGE_REQUEST_DIFF_BASE_SHA` (GitLab MR) when non-empty.
|
|
53
|
+
* 2. `$CI_COMMIT_BEFORE_SHA` (GitLab push) when non-empty AND not the
|
|
54
|
+
* all-zeros new-branch sentinel.
|
|
55
|
+
* 3. `$GITHUB_BASE_REF` (GitHub Actions PR) → `git merge-base origin/<ref> HEAD`.
|
|
56
|
+
* 4. Local fallback: `git merge-base <candidate> HEAD` over
|
|
57
|
+
* `origin/main`, `origin/master`, `main`, `master` — first that resolves.
|
|
58
|
+
* 5. Nothing resolves → `null` (caller loud-degrades).
|
|
59
|
+
*/
|
|
60
|
+
export async function resolveBaseRef(env, cwd) {
|
|
61
|
+
// 1. GitLab merge request — the base sha is provided directly.
|
|
62
|
+
if (isNonEmpty(env.CI_MERGE_REQUEST_DIFF_BASE_SHA)) {
|
|
63
|
+
return env.CI_MERGE_REQUEST_DIFF_BASE_SHA;
|
|
64
|
+
}
|
|
65
|
+
// 2. GitLab push — the previous sha, unless it is the new-branch sentinel.
|
|
66
|
+
if (isNonEmpty(env.CI_COMMIT_BEFORE_SHA) &&
|
|
67
|
+
env.CI_COMMIT_BEFORE_SHA !== NEW_BRANCH_SENTINEL) {
|
|
68
|
+
return env.CI_COMMIT_BEFORE_SHA;
|
|
69
|
+
}
|
|
70
|
+
// 3. GitHub Actions — a PR sets GITHUB_BASE_REF (merge-base against the
|
|
71
|
+
// target branch); a push has no base ref and falls back to GITHUB_SHA.
|
|
72
|
+
if (isNonEmpty(env.GITHUB_BASE_REF)) {
|
|
73
|
+
const base = await tryMergeBase(`origin/${env.GITHUB_BASE_REF}`, cwd);
|
|
74
|
+
if (base !== null)
|
|
75
|
+
return base;
|
|
76
|
+
}
|
|
77
|
+
else if (isNonEmpty(env.GITHUB_SHA)) {
|
|
78
|
+
return env.GITHUB_SHA;
|
|
79
|
+
}
|
|
80
|
+
// 4. Local fallback — first candidate whose merge-base resolves.
|
|
81
|
+
for (const candidate of LOCAL_BASE_CANDIDATES) {
|
|
82
|
+
const base = await tryMergeBase(candidate, cwd);
|
|
83
|
+
if (base !== null)
|
|
84
|
+
return base;
|
|
85
|
+
}
|
|
86
|
+
// 5. Nothing resolved.
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Parse `git diff --name-only` stdout into a list of repo-root-relative paths,
|
|
91
|
+
* dropping blank lines.
|
|
92
|
+
*/
|
|
93
|
+
function parseNameOnly(stdout) {
|
|
94
|
+
return stdout
|
|
95
|
+
.split("\n")
|
|
96
|
+
.map((line) => line.trim())
|
|
97
|
+
.filter((line) => line.length > 0);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The union (deduped) of files changed relative to `base`:
|
|
101
|
+
* - committed: `git diff --name-only --diff-filter=ACMR <base>...HEAD`
|
|
102
|
+
* (three-dot; ACMR keeps Added/Copied/Modified/Renamed, drops deletions)
|
|
103
|
+
* - unstaged: `git diff --name-only`
|
|
104
|
+
* - staged: `git diff --name-only --cached`
|
|
105
|
+
*
|
|
106
|
+
* Invoked as `execFileAsync("git", [...], { cwd })` — an argv array, never a
|
|
107
|
+
* shell string.
|
|
108
|
+
*
|
|
109
|
+
* THROWS if any git invocation fails. A base sha absent from local history
|
|
110
|
+
* (CI shallow clone / bad object) makes the committed diff error; that failure
|
|
111
|
+
* MUST propagate so the caller can skip the scope:changed gate with a named
|
|
112
|
+
* warning. It MUST NOT be swallowed into an empty set — an empty set means
|
|
113
|
+
* "no changed files" and would silently pass a scope:changed gate.
|
|
114
|
+
*/
|
|
115
|
+
export async function changedFiles(base, cwd) {
|
|
116
|
+
const invocations = [
|
|
117
|
+
["diff", "--name-only", "--diff-filter=ACMR", `${base}...HEAD`],
|
|
118
|
+
["diff", "--name-only"],
|
|
119
|
+
["diff", "--name-only", "--cached"],
|
|
120
|
+
];
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
for (const args of invocations) {
|
|
123
|
+
// Deliberately NOT wrapped in try/catch: a git failure here (shallow
|
|
124
|
+
// clone / missing base object) must surface to the caller.
|
|
125
|
+
const { stdout } = await execFileAsync("git", args, { cwd });
|
|
126
|
+
for (const file of parseNameOnly(stdout)) {
|
|
127
|
+
seen.add(file);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...seen];
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=git-diff.js.map
|
package/dist/ui/CI.js
CHANGED
|
@@ -13,6 +13,7 @@ const STATUS_ICON = {
|
|
|
13
13
|
done: "✓",
|
|
14
14
|
error: "✗",
|
|
15
15
|
skipped: "–",
|
|
16
|
+
warning: "⚠",
|
|
16
17
|
};
|
|
17
18
|
const STATUS_COLOR = {
|
|
18
19
|
pending: theme.muted,
|
|
@@ -20,6 +21,7 @@ const STATUS_COLOR = {
|
|
|
20
21
|
done: theme.success,
|
|
21
22
|
error: theme.error,
|
|
22
23
|
skipped: theme.muted,
|
|
24
|
+
warning: theme.warning,
|
|
23
25
|
};
|
|
24
26
|
// =============================================================================
|
|
25
27
|
// Component
|