From fa0725fc9b3343be4eaf0adbe95a8a7bf97dcd1f Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 28 Oct 2026 16:09:45 -0500 Subject: [PATCH 1/7] fix(init): skip .gitignore entry when .clerk/ is already ignored `ensureGitignoreEntry` matched lines exactly, so a project that already ignored the folder as `/.clerk/` (as clerk-b2b-saas-starter does) got a second `.clerk/` line appended on every `clerk init`. - Treat root-level equivalents (`/.clerk/`, `.clerk/`, `/.clerk`, `.clerk`, `.clerk/*`, `.clerk/**`) as already listed, ignoring surrounding whitespace and comment lines. - Keep appending when none is present, including when only a commented, negated, or nested entry exists. - Note the equivalence in the init README and add a patch changeset. Co-Authored-By: Claude Opus 5.5 --- .changeset/init-gitignore-dedupe.md | 5 ++ packages/cli-core/src/commands/init/README.md | 2 +- packages/cli-core/src/lib/git.test.ts | 66 ++++++++++++++++++- packages/cli-core/src/lib/git.ts | 21 +++++- 4 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 .changeset/init-gitignore-dedupe.md 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..22514b473 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). +`.clerk/` is automatically added to `.gitignore` when the breadcrumb is written, unless the file already ignores it under an equivalent pattern such as `/.clerk/`, `.clerk`, or `.clerk/*`. The breadcrumb is removed after a successful claim (or when the claim token expires/is already consumed). ### 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..91750e85a 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,62 @@ 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/ "])( + "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 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..e9052d624 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,13 @@ 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); + const listed = content + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")) + .some((l) => normalizeGitignorePattern(l) === target); + 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`); From 008aef7af603f1591b4eacb48ad482c5534e08c6 Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 28 Oct 2026 16:36:35 -0500 Subject: [PATCH 2/7] fix(init): respect leading whitespace and negations in .gitignore Git keeps leading whitespace as part of a pattern and lets the last matching rule win. ` /.clerk/` and `/.clerk/` followed by `!.clerk/` both leave the breadcrumb unignored, yet the helper treated them as listed. - Trim trailing whitespace only, which also handles CRLF line endings. - Scan rules in order so a later equivalent negation clears the match. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/lib/git.test.ts | 43 +++++++++++++++++++++------ packages/cli-core/src/lib/git.ts | 14 +++++---- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/cli-core/src/lib/git.test.ts b/packages/cli-core/src/lib/git.test.ts index 91750e85a..f32f9ea68 100644 --- a/packages/cli-core/src/lib/git.test.ts +++ b/packages/cli-core/src/lib/git.test.ts @@ -100,15 +100,21 @@ describe("ensureGitignoreEntry", () => { expect(await Bun.file(gitignorePath).text()).toBe("node_modules\n.clerk/\n"); }); - test.each([".clerk/", "/.clerk/", "/.clerk", ".clerk", ".clerk/*", ".clerk/**", " /.clerk/ "])( - "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.each([ + ".clerk/", + "/.clerk/", + "/.clerk", + ".clerk", + ".clerk/*", + ".clerk/**", + "/.clerk/ ", + "/.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"); @@ -122,6 +128,25 @@ describe("ensureGitignoreEntry", () => { 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("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/"); diff --git a/packages/cli-core/src/lib/git.ts b/packages/cli-core/src/lib/git.ts index e9052d624..a93576a0f 100644 --- a/packages/cli-core/src/lib/git.ts +++ b/packages/cli-core/src/lib/git.ts @@ -124,11 +124,15 @@ export async function ensureGitignoreEntry(cwd: string, entry: string): Promise< .text() .catch(() => ""); const target = normalizeGitignorePattern(entry); - const listed = content - .split("\n") - .map((l) => l.trim()) - .filter((l) => l && !l.startsWith("#")) - .some((l) => normalizeGitignorePattern(l) === target); + // Git ignores trailing whitespace but not leading, and 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.trimEnd())) { + 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`); From 9e13861d39b9c5d9550468a0abed02267b3a582d Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 28 Oct 2026 16:36:35 -0500 Subject: [PATCH 3/7] docs(init): use active voice for the breadcrumb .gitignore note Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/init/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 22514b473..18f075d19 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, unless the file already ignores it under an equivalent pattern such as `/.clerk/`, `.clerk`, or `.clerk/*`. 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 CLI removes the breadcrumb after a successful claim, or when the claim token expires or was already consumed. ### Re-running init on an already-accountless project From 58c56b7e0bd49116935b7e5238dafe594c9d1f7d Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 28 Oct 2026 16:38:08 -0500 Subject: [PATCH 4/7] docs(init): drop the last passive clause from the breadcrumb note Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/init/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 18f075d19..85f9de997 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 } ``` -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 CLI removes the breadcrumb after a successful claim, or when the claim token expires or was 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 CLI removes the breadcrumb after a successful claim, or once the claim token expires or another claim uses it. ### Re-running init on an already-accountless project From 0ad558c0e2549317f2b6b6f4387ab318961a6b2f Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 28 Oct 2026 16:39:39 -0500 Subject: [PATCH 5/7] docs(init): say when auth login removes the breadcrumb Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/init/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 85f9de997..a4ee28156 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 } ``` -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 CLI removes the breadcrumb after a successful claim, or once the claim token expires or another claim uses it. +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` claims the application and removes the breadcrumb, or removes it without claiming when 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 From ea38874aa9f4608003cebaafcf09ea42386e75fa Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 28 Oct 2026 16:40:47 -0500 Subject: [PATCH 6/7] docs(init): note that auth login tries to claim the app Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/init/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index a4ee28156..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 } ``` -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` claims the application and removes the breadcrumb, or removes it without claiming when the token has expired. See the [breadcrumb lifecycle](../auth/README.md#accountless-autoclaim-breadcrumb-lifecycle) for every case. +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 From c761507946288d2609bc63c53f5a7e65a7115912 Mon Sep 17 00:00:00 2001 From: Michael Novotny Date: Wed, 28 Oct 2026 16:46:51 -0500 Subject: [PATCH 7/7] fix(init): keep trailing tabs when matching .gitignore patterns Git strips a trailing CR and trailing spaces from a pattern but keeps tabs, so `/.clerk/` doesn't ignore the breadcrumb. `trimEnd()` treated it as listed and skipped the entry. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/lib/git.test.ts | 7 +++++++ packages/cli-core/src/lib/git.ts | 7 ++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/lib/git.test.ts b/packages/cli-core/src/lib/git.test.ts index f32f9ea68..5a7fe698a 100644 --- a/packages/cli-core/src/lib/git.test.ts +++ b/packages/cli-core/src/lib/git.test.ts @@ -109,6 +109,7 @@ describe("ensureGitignoreEntry", () => { ".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); @@ -134,6 +135,12 @@ describe("ensureGitignoreEntry", () => { 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/"); diff --git a/packages/cli-core/src/lib/git.ts b/packages/cli-core/src/lib/git.ts index a93576a0f..d39398e24 100644 --- a/packages/cli-core/src/lib/git.ts +++ b/packages/cli-core/src/lib/git.ts @@ -124,10 +124,11 @@ export async function ensureGitignoreEntry(cwd: string, entry: string): Promise< .text() .catch(() => ""); const target = normalizeGitignorePattern(entry); - // Git ignores trailing whitespace but not leading, and the last matching - // rule wins, so a later `!.clerk/` re-includes the folder. + // 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.trimEnd())) { + 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;