cloudflare/kumo

Public

mirrored fromhttps://github.com/cloudflare/kumoAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
6dc9a73c0bb7ae2731f474f987d92742b4e3b764

Branches

Tags

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

Clone

HTTPS

Download ZIP

ci/utils/git-operations.ts

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