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