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