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