cloudflare/kumo
Publicmirrored from https://github.com/cloudflare/kumoAvailable
ci/utils/git-operations.ts
262lines · modecode
| 1 | import { 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 | |
| 8 | export interface GitRefs { |
| 9 | baseRef: string | undefined; |
| 10 | headRef: string; |
| 11 | } |
| 12 | |
| 13 | export 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 | */ |
| 26 | function 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 | */ |
| 52 | export 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 | */ |
| 116 | export 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 | */ |
| 162 | export 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 | */ |
| 179 | export 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 | */ |
| 231 | export 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 | */ |
| 248 | export 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 | |