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