1import crypto from 'crypto';
2import fs from 'fs';
3
4import { Log } from '../log';
5
6type MergeResults = {
7  contents: string;
8  didClear: boolean;
9  didMerge: boolean;
10};
11
12const generatedHeaderPrefix = `# @generated expo-cli`;
13export const generatedFooterComment = `# @end expo-cli`;
14
15/**
16 * Merge two gitignore files together and add a generated header.
17 *
18 * @param targetGitIgnorePath
19 * @param sourceGitIgnorePath
20 *
21 * @returns `null` if one of the gitignore files doesn't exist. Otherwise, returns the merged contents.
22 */
23export function mergeGitIgnorePaths(
24  targetGitIgnorePath: string,
25  sourceGitIgnorePath: string
26): null | MergeResults {
27  if (!fs.existsSync(targetGitIgnorePath)) {
28    // No gitignore in the project already, no need to merge anything into anything. I guess they
29    // are not using git :O
30    return null;
31  }
32
33  if (!fs.existsSync(sourceGitIgnorePath)) {
34    // Maybe we don't have a gitignore in the template project
35    return null;
36  }
37
38  const targetGitIgnore = fs.readFileSync(targetGitIgnorePath).toString();
39  const sourceGitIgnore = fs.readFileSync(sourceGitIgnorePath).toString();
40  const merged = mergeGitIgnoreContents(targetGitIgnore, sourceGitIgnore);
41  // Only rewrite the file if it was modified.
42  if (merged.contents) {
43    fs.writeFileSync(targetGitIgnorePath, merged.contents);
44  }
45
46  return merged;
47}
48
49/**
50 * Get line indexes for the generated section of a gitignore.
51 *
52 * @param gitIgnore
53 */
54function getGeneratedSectionIndexes(gitIgnore: string): {
55  contents: string[];
56  start: number;
57  end: number;
58} {
59  const contents = gitIgnore.split('\n');
60  const start = contents.findIndex((line) => line.startsWith(generatedHeaderPrefix));
61  const end = contents.findIndex((line) => line.startsWith(generatedFooterComment));
62
63  return { contents, start, end };
64}
65
66/**
67 * Removes the generated section from a gitignore, returns null when nothing can be removed.
68 * This sways heavily towards not removing lines unless it's certain that modifications were not made to the gitignore manually.
69 *
70 * @param gitIgnore
71 */
72export function removeGeneratedGitIgnoreContents(gitIgnore: string): string | null {
73  const { contents, start, end } = getGeneratedSectionIndexes(gitIgnore);
74  if (start > -1 && end > -1 && start < end) {
75    contents.splice(start, end - start + 1);
76    // TODO: We could in theory check that the contents we're removing match the hash used in the header,
77    // this would ensure that we don't accidentally remove lines that someone added or removed from the generated section.
78    return contents.join('\n');
79  }
80  return null;
81}
82
83/**
84 * Merge the contents of two gitignores together and add a generated header.
85 *
86 * @param targetGitIgnore contents of the existing gitignore
87 * @param sourceGitIgnore contents of the extra gitignore
88 */
89export function mergeGitIgnoreContents(
90  targetGitIgnore: string,
91  sourceGitIgnore: string
92): MergeResults {
93  const header = createGeneratedHeaderComment(sourceGitIgnore);
94  if (!targetGitIgnore.includes(header)) {
95    // Ensure the old generated gitignore contents are removed.
96    const sanitizedTarget = removeGeneratedGitIgnoreContents(targetGitIgnore);
97    return {
98      contents: [
99        sanitizedTarget ?? targetGitIgnore,
100        header,
101        `# The following patterns were generated by expo-cli`,
102        ``,
103        sourceGitIgnore,
104        generatedFooterComment,
105      ].join('\n'),
106      didMerge: true,
107      didClear: !!sanitizedTarget,
108    };
109  }
110  return { contents: targetGitIgnore, didClear: false, didMerge: false };
111}
112
113/**
114 * Adds the contents into an existing gitignore "generated by expo-cli section"
115 * If no section exists, it will be created (hence the name upsert)
116 */
117export function upsertGitIgnoreContents(
118  targetGitIgnorePath: string,
119  contents: string
120): MergeResults | null {
121  const targetGitIgnore = fs.readFileSync(targetGitIgnorePath, {
122    encoding: 'utf-8',
123    flag: 'a+',
124  });
125
126  if (targetGitIgnore.match(new RegExp(`^${contents}[\\n\\r\\s]*$`, 'm'))) {
127    return null;
128  }
129
130  // If there is an existing section, update it with the new content
131  if (targetGitIgnore.includes(generatedHeaderPrefix)) {
132    const indexes = getGeneratedSectionIndexes(targetGitIgnore);
133
134    contents = `${indexes.contents.slice(indexes.start + 3, indexes.end).join('\n')}\n${contents}`;
135  }
136
137  const merged = mergeGitIgnoreContents(targetGitIgnore, contents);
138
139  if (merged.contents) {
140    fs.writeFileSync(targetGitIgnorePath, merged.contents);
141  }
142  return merged;
143}
144
145export function createGeneratedHeaderComment(gitIgnore: string): string {
146  const hashKey = createGitIgnoreHash(getSanitizedGitIgnoreLines(gitIgnore).join('\n'));
147
148  return `${generatedHeaderPrefix} ${hashKey}`;
149}
150
151/**
152 * Normalize the contents of a gitignore to ensure that minor changes like new lines or sort order don't cause a regeneration.
153 */
154export function getSanitizedGitIgnoreLines(gitIgnore: string): string[] {
155  // filter, trim, and sort the lines.
156  return gitIgnore
157    .split('\n')
158    .filter((v) => {
159      const line = v.trim();
160      // Strip comments
161      if (line.startsWith('#')) {
162        return false;
163      }
164      return !!line;
165    })
166    .sort();
167}
168
169export function createGitIgnoreHash(gitIgnore: string): string {
170  // this doesn't need to be secure, the shorter the better.
171  const hash = crypto.createHash('sha1').update(gitIgnore).digest('hex');
172  return `sync-${hash}`;
173}
174
175export function removeFromGitIgnore(targetGitIgnorePath: string, contents: string) {
176  try {
177    if (!fs.existsSync(targetGitIgnorePath)) {
178      return;
179    }
180
181    let targetGitIgnore = fs.readFileSync(targetGitIgnorePath, 'utf-8');
182
183    if (!targetGitIgnore.includes(contents)) {
184      return null;
185    }
186
187    targetGitIgnore = targetGitIgnore.replace(`${contents}\n`, '');
188
189    const indexes = getGeneratedSectionIndexes(targetGitIgnore);
190
191    if (indexes.start === indexes.end - 3) {
192      targetGitIgnore = targetGitIgnore.replace(
193        new RegExp(`^${generatedHeaderPrefix}((.|\n)*)${generatedFooterComment}$`, 'm'),
194        ''
195      );
196    }
197
198    return fs.writeFileSync(targetGitIgnorePath, targetGitIgnore);
199  } catch (error) {
200    Log.error(`Failed to read/write to .gitignore path: ${targetGitIgnorePath}`);
201    throw error;
202  }
203}
204