Skip to content

Commit ce44816

Browse files
betegonclaude
andcommitted
feat(init): add git safety checks before wizard modifies files
Checks that the user is inside a git repo with a clean working tree before the init wizard starts. Prompts to confirm if concerns are found, or warns and continues when --yes is passed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 081b94c commit ce44816

File tree

4 files changed

+353
-7
lines changed

4 files changed

+353
-7
lines changed

src/lib/init/git.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Git Safety Checks
3+
*
4+
* Pre-flight checks to verify the user is in a git repo with a clean
5+
* working tree before the init wizard starts modifying files.
6+
*/
7+
8+
import { execFileSync } from "node:child_process";
9+
import { confirm, log } from "@clack/prompts";
10+
import { abortIfCancelled } from "./clack-utils.js";
11+
12+
export function isInsideGitWorkTree(opts: { cwd: string }): boolean {
13+
try {
14+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
15+
stdio: "ignore",
16+
cwd: opts.cwd,
17+
});
18+
return true;
19+
} catch {
20+
return false;
21+
}
22+
}
23+
24+
export function getUncommittedOrUntrackedFiles(opts: {
25+
cwd: string;
26+
}): string[] {
27+
try {
28+
const output = execFileSync("git", ["status", "--porcelain=v1"], {
29+
stdio: ["ignore", "pipe", "ignore"],
30+
cwd: opts.cwd,
31+
});
32+
return output
33+
.toString()
34+
.trim()
35+
.split("\n")
36+
.filter((line) => line.length > 0)
37+
.map((line) => `- ${line.trim()}`);
38+
} catch {
39+
return [];
40+
}
41+
}
42+
43+
/**
44+
* Checks git status and prompts the user if there are concerns.
45+
* Returns `true` to continue, `false` to abort.
46+
*/
47+
export async function checkGitStatus(opts: {
48+
cwd: string;
49+
yes: boolean;
50+
}): Promise<boolean> {
51+
const { cwd, yes } = opts;
52+
53+
if (!isInsideGitWorkTree({ cwd })) {
54+
if (yes) {
55+
log.warn(
56+
"You are not inside a git repository. Unable to revert changes if something goes wrong."
57+
);
58+
return true;
59+
}
60+
const proceed = await confirm({
61+
message:
62+
"You are not inside a git repository. Unable to revert changes if something goes wrong. Continue?",
63+
});
64+
abortIfCancelled(proceed);
65+
return !!proceed;
66+
}
67+
68+
const uncommitted = getUncommittedOrUntrackedFiles({ cwd });
69+
if (uncommitted.length > 0) {
70+
const fileList = uncommitted.join("\n");
71+
if (yes) {
72+
log.warn(
73+
`You have uncommitted or untracked files:\n${fileList}\nProceeding anyway (--yes).`
74+
);
75+
return true;
76+
}
77+
log.warn(`You have uncommitted or untracked files:\n${fileList}`);
78+
const proceed = await confirm({
79+
message: "Continue with uncommitted changes?",
80+
});
81+
abortIfCancelled(proceed);
82+
return !!proceed;
83+
}
84+
85+
return true;
86+
}

