UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

934 lines (933 loc) 34.9 kB
"use strict"; 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; } }