cloudflare/kumo
Publicmirrored fromhttps://github.com/cloudflare/kumoAvailable
ci/utils/git-operations.ts
234lines · 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 | * Gets the base and head refs for the current CI context |
| 22 | * Uses GitHub Actions variables with fallback for shallow clones |
| 23 | */ |
| 24 | export 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 | */ |
| 88 | export 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 | */ |
| 134 | export 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 | */ |
| 151 | export 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 | */ |
| 203 | export 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 | */ |
| 220 | export 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 | |