src/lib/init/wizard-runner.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
WORKFLOW_ID,
2626
} from "./constants.js";
2727
import { formatError, formatResult } from "./formatters.js";
28+
import { checkGitStatus } from "./git.js";
2829
import { handleInteractive } from "./interactive.js";
2930
import { handleLocalOp, precomputeDirListing } from "./local-ops.js";
3031
import type {
@@ -191,7 +192,11 @@ async function confirmExperimental(yes: boolean): Promise<boolean> {
191192
* Pre-flight checks: TTY guard, banner, intro, and experimental warning.
192193
* Returns `true` when the wizard should continue, `false` to abort.
193194
*/
194-
async function preamble(yes: boolean, dryRun: boolean): Promise<boolean> {
195+
async function preamble(
196+
directory: string,
197+
yes: boolean,
198+
dryRun: boolean
199+
): Promise<boolean> {
195200
if (!(yes || process.stdin.isTTY)) {
196201
process.stderr.write(
197202
"Error: Interactive mode requires a terminal. Use --yes for non-interactive mode.\n"
@@ -214,13 +219,20 @@ async function preamble(yes: boolean, dryRun: boolean): Promise<boolean> {
214219
log.warn("Dry-run mode: no files will be modified.");
215220
}
216221

222+
const gitOk = await checkGitStatus({ cwd: directory, yes });
223+
if (!gitOk) {
224+
cancel("Setup cancelled.");
225+
process.exitCode = 0;
226+
return false;
227+
}
228+
217229
return true;
218230
}
219231

220232
export async function runWizard(options: WizardOptions): Promise<void> {
221233
const { directory, yes, dryRun, features } = options;
222234

223-
if (!(await preamble(yes, dryRun))) {
235+
if (!(await preamble(directory, yes, dryRun))) {
224236
return;
225237
}
226238

test/lib/init/git.test.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/**
2+
* Git Safety Checks Unit Tests
3+
*
4+
* Tests for isInsideGitWorkTree, getUncommittedOrUntrackedFiles,
5+
* and checkGitStatus using spyOn on namespace imports.
6+
*/
7+
8+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
9+
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
10+
import * as cp from "node:child_process";
11+
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
12+
import * as clack from "@clack/prompts";
13+
import {
14+
checkGitStatus,
15+
getUncommittedOrUntrackedFiles,
16+
isInsideGitWorkTree,
17+
} from "../../../src/lib/init/git.js";
18+
19+
// ── Helpers ─────────────────────────────────────────────────────────────────
20+
21+
const noop = () => {
22+
/* suppress output */
23+
};
24+
25+
// ── Spy declarations ────────────────────────────────────────────────────────
26+
27+
let execFileSyncSpy: ReturnType<typeof spyOn>;
28+
let confirmSpy: ReturnType<typeof spyOn>;
29+
let logWarnSpy: ReturnType<typeof spyOn>;
30+
31+
// ── Setup / Teardown ────────────────────────────────────────────────────────
32+
33+
beforeEach(() => {
34+
execFileSyncSpy = spyOn(cp, "execFileSync");
35+
confirmSpy = spyOn(clack, "confirm").mockResolvedValue(true);
36+
logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop);
37+
});
38+
39+
afterEach(() => {
40+
execFileSyncSpy.mockRestore();
41+
confirmSpy.mockRestore();
42+
logWarnSpy.mockRestore();
43+
});
44+
45+
// ── Tests ───────────────────────────────────────────────────────────────────
46+
47+
describe("isInsideGitWorkTree", () => {
48+
test("returns true when git succeeds", () => {
49+
execFileSyncSpy.mockReturnValue(Buffer.from("true\n"));
50+
51+
expect(isInsideGitWorkTree({ cwd: "/tmp" })).toBe(true);
52+
expect(execFileSyncSpy).toHaveBeenCalledWith(
53+
"git",
54+
["rev-parse", "--is-inside-work-tree"],
55+
expect.objectContaining({ cwd: "/tmp" })
56+
);
57+
});
58+
59+
test("returns false when git fails", () => {
60+
execFileSyncSpy.mockImplementation(() => {
61+
throw new Error("not a git repository");
62+
});
63+
64+
expect(isInsideGitWorkTree({ cwd: "/tmp" })).toBe(false);
65+
});
66+
});
67+
68+
describe("getUncommittedOrUntrackedFiles", () => {
69+
test("parses porcelain output into file list", () => {
70+
execFileSyncSpy.mockReturnValue(
71+
Buffer.from(" M src/index.ts\n?? new-file.ts\n")
72+
);
73+
74+
const files = getUncommittedOrUntrackedFiles({ cwd: "/tmp" });
75+
76+
expect(files).toEqual(["- M src/index.ts", "- ?? new-file.ts"]);
77+
});
78+
79+
test("returns empty array for clean repo", () => {
80+
execFileSyncSpy.mockReturnValue(Buffer.from(""));
81+
82+
expect(getUncommittedOrUntrackedFiles({ cwd: "/tmp" })).toEqual([]);
83+
});
84+
85+
test("returns empty array on error", () => {
86+
execFileSyncSpy.mockImplementation(() => {
87+
throw new Error("git failed");
88+
});
89+
90+
expect(getUncommittedOrUntrackedFiles({ cwd: "/tmp" })).toEqual([]);
91+
});
92+
});
93+
94+
describe("checkGitStatus", () => {
95+
test("returns true silently for clean git repo", async () => {
96+
// isInsideGitWorkTree -> true
97+
execFileSyncSpy
98+
.mockReturnValueOnce(Buffer.from("true\n"))
99+
// getUncommittedOrUntrackedFiles -> clean
100+
.mockReturnValueOnce(Buffer.from(""));
101+
102+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
103+
104+
expect(result).toBe(true);
105+
expect(confirmSpy).not.toHaveBeenCalled();
106+
expect(logWarnSpy).not.toHaveBeenCalled();
107+
});
108+
109+
test("prompts when not in git repo (interactive) and returns true on confirm", async () => {
110+
execFileSyncSpy.mockImplementation(() => {
111+
throw new Error("not a git repo");
112+
});
113+
confirmSpy.mockResolvedValue(true);
114+
115+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
116+
117+
expect(result).toBe(true);
118+
expect(confirmSpy).toHaveBeenCalledWith(
119+
expect.objectContaining({
120+
message: expect.stringContaining("not inside a git repository"),
121+
})
122+
);
123+
});
124+
125+
test("prompts when not in git repo (interactive) and returns false on decline", async () => {
126+
execFileSyncSpy.mockImplementation(() => {
127+
throw new Error("not a git repo");
128+
});
129+
confirmSpy.mockResolvedValue(false);
130+
131+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
132+
133+
expect(result).toBe(false);
134+
});
135+
136+
test("warns and auto-continues when not in git repo with --yes", async () => {
137+
execFileSyncSpy.mockImplementation(() => {
138+
throw new Error("not a git repo");
139+
});
140+
141+
const result = await checkGitStatus({ cwd: "/tmp", yes: true });
142+
143+
expect(result).toBe(true);
144+
expect(logWarnSpy).toHaveBeenCalledWith(
145+
expect.stringContaining("not inside a git repository")
146+
);
147+
expect(confirmSpy).not.toHaveBeenCalled();
148+
});
149+
150+
test("shows files and prompts for dirty tree (interactive), returns true on confirm", async () => {
151+
// isInsideGitWorkTree -> true
152+
execFileSyncSpy
153+
.mockReturnValueOnce(Buffer.from("true\n"))
154+
// getUncommittedOrUntrackedFiles -> dirty
155+
.mockReturnValueOnce(Buffer.from(" M dirty.ts\n"));
156+
confirmSpy.mockResolvedValue(true);
157+
158+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
159+
160+
expect(result).toBe(true);
161+
expect(logWarnSpy).toHaveBeenCalledWith(
162+
expect.stringContaining("uncommitted")
163+
);
164+
expect(confirmSpy).toHaveBeenCalledWith(
165+
expect.objectContaining({
166+
message: expect.stringContaining("uncommitted changes"),
167+
})
168+
);
169+
});
170+
171+
test("shows files and prompts for dirty tree (interactive), returns false on decline", async () => {
172+
execFileSyncSpy
173+
.mockReturnValueOnce(Buffer.from("true\n"))
174+
.mockReturnValueOnce(Buffer.from(" M dirty.ts\n"));
175+
confirmSpy.mockResolvedValue(false);
176+
177+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
178+
179+
expect(result).toBe(false);
180+
});
181+
182+
test("warns with file list and auto-continues for dirty tree with --yes", async () => {
183+
execFileSyncSpy
184+
.mockReturnValueOnce(Buffer.from("true\n"))
185+
.mockReturnValueOnce(Buffer.from(" M dirty.ts\n?? new.ts\n"));
186+
187+
const result = await checkGitStatus({ cwd: "/tmp", yes: true });
188+
189+
expect(result).toBe(true);
190+
expect(logWarnSpy).toHaveBeenCalled();
191+
const warnMsg: string = logWarnSpy.mock.calls[0][0];
192+
expect(warnMsg).toContain("uncommitted");
193+
expect(warnMsg).toContain("M dirty.ts");
194+
expect(confirmSpy).not.toHaveBeenCalled();
195+
});
196+
});

0 commit comments

Comments
 (0)