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