mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
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:
+1
-1
@@ -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) => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user