@rushstack/heft
Version:
Build all your JavaScript projects the same way: A way that works.
400 lines • 19.4 kB
JavaScript
;
// 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