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