Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
import { execa } from 'execa';
import { access, constants, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PreflightError } from './preflight.js';
/**
* Paths copperhead must keep out of `git add -A`. KiCad ≥9 writes a
* git-backed local-history directory (`.history/`, complete with its own nested
* `.git`) into the project the first time kicad-cli touches it. Left untracked,
* that nested repo has an unborn HEAD, so a plain `git add -A` in the parent
* aborts with `error: '.history/' does not have a commit checked out` (exit
* 128) — which fails the commit at the end of every KiCad-touching stage
* (schematic, layout, outputs). Ignoring it is both correct (local history is
* never a project artifact) and the fix for that abort. Kept as a list so other
* KiCad transients can join it if they surface.
*/
const GIT_ADD_EXCLUDES = ['.history/'];
/**
* Ensure the repo's root .gitignore lists each entry, appending only the
* missing ones. Idempotent and best-effort: a failure here must never block a
* commit, so it swallows its own errors. Run before any `git add -A` so a
* git-backed KiCad `.history/` (or similar nested repo) is skipped instead of
* aborting the add.
*/
export async function ensureIgnored(repo: string, entries: string[]): Promise<void> {
try {
const p = path.join(repo, '.gitignore');
const text = existsSync(p) ? await readFile(p, 'utf8') : '';
const present = new Set(text.split('\n').map((l) => l.trim()));
const missing = entries.filter((e) => !present.has(e));
if (!missing.length) return;
const prefix = text.length && !text.endsWith('\n') ? '\n' : '';
await writeFile(p, text + prefix + missing.join('\n') + '\n', 'utf8');
} catch {
// best-effort: .gitignore maintenance must never be the thing that fails a run
}
}
export interface GitSnapshot {
head: string;
stash: string | null;
/**
* Tree object holding the untracked-but-not-ignored files that `git stash
* create` cannot capture. Without it a rollback's `git clean -fd` deletes
* them for good; see snapshotUntracked().
*/
untracked: string | null;
}
async function git(repo: string, args: string[]): Promise<string> {
const { stdout } = await execa('git', args, { cwd: repo });
return stdout.trim();
}
/** Same as git(), with a scratch index so the repo's real index is untouched. */
async function gitWithIndex(repo: string, indexFile: string, args: string[]): Promise<string> {
const { stdout } = await execa('git', args, {
cwd: repo,
env: { GIT_INDEX_FILE: indexFile },
});
return stdout.trim();
}
/** Monotonic suffix so two scratch indexes in one process never collide. */
let scratchIndexSeq = 0;
/**
* Path for a throwaway index, inside the repo's own git dir rather than
* TMPDIR: git guarantees that directory exists and is writable, so a hostile
* or missing temp dir cannot make a snapshot fail and block a run from
* starting. Absolute, because git resolves GIT_INDEX_FILE against cwd.
*/
async function scratchIndexPath(repo: string): Promise<string> {
const gitDir = await git(repo, ['rev-parse', '--absolute-git-dir']);
return path.join(gitDir, `copperhead-index-${process.pid}-${scratchIndexSeq++}`);
}
/**
* Write the untracked-but-not-ignored files to a tree object and return its
* sha, or null when there are none.
*
* `git stash create` only ever captures tracked changes, so on its own it
* leaves every new file a run (or the user) has not added yet outside the
* snapshot — and restore()'s `git clean -fd` then deletes exactly those. The
* two sets line up: `--exclude-standard` skips ignored paths and plain
* `clean -fd` (no -x) leaves them alone, so what is captured here is precisely
* what the rollback would otherwise destroy.
*
* Built through a scratch GIT_INDEX_FILE rather than `git add -A`, because
* this runs at the *start* of a run: staging the user's files would outlive a
* successful run and silently rewrite their staged/unstaged split.
*/
async function snapshotUntracked(repo: string): Promise<string | null> {
const listed = await git(repo, ['ls-files', '--others', '--exclude-standard', '-z']);
const paths = listed.split('\0').filter(Boolean);
if (!paths.length) return null;
const usable = await readableOnly(repo, paths);
if (!usable.length) return null;
const indexFile = await scratchIndexPath(repo);
try {
await execa('git', ['update-index', '-z', '--add', '--stdin'], {
cwd: repo,
env: { GIT_INDEX_FILE: indexFile },
input: usable.join('\0') + '\0',
});
return (await gitWithIndex(repo, indexFile, ['write-tree'])) || null;
} catch (err) {
// A path that passed the readability check above and still failed here
// lost the race (permissions or existence changed in between). Same
// refusal, since the same file would be destroyed by the rollback.
throw unsnapshottable(String((err as Error).message).split('\n')[0] ?? 'unknown path');
} finally {
await rm(indexFile, { force: true }).catch(() => {});
}
}
/**
* Drop untracked paths that no longer exist, and refuse on any that exist but
* cannot be read.
*
* `git update-index` aborts the whole batch with exit 128 on the first path it
* cannot open, and this runs before the first turn, so one stray root-owned or
* mode-000 file would otherwise refuse every `--allow-dirty` run with a bare
* `fatal: Unable to process path …`. A vanished path is dropped rather than
* refused: a file that no longer exists cannot be lost. One that exists but is
* unreadable is refused deliberately rather than skipped, because `restore()`'s
* `git clean -fd` deletes it either way, and skipping would quietly reinstate
* exactly the data loss the untracked snapshot exists to prevent.
*/
async function readableOnly(repo: string, paths: string[]): Promise<string[]> {
const usable: string[] = [];
for (const p of paths) {
try {
await access(path.join(repo, p), constants.R_OK);
usable.push(p);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue;
throw unsnapshottable(p);
}
}
return usable;
}
function unsnapshottable(what: string): PreflightError {
return new PreflightError(
`cannot read untracked file: ${what}`,
'a run started with --allow-dirty promises your uncommitted work survives a failed run, but a file copperhead cannot read cannot be snapshotted, and the rollback would delete it with nothing to restore it from',
[
`make it readable: chmod +r "${what}"`,
'or delete it, or add it to .gitignore so the rollback leaves it alone',
'or commit your work and rerun without --allow-dirty',
],
);
}
/**
* Re-materialize the untracked files captured by snapshotUntracked(). Runs
* after `stash apply` so tracked restores win any path collision.
*/
async function restoreUntracked(repo: string, tree: string): Promise<void> {
const indexFile = await scratchIndexPath(repo);
try {
await gitWithIndex(repo, indexFile, ['read-tree', tree]);
await gitWithIndex(repo, indexFile, ['checkout-index', '-a', '-f']);
} finally {
await rm(indexFile, { force: true }).catch(() => {});
}
}
export async function isGitRepo(repo: string): Promise<boolean> {
try {
await git(repo, ['rev-parse', '--git-dir']);
return true;
} catch {
return false;
}
}
/** False on an unborn HEAD (fresh `git init` with no commits yet). */
export async function hasCommits(repo: string): Promise<boolean> {
try {
await git(repo, ['rev-parse', '--quiet', '--verify', 'HEAD']);
return true;
} catch {
return false;
}
}
export async function isDirty(repo: string): Promise<boolean> {
const status = await git(repo, ['status', '--porcelain']);
return status.length > 0;
}
/**
* The run-blocking git gates, in order: repo -> commits -> dirty (AC-3.8).
* Throws a PreflightError whose message explains why the run is refused and
* how to fix it; a caller that catches only needs err.message.
*/
export async function gitPreflight(repo: string, opts: { allowDirty?: boolean } = {}): Promise<void> {
if (!(await isGitRepo(repo))) {
throw new PreflightError(
'not a git repository; copperhead requires git for snapshots and rollback',
'every run snapshots HEAD before editing so a failed run can be rolled back losslessly; without git there is no snapshot and no undo',
['git init', 'git add -A && git commit -m "initial commit"', 'rerun the same copperhead command'],
);
}
if (!(await hasCommits(repo))) {
throw new PreflightError(
'repository has no commits; copperhead requires at least one commit for snapshots and rollback',
'the pre-run snapshot is the current HEAD commit; with an unborn HEAD there is nothing to roll back to if verification fails',
['git add -A && git commit -m "initial commit"', 'rerun the same copperhead command'],
);
}
if ((await isDirty(repo)) && !opts.allowDirty) {
throw new PreflightError(
'working tree is dirty; copperhead refuses to run on uncommitted changes by default',
'a rollback hard-resets to the pre-run snapshot, which would silently destroy your uncommitted work',
[
'git add -A && git commit — to keep your changes (recommended)',
'git stash — to set them aside for now',
'or rerun with --allow-dirty to let copperhead preserve them via "git stash create"',
],
);
}
}
/**
* Snapshot the working tree before a run. On a clean tree HEAD is enough;
* with --allow-dirty we keep a `git stash create` object for tracked changes
* plus a tree of the untracked files it cannot see, so uncommitted work
* survives a rollback intact (SPEC §7).
*/
export async function snapshot(repo: string): Promise<GitSnapshot> {
const head = await git(repo, ['rev-parse', 'HEAD']);
let stash: string | null = null;
let untracked: string | null = null;
if (await isDirty(repo)) {
stash = (await git(repo, ['stash', 'create'])) || null;
untracked = await snapshotUntracked(repo);
}
return { head, stash, untracked };
}
/**
* Hard-restore the working tree to a snapshot (AC-3.6). The run audit trail
* (.copperhead/runs/) survives rollback: it is the evidence of what failed.
*/
export async function restore(repo: string, snap: GitSnapshot): Promise<void> {
// `git clean -e` only protects untracked paths. A run directory can become
// staged (for example while preserving failed work), and `reset --hard`
// deletes such paths before clean runs. Copy it outside the repository so
// the audit trail survives regardless of its index state.
const runs = path.join(repo, '.copperhead', 'runs');
let backupRoot: string | null = null;
let backup: string | null = null;
try {
try {
backupRoot = await mkdtemp(path.join(tmpdir(), 'copperhead-runs-'));
backup = path.join(backupRoot, 'runs');
if (existsSync(runs)) await cp(runs, backup, { recursive: true });
} catch (err) {
backup = null;
console.warn(`warning: could not preserve failed-run audit trail before rollback: ${(err as Error).message}`);
}
try {
await git(repo, ['reset', '--hard', snap.head]);
await git(repo, ['clean', '-fd', '-e', '.copperhead/runs']);
if (snap.stash) {
await git(repo, ['stash', 'apply', snap.stash]);
}
// The clean above deleted every untracked file; put back the ones that
// were there before the run. Never fatal: a rollback that restored the
// tracked state is still better than one that threw halfway.
if (snap.untracked) {
try {
await restoreUntracked(repo, snap.untracked);
} catch (err) {
console.warn(
`warning: could not restore untracked files after rollback: ${(err as Error).message}`,
);
}
}
} finally {
if (backup && existsSync(backup)) {
try {
await mkdir(path.dirname(runs), { recursive: true });
// Restored runs are intentionally untracked; their audit contents
// are ignored by the target-repository convention.
await cp(backup, runs, { recursive: true, force: true });
} catch (err) {
console.warn(`warning: could not restore failed-run audit trail: ${(err as Error).message}`);
}
}
}
} finally {
if (backupRoot) {
try {
await rm(backupRoot, { recursive: true, force: true });
} catch (err) {
console.warn(`warning: could not clean failed-run audit backup: ${(err as Error).message}`);
}
}
}
}
/**
* Preserve a failed run's work as a stash entry before rollback, so a failure
* is recoverable instead of destroyed. `git stash create` alone ignores
* untracked files (most of what a docs-stage run produces), so everything is
* staged first; restore() resets the index anyway. Never throws: preservation
* must not be able to block the rollback itself.
*/
export async function preserveFailedRun(repo: string, runId: string): Promise<string | null> {
try {
if (!(await isDirty(repo))) return null;
await ensureIgnored(repo, GIT_ADD_EXCLUDES);
// Never leave the audit trail staged: a staged-but-not-in-HEAD path is
// deleted by restore()'s `reset --hard`, which silently defeats its
// `clean -e .copperhead/runs` protection (that flag only spares untracked
// files) — the in-flight run's transcript dir vanishes mid-run. Staging
// then unstaging (rather than an exclude pathspec) because `git add`
// errors outright when a pathspec touches gitignored paths, and runs/ is
// gitignored in some target repos but tracked in others.
await git(repo, ['add', '-A']);
await git(repo, ['reset', '-q', '--', '.copperhead/runs']);
const sha = await git(repo, ['stash', 'create']);
if (!sha) return null;
await git(repo, ['stash', 'store', '-m', `copperhead failed run ${runId}`, sha]);
return sha;
} catch {
return null;
}
}
/** Current branch name, or "HEAD" when detached. Read-only metadata probe. */
export async function branchName(repo: string): Promise<string> {
return git(repo, ['rev-parse', '--abbrev-ref', 'HEAD']);
}
export async function headCommit(repo: string): Promise<string> {
return git(repo, ['rev-parse', 'HEAD']);
}
/** A file's exact bytes at a commit (no trimming), or null when it is not there. */
export async function fileAtCommit(repo: string, commit: string, rel: string): Promise<string | null> {
const res = await execa('git', ['show', `${commit}:${rel.split(path.sep).join('/')}`], { cwd: repo, reject: false, stripFinalNewline: false });
return res.exitCode === 0 ? String(res.stdout) : null;
}
/** Count of uncommitted paths (staged, unstaged, and untracked). */
export async function uncommittedCount(repo: string): Promise<number> {
const status = await git(repo, ['status', '--porcelain']);
return status ? status.split('\n').length : 0;
}
export async function commitAll(repo: string, message: string): Promise<string> {
await ensureIgnored(repo, GIT_ADD_EXCLUDES);
await git(repo, ['add', '-A']);
await git(repo, ['commit', '-m', message]);
return git(repo, ['rev-parse', 'HEAD']);
}
export async function changedFiles(repo: string, sinceHead: string): Promise<string[]> {
const tracked = await git(repo, ['diff', '--name-only', sinceHead]);
const untracked = await git(repo, ['ls-files', '--others', '--exclude-standard']);
return [...new Set([...tracked.split('\n'), ...untracked.split('\n')])].filter(Boolean);
}
10597ef62This file is pinned to the selected release. Download the original to open it in your design tool.