cloudflare/kumo

Public

mirrored from https://github.com/cloudflare/kumoAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
main

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

ci/utils/git-operations.ts

262lines · modecode

1import { execSync, execFileSync } from "child_process";
2
3/**
4 * Git operations utility for CI scripts
5 * Provides reusable functions for working with Git refs and file changes
6 */
7
8export interface GitRefs {
9 baseRef: string | undefined;
10 headRef: string;
11}
12
13export interface ChangedFilesOptions {
14 /** Base directory to filter files by (e.g., 'packages/kumo') */
15 filterPath?: string;
16 /** Working directory for git commands */
17 cwd?: string;
18}
19
20/**
21 * Resolves the head ref to a valid local git ref.
22 * GITHUB_HEAD_REF is a branch name (e.g. "user/feature-branch") which may not
23 * exist as a local ref — especially for fork PRs. Falls back through
24 * GITHUB_SHA → "HEAD" if the branch name doesn't resolve.
25 */
26function resolveHeadRef(): string {
27 const candidates = [
28 process.env.GITHUB_HEAD_REF,
29 process.env.GITHUB_SHA,
30 "HEAD",
31 ].filter(Boolean) as string[];
32
33 for (const ref of candidates) {
34 try {
35 execSync(`git rev-parse --verify ${ref}`, {
36 encoding: "utf8",
37 stdio: "pipe",
38 });
39 return ref;
40 } catch {
41 // ref doesn't exist locally, try next
42 }
43 }
44
45 return "HEAD";
46}
47
48/**
49 * Gets the base and head refs for the current CI context
50 * Uses GitHub Actions variables with fallback for shallow clones
51 */
52export function getGitRefs(): GitRefs {
53 // GitHub Actions provides these environment variables
54 const baseRef = process.env.GITHUB_BASE_REF;
55 const headRef = resolveHeadRef();
56
57 // If baseRef exists (PR context), verify it's available
58 if (baseRef) {
59 try {
60 // Fetch the base branch for comparison
61 execSync(`git fetch origin ${baseRef}:refs/remotes/origin/${baseRef}`, {
62 encoding: "utf8",
63 stdio: "pipe",
64 });
65 const fallbackRef = `origin/${baseRef}`;
66 console.log(`Using base ref: ${fallbackRef}`);
67 return { baseRef: fallbackRef, headRef: headRef };
68 } catch (error) {
69 console.warn(
70 ` Could not fetch base branch ${baseRef}: ${error}`,
71 );
72 }
73 }
74
75 // Local development fallback: use merge-base with origin/main
76 // This ensures we only see changes introduced by the branch, not changes on main
77 if (!process.env.CI && !process.env.GITHUB_ACTIONS) {
78 // First verify origin/main exists
79 try {
80 execSync("git rev-parse --verify origin/main", {
81 encoding: "utf8",
82 stdio: "pipe",
83 });
84 } catch {
85 console.error(
86 " Error: origin/main ref not found. Please fetch the main branch:",
87 );
88 console.error(" git fetch origin main");
89 return { baseRef: undefined, headRef: "HEAD" };
90 }
91
92 // Try to find merge-base
93 try {
94 const mergeBase = execSync("git merge-base origin/main HEAD", {
95 encoding: "utf8",
96 stdio: "pipe",
97 }).trim();
98 console.log(
99 `Using local fallback ref (merge-base): ${mergeBase.slice(0, 8)}`,
100 );
101 return { baseRef: mergeBase, headRef: "HEAD" };
102 } catch {
103 // Merge-base failed (e.g., no common ancestor), use origin/main directly
104 console.log("Using local fallback ref: origin/main");
105 return { baseRef: "origin/main", headRef: "HEAD" };
106 }
107 }
108
109 return { baseRef, headRef };
110}
111
112/**
113 * Gets the list of changed files between base and head refs
114 * Returns an array of file paths, or null if no changes found
115 */
116export function getChangedFiles(
117 options: ChangedFilesOptions = {},
118): string[] | null {
119 try {
120 const { baseRef, headRef } = getGitRefs();
121
122 if (!baseRef) {
123 console.warn(
124 " Warning: Could not determine base ref for file changes",
125 );
126 return null;
127 }
128
129 // Use two-dot diff for shallow clones (no merge base needed)
130 // Two dots compares the tips directly: baseRef..headRef
131 const changedFiles = execSync(
132 `git diff --name-only ${baseRef}..${headRef}`,
133 {
134 encoding: "utf8",
135 cwd: options.cwd || process.cwd(),
136 },
137 ).trim();
138
139 if (!changedFiles) {
140 return [];
141 }
142
143 const files = changedFiles.split("\n");
144
145 // Apply path filter if specified
146 if (options.filterPath) {
147 return files.filter((file) => file.startsWith(`${options.filterPath}/`));
148 }
149
150 return files;
151 } catch (error) {
152 console.warn(" Warning: Could not get changed files");
153 console.warn(`Error: ${error}`);
154 return null;
155 }
156}
157
158/**
159 * Checks if any files have changed in a specific directory path
160 * Returns true if changes exist, false if no changes, null if unable to determine
161 */
162export function hasChangesInPath(
163 path: string,
164 options: Omit<ChangedFilesOptions, "filterPath"> = {},
165): boolean | null {
166 const changedFiles = getChangedFiles({ ...options, filterPath: path });
167
168 if (changedFiles === null) {
169 return null; // Unable to determine
170 }
171
172 return changedFiles.length > 0;
173}
174
175/**
176 * Gets newly added files in a specific directory between base and head refs
177 * Returns file paths with their status (A = Added, M = Modified, D = Deleted, etc.)
178 */
179export function getNewlyAddedFiles(
180 directory: string,
181 options: ChangedFilesOptions = {},
182): Array<{ status: string; path: string }> {
183 try {
184 const { baseRef, headRef } = getGitRefs();
185
186 if (!baseRef) {
187 console.warn(
188 "Warning: Could not determine base ref for newly added files",
189 );
190 return [];
191 }
192
193 // Use execFileSync with array arguments to prevent command injection
194 // This passes arguments directly to git without shell interpretation
195 // Use two-dot diff for shallow clones (no merge base needed)
196 const newFiles = execFileSync(
197 "git",
198 ["diff", "--name-status", `${baseRef}..${headRef}`, "--", directory],
199 {
200 encoding: "utf8",
201 cwd: options.cwd || process.cwd(),
202 },
203 ).trim();
204
205 if (!newFiles) {
206 return [];
207 }
208
209 const files: Array<{ status: string; path: string }> = [];
210 const lines = newFiles.split("\n");
211
212 for (const line of lines) {
213 const [status, filePath] = line.split("\t");
214 if (status && filePath) {
215 files.push({ status, path: filePath });
216 }
217 }
218
219 return files;
220 } catch (error) {
221 console.warn("Warning: Could not get newly added files");
222 console.warn(`Error: ${error}`);
223 return [];
224 }
225}
226
227/**
228 * Checks if we're running in a pull request context
229 * Supports multiple detection methods for different CI scenarios
230 */
231export function isPullRequestContext(): boolean {
232 // Check if we're in a pull request context
233 // GitHub Actions detection:
234 // 1. GITHUB_EVENT_NAME === 'pull_request' or 'pull_request_target'
235 // 2. GITHUB_PR_NUMBER is set
236 // 3. Manual override: CI_FORCE_PR_VALIDATION === 'true'
237 return (
238 process.env.GITHUB_EVENT_NAME === "pull_request" ||
239 process.env.GITHUB_EVENT_NAME === "pull_request_target" ||
240 process.env.GITHUB_PR_NUMBER !== undefined ||
241 process.env.CI_FORCE_PR_VALIDATION === "true"
242 );
243}
244
245/**
246 * Logs the detected pull request context for transparency
247 */
248export function logPullRequestContext(): void {
249 if (process.env.GITHUB_EVENT_NAME === "pull_request") {
250 console.log("Detected PR context: Pull request event");
251 } else if (process.env.GITHUB_EVENT_NAME === "pull_request_target") {
252 console.log("Detected PR context: Pull request target event");
253 } else if (process.env.GITHUB_PR_NUMBER) {
254 console.log(
255 `Detected PR context: PR #${process.env.GITHUB_PR_NUMBER}`,
256 );
257 } else if (process.env.CI_FORCE_PR_VALIDATION === "true") {
258 console.log("Detected PR context: Manual validation override");
259 }
260}
261
262
263