Skip to content

Commit 6d389c5

Browse files
betegonclaude
andauthored
feat(init): add git safety checks before wizard modifies files (#379)
## Summary Adds pre-flight git safety checks to `sentry init` so users are warned before the wizard modifies their files. Checks that they're inside a git repo with a clean working tree, and prompts to confirm if there are concerns. With `--yes`, it warns and continues automatically. ## Changes Adds a `checkGitStatus()` function in `src/lib/init/git.ts` that verifies git repo status and working tree cleanliness. This gets called in the `preamble()` phase of `runWizard` — after the experimental warning, before any setup logic runs. The approach follows sentry-wizard's pattern: warn users about uncommitted changes so they can `git stash` or commit first, making it easy to revert if something goes wrong. 12 unit tests cover the git module (repo detection, porcelain parsing, interactive/non-interactive confirm paths). 3 integration tests in wizard-runner verify the check is wired up correctly. ## Test Plan - `bun test test/lib/init/git.test.ts` — 12 tests pass - `bun test test/lib/init/wizard-runner.test.ts` — 25 tests pass - `bun run lint` — clean --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 35ca172 commit 6d389c5

File tree

4 files changed

+425
-9
lines changed

4 files changed

+425
-9
lines changed

src/lib/init/git.ts

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

src/lib/init/wizard-runner.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import { randomBytes } from "node:crypto";
1010
import { cancel, confirm, intro, log, spinner } from "@clack/prompts";
1111
import { MastraClient } from "@mastra/client-js";
12+
import { captureException } from "@sentry/bun";
1213
import { formatBanner } from "../banner.js";
1314
import { CLI_VERSION } from "../constants.js";
1415
import { getAuthToken } from "../db/auth.js";
@@ -25,6 +26,7 @@ import {
2526
WORKFLOW_ID,
2627
} from "./constants.js";
2728
import { formatError, formatResult } from "./formatters.js";
29+
import { checkGitStatus } from "./git.js";
2830
import { handleInteractive } from "./interactive.js";
2931
import { handleLocalOp, precomputeDirListing } from "./local-ops.js";
3032
import type {
@@ -191,7 +193,11 @@ async function confirmExperimental(yes: boolean): Promise<boolean> {
191193
* Pre-flight checks: TTY guard, banner, intro, and experimental warning.
192194
* Returns `true` when the wizard should continue, `false` to abort.
193195
*/
194-
async function preamble(yes: boolean, dryRun: boolean): Promise<boolean> {
196+
async function preamble(
197+
directory: string,
198+
yes: boolean,
199+
dryRun: boolean
200+
): Promise<boolean> {
195201
if (!(yes || process.stdin.isTTY)) {
196202
process.stderr.write(
197203
"Error: Interactive mode requires a terminal. Use --yes for non-interactive mode.\n"
@@ -203,7 +209,19 @@ async function preamble(yes: boolean, dryRun: boolean): Promise<boolean> {
203209
process.stderr.write(`\n${formatBanner()}\n\n`);
204210
intro("sentry init");
205211

206-
const confirmed = await confirmExperimental(yes);
212+
let confirmed: boolean;
213+
try {
214+
confirmed = await confirmExperimental(yes);
215+
} catch (err) {
216+
if (err instanceof WizardCancelledError) {
217+
// Intentionally captured: track why users bail before completing
218+
// instrumentation so we can improve the onboarding flow.
219+
captureException(err);
220+
process.exitCode = 0;
221+
return false;
222+
}
223+
throw err;
224+
}
207225
if (!confirmed) {
208226
cancel("Setup cancelled.");
209227
process.exitCode = 0;
@@ -214,13 +232,20 @@ async function preamble(yes: boolean, dryRun: boolean): Promise<boolean> {
214232
log.warn("Dry-run mode: no files will be modified.");
215233
}
216234

235+
const gitOk = await checkGitStatus({ cwd: directory, yes });
236+
if (!gitOk) {
237+
cancel("Setup cancelled.");
238+
process.exitCode = 0;
239+
return false;
240+
}
241+
217242
return true;
218243
}
219244

220245
export async function runWizard(options: WizardOptions): Promise<void> {
221246
const { directory, yes, dryRun, features } = options;
222247

223-
if (!(await preamble(yes, dryRun))) {
248+
if (!(await preamble(directory, yes, dryRun))) {
224249
return;
225250
}
226251

@@ -323,7 +348,10 @@ export async function runWizard(options: WizardOptions): Promise<void> {
323348
}
324349
} catch (err) {
325350
if (err instanceof WizardCancelledError) {
326-
process.exitCode = 1;
351+
// Intentionally captured: track why users bail before completing
352+
// instrumentation so we can improve the onboarding flow.
353+
captureException(err);
354+
process.exitCode = 0;
327355
return;
328356
}
329357
if (spinState.running) {

test/lib/init/git.test.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2+
// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference
3+
import * as clack from "@clack/prompts";
4+
import {
5+
checkGitStatus,
6+
getUncommittedOrUntrackedFiles,
7+
isInsideGitWorkTree,
8+
} from "../../../src/lib/init/git.js";
9+
10+
const noop = () => {
11+
/* suppress output */
12+
};
13+
14+
let spawnSyncSpy: ReturnType<typeof spyOn>;
15+
let confirmSpy: ReturnType<typeof spyOn>;
16+
let isCancelSpy: ReturnType<typeof spyOn>;
17+
let logWarnSpy: ReturnType<typeof spyOn>;
18+
19+
beforeEach(() => {
20+
spawnSyncSpy = spyOn(Bun, "spawnSync");
21+
confirmSpy = spyOn(clack, "confirm").mockResolvedValue(true);
22+
isCancelSpy = spyOn(clack, "isCancel").mockImplementation(
23+
(v: unknown) => v === Symbol.for("cancel")
24+
);
25+
logWarnSpy = spyOn(clack.log, "warn").mockImplementation(noop);
26+
});
27+
28+
afterEach(() => {
29+
spawnSyncSpy.mockRestore();
30+
confirmSpy.mockRestore();
31+
isCancelSpy.mockRestore();
32+
logWarnSpy.mockRestore();
33+
});
34+
35+
describe("isInsideGitWorkTree", () => {
36+
test("returns true when git succeeds", () => {
37+
spawnSyncSpy.mockReturnValue({ exitCode: 0, success: true });
38+
39+
expect(isInsideGitWorkTree({ cwd: "/tmp" })).toBe(true);
40+
expect(spawnSyncSpy).toHaveBeenCalledWith(
41+
["git", "rev-parse", "--is-inside-work-tree"],
42+
expect.objectContaining({ cwd: "/tmp" })
43+
);
44+
});
45+
46+
test("returns false when git fails", () => {
47+
spawnSyncSpy.mockReturnValue({ exitCode: 128, success: false });
48+
49+
expect(isInsideGitWorkTree({ cwd: "/tmp" })).toBe(false);
50+
});
51+
});
52+
53+
describe("getUncommittedOrUntrackedFiles", () => {
54+
test("parses porcelain output into file list", () => {
55+
spawnSyncSpy.mockReturnValue({
56+
stdout: Buffer.from(" M src/index.ts\n?? new-file.ts\n"),
57+
exitCode: 0,
58+
success: true,
59+
});
60+
61+
const files = getUncommittedOrUntrackedFiles({ cwd: "/tmp" });
62+
63+
expect(files).toEqual(["- M src/index.ts", "- ?? new-file.ts"]);
64+
});
65+
66+
test("returns empty array for clean repo", () => {
67+
spawnSyncSpy.mockReturnValue({
68+
stdout: Buffer.from(""),
69+
exitCode: 0,
70+
success: true,
71+
});
72+
73+
expect(getUncommittedOrUntrackedFiles({ cwd: "/tmp" })).toEqual([]);
74+
});
75+
76+
test("returns empty array on error", () => {
77+
spawnSyncSpy.mockReturnValue({
78+
stdout: Buffer.from(""),
79+
exitCode: 128,
80+
success: false,
81+
});
82+
83+
expect(getUncommittedOrUntrackedFiles({ cwd: "/tmp" })).toEqual([]);
84+
});
85+
});
86+
87+
describe("checkGitStatus", () => {
88+
test("returns true silently for clean git repo", async () => {
89+
spawnSyncSpy
90+
// isInsideGitWorkTree -> true
91+
.mockReturnValueOnce({ exitCode: 0, success: true })
92+
// getUncommittedOrUntrackedFiles -> clean
93+
.mockReturnValueOnce({
94+
stdout: Buffer.from(""),
95+
exitCode: 0,
96+
success: true,
97+
});
98+
99+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
100+
101+
expect(result).toBe(true);
102+
expect(confirmSpy).not.toHaveBeenCalled();
103+
expect(logWarnSpy).not.toHaveBeenCalled();
104+
});
105+
106+
test("prompts when not in git repo (interactive) and returns true on confirm", async () => {
107+
spawnSyncSpy.mockReturnValue({ exitCode: 128, success: false });
108+
confirmSpy.mockResolvedValue(true);
109+
110+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
111+
112+
expect(result).toBe(true);
113+
expect(confirmSpy).toHaveBeenCalledWith(
114+
expect.objectContaining({
115+
message: expect.stringContaining("not inside a git repository"),
116+
})
117+
);
118+
});
119+
120+
test("prompts when not in git repo (interactive) and returns false on decline", async () => {
121+
spawnSyncSpy.mockReturnValue({ exitCode: 128, success: false });
122+
confirmSpy.mockResolvedValue(false);
123+
124+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
125+
126+
expect(result).toBe(false);
127+
});
128+
129+
test("returns false without throwing when user cancels not-in-git-repo prompt", async () => {
130+
spawnSyncSpy.mockReturnValue({ exitCode: 128, success: false });
131+
confirmSpy.mockResolvedValue(Symbol.for("cancel"));
132+
133+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
134+
135+
expect(result).toBe(false);
136+
});
137+
138+
test("warns and auto-continues when not in git repo with --yes", async () => {
139+
spawnSyncSpy.mockReturnValue({ exitCode: 128, success: false });
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+
spawnSyncSpy
152+
// isInsideGitWorkTree -> true
153+
.mockReturnValueOnce({ exitCode: 0, success: true })
154+
// getUncommittedOrUntrackedFiles -> dirty
155+
.mockReturnValueOnce({
156+
stdout: Buffer.from(" M dirty.ts\n"),
157+
exitCode: 0,
158+
success: true,
159+
});
160+
confirmSpy.mockResolvedValue(true);
161+
162+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
163+
164+
expect(result).toBe(true);
165+
expect(logWarnSpy).toHaveBeenCalledWith(
166+
expect.stringContaining("uncommitted")
167+
);
168+
expect(confirmSpy).toHaveBeenCalledWith(
169+
expect.objectContaining({
170+
message: expect.stringContaining("uncommitted changes"),
171+
})
172+
);
173+
});
174+
175+
test("shows files and prompts for dirty tree (interactive), returns false on decline", async () => {
176+
spawnSyncSpy
177+
.mockReturnValueOnce({ exitCode: 0, success: true })
178+
.mockReturnValueOnce({
179+
stdout: Buffer.from(" M dirty.ts\n"),
180+
exitCode: 0,
181+
success: true,
182+
});
183+
confirmSpy.mockResolvedValue(false);
184+
185+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
186+
187+
expect(result).toBe(false);
188+
});
189+
190+
test("returns false without throwing when user cancels dirty-tree prompt", async () => {
191+
spawnSyncSpy
192+
.mockReturnValueOnce({ exitCode: 0, success: true })
193+
.mockReturnValueOnce({
194+
stdout: Buffer.from(" M dirty.ts\n"),
195+
exitCode: 0,
196+
success: true,
197+
});
198+
confirmSpy.mockResolvedValue(Symbol.for("cancel"));
199+
200+
const result = await checkGitStatus({ cwd: "/tmp", yes: false });
201+
202+
expect(result).toBe(false);
203+
});
204+
205+
test("warns with file list and auto-continues for dirty tree with --yes", async () => {
206+
spawnSyncSpy
207+
.mockReturnValueOnce({ exitCode: 0, success: true })
208+
.mockReturnValueOnce({
209+
stdout: Buffer.from(" M dirty.ts\n?? new.ts\n"),
210+
exitCode: 0,
211+
success: true,
212+
});
213+
214+
const result = await checkGitStatus({ cwd: "/tmp", yes: true });
215+
216+
expect(result).toBe(true);
217+
expect(logWarnSpy).toHaveBeenCalled();
218+
const warnMsg: string = logWarnSpy.mock.calls[0][0];
219+
expect(warnMsg).toContain("uncommitted");
220+
expect(warnMsg).toContain("M dirty.ts");
221+
expect(confirmSpy).not.toHaveBeenCalled();
222+
});
223+
});

0 commit comments

Comments
 (0)