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