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