From 9c4fc77c6656986fe51832898171b836ce356e71 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Fri, 12 Jun 2026 14:58:27 +0200 Subject: [PATCH] feat: Warn when there are untracked changes in git --- packages/spark/src/clack-copy.ts | 27 +++++++++ packages/spark/src/clack-wizard.ts | 22 +++++++- packages/spark/src/git.ts | 19 +++++++ packages/spark/test/clack-wizard.test.ts | 72 +++++++++++++++++++++++- packages/spark/test/git.test.ts | 21 ++++++- 5 files changed, 155 insertions(+), 6 deletions(-) diff --git a/packages/spark/src/clack-copy.ts b/packages/spark/src/clack-copy.ts index e209471..3252b98 100644 --- a/packages/spark/src/clack-copy.ts +++ b/packages/spark/src/clack-copy.ts @@ -10,6 +10,7 @@ const BRAINTRUST_CLI_CONTEXT_FALLBACKS = { const GITHUB_ISSUE_URL = "https://github.com/braintrustdata/spark/issues/new"; const SUPPORT_URL = "https://www.braintrust.dev/contact"; const INSTRUMENTATION_DOCS_URL = "https://www.braintrust.dev/docs/instrument"; +const DIRTY_GIT_FILE_LIMIT = 20; export const CLACK_WIZARD_COPY = { shared: { @@ -41,6 +42,32 @@ export const CLACK_WIZARD_COPY = { hint: "Stop wizard", }, }, + dirtyRepoWarning: (files: readonly string[]) => { + const visibleFiles = files.slice(0, DIRTY_GIT_FILE_LIMIT); + const remainingFileCount = files.length - visibleFiles.length; + return [ + `${chalk.yellow.bold("Git changes detected.")} This repository already has local changes:`, + "", + ...visibleFiles.map((file, index) => + chalk.dim(`${index + 1}. ${file}`), + ), + ...(remainingFileCount > 0 + ? [chalk.dim(`... plus ${remainingFileCount} more`)] + : []), + "", + `Braintrust Setup can continue, but its edits will be mixed with these changes. ${chalk.bold("Continue?")}`, + ].join("\n"); + }, + continueWithDirtyRepoChoices: { + yes: { + label: "Yes", + hint: "Continue", + }, + no: { + label: "No", + hint: "Cancel setup and close wizard", + }, + }, }, auth: { diff --git a/packages/spark/src/clack-wizard.ts b/packages/spark/src/clack-wizard.ts index b395810..3b8fcd2 100644 --- a/packages/spark/src/clack-wizard.ts +++ b/packages/spark/src/clack-wizard.ts @@ -31,7 +31,11 @@ import { type CodingToolRunResult, type CodingToolStatus, } from "./coding-tools"; -import { isGitRepo, writeEnvBraintrust } from "./git"; +import { + getUncommittedOrUntrackedFiles, + isGitRepo, + writeEnvBraintrust, +} from "./git"; import { allocateResultFile, readResultFile } from "./instrument"; import type { WizardOptions } from "./options"; import { renderPrompt } from "./prompt"; @@ -189,7 +193,8 @@ export async function runClackWizard(deps: WizardDeps): Promise { process.stdout.write("\n"); clack.intro(COPY.welcome.intro); - if (!(await isGitRepo(deps.cwd))) { + const inGitRepo = await isGitRepo(deps.cwd); + if (!inGitRepo) { const continueOutsideGit = await selectBoolean({ message: COPY.gitRepository.outsideRepoWarning, choices: COPY.gitRepository.continueOutsideRepoChoices, @@ -199,6 +204,19 @@ export async function runClackWizard(deps: WizardDeps): Promise { clack.cancel(WIZARD_CANCEL_MESSAGE); throw new WizardCancelledError(); } + } else { + const dirtyFiles = await getUncommittedOrUntrackedFiles(deps.cwd); + if (dirtyFiles.length > 0) { + const continueWithDirtyRepo = await selectBoolean({ + message: COPY.gitRepository.dirtyRepoWarning(dirtyFiles), + choices: COPY.gitRepository.continueWithDirtyRepoChoices, + yesFirst: false, + }); + if (!continueWithDirtyRepo) { + clack.cancel(WIZARD_CANCEL_MESSAGE); + throw new WizardCancelledError(); + } + } } let session: WizardSessionCompleteResult; diff --git a/packages/spark/src/git.ts b/packages/spark/src/git.ts index 64788ce..9196eb1 100644 --- a/packages/spark/src/git.ts +++ b/packages/spark/src/git.ts @@ -42,6 +42,25 @@ export async function isGitRepo(cwd: string): Promise { } } +export async function getUncommittedOrUntrackedFiles( + cwd: string, +): Promise { + try { + const { stdout } = await execFileAsync( + "git", + ["status", "--porcelain=v1", "--untracked-files=normal"], + { cwd }, + ); + return stdout + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter(Boolean) + .map((line) => line.slice(3)); + } catch { + return []; + } +} + const ENV_FILENAME = ".env.braintrust"; const BRAINTRUST_JSON_FILENAME = ".braintrust.json"; const GENERATED_FILE_COMMENT = diff --git a/packages/spark/test/clack-wizard.test.ts b/packages/spark/test/clack-wizard.test.ts index a228247..4597a06 100644 --- a/packages/spark/test/clack-wizard.test.ts +++ b/packages/spark/test/clack-wizard.test.ts @@ -220,6 +220,7 @@ const MANUAL_INSTRUMENTATION_MESSAGE = const TRACE_LOGS_CHECK_MESSAGE = "Your application should now be instrumented with Braintrust tracing."; const PRODUCTION_TOKEN_MESSAGE = "Production Setup: Add the"; +const DIRTY_REPOSITORY_MESSAGE = "Git changes detected."; const GENERATED_FILE_COMMENT = "This file was generated by the Braintrust wizard. This file contains sensitive information. Do not commit this file to version control! The file can be safely deleted after confirming your application sends traces."; const ENV_BRAINTRUST_FILE_CONTENT = `# ${GENERATED_FILE_COMMENT}\nBRAINTRUST_API_KEY=bt-secret-key\n`; @@ -738,7 +739,15 @@ describe("runClackWizard", () => { const envFilePath = join(cwd, ".env.braintrust"); writeFileSync(envFilePath, "BRAINTRUST_API_KEY=old\n"); const { events } = createPrompts({ - selects: ["yes", "no", "manual", "confirm", "checked", "confirmed"], + selects: [ + "yes", + "yes", + "no", + "manual", + "confirm", + "checked", + "confirmed", + ], }); const deps = buildDeps({ cwd }); @@ -787,7 +796,15 @@ describe("runClackWizard", () => { ".env.braintrust\n.braintrust.json\n", ); const { events } = createPrompts({ - selects: ["yes", "no", "manual", "confirm", "checked", "confirmed"], + selects: [ + "yes", + "yes", + "no", + "manual", + "confirm", + "checked", + "confirmed", + ], }); const deps = buildDeps({ cwd }); @@ -1423,6 +1440,57 @@ describe("runClackWizard", () => { expect(events).toContain(`cancel:${WIZARD_CANCEL_MESSAGE}`); }); + it("asks before continuing with uncommitted or untracked files", async () => { + const cwd = createGitTempDir(); + writeFileSync(join(cwd, "existing-work.ts"), "changed\n"); + const { events } = createPrompts({ + selects: [ + "yes", + "yes", + "no", + "manual", + "confirm", + "checked", + "confirmed", + ], + }); + const deps = buildDeps({ cwd }); + + await runClackWizard(deps); + + expect( + events.some( + (event) => + event.startsWith("select:") && + event.includes(DIRTY_REPOSITORY_MESSAGE) && + event.includes("1. existing-work.ts") && + event.includes("Continue?"), + ), + ).toBe(true); + expect(events.indexOf(`select:${ACCOUNT_QUESTION}`)).toBeGreaterThan( + events.findIndex((event) => event.includes(DIRTY_REPOSITORY_MESSAGE)), + ); + }); + + it("cancels when the user does not continue with dirty repo files", async () => { + const cwd = createGitTempDir(); + writeFileSync(join(cwd, "existing-work.ts"), "changed\n"); + const { events } = createPrompts({ selects: ["no"] }); + const deps = buildDeps({ cwd }); + + await expect(runClackWizard(deps)).rejects.toThrow(WizardCancelledError); + expect( + events.some( + (event) => + event.startsWith("select:") && + event.includes(DIRTY_REPOSITORY_MESSAGE) && + event.includes("1. existing-work.ts") && + event.includes("Continue?"), + ), + ).toBe(true); + expect(events).toContain(`cancel:${WIZARD_CANCEL_MESSAGE}`); + }); + it("supports manual instrumentation after creating local token files", async () => { const { events } = createPrompts({ selects: ["yes", "no", "manual", "confirm", "checked", "confirmed"], diff --git a/packages/spark/test/git.test.ts b/packages/spark/test/git.test.ts index bd047ee..ff90d73 100644 --- a/packages/spark/test/git.test.ts +++ b/packages/spark/test/git.test.ts @@ -1,11 +1,15 @@ import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { findGitRoot, isGitRepo } from "../src/git"; +import { + findGitRoot, + getUncommittedOrUntrackedFiles, + isGitRepo, +} from "../src/git"; describe("git repository detection", () => { it("detects git worktrees from nested directories", async () => { @@ -24,4 +28,17 @@ describe("git repository detection", () => { await expect(isGitRepo(root)).resolves.toBe(false); await expect(findGitRoot(root)).resolves.toBeUndefined(); }); + + it("lists uncommitted and untracked files", async () => { + const root = mkdtempSync(join(tmpdir(), "braintrust-setup-git-")); + execFileSync("git", ["init", "--quiet"], { cwd: root }); + writeFileSync(join(root, "tracked.ts"), "initial\n"); + execFileSync("git", ["add", "tracked.ts"], { cwd: root }); + writeFileSync(join(root, "tracked.ts"), "changed\n"); + writeFileSync(join(root, "untracked.ts"), "new\n"); + + await expect(getUncommittedOrUntrackedFiles(root)).resolves.toEqual( + expect.arrayContaining(["tracked.ts", "untracked.ts"]), + ); + }); });