UNPKG

@rushstack/heft

Version:

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

400 lines 19.4 kB
"use strict"; // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.GitUtilities = void 0; const path = __importStar(require("node:path")); const git_repo_info_1 = __importDefault(require("git-repo-info")); const ignore_1 = __importDefault(require("ignore")); const node_core_library_1 = require("@rushstack/node-core-library"); // Matches lines starting with "#" and whitepace lines const GITIGNORE_IGNORABLE_LINE_REGEX = /^(?:(?:#.*)|(?:\s+))$/; const UNINITIALIZED = 'UNINITIALIZED'; 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 = node_core_library_1.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 = (0, git_repo_info_1.default)(); } 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 = node_core_library_1.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 = node_core_library_1.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 node_core_library_1.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 = node_core_library_1.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 = node_core_library_1.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, (0, ignore_1.default)().add(ignoreMatcherPatterns)); } return this._ignoreMatcherByGitignoreFolder; } async _tryReadGitIgnoreFileAsync(filePath) { let gitIgnoreContent; try { gitIgnoreContent = await node_core_library_1.FileSystem.readFileAsync(filePath); } catch (error) { if (!node_core_library_1.FileSystem.isFileDoesNotExistError(error)) { throw error; } } const foundIgnorePatterns = []; if (gitIgnoreContent) { const gitIgnorePatterns = node_core_library_1.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 = node_core_library_1.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) }; } } exports.GitUtilities = GitUtilities; //# sourceMappingURL=GitUtilities.js.map