javi-forge 1.11.0 → 1.12.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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {