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
110export function createGeneratedHeaderComment(gitIgnore: string): string {
111  const hashKey = createGitIgnoreHash(getSanitizedGitIgnoreLines(gitIgnore).join('\n'));
112
113  return `${generatedHeaderPrefix} ${hashKey}`;
114}
115
116/**
117 * Normalize the contents of a gitignore to ensure that minor changes like new lines or sort order don't cause a regeneration.
118 */
119export function getSanitizedGitIgnoreLines(gitIgnore: string): string[] {
120  // filter, trim, and sort the lines.
121  return gitIgnore
122    .split('\n')
123    .filter((v) => {
124      const line = v.trim();
125      // Strip comments
126      if (line.startsWith('#')) {
127        return false;
128      }
129      return !!line;
130    })
131    .sort();
132}
133
134export function createGitIgnoreHash(gitIgnore: string): string {
135  // this doesn't need to be secure, the shorter the better.
136  const hash = crypto.createHash('sha1').update(gitIgnore).digest('hex');
137  return `sync-${hash}`;
138}
139