1import fs from 'fs-extra'; 2import parseDiff from 'parse-diff'; 3import { join, relative } from 'path'; 4 5import { EXPO_DIR } from './Constants'; 6import { spawnAsync, SpawnResult, SpawnOptions } from './Utils'; 7 8export type GitPullOptions = { 9 rebase?: boolean; 10}; 11 12export type GitPushOptions = { 13 track?: string; 14}; 15 16export type GitLogOptions = { 17 fromCommit?: string; 18 toCommit?: string; 19 paths?: string[]; 20 cherryPick?: 'left' | 'right'; 21 symmetricDifference?: boolean; 22}; 23 24export type GitLog = { 25 hash: string; 26 parent: string; 27 title: string; 28 authorName: string; 29 authorDate: string; 30 committerRelativeDate: string; 31}; 32 33export type GitFileLog = { 34 path: string; 35 relativePath: string; 36 status: GitFileStatus; 37}; 38 39export enum GitFileStatus { 40 M = 'modified', 41 C = 'copy', 42 R = 'rename', 43 A = 'added', 44 D = 'deleted', 45 U = 'unmerged', 46} 47 48export type GitBranchesStats = { 49 ahead: number; 50 behind: number; 51}; 52 53export type GitCommitOptions = { 54 title: string; 55 body?: string; 56}; 57 58export type GitCherryPickOptions = { 59 inheritStdio?: boolean; 60}; 61 62export type GitFetchOptions = { 63 depth?: number; 64 remote?: string; 65 ref?: string; 66}; 67 68export type GitFileDiff = parseDiff.File & { 69 path: string; 70}; 71 72export type GitListTree = { 73 mode: string; 74 type: string; 75 object: string; 76 size: number; 77 path: string; 78}; 79 80/** 81 * Helper class that stores the directory inside the repository so we don't have to pass it many times. 82 * This directory path doesn't have to be the repo's root path, 83 * it's just like current working directory for all other commands. 84 */ 85export class GitDirectory { 86 readonly Directory = GitDirectory; 87 88 constructor(readonly path) {} 89 90 /** 91 * Generic command used by other methods. Spawns `git` process at instance's repository path. 92 */ 93 async runAsync(args: string[], options: SpawnOptions = {}): Promise<SpawnResult> { 94 return spawnAsync('git', args, { 95 cwd: this.path, 96 ...options, 97 }); 98 } 99 100 /** 101 * Same as `runAsync` but returns boolean value whether the process succeeded or not. 102 */ 103 async tryAsync(args: string[], options: SpawnOptions = {}): Promise<boolean> { 104 try { 105 await this.runAsync(args, options); 106 return true; 107 } catch { 108 return false; 109 } 110 } 111 112 /** 113 * Initializes git repository in the directory. 114 */ 115 async initAsync() { 116 const dotGitPath = join(this.path, '.git'); 117 if (!(await fs.pathExists(dotGitPath))) { 118 await this.runAsync(['init']); 119 } 120 } 121 122 /** 123 * Adds a new remote to the local repository. 124 */ 125 async addRemoteAsync(name: string, url: string): Promise<void> { 126 await this.runAsync(['remote', 'add', name, url]); 127 } 128 129 /** 130 * Switches to given commit reference. 131 */ 132 async checkoutAsync(ref: string) { 133 await this.runAsync(['checkout', ref]); 134 } 135 136 /** 137 * Returns repository's branch name that you're checked out on. 138 */ 139 async getCurrentBranchNameAsync(): Promise<string> { 140 const { stdout } = await this.runAsync(['rev-parse', '--abbrev-ref', 'HEAD']); 141 return stdout.replace(/\n+$/, ''); 142 } 143 144 /** 145 * Returns name of remote branch that the current local branch is tracking. 146 */ 147 async getTrackingBranchNameAsync(): Promise<string> { 148 const { stdout } = await this.runAsync([ 149 'rev-parse', 150 '--abbrev-ref', 151 '--symbolic-full-name', 152 '@{u}', 153 ]); 154 return stdout.trim(); 155 } 156 157 /** 158 * Tries to deduce the SDK version from branch name. Returns null if the branch name is not a release branch. 159 */ 160 async getSDKVersionFromBranchNameAsync(): Promise<string | null> { 161 const currentBranch = await this.getCurrentBranchNameAsync(); 162 const match = currentBranch.match(/\bsdk-(\d+)$/); 163 164 if (match) { 165 const sdkMajorNumber = match[1]; 166 return `${sdkMajorNumber}.0.0`; 167 } 168 return null; 169 } 170 171 /** 172 * Returns full head commit hash. 173 */ 174 async getHeadCommitHashAsync(): Promise<string> { 175 const { stdout } = await this.runAsync(['rev-parse', 'HEAD']); 176 return stdout.trim(); 177 } 178 179 /** 180 * Fetches updates from remote repository. 181 */ 182 async fetchAsync(options: GitFetchOptions = {}): Promise<void> { 183 const args = ['fetch']; 184 185 if (options.depth) { 186 args.push('--depth', options.depth.toString()); 187 } 188 if (options.remote) { 189 args.push(options.remote); 190 } 191 if (options.ref) { 192 args.push(options.ref); 193 } 194 await this.runAsync(args); 195 } 196 197 /** 198 * Pulls changes from the tracking remote branch. 199 */ 200 async pullAsync(options: GitPullOptions): Promise<void> { 201 const args = ['pull']; 202 if (options.rebase) { 203 args.push('--rebase'); 204 } 205 await this.runAsync(args); 206 } 207 208 /** 209 * Pushes new commits to the tracking remote branch. 210 */ 211 async pushAsync(options: GitPushOptions): Promise<void> { 212 const args = ['push']; 213 if (options.track) { 214 args.push('--set-upstream', 'origin', options.track); 215 } 216 await this.runAsync(args); 217 } 218 219 /** 220 * Returns formatted results of `git log` command. 221 */ 222 async logAsync(options: GitLogOptions = {}): Promise<GitLog[]> { 223 const fromCommit = options.fromCommit ?? ''; 224 const toCommit = options.toCommit ?? 'HEAD'; 225 const commitSeparator = options.symmetricDifference ? '...' : '..'; 226 const paths = options.paths ?? ['.']; 227 const cherryPickOptions = options.cherryPick 228 ? ['--cherry-pick', options.cherryPick === 'left' ? '--left-only' : '--right-only'] 229 : []; 230 231 const template = { 232 hash: '%H', 233 parent: '%P', 234 title: '%s', 235 authorName: '%aN', 236 authorDate: '%aI', 237 committerRelativeDate: '%cr', 238 }; 239 240 // We use random \u200b character (zero-width space) instead of double quotes 241 // because we need to know which quotes to escape before we pass it to `JSON.parse`. 242 // Otherwise, double quotes in commits message would cause this function to throw JSON exceptions. 243 const format = 244 ',{' + 245 Object.entries(template) 246 .map(([key, value]) => `\u200b${key}\u200b:\u200b${value}\u200b`) 247 .join(',') + 248 '}'; 249 250 const { stdout } = await this.runAsync([ 251 'log', 252 `--pretty=format:${format}`, 253 ...cherryPickOptions, 254 `${fromCommit}${commitSeparator}${toCommit}`, 255 '--', 256 ...paths, 257 ]); 258 259 // Remove comma at the beginning, escape double quotes and replace \u200b with unescaped double quotes. 260 const jsonItemsString = stdout 261 .slice(1) 262 .replace(/"/g, '\\"') 263 .replace(/\u200b/gu, '"'); 264 265 return JSON.parse(`[${jsonItemsString}]`); 266 } 267 268 /** 269 * Returns a list of files that have been modified, deleted or added between specified commits. 270 */ 271 async logFilesAsync(options: GitLogOptions = {}): Promise<GitFileLog[]> { 272 const fromCommit = options.fromCommit ?? ''; 273 const toCommit = options.toCommit ?? 'HEAD'; 274 275 // This diff command returns a list of relative paths of files that have changed preceded by their status. 276 // Status is just a letter, which is also a key of `GitFileStatus` enum. 277 const { stdout } = await this.runAsync([ 278 'diff', 279 '--name-status', 280 `${fromCommit}..${toCommit}`, 281 '--relative', 282 '--', 283 '.', 284 ]); 285 286 return stdout 287 .split(/\n/g) 288 .filter(Boolean) 289 .map((line) => { 290 // Consecutive columns are separated by horizontal tabs. 291 // In case of `R` (rename) status, there are three columns instead of two, 292 // where the third is the new path after renaming and we should use the new one. 293 const [status, relativePath, relativePathAfterRename] = line.split(/\t+/g); 294 const newPath = relativePathAfterRename ?? relativePath; 295 296 return { 297 relativePath: newPath, 298 path: join(this.path, newPath), 299 // `R` status also has a number, but we take care of only the first character. 300 status: GitFileStatus[status[0]] ?? status, 301 }; 302 }); 303 } 304 305 /** 306 * Adds files at given glob paths. 307 */ 308 async addFilesAsync(paths?: string[]): Promise<void> { 309 if (!paths || paths.length === 0) { 310 return; 311 } 312 await this.runAsync(['add', '--', ...paths]); 313 } 314 315 /** 316 * Checkouts changes and cleans untracked files at given glob paths. 317 */ 318 async discardFilesAsync(paths?: string[]): Promise<void> { 319 if (!paths || paths.length === 0) { 320 return; 321 } 322 await this.runAsync(['checkout', '--', ...paths]); 323 await this.runAsync(['clean', '-df', '--', ...paths]); 324 } 325 326 /** 327 * Commits staged changes with given options including commit's title and body. 328 */ 329 async commitAsync(options: GitCommitOptions): Promise<void> { 330 const args = ['commit', '--message', options.title]; 331 332 if (options.body) { 333 args.push('--message', options.body); 334 } 335 await this.runAsync(args); 336 } 337 338 /** 339 * Cherry-picks the given commits onto the checked out branch. 340 */ 341 async cherryPickAsync(commits: string[], options: GitCherryPickOptions = {}): Promise<void> { 342 const spawnOptions: SpawnOptions = options.inheritStdio ? { stdio: 'inherit' } : {}; 343 await this.runAsync(['cherry-pick', ...commits], spawnOptions); 344 } 345 346 /** 347 * Checks how many commits ahead and behind the former branch is relative to the latter. 348 */ 349 async compareBranchesAsync(a: string, b?: string): Promise<GitBranchesStats> { 350 const { stdout } = await this.runAsync(['rev-list', '--left-right', '--count', `${a}...${b}`]); 351 const numbers = stdout 352 .trim() 353 .split(/\s+/g) 354 .map((n) => +n); 355 356 if (numbers.length !== 2) { 357 throw new Error(`Oops, something went really wrong. Unable to parse "${stdout}"`); 358 } 359 const [ahead, behind] = numbers; 360 return { ahead, behind }; 361 } 362 363 /** 364 * Resolves to boolean value meaning whether the repository contains any unstaged changes. 365 */ 366 async hasUnstagedChangesAsync(paths: string[] = []): Promise<boolean> { 367 return !(await this.tryAsync(['diff', '--quiet', '--', ...paths])); 368 } 369 370 /** 371 * Returns a list of files with staged changes. 372 */ 373 async getStagedFilesAsync(): Promise<string[]> { 374 const { stdout } = await this.runAsync(['diff', '--name-only', '--cached']); 375 return stdout.trim().split(/\n+/g).filter(Boolean); 376 } 377 378 /** 379 * Checks whether given commit is an ancestor of head commit. 380 */ 381 async isAncestorAsync(commit: string): Promise<boolean> { 382 return this.tryAsync(['merge-base', '--is-ancestor', commit, 'HEAD']); 383 } 384 385 /** 386 * Finds the best common ancestor between the current ref and the given ref. 387 */ 388 async mergeBaseAsync(ref: string, base: string = 'HEAD'): Promise<string> { 389 const { stdout } = await this.runAsync(['merge-base', base, ref]); 390 return stdout.trim(); 391 } 392 393 /** 394 * Gets the diff between two commits and parses it to the list of changed files and their chunks. 395 */ 396 async getDiffAsync(commit1: string, commit2: string): Promise<GitFileDiff[]> { 397 const { stdout } = await this.runAsync(['diff', `${commit1}..${commit2}`]); 398 const diff = parseDiff(stdout); 399 400 return diff.map((entry) => { 401 const finalPath = entry.deleted ? entry.from : entry.to; 402 403 return { 404 ...entry, 405 path: join(this.path, finalPath!), 406 }; 407 }); 408 } 409 410 /** 411 * Lists the contents of a given tree object, like what "ls -a" does in the current working directory. 412 */ 413 async listTreeAsync(ref: string, paths: string[]): Promise<GitListTree[]> { 414 const { stdout } = await this.runAsync(['ls-tree', '-l', ref, '--', ...paths]); 415 416 return stdout 417 .trim() 418 .split(/\n+/g) 419 .map((line) => { 420 const columns = line.split(/\b(?=\s+)/g); 421 422 return { 423 mode: columns[0].trim(), 424 type: columns[1].trim(), 425 object: columns[2].trim(), 426 size: Number(columns[3].trim()), 427 path: columns.slice(4).join('').trim(), 428 }; 429 }); 430 } 431 432 /** 433 * Reads a file content from a given ref. 434 */ 435 async readFileAsync(ref: string, path: string): Promise<string> { 436 const { stdout } = await this.runAsync(['show', `${ref}:${relative(EXPO_DIR, path)}`]); 437 return stdout; 438 } 439 440 /** 441 * Clones the repository but in a shallow way, which means 442 * it downloads just one commit instead of the entire repository. 443 * Returns `GitDirectory` instance of the cloned repository. 444 */ 445 static async shallowCloneAsync( 446 directory: string, 447 remoteUrl: string, 448 ref: string = 'main' 449 ): Promise<GitDirectory> { 450 const git = new GitDirectory(directory); 451 452 await fs.mkdirs(directory); 453 await git.initAsync(); 454 await git.addRemoteAsync('origin', remoteUrl); 455 await git.fetchAsync({ depth: 1, remote: 'origin', ref }); 456 await git.checkoutAsync('FETCH_HEAD'); 457 return git; 458 } 459} 460 461export default new GitDirectory(EXPO_DIR); 462