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