diff --git a/.changeset/init-gitignore-dedupe.md b/.changeset/init-gitignore-dedupe.md new file mode 100644 index 000000000..90350db88 --- /dev/null +++ b/.changeset/init-gitignore-dedupe.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Stop adding a duplicate `.clerk/` line to `.gitignore` when the folder is already ignored under an equivalent pattern such as `/.clerk/`, `.clerk`, or `.clerk/*`. diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 666adbc69..420b8d2ca 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -346,7 +346,7 @@ In accountless mode, after calling `POST /v1/accountless_applications`, `clerk i } ``` -`.clerk/` is automatically added to `.gitignore` when the breadcrumb is written. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed). +When `clerk init` writes the breadcrumb, it adds `.clerk/` to `.gitignore` unless an equivalent pattern such as `/.clerk/`, `.clerk`, or `.clerk/*` already ignores the folder. The next `clerk auth login` tries to claim the application and removes the breadcrumb if the claim succeeds or the token has expired. See the [breadcrumb lifecycle](../auth/README.md#accountless-autoclaim-breadcrumb-lifecycle) for every case. ### Re-running init on an already-accountless project diff --git a/packages/cli-core/src/lib/git.test.ts b/packages/cli-core/src/lib/git.test.ts index eea94dfea..5a7fe698a 100644 --- a/packages/cli-core/src/lib/git.test.ts +++ b/packages/cli-core/src/lib/git.test.ts @@ -1,5 +1,8 @@ -import { test, expect, describe } from "bun:test"; -import { normalizeGitRemoteUrl } from "./git.ts"; +import { test, expect, describe, beforeEach, afterEach } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { ensureGitignoreEntry, normalizeGitRemoteUrl } from "./git.ts"; describe("normalizeGitRemoteUrl", () => { test("normalizes SSH SCP-style URL", () => { @@ -66,3 +69,94 @@ describe("normalizeGitRemoteUrl", () => { ); }); }); + +describe("ensureGitignoreEntry", () => { + let tempDir: string; + let gitignorePath: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "clerk-git-test-")); + gitignorePath = join(tempDir, ".gitignore"); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + test("creates .gitignore when none exists", async () => { + await ensureGitignoreEntry(tempDir, ".clerk/"); + expect(await Bun.file(gitignorePath).text()).toBe(".clerk/\n"); + }); + + test("appends the entry when it is not listed", async () => { + await Bun.write(gitignorePath, "node_modules\n"); + await ensureGitignoreEntry(tempDir, ".clerk/"); + expect(await Bun.file(gitignorePath).text()).toBe("node_modules\n.clerk/\n"); + }); + + test("adds a newline before appending when the file lacks a trailing one", async () => { + await Bun.write(gitignorePath, "node_modules"); + await ensureGitignoreEntry(tempDir, ".clerk/"); + expect(await Bun.file(gitignorePath).text()).toBe("node_modules\n.clerk/\n"); + }); + + test.each([ + ".clerk/", + "/.clerk/", + "/.clerk", + ".clerk", + ".clerk/*", + ".clerk/**", + "/.clerk/ ", + "/.clerk/\r", + "/.clerk/ \r", + ])("leaves .gitignore unchanged when %p is already listed", async (existing) => { + const content = `node_modules\n# clerk configuration (can include secrets)\n${existing}\n`; + await Bun.write(gitignorePath, content); + await ensureGitignoreEntry(tempDir, ".clerk/"); + expect(await Bun.file(gitignorePath).text()).toBe(content); + }); + + test("does not treat a commented-out entry as listed", async () => { + await Bun.write(gitignorePath, "# .clerk/\n"); + await ensureGitignoreEntry(tempDir, ".clerk/"); + expect(await Bun.file(gitignorePath).text()).toBe("# .clerk/\n.clerk/\n"); + }); + + test("does not treat a negated entry as listed", async () => { + await Bun.write(gitignorePath, "!.clerk/\n"); + await ensureGitignoreEntry(tempDir, ".clerk/"); + expect(await Bun.file(gitignorePath).text()).toBe("!.clerk/\n.clerk/\n"); + }); + + test("does not treat an entry with leading whitespace as listed", async () => { + await Bun.write(gitignorePath, " /.clerk/\n"); + await ensureGitignoreEntry(tempDir, ".clerk/"); + expect(await Bun.file(gitignorePath).text()).toBe(" /.clerk/\n.clerk/\n"); + }); + + test("does not treat an entry with a trailing tab as listed", async () => { + await Bun.write(gitignorePath, "/.clerk/\t\n"); + await ensureGitignoreEntry(tempDir, ".clerk/"); + expect(await Bun.file(gitignorePath).text()).toBe("/.clerk/\t\n.clerk/\n"); + }); + + test("appends the entry when a later negation re-includes the folder", async () => { + await Bun.write(gitignorePath, "/.clerk/\n!.clerk/\n"); + await ensureGitignoreEntry(tempDir, ".clerk/"); + expect(await Bun.file(gitignorePath).text()).toBe("/.clerk/\n!.clerk/\n.clerk/\n"); + }); + + test("leaves .gitignore unchanged when the entry follows a negation", async () => { + const content = "!.clerk/\n/.clerk/\n"; + await Bun.write(gitignorePath, content); + await ensureGitignoreEntry(tempDir, ".clerk/"); + expect(await Bun.file(gitignorePath).text()).toBe(content); + }); + + test("does not treat a nested path as listed", async () => { + await Bun.write(gitignorePath, "apps/web/.clerk/\n"); + await ensureGitignoreEntry(tempDir, ".clerk/"); + expect(await Bun.file(gitignorePath).text()).toBe("apps/web/.clerk/\n.clerk/\n"); + }); +}); diff --git a/packages/cli-core/src/lib/git.ts b/packages/cli-core/src/lib/git.ts index 34fced039..d39398e24 100644 --- a/packages/cli-core/src/lib/git.ts +++ b/packages/cli-core/src/lib/git.ts @@ -102,7 +102,17 @@ export function normalizeGitRemoteUrl(raw: string): string { } /** - * Adds `entry` to the project's `.gitignore` unless it is already listed. + * Reduces a root-level ignore pattern to its bare path so equivalent spellings + * compare equal: `/.clerk/`, `.clerk/`, `/.clerk`, `.clerk`, and `.clerk/*` + * all become `.clerk`. + */ +function normalizeGitignorePattern(pattern: string): string { + return pattern.replace(/^\//, "").replace(/\/\*{0,2}$/, ""); +} + +/** + * Adds `entry` to the project's `.gitignore` unless it, or an equivalent + * pattern such as `/.clerk/` for `.clerk/`, is already listed. * * The CLI writes files into a user's repository that must not be committed, * such as the keyless breadcrumb. Creating one without this is how a live @@ -113,8 +123,18 @@ export async function ensureGitignoreEntry(cwd: string, entry: string): Promise< const content = await Bun.file(gitignorePath) .text() .catch(() => ""); - const lines = content.split("\n").map((l) => l.trim()); - if (lines.includes(entry)) return; + const target = normalizeGitignorePattern(entry); + // Git strips a trailing CR and trailing spaces, but keeps tabs and leading + // whitespace. The last matching rule wins, so a later `!.clerk/` re-includes + // the folder. + let listed = false; + for (const line of content.split("\n").map((l) => l.replace(/\r$/, "").replace(/ +$/, ""))) { + if (!line || line.startsWith("#")) continue; + const negated = line.startsWith("!"); + const pattern = negated ? line.slice(1) : line; + if (normalizeGitignorePattern(pattern) === target) listed = !negated; + } + if (listed) return; const separator = content && !content.endsWith("\n") ? "\n" : ""; await Bun.write(gitignorePath, `${content}${separator}${entry}\n`); log.debug(`git: added ${entry} to .gitignore`);