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