UNPKG

@rushstack/heft

Version:

Build all your JavaScript projects the same way: A way that works.

360 lines 17.5 kB
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. import * as path from 'node:path'; import { default as getGitRepoInfo } from 'git-repo-info'; import { default as ignore } from 'ignore'; import { Executable, FileSystem, InternalError, Path, Text } from '@rushstack/node-core-library'; // Matches lines starting with "#" and whitespace lines const GITIGNORE_IGNORABLE_LINE_REGEX = /^(?:(?:#.*)|(?:\s+))$/; const UNINITIALIZED = 'UNINITIALIZED'; export class GitUtilities { constructor(workingDirectory) { this._gitPath = UNINITIALIZED; this._gitInfo = UNINITIALIZED; this._gitVersion = UNINITIALIZED; this._workingDirectory = path.resolve(process.cwd(), workingDirectory); } /** * Returns the path to the Git binary if found. Otherwise, return undefined. */ get gitPath() { if (this._gitPath === UNINITIALIZED) { this._gitPath = Executable.tryResolve('git'); } return this._gitPath; } /** * Get information about the current Git working tree. * Returns undefined if the current path is not under a Git working tree. */ getGitInfo() { if (this._gitInfo === UNINITIALIZED) { let repoInfo; try { // getGitRepoInfo() shouldn't usually throw, but wrapping in a try/catch just in case repoInfo = getGitRepoInfo(); } catch (ex) { // if there's an error, assume we're not in a Git working tree } this._gitInfo = repoInfo && this.isPathUnderGitWorkingTree(repoInfo) ? repoInfo : undefined; } return this._gitInfo; } /** * Gets the Git version and returns it. */ getGitVersion() { if (this._gitVersion === UNINITIALIZED) { if (this.gitPath) { const result = Executable.spawnSync(this.gitPath, ['version']); if (result.status !== 0) { throw new Error(`While validating the Git installation, the "git version" command failed with ` + `status ${result.status}: ${result.stderr}`); } this._gitVersion = this._parseGitVersion(result.stdout); } else { this._gitVersion = undefined; } } return this._gitVersion; } /** * Returns true if the Git binary can be found. */ isGitPresent() { return !!this.gitPath; } /** * Returns true if the Git binary was found and the current path is under a Git working tree. * @param repoInfo - If provided, do the check based on this Git repo info. If not provided, * the result of `this.getGitInfo()` is used. */ isPathUnderGitWorkingTree(repoInfo) { if (this.isGitPresent()) { // Do we even have a Git binary? if (!repoInfo) { repoInfo = this.getGitInfo(); } return !!(repoInfo && repoInfo.sha); } else { return false; } } /** * Returns an asynchronous filter function which can be used to filter out files that are ignored by Git. */ async tryCreateGitignoreFilterAsync() { var _a; let gitInfo; if (!this.isGitPresent() || !((_a = (gitInfo = this.getGitInfo())) === null || _a === void 0 ? void 0 : _a.sha)) { return; } const gitRepoRootPath = gitInfo.root; const ignoreMatcherMap = await this._getIgnoreMatchersAsync(gitRepoRootPath); const matcherFiltersByMatcher = new Map(); return (filePath) => { const matcher = this._findIgnoreMatcherForFilePath(filePath, ignoreMatcherMap); let matcherFilter = matcherFiltersByMatcher.get(matcher); if (!matcherFilter) { matcherFilter = matcher.createFilter(); matcherFiltersByMatcher.set(matcher, matcherFilter); } // Now that we have the matcher, we can finally check to see if the file is ignored. We need to use // the path relative to the git repo root, since all produced matchers are relative to the git repo // root. Additionally, the ignore library expects relative paths to be sourced from the path library, // so use path.relative() to ensure the path is correctly normalized. const relativeFilePath = path.relative(gitRepoRootPath, filePath); return matcherFilter(relativeFilePath); }; } _findIgnoreMatcherForFilePath(filePath, ignoreMatcherMap) { if (!path.isAbsolute(filePath)) { throw new Error(`The filePath must be an absolute path: "${filePath}"`); } const normalizedFilePath = Path.convertToSlashes(filePath); // Find the best matcher for the file path by finding the longest matcher path that is a prefix to // the file path // TODO: Use LookupByPath to make this more efficient. Currently not possible because LookupByPath // does not have support for leaf node traversal. let longestMatcherPath; let foundMatcher; for (const [matcherPath, matcher] of ignoreMatcherMap) { if (normalizedFilePath.startsWith(matcherPath) && matcherPath.length > ((longestMatcherPath === null || longestMatcherPath === void 0 ? void 0 : longestMatcherPath.length) || 0)) { longestMatcherPath = matcherPath; foundMatcher = matcher; } } if (!foundMatcher) { throw new InternalError(`Unable to find a gitignore matcher for "${filePath}"`); } return foundMatcher; } async _getIgnoreMatchersAsync(gitRepoRootPath) { // Return early if we've already parsed the .gitignore matchers if (this._ignoreMatcherByGitignoreFolder !== undefined) { return this._ignoreMatcherByGitignoreFolder; } else { this._ignoreMatcherByGitignoreFolder = new Map(); } // Store the raw loaded ignore patterns in a map, keyed by the directory they were loaded from const rawIgnorePatternsByGitignoreFolder = new Map(); // Load the .gitignore files for the working directory and all parent directories. We can loop through // and compare the currentPath length to the gitRepoRootPath length because we know the currentPath // must be under the gitRepoRootPath const normalizedWorkingDirectory = Path.convertToSlashes(this._workingDirectory); let currentPath = normalizedWorkingDirectory; while (currentPath.length >= gitRepoRootPath.length) { const gitIgnoreFilePath = `${currentPath}/.gitignore`; const gitIgnorePatterns = await this._tryReadGitIgnoreFileAsync(gitIgnoreFilePath); if (gitIgnorePatterns) { rawIgnorePatternsByGitignoreFolder.set(currentPath, gitIgnorePatterns); } currentPath = currentPath.slice(0, currentPath.lastIndexOf('/')); } // Load the .gitignore files for all subdirectories const gitignoreRelativeFilePaths = await this._findUnignoredFilesAsync('*.gitignore'); for (const gitignoreRelativeFilePath of gitignoreRelativeFilePaths) { const gitignoreFilePath = `${normalizedWorkingDirectory}/${gitignoreRelativeFilePath}`; const gitIgnorePatterns = await this._tryReadGitIgnoreFileAsync(gitignoreFilePath); if (gitIgnorePatterns) { const parentPath = gitignoreFilePath.slice(0, gitignoreFilePath.lastIndexOf('/')); rawIgnorePatternsByGitignoreFolder.set(parentPath, gitIgnorePatterns); } } // Create the ignore matchers for each found .gitignore file for (const gitIgnoreParentPath of rawIgnorePatternsByGitignoreFolder.keys()) { let ignoreMatcherPatterns = []; currentPath = gitIgnoreParentPath; // Travel up the directory tree, adding the ignore patterns from each .gitignore file while (currentPath.length >= gitRepoRootPath.length) { // Get the root-relative path of the .gitignore file directory. Replace backslashes with forward // slashes if backslashes are the system default path separator, since gitignore patterns use // forward slashes. const rootRelativePath = Path.convertToSlashes(path.relative(gitRepoRootPath, currentPath)); // Parse the .gitignore patterns according to the Git documentation: // https://git-scm.com/docs/gitignore#_pattern_format const resolvedGitIgnorePatterns = []; const gitIgnorePatterns = rawIgnorePatternsByGitignoreFolder.get(currentPath); for (let gitIgnorePattern of gitIgnorePatterns || []) { // If the pattern is negated, track this and trim the negation so that we can do path resolution let isNegated = false; if (gitIgnorePattern.startsWith('!')) { isNegated = true; gitIgnorePattern = gitIgnorePattern.substring(1); } // Validate if the path is a relative path. If so, make the path relative to the root directory // of the Git repo. Slashes at the end of the path indicate that the pattern targets a directory // and do not indicate the pattern is relative to the gitignore file. Non-relative patterns are // not processed here since they are valid for all subdirectories at or below the gitignore file // directory. const slashIndex = gitIgnorePattern.indexOf('/'); if (slashIndex >= 0 && slashIndex !== gitIgnorePattern.length - 1) { // Trim the leading slash (if present) and append to the root relative path if (slashIndex === 0) { gitIgnorePattern = gitIgnorePattern.substring(1); } gitIgnorePattern = `${rootRelativePath}/${gitIgnorePattern}`; } // Add the negation back to the pattern if it was negated if (isNegated) { gitIgnorePattern = `!${gitIgnorePattern}`; } // Add the pattern to the list of resolved patterns in the order they are read, since the order // of declaration of patterns in a .gitignore file matters for negations resolvedGitIgnorePatterns.push(gitIgnorePattern); } // Add the patterns to the ignore matcher patterns. Since we are crawling up the directory tree to // the root of the Git repo we need to prepend the patterns, since the order of declaration of // patterns in a .gitignore file matters for negations. Do this using Array.concat so that we can // avoid stack overflows due to the variadic nature of Array.unshift. ignoreMatcherPatterns = [].concat(resolvedGitIgnorePatterns, ignoreMatcherPatterns); currentPath = currentPath.slice(0, currentPath.lastIndexOf('/')); } this._ignoreMatcherByGitignoreFolder.set(gitIgnoreParentPath, ignore().add(ignoreMatcherPatterns)); } return this._ignoreMatcherByGitignoreFolder; } async _tryReadGitIgnoreFileAsync(filePath) { let gitIgnoreContent; try { gitIgnoreContent = await FileSystem.readFileAsync(filePath); } catch (error) { if (!FileSystem.isFileDoesNotExistError(error)) { throw error; } } const foundIgnorePatterns = []; if (gitIgnoreContent) { const gitIgnorePatterns = Text.splitByNewLines(gitIgnoreContent); for (const gitIgnorePattern of gitIgnorePatterns) { // Ignore whitespace-only lines and comments if (gitIgnorePattern.length === 0 || GITIGNORE_IGNORABLE_LINE_REGEX.test(gitIgnorePattern)) { continue; } // Push them into the array in the order that they are read, since order matters foundIgnorePatterns.push(gitIgnorePattern); } } // Only return if we found any valid patterns return foundIgnorePatterns.length ? foundIgnorePatterns : undefined; } async _findUnignoredFilesAsync(searchPattern) { this._ensureGitMinimumVersion({ major: 2, minor: 22, patch: 0 }); this._ensurePathIsUnderGitWorkingTree(); const args = [ '--cached', '--modified', '--others', '--deduplicate', '--exclude-standard', '-z' ]; if (searchPattern) { args.push(searchPattern); } return await this._executeGitCommandAndCaptureOutputAsync({ command: 'ls-files', args, delimiter: '\0' }); } async _executeGitCommandAndCaptureOutputAsync(options) { const gitPath = this._getGitPathOrThrow(); const processArgs = [options.command].concat(options.args || []); const childProcess = Executable.spawn(gitPath, processArgs, { currentWorkingDirectory: this._workingDirectory, stdio: ['ignore', 'pipe', 'pipe'] }); if (!childProcess.stdout || !childProcess.stderr) { throw new Error(`Failed to spawn Git process: ${gitPath} ${processArgs.join(' ')}`); } childProcess.stdout.setEncoding('utf8'); childProcess.stderr.setEncoding('utf8'); return await new Promise((resolve, reject) => { const output = []; const stdoutBuffer = []; let errorMessage = ''; childProcess.stdout.on('data', (chunk) => { stdoutBuffer.push(chunk.toString()); }); childProcess.stderr.on('data', (chunk) => { errorMessage += chunk.toString(); }); childProcess.on('close', (exitCode, signal) => { if (exitCode) { reject(new Error(`git exited with error code ${exitCode}${errorMessage ? `: ${errorMessage}` : ''}`)); } else if (signal) { reject(new Error(`git terminated by signal ${signal}`)); } let remainder = ''; for (let chunk of stdoutBuffer) { let delimiterIndex; while ((delimiterIndex = chunk.indexOf(options.delimiter || '\n')) >= 0) { output.push(`${remainder}${chunk.slice(0, delimiterIndex)}`); remainder = ''; chunk = chunk.slice(delimiterIndex + 1); } remainder = chunk; } resolve(output); }); }); } _getGitPathOrThrow() { const gitPath = this.gitPath; if (!gitPath) { throw new Error('Git is not present'); } else { return gitPath; } } _ensureGitMinimumVersion(minimumGitVersion) { const gitVersion = this.getGitVersion(); if (!gitVersion) { throw new Error('Git is not present'); } else if (gitVersion.major < minimumGitVersion.major || (gitVersion.major === minimumGitVersion.major && gitVersion.minor < minimumGitVersion.minor) || (gitVersion.major === minimumGitVersion.major && gitVersion.minor === minimumGitVersion.minor && gitVersion.patch < minimumGitVersion.patch)) { throw new Error(`The minimum Git version required is ` + `${minimumGitVersion.major}.${minimumGitVersion.minor}.${minimumGitVersion.patch}. ` + `Your version is ${gitVersion.major}.${gitVersion.minor}.${gitVersion.patch}.`); } } _ensurePathIsUnderGitWorkingTree() { if (!this.isPathUnderGitWorkingTree()) { throw new Error(`The path "${this._workingDirectory}" is not under a Git working tree`); } } _parseGitVersion(gitVersionOutput) { // This regexp matches output of "git version" that looks like // `git version <number>.<number>.<number>(+whatever)` // Examples: // - git version 1.2.3 // - git version 1.2.3.4.5 // - git version 1.2.3windows.1 // - git version 1.2.3.windows.1 const versionRegex = /^git version (\d+)\.(\d+)\.(\d+)/; const match = versionRegex.exec(gitVersionOutput); if (!match) { throw new Error(`While validating the Git installation, the "git version" command produced ` + `unexpected output: ${JSON.stringify(gitVersionOutput)}`); } return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10), patch: parseInt(match[3], 10) }; } } //# sourceMappingURL=GitUtilities.js.map