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
5 changes: 5 additions & 0 deletions .changeset/init-gitignore-dedupe.md
Original file line number Diff line number Diff line change
@@ -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/*`.
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/init/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
98 changes: 96 additions & 2 deletions packages/cli-core/src/lib/git.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
26 changes: 23 additions & 3 deletions packages/cli-core/src/lib/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`);
Expand Down
Loading