feat: guard mutating commands against a dirty git working tree

- Add `apps/cli/src/lib/git.ts` with `worktreeStatus()` (runs `git status --porcelain` in the target dir; treats missing git / non-repo as clean) and `ensureCleanWorktree()` which prints a formatted error listing the dirty paths and returns false so callers can bail before writing anything
- Wire the guard into `cmdInit`, `cmdAdd`, and `cmdRemove` — all three check the worktree before the interactive wizard or any file mutations; `--dry-run` skips the check since nothing is written
- Add `--dangerously-allow-dirty` boolean flag (registered in `mri` and documented in the `--help` output) to override the refusal with a warning when callers explicitly opt in
This commit is contained in:
2026-05-21 17:24:27 -04:00
parent 74abfecc53
commit c044a56487
6 changed files with 94 additions and 4 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ import { run } from "./run";
const argv = mri(process.argv.slice(2), {
alias: { h: "help", v: "version" },
boolean: ["help", "version", "yes", "dry-run", "no-telemetry"],
boolean: ["help", "version", "yes", "dry-run", "no-telemetry", "dangerously-allow-dirty"],
});
run(argv).catch((err: unknown) => {
+7 -1
View File
@@ -5,6 +5,7 @@ import kleur from "kleur";
import type { Argv } from "mri";
import { applyModule } from "../lib/codemod-runner";
import { ensureCleanWorktree } from "../lib/git";
import { findProjectRoot, readManifest } from "../lib/manifest";
import { loadRegistry, pickRegistryRoot } from "../lib/registry-loader";
@@ -37,6 +38,12 @@ export async function cmdAdd(args: {
return;
}
const dryRun = Boolean(args.argv["dry-run"]);
if (!dryRun && !ensureCleanWorktree(projectRoot, Boolean(args.argv["dangerously-allow-dirty"]))) {
process.exitCode = 1;
return;
}
const manifest = readManifest(projectRoot);
if (isSlot) {
const slot = group as SlotId;
@@ -72,7 +79,6 @@ export async function cmdAdd(args: {
return;
}
const dryRun = Boolean(args.argv["dry-run"]);
const spinner = p.spinner();
spinner.start(`Adding ${mod.label}`);
+12 -1
View File
@@ -16,6 +16,7 @@ import kleur from "kleur";
import type { Argv } from "mri";
import { applyModule } from "../lib/codemod-runner";
import { ensureCleanWorktree } from "../lib/git";
import { initManifest } from "../lib/manifest";
import { loadRegistry, pickRegistryRoot } from "../lib/registry-loader";
import { runInitWizard, type WizardOverrides } from "../lib/wizard";
@@ -24,6 +25,17 @@ export async function cmdInit(args: { name?: string; argv: Argv }): Promise<void
const registry = await loadRegistry();
const defaultName = args.name ?? path.basename(process.cwd());
const dryRun = Boolean(args.argv["dry-run"]);
// Guard the cwd's repo (init scaffolds into a new subdir of it). Skipped in
// dry-run, which writes nothing. Fails fast — before the interactive wizard.
if (
!dryRun &&
!ensureCleanWorktree(process.cwd(), Boolean(args.argv["dangerously-allow-dirty"]))
) {
process.exitCode = 1;
return;
}
// --yes turns CLI flags into the wizard's answers (each `--<slot>=<id>` picks
// a module for that slot; `--pm=<...>` picks the package manager). Missing
// slots are simply skipped — no auto-defaulting, explicit is better.
@@ -58,7 +70,6 @@ export async function cmdInit(args: { name?: string; argv: Argv }): Promise<void
const registryRoot = pickRegistryRoot();
const dryRun = Boolean(args.argv["dry-run"]);
if (dryRun) p.log.info(kleur.yellow("[dry-run] no files will be written"));
const spinner = p.spinner();
+7 -1
View File
@@ -9,6 +9,7 @@ import kleur from "kleur";
import type { Argv } from "mri";
import { revertCodemods } from "../lib/codemod-runner";
import { ensureCleanWorktree } from "../lib/git";
import { findProjectRoot, readManifest, writeManifest } from "../lib/manifest";
import { regionsOwnedBy } from "../lib/region-tracker";
import { loadRegistry } from "../lib/registry-loader";
@@ -39,6 +40,12 @@ export async function cmdRemove(args: {
return;
}
const dryRun = Boolean(args.argv["dry-run"]);
if (!dryRun && !ensureCleanWorktree(projectRoot, Boolean(args.argv["dangerously-allow-dirty"]))) {
process.exitCode = 1;
return;
}
let manifest = readManifest(projectRoot);
// Resolve which record we're removing — one-per-slot, or a named add-on.
@@ -70,7 +77,6 @@ export async function cmdRemove(args: {
installed = record;
}
const dryRun = Boolean(args.argv["dry-run"]);
const manualCleanup: string[] = [];
// Step 1: revert imperative codemods first. They modify framework- or
+65
View File
@@ -0,0 +1,65 @@
import { execFileSync } from "node:child_process";
import * as p from "@clack/prompts";
import kleur from "kleur";
export type WorktreeStatus = { dirty: false } | { dirty: true; changes: string[] };
/**
* Reports whether `dir` sits in a git work tree with uncommitted changes.
* When git is missing or `dir` isn't a repo there's nothing to protect, so we
* report clean — the guard only exists to keep our edits in a reviewable diff.
*/
export function worktreeStatus(dir: string): WorktreeStatus {
let insideWorkTree = "";
try {
insideWorkTree = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
cwd: dir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return { dirty: false };
}
if (insideWorkTree !== "true") return { dirty: false };
const out = execFileSync("git", ["status", "--porcelain"], {
cwd: dir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
const changes = out
.split("\n")
.map((line) => line.replace(/\s+$/, ""))
.filter(Boolean);
return changes.length === 0 ? { dirty: false } : { dirty: true, changes };
}
/**
* Returns true when it's safe to mutate files in `dir`. If the work tree is
* dirty and the `--dangerously-allow-dirty` override isn't set, prints an
* explanation and returns false so the caller can bail before writing anything.
*/
export function ensureCleanWorktree(dir: string, allowDirty: boolean): boolean {
const status = worktreeStatus(dir);
if (!status.dirty) return true;
if (allowDirty) {
p.log.warn(kleur.yellow("[dangerously-allow-dirty] proceeding despite uncommitted changes."));
return true;
}
const preview = status.changes.slice(0, 10);
const more = status.changes.length - preview.length;
p.log.error(
[
"Refusing to run: the git working tree has uncommitted changes.",
"Commit or stash them first so this command's edits land in their own reviewable diff.",
"",
...preview.map((c) => ` ${c}`),
...(more > 0 ? [` …and ${more} more`] : []),
"",
`Re-run with ${kleur.cyan("--dangerously-allow-dirty")} to override.`,
].join("\n"),
);
return false;
}
+2
View File
@@ -30,6 +30,8 @@ ${kleur.bold("Options")}
--testing / --tooling / --deploy / --email
(comma-separated). Missing slots are skipped.
--dry-run Print the actions that would be taken; write nothing.
--dangerously-allow-dirty Allow mutating commands to run with a dirty git
working tree (by default they refuse).
--no-telemetry Disable telemetry for this invocation.
${kleur.bold("Examples")}