1import path from 'path'; 2 3import { spawnAsync, SpawnResult, SpawnOptions } from './Utils'; 4import { EXPO_DIR } from './Constants'; 5 6export type GitPullOptions = { 7 rebase?: boolean; 8}; 9 10export type GitPushOptions = { 11 track?: string; 12}; 13 14export type GitLogOptions = { 15 fromCommit?: string; 16 toCommit?: string; 17 paths?: string[]; 18}; 19 20export type GitLog = { 21 hash: string; 22 parent: string; 23 title: string; 24 authorName: string; 25 committerRelativeDate: string; 26}; 27 28export type GitFileLog = { 29 path: string; 30 relativePath: string; 31 status: GitFileStatus; 32}; 33 34export enum GitFileStatus { 35 M = 'modified', 36 C = 'copy', 37 R = 'rename', 38 A = 'added', 39 D = 'deleted', 40 U = 'unmerged', 41} 42 43export type GitBranchesStats = { 44 ahead: number; 45 behind: number; 46}; 47 48export type GitCommitOptions = { 49 title: string; 50 body?: string; 51}; 52 53/** 54 * Helper class that stores the directory inside the repository so we don't have to pass it many times. 55 * This directory path doesn't have to be the repo's root path, 56 * it's just like current working directory for all other commands. 57 */ 58export class GitDirectory { 59 readonly Directory = GitDirectory; 60 61 constructor(readonly path) {} 62 63 /** 64 * Generic command used by other methods. Spawns `git` process at instance's repository path. 65 */ 66 async runAsync(args: string[], options: SpawnOptions = {}): Promise<SpawnResult> { 67 return spawnAsync('git', args, { 68 cwd: this.path, 69 ...options, 70 }); 71 } 72 73 /** 74 * Same as `runAsync` but returns boolean value whether the process succeeded or not. 75 */ 76 async tryAsync(args: string[], options: SpawnOptions = {}): Promise<boolean> { 77 try { 78 await this.runAsync(args, options); 79 return true; 80 } catch (error) { 81 return false; 82 } 83 } 84 85 /** 86 * Returns repository's branch name that you're checked out on. 87 */ 88 async getCurrentBranchNameAsync(): Promise<string> { 89 const { stdout } = await this.runAsync(['rev-parse', '--abbrev-ref', 'HEAD']); 90 return stdout.replace(/\n+$/, ''); 91 } 92 93 /** 94 * Returns name of remote branch that the current local branch is tracking. 95 */ 96 async getTrackingBranchNameAsync(): Promise<string> { 97 const { stdout } = await this.runAsync([ 98 'rev-parse', 99 '--abbrev-ref', 100 '--symbolic-full-name', 101 '@{u}', 102 ]); 103 return stdout.trim(); 104 } 105 106 /** 107 * Tries to deduce the SDK version from branch name. Returns null if the branch name is not a release branch. 108 */ 109 async getSDKVersionFromBranchNameAsync(): Promise<string | null> { 110 const currentBranch = await this.getCurrentBranchNameAsync(); 111 const match = currentBranch.match(/\bsdk-(\d+)$/); 112 113 if (match) { 114 const sdkMajorNumber = match[1]; 115 return `${sdkMajorNumber}.0.0`; 116 } 117 return null; 118 } 119 120 /** 121 * Returns full head commit hash. 122 */ 123 async getHeadCommitHashAsync(): Promise<string> { 124 const { stdout } = await this.runAsync(['rev-parse', 'HEAD']); 125 return stdout.trim(); 126 } 127 128 /** 129 * Fetches updates from remote repository. 130 */ 131 async fetchAsync(): Promise<void> { 132 await this.runAsync(['fetch']); 133 } 134 135 /** 136 * Pulls changes from the tracking remote branch. 137 */ 138 async pullAsync(options: GitPullOptions): Promise<void> { 139 const args = ['pull']; 140 if (options.rebase) { 141 args.push('--rebase'); 142 } 143 await this.runAsync(args); 144 } 145 146 /** 147 * Pushes new commits to the tracking remote branch. 148 */ 149 async pushAsync(options: GitPushOptions): Promise<void> { 150 const args = ['push']; 151 if (options.track) { 152 args.push('--set-upstream', 'origin', options.track); 153 } 154 await this.runAsync(args); 155 } 156 157 /** 158 * Returns formatted results of `git log` command. 159 */ 160 async logAsync(options: GitLogOptions = {}): Promise<GitLog[]> { 161 const fromCommit = options.fromCommit ?? ''; 162 const toCommit = options.toCommit ?? 'head'; 163 const paths = options.paths ?? ['.']; 164 165 const template = { 166 hash: '%H', 167 parent: '%P', 168 title: '%s', 169 authorName: '%aN', 170 committerRelativeDate: '%cr', 171 }; 172 173 // We use random \u200b character (zero-width space) instead of double quotes 174 // because we need to know which quotes to escape before we pass it to `JSON.parse`. 175 // Otherwise, double quotes in commits message would cause this function to throw JSON exceptions. 176 const format = 177 ',{' + 178 Object.entries(template) 179 .map(([key, value]) => `\u200b${key}\u200b:\u200b${value}\u200b`) 180 .join(',') + 181 '}'; 182 183 const { stdout } = await this.runAsync([ 184 'log', 185 `--pretty=format:${format}`, 186 `${fromCommit}..${toCommit}`, 187 '--', 188 ...paths, 189 ]); 190 191 // Remove comma at the beginning, escape double quotes and replace \u200b with unescaped double quotes. 192 const jsonItemsString = stdout 193 .slice(1) 194 .replace(/"/g, '\\"') 195 .replace(/\u200b/gu, '"'); 196 197 return JSON.parse(`[${jsonItemsString}]`); 198 } 199 200 /** 201 * Returns a list of files that have been modified, deleted or added between specified commits. 202 */ 203 async logFilesAsync(options: GitLogOptions = {}): Promise<GitFileLog[]> { 204 const fromCommit = options.fromCommit ?? ''; 205 const toCommit = options.toCommit ?? 'HEAD'; 206 207 // This diff command returns a list of relative paths of files that have changed preceded by their status. 208 // Status is just a letter, which is also a key of `GitFileStatus` enum. 209 const { stdout } = await this.runAsync([ 210 'diff', 211 '--name-status', 212 `${fromCommit}..${toCommit}`, 213 '--relative', 214 '--', 215 '.', 216 ]); 217 218 return stdout 219 .split(/\n/g) 220 .filter(Boolean) 221 .map((line) => { 222 // Consecutive columns are separated by horizontal tabs. 223 // In case of `R` (rename) status, there are three columns instead of two, 224 // where the third is the new path after renaming and we should use the new one. 225 const [status, relativePath, relativePathAfterRename] = line.split(/\t+/g); 226 const newPath = relativePathAfterRename ?? relativePath; 227 228 return { 229 relativePath: newPath, 230 path: path.join(this.path, newPath), 231 // `R` status also has a number, but we take care of only the first character. 232 status: GitFileStatus[status[0]] ?? status, 233 }; 234 }); 235 } 236 237 /** 238 * Adds files at given glob paths. 239 */ 240 async addFilesAsync(paths?: string[]): Promise<void> { 241 if (!paths || paths.length === 0) { 242 return; 243 } 244 await this.runAsync(['add', '--', ...paths]); 245 } 246 247 /** 248 * Checkouts changes and cleans untracked files at given glob paths. 249 */ 250 async discardFilesAsync(paths?: string[]): Promise<void> { 251 if (!paths || paths.length === 0) { 252 return; 253 } 254 await this.runAsync(['checkout', '--', ...paths]); 255 await this.runAsync(['clean', '-df', '--', ...paths]); 256 } 257 258 /** 259 * Commits staged changes with given options including commit's title and body. 260 */ 261 async commitAsync(options: GitCommitOptions): Promise<void> { 262 const args = ['commit', '--message', options.title]; 263 264 if (options.body) { 265 args.push('--message', options.body); 266 } 267 await this.runAsync(args); 268 } 269 270 /** 271 * Checks how many commits ahead and behind the former branch is relative to the latter. 272 */ 273 async compareBranchesAsync(a: string, b?: string): Promise<GitBranchesStats> { 274 const { stdout } = await this.runAsync(['rev-list', '--left-right', '--count', `${a}...${b}`]); 275 const numbers = stdout 276 .trim() 277 .split(/\s+/g) 278 .map((n) => +n); 279 280 if (numbers.length !== 2) { 281 throw new Error(`Oops, something went really wrong. Unable to parse "${stdout}"`); 282 } 283 const [ahead, behind] = numbers; 284 return { ahead, behind }; 285 } 286 287 /** 288 * Resolves to boolean value meaning whether the repository contains any unstaged changes. 289 */ 290 async hasUnstagedChangesAsync(paths: string[] = []): Promise<boolean> { 291 return !(await this.tryAsync(['diff', '--quiet', '--', ...paths])); 292 } 293 294 /** 295 * Returns a list of files with staged changes. 296 */ 297 async getStagedFilesAsync(): Promise<string[]> { 298 const { stdout } = await this.runAsync(['diff', '--name-only', '--cached']); 299 return stdout.trim().split(/\n+/g).filter(Boolean); 300 } 301 302 /** 303 * Checks whether given commit is an ancestor of head commit. 304 */ 305 async isAncestorAsync(commit: string): Promise<boolean> { 306 return this.tryAsync(['merge-base', '--is-ancestor', commit, 'HEAD']); 307 } 308 309 /** 310 * Finds the best common ancestor between the current ref and the given ref. 311 */ 312 async mergeBaseAsync(ref: string): Promise<string> { 313 const { stdout } = await this.runAsync(['merge-base', 'HEAD', ref]); 314 return stdout.trim(); 315 } 316} 317 318export default new GitDirectory(EXPO_DIR); 319