xref: /expo/tools/src/Git.ts (revision a95a2034)
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