nx
Version:
934 lines (933 loc) • 34.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.GIT_SHA = exports.GitRepository = void 0;
exports.cloneFromUpstream = cloneFromUpstream;
exports.parseVcsRemoteUrl = parseVcsRemoteUrl;
exports.locateGitDir = locateGitDir;
exports.getVcsRemoteInfo = getVcsRemoteInfo;
exports.getGitRootPath = getGitRootPath;
exports.getGitRootRelativePath = getGitRootRelativePath;
exports.isShallowRepository = isShallowRepository;
exports.getFirstCommitSha = getFirstCommitSha;
exports.isGitRepository = isGitRepository;
exports.getGitRepositoryStatus = getGitRepositoryStatus;
exports.getGitCurrentBranch = getGitCurrentBranch;
exports.getGitRemoteNames = getGitRemoteNames;
exports.getWorkingTreeStatus = getWorkingTreeStatus;
exports.hasUncommittedChanges = hasUncommittedChanges;
exports.getPathCommitExposure = getPathCommitExposure;
exports.getUncommittedChangesSnapshot = getUncommittedChangesSnapshot;
exports.commitChanges = commitChanges;
exports.tryCommitChanges = tryCommitChanges;
exports.getLatestCommitSha = getLatestCommitSha;
exports.isAncestorCommit = isAncestorCommit;
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 owned_private_dir_1 = require("./owned-private-dir");
function execFileAsync(file, args, execOptions) {
return new Promise((res, rej) => {
(0, child_process_1.execFile)(file, args, { ...execOptions, windowsHide: true }, (err, stdout) => {
if (err) {
return rej(err);
}
res(stdout.toString());
});
});
}
async function cloneFromUpstream(url, destination, { originName, depth } = {
originName: 'origin',
}) {
await execFileAsync('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 getGitRootPath(cwd);
}
async hasUncommittedChanges() {
const data = await this.execGit(['status', '--porcelain']);
return data.trim() !== '';
}
async addFetchRemote(remoteName, branch) {
return await this.execGit([
'config',
'--add',
`remote.${remoteName}.fetch`,
`+refs/heads/${branch}:refs/remotes/${remoteName}/${branch}`,
]);
}
async showStat() {
return await this.execGit(['show', '--stat']);
}
async listBranches() {
return (await this.execGit(['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.execGit(['ls-files', '-z', '--', path]))
.trim()
.split('\x00')
.map((s) => s.trim())
.filter(Boolean);
}
async reset(ref) {
return await this.execGit(['reset', '--hard', ref]);
}
async mergeUnrelatedHistories(ref, message) {
return await this.execGit([
'merge',
ref,
'-X',
'ours',
'--allow-unrelated-histories',
'-m',
message,
]);
}
async fetch(remote, ref) {
return await this.execGit(['fetch', remote, ...(ref ? [ref] : [])]);
}
async checkout(branch, opts) {
return await this.execGit([
'checkout',
...(opts.new ? ['-b'] : []),
branch,
...(opts.base ? [opts.base] : []),
]);
}
async move(path, destination) {
return await this.execGit(['mv', '--', path, destination]);
}
async push(ref, remoteName) {
return await this.execGit(['push', '-u', '-f', remoteName, ref]);
}
async commit(message) {
return await this.execGit(['commit', '-am', message]);
}
async amendCommit() {
return await this.execGit(['commit', '--amend', '-a', '--no-edit']);
}
async deleteGitRemote(name) {
return await this.execGit(['remote', 'rm', name]);
}
async addGitRemote(name, url) {
return await this.execGit(['remote', 'add', name, url]);
}
async hasFilterRepoInstalled() {
try {
await this.execGit(['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);
const sourcePath = ensureTrailingSlash(sourcePosixPath);
const destinationPath = ensureTrailingSlash(destinationPosixPath);
await this.execGit([
'filter-repo',
'-f',
...(source !== '' ? ['--path', sourcePosixPath] : []),
...(source !== destination
? ['--path-rename', `${sourcePath}:${destinationPath}`]
: []),
]);
}
async filterBranch(source, destination, branchName) {
// We need non-ASCII file names to not be quoted, or else filter-branch will exclude them.
await this.execGit(['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 = `node ${quoteForShell((0, path_1.join)(__dirname, 'git-utils.index-filter.js'))}`;
await this.execGit([
'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 = `node ${quoteForShell((0, path_1.join)(__dirname, 'git-utils.tree-filter.js'))}`;
await this.execGit([
'filter-branch',
'-f',
'--tree-filter',
treeFilterCommand,
'--',
branchName,
], {
NX_IMPORT_SOURCE: sourcePosixPath,
NX_IMPORT_DESTINATION: destinationPosixPath,
});
}
}
execGit(args, env) {
return execFileAsync('git', args, {
cwd: this.root,
maxBuffer: 10 * 1024 * 1024,
env: {
...process.env,
...env,
},
});
}
}
exports.GitRepository = GitRepository;
function ensureTrailingSlash(path) {
return path !== '' && !path.endsWith('/') ? `${path}/` : path;
}
function quoteForShell(arg) {
return `'${arg.replaceAll("'", "'\"'\"'")}'`;
}
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;
}
/**
* Where `directory`'s repository keeps its working root and its shared config.
*
* Walking up for `.git` is what lets the caller answer "which repo, and where
* am I inside it" without spawning git. It also reports the root as the caller
* referred to it, where `git rev-parse --show-toplevel` reports the realpath —
* relevant wherever a path crosses a symlink, macOS's /tmp being the common
* case.
*
* A linked worktree and a submodule both have `.git` as a FILE holding
* `gitdir: <path>`, and their remotes live in the shared common dir rather than
* in that per-worktree gitdir, which `commondir` names when it is not the
* gitdir itself.
*/
/**
* Contents of `path`, or null unless it is a regular file belonging to us.
*
* Three flags carry three separate guarantees, and the read needs all of them:
* `O_NOFOLLOW` refuses a symlink, keeping the read inside the repository;
* `O_NONBLOCK` stops a FIFO blocking the open forever, which at module scope of
* `cache-directory.ts` would hang every command in the workspace before it
* printed anything; and taking the type and owner from `fstat` on the
* descriptor that is then read leaves no window for the path to be swapped
* between the check and the read.
*/
function readOwnedFileSync(path) {
let fd;
try {
fd = fs.openSync(path, fs.constants.O_RDONLY |
(fs.constants.O_NOFOLLOW ?? 0) |
(fs.constants.O_NONBLOCK ?? 0));
const stats = fs.fstatSync(fd);
if (!stats.isFile() ||
(typeof process.getuid === 'function' && stats.uid !== process.getuid())) {
return null;
}
return fs.readFileSync(fd, 'utf8');
}
catch {
return null;
}
finally {
if (fd !== undefined) {
try {
fs.closeSync(fd);
}
catch { }
}
}
}
function locateGitDir(directory) {
let current = (0, path_1.resolve)(directory);
// Terminates at the filesystem root, where `dirname` is a fixed point.
while ((0, path_1.dirname)(current) !== current) {
const located = gitDirAt(current);
if (located !== undefined) {
return located;
}
current = (0, path_1.dirname)(current);
}
// The root itself: check it directly rather than walking past it.
return gitDirAt(current) ?? null;
}
/**
* The repository whose working root is `directory`, or:
*
* - `null` when a `.git` is there but is not one we will read, which ends the
* walk rather than continuing past it -- git would not look further either.
* - `undefined` when there is no `.git` there at all, so the caller keeps
* walking up.
*/
function gitDirAt(directory) {
const dotGit = (0, path_1.join)(directory, '.git');
let entry;
try {
entry = fs.statSync(dotGit);
}
catch {
return undefined;
}
if (entry.isDirectory()) {
// A directory named `.git` is not a repository. Git checks this before
// reading config, and asking git is what this function replaced -- so
// without it a `.git` planted in any writable ancestor (`/tmp` is 1777)
// decides the workspace identity for everything beneath it.
if (!fs.existsSync((0, path_1.join)(dotGit, 'HEAD')) ||
!fs.existsSync((0, path_1.join)(dotGit, 'objects'))) {
return null;
}
// Shape says it is a repository; ownership says it is ours. A real
// repository belonging to another user passes the check above, and git
// refuses exactly that (`safe.directory`, CVE-2022-24765). `lstat`
// inside also refuses a symlink standing in for `.git`.
return (0, owned_private_dir_1.isOwnedRealDirectory)(dotGit)
? { gitRoot: directory, commonDir: dotGit }
: null;
}
if (entry.isFile()) {
const pointer = readOwnedFileSync(dotGit)?.match(/^gitdir:\s*(.+)$/m);
if (!pointer) {
return null;
}
const gitDir = (0, path_1.resolve)(directory, pointer[1].trim());
// No commondir file: the gitdir is its own common dir.
const shared = readOwnedFileSync((0, path_1.join)(gitDir, 'commondir'))?.trim();
const commonDir = shared ? (0, path_1.resolve)(gitDir, shared) : gitDir;
// `gitdir:` and `commondir` are paths taken from file contents, so this
// branch can be pointed anywhere; the directory checks above apply to it
// just as much.
return (0, owned_private_dir_1.isOwnedRealDirectory)(commonDir)
? { gitRoot: directory, commonDir }
: null;
}
return undefined;
}
/**
* Remote name -> url from a git config file, or null when this parser cannot
* answer for the whole file.
*
* Null on `include`/`includeIf` specifically: git resolves those by reading
* other files, so a remote could live somewhere this does not look, and
* answering from a partial view would be worse than paying for git.
*/
function parseGitConfigRemotes(contents) {
const remotes = {};
let remoteName = null;
for (const rawLine of contents.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#') || line.startsWith(';')) {
continue;
}
if (line.startsWith('[')) {
const close = line.indexOf(']');
const section = (close === -1 ? line.slice(1) : line.slice(1, close)).trim();
if (/^include(If)?\b/i.test(section)) {
return null;
}
// `git remote -v` prints the rewritten url; resolving `insteadOf` here
// would mean reimplementing the longest-prefix match, so defer instead.
// A rewrite in the *global* config stays an accepted divergence: this
// parser deliberately never opens `~/.gitconfig`.
if (/^url\s+"/i.test(section)) {
return null;
}
const named = section.match(/^remote\s+"(.*)"$/i);
remoteName = named ? named[1] : null;
continue;
}
if (!remoteName) {
continue;
}
const equals = line.indexOf('=');
if (equals === -1) {
continue;
}
if (line.slice(0, equals).trim().toLowerCase() !== 'url') {
continue;
}
const raw = line.slice(equals + 1).trim();
// Git ends a value at an unquoted `#`/`;` and honours `\` escapes. Both are
// enough to change the url, and reimplementing them is how a parser starts
// answering confidently wrong, so hand the file to git when either appears.
if (/[#;\\]/.test(raw)) {
return null;
}
const value = raw.replace(/^"(.*)"$/, '$1');
if (value.includes('"')) {
return null;
}
// `remote.<name>.url` is multi-valued and git fetches from the first, so
// taking the first here matches it.
if (remotes[remoteName] === undefined) {
remotes[remoteName] = value;
}
}
return remotes;
}
/** `origin`, then `upstream`, then `base`, then whichever came first. */
function selectRemote(found, first) {
for (const remote of ['origin', 'upstream', 'base']) {
if (found[remote]) {
return found[remote];
}
}
return first;
}
/**
* The remote read straight from `.git/config`, or null when that cannot settle
* it and git itself has to be asked.
*
* Worth having because this runs on the import path of every Nx process:
* `cacheDir` is resolved at module scope, which reaches the repo identity, and
* `git remote -v` spawns a shell and git to answer a question a file read
* answers in microseconds.
*/
function remoteInfoFromGitConfig(directory) {
try {
const located = locateGitDir(directory);
if (!located) {
return null;
}
const contents = readOwnedFileSync((0, path_1.join)(located.commonDir, 'config'));
if (contents === null) {
return null;
}
const remotes = parseGitConfigRemotes(contents);
if (!remotes) {
return null;
}
const found = {};
let first = null;
for (const [name, url] of Object.entries(remotes)) {
const info = parseVcsRemoteUrl(url);
if (info && !found[name]) {
found[name] = info;
first ??= info;
}
}
return selectRemote(found, first);
}
catch {
return null;
}
}
function getVcsRemoteInfo(directory) {
const fromConfig = remoteInfoFromGitConfig(directory ?? process.cwd());
if (fromConfig) {
return fromConfig;
}
// Reached when there is no readable config, no remote in it, or an `include`
// this parser will not follow. Note for anyone running a spec that asserts no
// subprocess: a repository with no remote at all lands here every time, so
// the shell-out is on the failure path rather than gone.
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 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 selectRemote(foundRemotes, firstRemote);
}
catch (e) {
return null;
}
}
function getGitRootPath(cwd) {
const located = locateGitDir(cwd ?? process.cwd());
if (located) {
return located.gitRoot;
}
// Outside a repository this throws, which is what `getGitRootRelativePath`
// turns into null. Kept as the fallback rather than the primary so the common
// case pays no subprocess.
return (0, child_process_1.execFileSync)('git', ['rev-parse', '--show-toplevel'], {
cwd,
windowsHide: true,
})
.toString()
.trim();
}
/**
* Path of `directory` relative to its git root, posix-separated so it is
* identical on every OS, and '' when the directory is the git root itself.
* Null outside a git repository.
*/
function getGitRootRelativePath(directory) {
try {
return (0, path_1.relative)(getGitRootPath(directory), directory)
.split(path_1.sep)
.join(path_1.posix.sep);
}
catch {
return null;
}
}
/** A shallow clone's truncated history has no stable root commit. */
function isShallowRepository(directory) {
try {
return ((0, child_process_1.execFileSync)('git', ['rev-parse', '--is-shallow-repository'], {
encoding: 'utf8',
stdio: 'pipe',
cwd: directory,
windowsHide: true,
}).trim() === 'true');
}
catch {
return false;
}
}
/**
* SHA of the repository's first commit. Merged unrelated histories leave
* several root commits — the sorted-first one is picked so every clone
* agrees. Null when there are no commits, or outside a git repository.
*/
function getFirstCommitSha(directory) {
try {
const roots = (0, child_process_1.execFileSync)('git', ['rev-list', '--max-parents=0', 'HEAD'], {
encoding: 'utf8',
stdio: 'pipe',
cwd: directory,
windowsHide: true,
})
.trim()
.split(/\r?\n/)
.filter(Boolean);
return roots.sort()[0] ?? null;
}
catch {
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;
}
}
/**
* Like `isGitRepository`, but separates "this is not a git repository" from
* "the probe itself failed" (git not installed, permissions). Callers gating
* destructive or unverifiable behavior on the answer must fail closed on
* 'unknown' instead of reading a broken probe as a missing repository.
*/
function getGitRepositoryStatus(directory) {
try {
(0, child_process_1.execSync)('git rev-parse --is-inside-work-tree', {
stdio: 'pipe',
cwd: directory,
windowsHide: true,
// Force untranslated messages; the classification matches on the
// English "not a git repository".
env: { ...process.env, LC_ALL: 'C' },
});
return 'git';
}
catch (err) {
const stderr = err?.stderr?.toString() ?? '';
return /not a git repository/i.test(stderr) ? 'not-git' : 'unknown';
}
}
// Checked-out branch name, or null when there isn't one to act on: a detached
// HEAD reports the literal "HEAD" (treated as no branch), and any git error
// (not a repo, no commits yet) also yields null.
function getGitCurrentBranch(directory) {
try {
const branch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
encoding: 'utf8',
cwd: directory,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
}).trim();
return branch && branch !== 'HEAD' ? branch : null;
}
catch {
return null;
}
}
// Names of the remotes configured in the repository, empty when there are
// none or the probe itself failed.
function getGitRemoteNames(directory) {
try {
return (0, child_process_1.execSync)('git remote', {
encoding: 'utf8',
cwd: directory,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
})
.split('\n')
.map((name) => name.trim())
.filter(Boolean);
}
catch {
return [];
}
}
// Tri-state working-tree probe: 'unknown' means the probe itself failed (git
// missing, spawn failure, permissions), not that the tree is clean. Callers
// that gate destructive actions on tree cleanliness must treat 'unknown' as
// unsafe rather than clean.
// `excludePaths` are left out of the probe the way `tryCommitChanges` leaves
// them out of the commit, so a tree dirty only under them reads as clean. An
// exclude-only pathspec still covers the whole tree, matching `git add -A`.
function getWorkingTreeStatus(directory, excludePaths = []) {
const pathspecs = excludePaths
.map((excludePath) => ` ":(exclude)${excludePath}"`)
.join('');
try {
const out = (0, child_process_1.execSync)(`git status --porcelain${pathspecs ? ` --${pathspecs}` : ''}`, {
encoding: 'utf8',
cwd: directory,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
return out.trim() === '' ? 'clean' : 'dirty';
}
catch {
return 'unknown';
}
}
// Sync companion to `GitRepository.hasUncommittedChanges` for callers that
// can't drop into the async class. A failed probe reads as false; callers for
// whom that tolerance is unsafe use `getWorkingTreeStatus` instead.
function hasUncommittedChanges(directory, excludePaths = []) {
return getWorkingTreeStatus(directory, excludePaths) === 'dirty';
}
// Classifies whether `git add -A` commits made in `directory` can sweep in
// the directory at `dirPath`. Tracked files stay committable no matter what
// the ignore rules say (ignore rules never apply to tracked files), so
// `git ls-files` decides 'tracked' first; `git check-ignore` then splits the
// untracked remainder into 'ignored' (covered) vs 'unignored' (no
// coverage). 'unknown' means the probe itself failed (not a git repository,
// git missing); callers gating destructive behavior on the result must
// treat it as unsafe.
function getPathCommitExposure(dirPath, directory) {
try {
const tracked = (0, child_process_1.execSync)(`git ls-files -- ${dirPath}`, {
encoding: 'utf8',
cwd: directory,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
if (tracked.trim() !== '') {
return 'tracked';
}
}
catch {
return 'unknown';
}
// Query with a trailing slash so git treats the path as a directory even
// when it does not exist on disk yet: a directory-only ignore rule (a
// trailing-slash .gitignore entry) does not match a bare query for an
// absent path, which would misreport covered workspaces as unignored.
const asDir = dirPath.endsWith('/') ? dirPath : `${dirPath}/`;
try {
(0, child_process_1.execSync)(`git check-ignore -q -- ${asDir}`, {
cwd: directory,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
return 'ignored';
}
catch (e) {
// check-ignore exits 1 for "not ignored"; anything else is a probe
// failure.
return e?.status === 1 ? 'unignored' : 'unknown';
}
}
// 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.
// Required here, not imported: `logger` reaches `daemon/*`, and this
// module is on the import path of `cache-directory`, whose bindings
// `daemon/tmp-dir.ts` reads at module scope. A static import would make
// that a cycle. This is the only logger use in the file.
const { logger } = require('./logger');
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.
*
* `excludePaths` are `directory`-relative paths the commit must not capture,
* whatever the ignore rules say. Their working-tree files are left intact;
* only their index entries are put back to HEAD's state. Paths come from
* callers' own constants, never from user input.
*/
function tryCommitChanges(commitMessage, directory, excludePaths = []) {
try {
(0, child_process_1.execSync)('git add -A', {
encoding: 'utf8',
stdio: 'pipe',
cwd: directory,
windowsHide: true,
});
// Exclusion happens as an unstage rather than an add-time pathspec:
// `git add` refuses a pathspec naming an ignored directory (exit 1) even
// as an exclusion, and an add-time pathspec cannot cover entries that
// were already staged before this call. The reset is relative to cwd, so
// a workspace nested inside a larger repo excludes its own path; a path
// with no index entry is a quiet no-op, unborn HEAD included.
for (const excludePath of excludePaths) {
(0, child_process_1.execSync)(`git reset -q -- "${excludePath}"`, {
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;
}
}
/**
* The shape of a recorded `git rev-parse` output: 40 hex chars, or 64 in a
* sha256 repository. Anything a caller persists and later interpolates into a
* command line has to be checked against this first.
*/
exports.GIT_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
/**
* Whether `ancestor` is reachable from `descendant`, i.e. resetting to
* `descendant` keeps `ancestor` in history. Returns false when the answer
* cannot be established (invalid input, not a repository, unknown commits),
* so callers treat an unverifiable commit as not preserved.
*/
function isAncestorCommit(ancestor, descendant, directory) {
if (!exports.GIT_SHA.test(ancestor) || !exports.GIT_SHA.test(descendant)) {
return false;
}
try {
(0, child_process_1.execSync)(`git merge-base --is-ancestor ${ancestor} ${descendant}`, {
stdio: 'pipe',
windowsHide: true,
cwd: directory,
});
return true;
}
catch {
return false;
}
}