nx
Version:
437 lines (436 loc) • 16.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.GitRepository = void 0;
exports.cloneFromUpstream = cloneFromUpstream;
exports.parseVcsRemoteUrl = parseVcsRemoteUrl;
exports.getVcsRemoteInfo = getVcsRemoteInfo;
exports.isGitRepository = isGitRepository;
exports.hasUncommittedChanges = hasUncommittedChanges;
exports.getUncommittedChangesSnapshot = getUncommittedChangesSnapshot;
exports.commitChanges = commitChanges;
exports.tryCommitChanges = tryCommitChanges;
exports.getLatestCommitSha = getLatestCommitSha;
const tslib_1 = require("tslib");
const child_process_1 = require("child_process");
const crypto = tslib_1.__importStar(require("crypto"));
const fs = tslib_1.__importStar(require("fs"));
const path_1 = require("path");
const logger_1 = require("./logger");
function execAsync(command, execOptions) {
return new Promise((res, rej) => {
(0, child_process_1.exec)(command, { ...execOptions, windowsHide: true }, (err, stdout, stderr) => {
if (err) {
return rej(err);
}
res(stdout.toString());
});
});
}
async function cloneFromUpstream(url, destination, { originName, depth } = {
originName: 'origin',
}) {
await execAsync(`git clone ${url} ${destination} ${depth ? `--depth ${depth}` : ''} --origin ${originName}`, {
cwd: (0, path_1.dirname)(destination),
maxBuffer: 10 * 1024 * 1024,
});
return new GitRepository(destination);
}
class GitRepository {
constructor(directory) {
this.directory = directory;
this.root = this.getGitRootPath(this.directory);
}
getGitRootPath(cwd) {
return (0, child_process_1.execSync)('git rev-parse --show-toplevel', {
cwd,
windowsHide: true,
})
.toString()
.trim();
}
async hasUncommittedChanges() {
const data = await this.execAsync(`git status --porcelain`);
return data.trim() !== '';
}
async addFetchRemote(remoteName, branch) {
return await this.execAsync(`git config --add remote.${remoteName}.fetch "+refs/heads/${branch}:refs/remotes/${remoteName}/${branch}"`);
}
async showStat() {
return await this.execAsync(`git show --stat`);
}
async listBranches() {
return (await this.execAsync(`git ls-remote --heads --quiet`))
.trim()
.split('\n')
.map((s) => s
.trim()
.substring(s.indexOf('\t') + 1)
.replace('refs/heads/', ''));
}
async getGitFiles(path) {
// Use -z to return file names exactly as they are stored in git, separated by NULL (\x00) character.
// This avoids problems with special characters in file names.
return (await this.execAsync(`git ls-files -z ${path}`))
.trim()
.split('\x00')
.map((s) => s.trim())
.filter(Boolean);
}
async reset(ref) {
return await this.execAsync(`git reset ${ref} --hard`);
}
async mergeUnrelatedHistories(ref, message) {
return await this.execAsync(`git merge ${ref} -X ours --allow-unrelated-histories -m "${message}"`);
}
async fetch(remote, ref) {
return await this.execAsync(`git fetch ${remote}${ref ? ` ${ref}` : ''}`);
}
async checkout(branch, opts) {
return await this.execAsync(`git checkout ${opts.new ? '-b ' : ' '}${branch}${opts.base ? ' ' + opts.base : ''}`);
}
async move(path, destination) {
return await this.execAsync(`git mv ${this.quotePath(path)} ${this.quotePath(destination)}`);
}
async push(ref, remoteName) {
return await this.execAsync(`git push -u -f ${remoteName} ${ref}`);
}
async commit(message) {
return await this.execAsync(`git commit -am "${message}"`);
}
async amendCommit() {
return await this.execAsync(`git commit --amend -a --no-edit`);
}
async deleteGitRemote(name) {
return await this.execAsync(`git remote rm ${name}`);
}
async addGitRemote(name, url) {
return await this.execAsync(`git remote add ${name} ${url}`);
}
async hasFilterRepoInstalled() {
try {
await this.execAsync(`git filter-repo --help`);
return true;
}
catch {
return false;
}
}
// git-filter-repo is much faster than filter-branch, but needs to be installed by user
// Use `hasFilterRepoInstalled` to check if it's installed
async filterRepo(source, destination) {
// NOTE: filter-repo requires POSIX path to work
const sourcePosixPath = source.split(path_1.sep).join(path_1.posix.sep);
const destinationPosixPath = destination.split(path_1.sep).join(path_1.posix.sep);
await this.execAsync(`git filter-repo -f ${source !== '' ? `--path ${this.quotePath(sourcePosixPath)}` : ''} ${source !== destination
? `--path-rename ${this.quotePath(sourcePosixPath, true)}:${this.quotePath(destinationPosixPath, true)}`
: ''}`);
}
async filterBranch(source, destination, branchName) {
// We need non-ASCII file names to not be quoted, or else filter-branch will exclude them.
await this.execAsync(`git config core.quotepath false`);
// NOTE: filter-repo requires POSIX path to work
const sourcePosixPath = source.split(path_1.sep).join(path_1.posix.sep);
const destinationPosixPath = destination.split(path_1.sep).join(path_1.posix.sep);
// First, if the source is not a root project, then only include commits relevant to the subdirectory.
if (source !== '') {
const indexFilterCommand = this.quoteArg(`node ${(0, path_1.join)(__dirname, 'git-utils.index-filter.js')}`);
await this.execAsync(`git filter-branch -f --index-filter ${indexFilterCommand} --prune-empty -- ${branchName}`, {
NX_IMPORT_SOURCE: sourcePosixPath,
NX_IMPORT_DESTINATION: destinationPosixPath,
});
}
// Then, move files to their new location if necessary.
if (source === '' || source !== destination) {
const treeFilterCommand = this.quoteArg(`node ${(0, path_1.join)(__dirname, 'git-utils.tree-filter.js')}`);
await this.execAsync(`git filter-branch -f --tree-filter ${treeFilterCommand} -- ${branchName}`, {
NX_IMPORT_SOURCE: sourcePosixPath,
NX_IMPORT_DESTINATION: destinationPosixPath,
});
}
}
execAsync(command, env) {
return execAsync(command, {
cwd: this.root,
maxBuffer: 10 * 1024 * 1024,
env: {
...process.env,
...env,
},
});
}
quotePath(path, ensureTrailingSlash) {
return this.quoteArg(ensureTrailingSlash && path !== '' && !path.endsWith('/')
? `${path}/`
: path);
}
quoteArg(arg) {
return process.platform === 'win32'
? // Windows/CMD only understands double-quotes, single-quotes are treated as part of the file name
// Bash and other shells will substitute `$` in file names with a variable value.
`"${arg
// Need to keep two slashes for Windows or else the path will be invalid.
// e.g. 'C:\Users\bob\projects\repo' is invalid, but 'C:\\Users\\bob\\projects\\repo' is valid
.replaceAll('\\', '\\\\')}"`
: // e.g. `git mv "$$file.txt" "libs/a/$$file.txt"` will not work since `$$` is swapped with the PID of the last process.
// Using single-quotes prevents this substitution.
`'${arg}'`;
}
}
exports.GitRepository = GitRepository;
function parseVcsRemoteUrl(url) {
// Remove whitespace and handle common URL formats
const cleanUrl = url.trim();
// SSH format: git@domain:owner/repo.git
const sshMatch = cleanUrl.match(/^git@([^:]+):([^\/]+)\/(.+?)(\.git)?$/);
if (sshMatch) {
const [, domain, owner, repo] = sshMatch;
return {
domain,
slug: `${owner}/${repo}`,
};
}
// HTTPS with authentication: https://user@domain/owner/repo.git
const httpsAuthMatch = cleanUrl.match(/^https?:\/\/[^@]+@([^\/]+)\/([^\/]+)\/(.+?)(\.git)?$/);
if (httpsAuthMatch) {
const [, domain, owner, repo] = httpsAuthMatch;
return {
domain,
slug: `${owner}/${repo}`,
};
}
// HTTPS format: https://domain/owner/repo.git (without authentication)
const httpsMatch = cleanUrl.match(/^https?:\/\/([^@\/]+)\/([^\/]+)\/(.+?)(\.git)?$/);
if (httpsMatch) {
const [, domain, owner, repo] = httpsMatch;
return {
domain,
slug: `${owner}/${repo}`,
};
}
// SSH alternative format: ssh://git@domain/owner/repo.git or ssh://git@domain:port/owner/repo.git
const sshAltMatch = cleanUrl.match(/^ssh:\/\/[^@]+@([^:\/]+)(:[0-9]+)?\/([^\/]+)\/(.+?)(\.git)?$/);
if (sshAltMatch) {
const [, domain, , owner, repo] = sshAltMatch;
return {
domain,
slug: `${owner}/${repo}`,
};
}
return null;
}
function getVcsRemoteInfo(directory) {
try {
const gitRemote = (0, child_process_1.execSync)('git remote -v', {
stdio: 'pipe',
windowsHide: true,
cwd: directory,
})
.toString()
.trim();
if (!gitRemote || gitRemote.length === 0) {
return null;
}
const lines = gitRemote.split('\n').filter((line) => line.trim());
const remotesPriority = ['origin', 'upstream', 'base'];
const foundRemotes = {};
let firstRemote = null;
for (const line of lines) {
const match = line.trim().match(/^(\w+)\s+(\S+)\s+\((fetch|push)\)$/);
if (match) {
const [, remoteName, url] = match;
const remoteInfo = parseVcsRemoteUrl(url);
if (remoteInfo && !foundRemotes[remoteName]) {
foundRemotes[remoteName] = remoteInfo;
if (!firstRemote) {
firstRemote = remoteInfo;
}
}
}
}
// Return high-priority remote if found
for (const remote of remotesPriority) {
if (foundRemotes[remote]) {
return foundRemotes[remote];
}
}
// Return first found remote
return firstRemote;
}
catch (e) {
return null;
}
}
function isGitRepository(directory) {
try {
(0, child_process_1.execSync)('git rev-parse --is-inside-work-tree', {
stdio: 'ignore',
cwd: directory,
windowsHide: true,
});
return true;
}
catch {
return false;
}
}
// Sync companion to `GitRepository.hasUncommittedChanges` for callers that
// can't drop into the async class (e.g. the migrate orchestrator, which
// branches on this before spawning subprocesses synchronously).
function hasUncommittedChanges(directory) {
try {
const out = (0, child_process_1.execSync)('git status --porcelain', {
encoding: 'utf8',
cwd: directory,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
return out.trim() !== '';
}
catch {
return false;
}
}
// Returns a content-sensitive sha1 snapshot of the working tree state for
// before/after comparison. Hashes three probes:
// 1. `git diff HEAD` with defensive flags — every byte of tracked-file
// changes. `--no-ext-diff` / `--no-textconv` neuter user/repo driver
// overrides so output is deterministic; `--binary` keeps binary
// edits from collapsing to "Binary files differ".
// 2. `git status --porcelain=v1 -uall` — untracked paths the diff
// omits. `-uall` expands untracked directories per-file.
// 3. Untracked file content bytes — so a same-path content edit on an
// already-untracked file does not collapse against the baseline.
//
// Each probe is wrapped independently with a failure sentinel so a
// single-sided git error (e.g. `git diff HEAD` on an initial-commit-less
// repo) cannot mask surviving signal from the others.
function getUncommittedChangesSnapshot(directory) {
const hasher = crypto.createHash('sha1');
const cwd = directory ?? process.cwd();
const execOpts = {
encoding: 'utf8',
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
maxBuffer: 64 * 1024 * 1024,
};
let diffOutput;
try {
diffOutput = (0, child_process_1.execSync)('git diff HEAD --no-color --no-ext-diff --no-textconv --binary', execOpts);
}
catch {
diffOutput = '<diff-unavailable>';
}
hasher.update('diff:').update(diffOutput).update('\0');
let statusOutput;
try {
statusOutput = (0, child_process_1.execSync)('git status --porcelain=v1 -uall', execOpts);
}
catch {
statusOutput = '<status-unavailable>';
}
hasher.update('status:').update(statusOutput).update('\0');
let untrackedRaw;
try {
untrackedRaw = (0, child_process_1.execSync)('git ls-files --others --exclude-standard -z', execOpts);
}
catch {
untrackedRaw = '';
}
const untrackedPaths = untrackedRaw.split('\0').filter(Boolean).sort();
hasher.update('untracked:');
for (const p of untrackedPaths) {
hasher.update(p).update('\0');
try {
hasher.update(fs.readFileSync((0, path_1.join)(cwd, p)));
}
catch {
hasher.update('<file-unreadable>');
}
hasher.update('\0');
}
return hasher.digest('hex');
}
function commitChanges(commitMessage, directory) {
try {
(0, child_process_1.execSync)('git add -A', {
encoding: 'utf8',
stdio: 'pipe',
cwd: directory,
windowsHide: true,
});
(0, child_process_1.execSync)('git commit --no-verify -F -', {
encoding: 'utf8',
stdio: 'pipe',
input: commitMessage,
cwd: directory,
windowsHide: true,
});
}
catch (err) {
if (directory) {
// We don't want to throw during create-nx-workspace
// because maybe there was an error when setting up git
// initially.
logger_1.logger.verbose(`Git may not be set up correctly for this new workspace.
${err}`);
}
else {
throw new Error(`Error committing changes:\n${err.stderr}`);
}
}
return getLatestCommitSha(directory);
}
/**
* Throws on git failure with the real stderr attached. Use this when the
* caller needs to distinguish hook rejection / GPG signing failures / LFS
* lock errors from a successful no-op. Callers should pre-check
* `hasUncommittedChanges` to avoid the "nothing to commit" rejection
* (which `git commit` exits non-zero for).
*
* Returns `null` (rather than throwing) when the commit itself succeeded
* but `git rev-parse HEAD` failed transiently — by contract the diff is
* no longer in the working tree, so callers must NOT report it as such.
*/
function tryCommitChanges(commitMessage, directory) {
try {
(0, child_process_1.execSync)('git add -A', {
encoding: 'utf8',
stdio: 'pipe',
cwd: directory,
windowsHide: true,
});
(0, child_process_1.execSync)('git commit --no-verify -F -', {
encoding: 'utf8',
stdio: 'pipe',
input: commitMessage,
cwd: directory,
windowsHide: true,
});
}
catch (err) {
const stderr = err?.stderr?.toString();
const stdout = err?.stdout?.toString();
const detail = [stderr, stdout]
.map((s) => s?.trim())
.filter(Boolean)
.join('\n');
// `{ cause }` preserves structured fields (.signal, .status, .code)
// for callers to inspect; otherwise only stderr/stdout text survives.
throw new Error(detail || (err instanceof Error ? err.message : String(err)), { cause: err });
}
return getLatestCommitSha(directory);
}
function getLatestCommitSha(directory) {
try {
return (0, child_process_1.execSync)('git rev-parse HEAD', {
encoding: 'utf8',
stdio: 'pipe',
windowsHide: true,
cwd: directory,
}).trim();
}
catch {
return null;
}
}