Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/spark/src/clack-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
22 changes: 20 additions & 2 deletions packages/spark/src/clack-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -189,7 +193,8 @@ export async function runClackWizard(deps: WizardDeps): Promise<WizardResult> {
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,
Expand All @@ -199,6 +204,19 @@ export async function runClackWizard(deps: WizardDeps): Promise<WizardResult> {
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;
Expand Down
19 changes: 19 additions & 0 deletions packages/spark/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,25 @@ export async function isGitRepo(cwd: string): Promise<boolean> {
}
}

export async function getUncommittedOrUntrackedFiles(
cwd: string,
): Promise<readonly string[]> {
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 =
Expand Down
72 changes: 70 additions & 2 deletions packages/spark/test/clack-wizard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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"],
Expand Down
21 changes: 19 additions & 2 deletions packages/spark/test/git.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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"]),
);
});
});