claudeup 4.15.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/gitignore-detector.test.ts +156 -0
- package/src/__tests__/gitignore-fixer.test.ts +197 -0
- package/src/__tests__/gitignore-prerun.test.ts +75 -0
- package/src/__tests__/gitignore-resolver.test.ts +166 -0
- package/src/__tests__/gitignore-service.test.ts +93 -0
- package/src/__tests__/useGitignoreModal.test.ts +71 -0
- package/src/data/gitignore-defaults.js +24 -0
- package/src/data/gitignore-defaults.ts +26 -0
- package/src/data/gitignore-templates.js +21 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/settings-catalog.js +13 -13
- package/src/data/settings-catalog.ts +14 -14
- package/src/prerunner/index.js +17 -0
- package/src/prerunner/index.ts +20 -0
- package/src/services/gitignore-detector.js +155 -0
- package/src/services/gitignore-detector.ts +157 -0
- package/src/services/gitignore-fixer.js +171 -0
- package/src/services/gitignore-fixer.ts +198 -0
- package/src/services/gitignore-prerun.js +46 -0
- package/src/services/gitignore-prerun.ts +59 -0
- package/src/services/gitignore-resolver.js +143 -0
- package/src/services/gitignore-resolver.ts +190 -0
- package/src/services/gitignore-service.js +99 -0
- package/src/services/gitignore-service.ts +142 -0
- package/src/types/gitignore.js +6 -0
- package/src/types/gitignore.ts +68 -0
- package/src/ui/App.js +47 -3
- package/src/ui/App.tsx +51 -2
- package/src/ui/components/TabBar.js +1 -0
- package/src/ui/components/TabBar.tsx +1 -0
- package/src/ui/hooks/useGitignoreModal.js +75 -0
- package/src/ui/hooks/useGitignoreModal.ts +97 -0
- package/src/ui/renderers/gitignoreRenderers.js +33 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +70 -0
- package/src/ui/screens/GitignoreScreen.js +227 -0
- package/src/ui/screens/GitignoreScreen.tsx +364 -0
- package/src/ui/screens/index.js +1 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/types.ts +4 -2
package/package.json
CHANGED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { detectViolations } from "../services/gitignore-detector";
|
|
7
|
+
import type { ResolvedManifest } from "../types/gitignore";
|
|
8
|
+
|
|
9
|
+
function git(cwd: string, ...args: string[]): string {
|
|
10
|
+
// core.excludesFile=/dev/null bypasses the developer's global gitignore
|
|
11
|
+
// so the test environment doesn't leak into the result.
|
|
12
|
+
const r = spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], {
|
|
13
|
+
cwd,
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
});
|
|
16
|
+
if (r.status !== 0) {
|
|
17
|
+
throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`);
|
|
18
|
+
}
|
|
19
|
+
return r.stdout;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function makeRepo(): Promise<string> {
|
|
23
|
+
const dir = await mkdtemp(join(tmpdir(), "gitig-det-"));
|
|
24
|
+
git(dir, "init", "-q");
|
|
25
|
+
git(dir, "config", "user.email", "test@test");
|
|
26
|
+
git(dir, "config", "user.name", "test");
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function manifest(rules: ResolvedManifest["rules"]): ResolvedManifest {
|
|
31
|
+
return { rules, conflicts: [] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("detectViolations", () => {
|
|
35
|
+
let repo: string;
|
|
36
|
+
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
repo = await makeRepo();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await rm(repo, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns empty for non-git directory", async () => {
|
|
46
|
+
const tmp = await mkdtemp(join(tmpdir(), "gitig-nogit-"));
|
|
47
|
+
try {
|
|
48
|
+
const r = await detectViolations(
|
|
49
|
+
tmp,
|
|
50
|
+
manifest([{ pattern: ".env", action: "ignore", source: "builtin" }]),
|
|
51
|
+
);
|
|
52
|
+
expect(r).toEqual([]);
|
|
53
|
+
} finally {
|
|
54
|
+
await rm(tmp, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("flags tracked-but-should-ignore for a tracked file", async () => {
|
|
59
|
+
await writeFile(join(repo, "secret.txt"), "x");
|
|
60
|
+
git(repo, "add", "secret.txt");
|
|
61
|
+
git(repo, "commit", "-q", "-m", "add secret");
|
|
62
|
+
|
|
63
|
+
const r = await detectViolations(
|
|
64
|
+
repo,
|
|
65
|
+
manifest([{ pattern: "secret.txt", action: "ignore", source: "project" }]),
|
|
66
|
+
);
|
|
67
|
+
expect(r.length).toBe(1);
|
|
68
|
+
expect(r[0].kind).toBe("tracked-but-should-ignore");
|
|
69
|
+
expect(r[0].severity).toBe("destructive");
|
|
70
|
+
expect(r[0].path).toBe("secret.txt");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("flags missing-from-gitignore when path neither tracked nor in .gitignore", async () => {
|
|
74
|
+
const r = await detectViolations(
|
|
75
|
+
repo,
|
|
76
|
+
manifest([{ pattern: ".env", action: "ignore", source: "builtin" }]),
|
|
77
|
+
);
|
|
78
|
+
expect(r.length).toBe(1);
|
|
79
|
+
expect(r[0].kind).toBe("missing-from-gitignore");
|
|
80
|
+
expect(r[0].severity).toBe("safe");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does NOT flag missing-from-gitignore when .gitignore already covers the path", async () => {
|
|
84
|
+
await writeFile(join(repo, ".gitignore"), ".env\n");
|
|
85
|
+
const r = await detectViolations(
|
|
86
|
+
repo,
|
|
87
|
+
manifest([{ pattern: ".env", action: "ignore", source: "builtin" }]),
|
|
88
|
+
);
|
|
89
|
+
expect(r).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("flags ignored-but-should-track when track rule is excluded by .gitignore", async () => {
|
|
93
|
+
await writeFile(join(repo, ".gitignore"), ".mcp.json\n");
|
|
94
|
+
await writeFile(join(repo, ".mcp.json"), "{}");
|
|
95
|
+
const r = await detectViolations(
|
|
96
|
+
repo,
|
|
97
|
+
manifest([{ pattern: ".mcp.json", action: "track", source: "builtin" }]),
|
|
98
|
+
);
|
|
99
|
+
expect(r.length).toBe(1);
|
|
100
|
+
expect(r[0].kind).toBe("ignored-but-should-track");
|
|
101
|
+
expect(r[0].severity).toBe("destructive");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("flags untracked-and-should-track when path exists but isn't tracked", async () => {
|
|
105
|
+
await writeFile(join(repo, ".mcp.json"), "{}");
|
|
106
|
+
const r = await detectViolations(
|
|
107
|
+
repo,
|
|
108
|
+
manifest([{ pattern: ".mcp.json", action: "track", source: "builtin" }]),
|
|
109
|
+
);
|
|
110
|
+
expect(r.length).toBe(1);
|
|
111
|
+
expect(r[0].kind).toBe("untracked-and-should-track");
|
|
112
|
+
expect(r[0].severity).toBe("destructive");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("does NOT flag track rule when path is already tracked", async () => {
|
|
116
|
+
await writeFile(join(repo, ".mcp.json"), "{}");
|
|
117
|
+
git(repo, "add", ".mcp.json");
|
|
118
|
+
git(repo, "commit", "-q", "-m", "add mcp");
|
|
119
|
+
const r = await detectViolations(
|
|
120
|
+
repo,
|
|
121
|
+
manifest([{ pattern: ".mcp.json", action: "track", source: "builtin" }]),
|
|
122
|
+
);
|
|
123
|
+
expect(r).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("matches directory patterns against tracked subpaths", async () => {
|
|
127
|
+
await mkdir(join(repo, "ai-docs", "sessions"), { recursive: true });
|
|
128
|
+
await writeFile(join(repo, "ai-docs", "sessions", "leak.md"), "x");
|
|
129
|
+
git(repo, "add", "ai-docs/sessions/leak.md");
|
|
130
|
+
git(repo, "commit", "-q", "-m", "leak");
|
|
131
|
+
|
|
132
|
+
const r = await detectViolations(
|
|
133
|
+
repo,
|
|
134
|
+
manifest([{ pattern: "ai-docs/sessions/", action: "ignore", source: "builtin" }]),
|
|
135
|
+
);
|
|
136
|
+
expect(r.length).toBeGreaterThan(0);
|
|
137
|
+
expect(r[0].kind).toBe("tracked-but-should-ignore");
|
|
138
|
+
expect(r[0].path).toBe("ai-docs/sessions/leak.md");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns no violations when manifest is satisfied", async () => {
|
|
142
|
+
await writeFile(join(repo, ".gitignore"), ".env\n");
|
|
143
|
+
await writeFile(join(repo, ".mcp.json"), "{}");
|
|
144
|
+
git(repo, "add", ".gitignore", ".mcp.json");
|
|
145
|
+
git(repo, "commit", "-q", "-m", "init");
|
|
146
|
+
|
|
147
|
+
const r = await detectViolations(
|
|
148
|
+
repo,
|
|
149
|
+
manifest([
|
|
150
|
+
{ pattern: ".env", action: "ignore", source: "builtin" },
|
|
151
|
+
{ pattern: ".mcp.json", action: "track", source: "builtin" },
|
|
152
|
+
]),
|
|
153
|
+
);
|
|
154
|
+
expect(r).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
applySafeFixes,
|
|
8
|
+
applyDestructiveFix,
|
|
9
|
+
formatViolationSummary,
|
|
10
|
+
} from "../services/gitignore-fixer";
|
|
11
|
+
import type { Violation } from "../types/gitignore";
|
|
12
|
+
|
|
13
|
+
function git(cwd: string, ...args: string[]): { stdout: string; status: number } {
|
|
14
|
+
const r = spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], {
|
|
15
|
+
cwd,
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
});
|
|
18
|
+
return { stdout: r.stdout, status: r.status ?? -1 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function makeRepo(): Promise<string> {
|
|
22
|
+
const dir = await mkdtemp(join(tmpdir(), "gitig-fix-"));
|
|
23
|
+
git(dir, "init", "-q");
|
|
24
|
+
git(dir, "config", "user.email", "t@t");
|
|
25
|
+
git(dir, "config", "user.name", "t");
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("applySafeFixes", () => {
|
|
30
|
+
let repo: string;
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
repo = await makeRepo();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
await rm(repo, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("appends missing patterns to .gitignore under a header", async () => {
|
|
41
|
+
const violations: Violation[] = [
|
|
42
|
+
{ kind: "missing-from-gitignore", path: ".env", source: "builtin", severity: "safe" },
|
|
43
|
+
{ kind: "missing-from-gitignore", path: ".mnemex/", source: "builtin", severity: "safe" },
|
|
44
|
+
];
|
|
45
|
+
const r = await applySafeFixes(repo, violations);
|
|
46
|
+
expect(r.appended).toEqual([".env", ".mnemex/"]);
|
|
47
|
+
expect(r.alreadyPresent).toEqual([]);
|
|
48
|
+
|
|
49
|
+
const content = await readFile(join(repo, ".gitignore"), "utf8");
|
|
50
|
+
expect(content).toContain(".env");
|
|
51
|
+
expect(content).toContain(".mnemex/");
|
|
52
|
+
expect(content).toContain("# Added by claudeup");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("creates .gitignore if absent", async () => {
|
|
56
|
+
const r = await applySafeFixes(repo, [
|
|
57
|
+
{ kind: "missing-from-gitignore", path: "foo", source: "builtin", severity: "safe" },
|
|
58
|
+
]);
|
|
59
|
+
expect(r.appended).toEqual(["foo"]);
|
|
60
|
+
const content = await readFile(join(repo, ".gitignore"), "utf8");
|
|
61
|
+
expect(content).toMatch(/foo/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("is idempotent — re-running does not duplicate entries", async () => {
|
|
65
|
+
const v: Violation = {
|
|
66
|
+
kind: "missing-from-gitignore",
|
|
67
|
+
path: "dup",
|
|
68
|
+
source: "builtin",
|
|
69
|
+
severity: "safe",
|
|
70
|
+
};
|
|
71
|
+
await applySafeFixes(repo, [v]);
|
|
72
|
+
const second = await applySafeFixes(repo, [v]);
|
|
73
|
+
expect(second.appended).toEqual([]);
|
|
74
|
+
expect(second.alreadyPresent).toEqual(["dup"]);
|
|
75
|
+
const content = await readFile(join(repo, ".gitignore"), "utf8");
|
|
76
|
+
expect(content.match(/^dup$/gm)?.length).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("ignores destructive violations", async () => {
|
|
80
|
+
const r = await applySafeFixes(repo, [
|
|
81
|
+
{
|
|
82
|
+
kind: "tracked-but-should-ignore",
|
|
83
|
+
path: "secret",
|
|
84
|
+
source: "builtin",
|
|
85
|
+
severity: "destructive",
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
expect(r.appended).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("preserves existing .gitignore content", async () => {
|
|
92
|
+
await writeFile(join(repo, ".gitignore"), "node_modules/\n.DS_Store\n");
|
|
93
|
+
await applySafeFixes(repo, [
|
|
94
|
+
{ kind: "missing-from-gitignore", path: ".env", source: "builtin", severity: "safe" },
|
|
95
|
+
]);
|
|
96
|
+
const content = await readFile(join(repo, ".gitignore"), "utf8");
|
|
97
|
+
expect(content).toContain("node_modules/");
|
|
98
|
+
expect(content).toContain(".DS_Store");
|
|
99
|
+
expect(content).toContain(".env");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("applyDestructiveFix", () => {
|
|
104
|
+
let repo: string;
|
|
105
|
+
|
|
106
|
+
beforeEach(async () => {
|
|
107
|
+
repo = await makeRepo();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(async () => {
|
|
111
|
+
await rm(repo, { recursive: true, force: true });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("removes tracked file from index and adds to .gitignore", async () => {
|
|
115
|
+
await writeFile(join(repo, "secret"), "x");
|
|
116
|
+
git(repo, "add", "secret");
|
|
117
|
+
git(repo, "commit", "-q", "-m", "add");
|
|
118
|
+
|
|
119
|
+
const r = await applyDestructiveFix(repo, {
|
|
120
|
+
kind: "tracked-but-should-ignore",
|
|
121
|
+
path: "secret",
|
|
122
|
+
source: "builtin",
|
|
123
|
+
severity: "destructive",
|
|
124
|
+
});
|
|
125
|
+
expect(r.applied).toBe(true);
|
|
126
|
+
|
|
127
|
+
const ls = git(repo, "ls-files");
|
|
128
|
+
expect(ls.stdout.includes("secret")).toBe(false);
|
|
129
|
+
const ignore = await readFile(join(repo, ".gitignore"), "utf8");
|
|
130
|
+
expect(ignore).toContain("secret");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("strips .gitignore line and stages file for ignored-but-should-track", async () => {
|
|
134
|
+
await writeFile(join(repo, ".mcp.json"), "{}");
|
|
135
|
+
await writeFile(join(repo, ".gitignore"), ".mcp.json\nnode_modules/\n");
|
|
136
|
+
|
|
137
|
+
const r = await applyDestructiveFix(repo, {
|
|
138
|
+
kind: "ignored-but-should-track",
|
|
139
|
+
path: ".mcp.json",
|
|
140
|
+
source: "builtin",
|
|
141
|
+
severity: "destructive",
|
|
142
|
+
});
|
|
143
|
+
expect(r.applied).toBe(true);
|
|
144
|
+
|
|
145
|
+
const ignore = await readFile(join(repo, ".gitignore"), "utf8");
|
|
146
|
+
expect(ignore).not.toMatch(/^\.mcp\.json$/m);
|
|
147
|
+
expect(ignore).toContain("node_modules/");
|
|
148
|
+
|
|
149
|
+
const ls = git(repo, "ls-files");
|
|
150
|
+
// staged but not yet committed; ls-files only shows index
|
|
151
|
+
expect(ls.stdout).toContain(".mcp.json");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("stages an untracked file", async () => {
|
|
155
|
+
await writeFile(join(repo, ".mcp.json"), "{}");
|
|
156
|
+
const r = await applyDestructiveFix(repo, {
|
|
157
|
+
kind: "untracked-and-should-track",
|
|
158
|
+
path: ".mcp.json",
|
|
159
|
+
source: "builtin",
|
|
160
|
+
severity: "destructive",
|
|
161
|
+
});
|
|
162
|
+
expect(r.applied).toBe(true);
|
|
163
|
+
expect(git(repo, "ls-files").stdout).toContain(".mcp.json");
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("formatViolationSummary", () => {
|
|
168
|
+
it("returns friendly message when empty", () => {
|
|
169
|
+
expect(formatViolationSummary([])).toMatch(/No gitignore issues/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("groups by kind with counts and sample paths", () => {
|
|
173
|
+
const summary = formatViolationSummary([
|
|
174
|
+
{ kind: "missing-from-gitignore", path: "a", source: "builtin", severity: "safe" },
|
|
175
|
+
{ kind: "missing-from-gitignore", path: "b", source: "builtin", severity: "safe" },
|
|
176
|
+
{ kind: "tracked-but-should-ignore", path: "c", source: "project", severity: "destructive" },
|
|
177
|
+
]);
|
|
178
|
+
expect(summary).toContain("missing from .gitignore: 2");
|
|
179
|
+
expect(summary).toContain("tracked but should be ignored: 1");
|
|
180
|
+
expect(summary).toContain("- a");
|
|
181
|
+
expect(summary).toContain("- c");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("truncates long lists with `… and N more`", () => {
|
|
185
|
+
const vs: Violation[] = [];
|
|
186
|
+
for (let i = 0; i < 8; i++) {
|
|
187
|
+
vs.push({
|
|
188
|
+
kind: "missing-from-gitignore",
|
|
189
|
+
path: `p${i}`,
|
|
190
|
+
source: "builtin",
|
|
191
|
+
severity: "safe",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const summary = formatViolationSummary(vs);
|
|
195
|
+
expect(summary).toContain("and 3 more");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
checkGitignore,
|
|
8
|
+
formatPrerunWarning,
|
|
9
|
+
} from "../services/gitignore-prerun";
|
|
10
|
+
|
|
11
|
+
function git(cwd: string, ...args: string[]): void {
|
|
12
|
+
spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], { cwd });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("checkGitignore", () => {
|
|
16
|
+
let projectDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
projectDir = await mkdtemp(join(tmpdir(), "gitig-prerun-"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns 0 violations for non-git directory", async () => {
|
|
27
|
+
const r = await checkGitignore(projectDir);
|
|
28
|
+
expect(r.violationCount).toBe(0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("flags missing built-in patterns inside a fresh git repo", async () => {
|
|
32
|
+
git(projectDir, "init", "-q");
|
|
33
|
+
const r = await checkGitignore(projectDir);
|
|
34
|
+
// BUILTIN_DEFAULTS includes ".mnemex/" etc. — none are tracked, none in
|
|
35
|
+
// .gitignore, so each one becomes "missing-from-gitignore".
|
|
36
|
+
expect(r.violationCount).toBeGreaterThan(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns 0 when .gitignore covers all built-in patterns and tracked files match track rules", async () => {
|
|
40
|
+
git(projectDir, "init", "-q");
|
|
41
|
+
git(projectDir, "config", "user.email", "t@t");
|
|
42
|
+
git(projectDir, "config", "user.name", "t");
|
|
43
|
+
// Suppress all built-in ignore rules
|
|
44
|
+
await writeFile(
|
|
45
|
+
join(projectDir, ".gitignore"),
|
|
46
|
+
[
|
|
47
|
+
".claude/settings.local.json",
|
|
48
|
+
".claude/scheduled_tasks.lock",
|
|
49
|
+
".claude/cache/",
|
|
50
|
+
".claude/.statusline-worktree-*",
|
|
51
|
+
"ai-docs/sessions/",
|
|
52
|
+
".mnemex/",
|
|
53
|
+
".claudemem/",
|
|
54
|
+
"gtd/sessions/",
|
|
55
|
+
".agents/",
|
|
56
|
+
].join("\n") + "\n",
|
|
57
|
+
);
|
|
58
|
+
// Tracked files don't have to exist for the absent-track-path case to be ignored
|
|
59
|
+
const r = await checkGitignore(projectDir);
|
|
60
|
+
expect(r.violationCount).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("formatPrerunWarning", () => {
|
|
65
|
+
it("returns null when no violations", () => {
|
|
66
|
+
expect(formatPrerunWarning({ violationCount: 0, warnings: [] })).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns a warning string when violations exist", () => {
|
|
70
|
+
const w = formatPrerunWarning({ violationCount: 3, warnings: [] });
|
|
71
|
+
expect(w).not.toBeNull();
|
|
72
|
+
expect(w).toContain("3 gitignore");
|
|
73
|
+
expect(w).toContain("Gitignore tab");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { resolveGitignore } from "../services/gitignore-resolver";
|
|
6
|
+
import { BUILTIN_DEFAULTS } from "../data/gitignore-defaults";
|
|
7
|
+
|
|
8
|
+
describe("resolveGitignore", () => {
|
|
9
|
+
let projectDir: string;
|
|
10
|
+
let homeDir: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
projectDir = await mkdtemp(join(tmpdir(), "gitig-proj-"));
|
|
14
|
+
homeDir = await mkdtemp(join(tmpdir(), "gitig-home-"));
|
|
15
|
+
await mkdir(join(projectDir, ".claude"), { recursive: true });
|
|
16
|
+
await mkdir(join(homeDir, ".claude"), { recursive: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
21
|
+
await rm(homeDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns built-in defaults when no global or project manifest exists", async () => {
|
|
25
|
+
const r = await resolveGitignore(projectDir, { homeDir });
|
|
26
|
+
expect(r.conflicts).toEqual([]);
|
|
27
|
+
const ignored = r.rules.filter((x) => x.action === "ignore").map((x) => x.pattern);
|
|
28
|
+
for (const p of BUILTIN_DEFAULTS.ignore) {
|
|
29
|
+
expect(ignored).toContain(p);
|
|
30
|
+
}
|
|
31
|
+
const tracked = r.rules.filter((x) => x.action === "track").map((x) => x.pattern);
|
|
32
|
+
for (const p of BUILTIN_DEFAULTS.track) {
|
|
33
|
+
expect(tracked).toContain(p);
|
|
34
|
+
}
|
|
35
|
+
for (const rule of r.rules) {
|
|
36
|
+
expect(rule.source).toBe("builtin");
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("project tier overrides global tier", async () => {
|
|
41
|
+
await writeFile(
|
|
42
|
+
join(homeDir, ".claude", "gitignore.json"),
|
|
43
|
+
JSON.stringify({ ignore: [".env"], track: [] }),
|
|
44
|
+
);
|
|
45
|
+
await writeFile(
|
|
46
|
+
join(projectDir, ".claude", "gitignore.json"),
|
|
47
|
+
JSON.stringify({ ignore: [], track: [".env"] }),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const r = await resolveGitignore(projectDir, { homeDir });
|
|
51
|
+
const env = r.rules.find((x) => x.pattern === ".env");
|
|
52
|
+
expect(env?.action).toBe("track");
|
|
53
|
+
expect(env?.source).toBe("project");
|
|
54
|
+
expect(r.conflicts.find((c) => c.pattern === ".env")?.winner).toBe("project");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("global tier overrides built-in", async () => {
|
|
58
|
+
await writeFile(
|
|
59
|
+
join(homeDir, ".claude", "gitignore.json"),
|
|
60
|
+
JSON.stringify({ ignore: [], track: [".claude/settings.local.json"] }),
|
|
61
|
+
);
|
|
62
|
+
const r = await resolveGitignore(projectDir, { homeDir });
|
|
63
|
+
const rule = r.rules.find((x) => x.pattern === ".claude/settings.local.json");
|
|
64
|
+
expect(rule?.action).toBe("track");
|
|
65
|
+
expect(rule?.source).toBe("global");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("expands `extends: ['common-dev']` into the tier's rules", async () => {
|
|
69
|
+
await writeFile(
|
|
70
|
+
join(projectDir, ".claude", "gitignore.json"),
|
|
71
|
+
JSON.stringify({ ignore: [], track: [], extends: ["common-dev"] }),
|
|
72
|
+
);
|
|
73
|
+
const r = await resolveGitignore(projectDir, { homeDir });
|
|
74
|
+
const ignored = r.rules.filter((x) => x.action === "ignore").map((x) => x.pattern);
|
|
75
|
+
expect(ignored).toContain("node_modules/");
|
|
76
|
+
expect(ignored).toContain(".env");
|
|
77
|
+
expect(ignored).toContain(".DS_Store");
|
|
78
|
+
// Template rules carry the project source since they merged into project tier
|
|
79
|
+
const nodeMods = r.rules.find((x) => x.pattern === "node_modules/");
|
|
80
|
+
expect(nodeMods?.source).toBe("project");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("logs and skips unknown templates", async () => {
|
|
84
|
+
await writeFile(
|
|
85
|
+
join(projectDir, ".claude", "gitignore.json"),
|
|
86
|
+
JSON.stringify({ ignore: [], track: [], extends: ["does-not-exist"] }),
|
|
87
|
+
);
|
|
88
|
+
const messages: string[] = [];
|
|
89
|
+
const r = await resolveGitignore(projectDir, { homeDir, logger: (m) => messages.push(m) });
|
|
90
|
+
expect(messages.some((m) => m.includes("does-not-exist"))).toBe(true);
|
|
91
|
+
// Should still return built-in defaults
|
|
92
|
+
expect(r.rules.length).toBeGreaterThan(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("throws on intra-tier conflict", async () => {
|
|
96
|
+
await writeFile(
|
|
97
|
+
join(projectDir, ".claude", "gitignore.json"),
|
|
98
|
+
JSON.stringify({ ignore: ["foo"], track: ["foo"] }),
|
|
99
|
+
);
|
|
100
|
+
await expect(resolveGitignore(projectDir, { homeDir })).rejects.toThrow(
|
|
101
|
+
/Intra-tier conflict/,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("ignores malformed manifest JSON without throwing", async () => {
|
|
106
|
+
await writeFile(join(projectDir, ".claude", "gitignore.json"), "{not json");
|
|
107
|
+
const messages: string[] = [];
|
|
108
|
+
const r = await resolveGitignore(projectDir, { homeDir, logger: (m) => messages.push(m) });
|
|
109
|
+
expect(messages.some((m) => /failed to read/.test(m))).toBe(true);
|
|
110
|
+
// Falls back to built-in
|
|
111
|
+
expect(r.rules.find((x) => x.pattern === ".mnemex/")?.source).toBe("builtin");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("ignores manifest with wrong shape without throwing", async () => {
|
|
115
|
+
await writeFile(
|
|
116
|
+
join(projectDir, ".claude", "gitignore.json"),
|
|
117
|
+
JSON.stringify({ ignore: "not-array" }),
|
|
118
|
+
);
|
|
119
|
+
const messages: string[] = [];
|
|
120
|
+
const r = await resolveGitignore(projectDir, { homeDir, logger: (m) => messages.push(m) });
|
|
121
|
+
expect(messages.some((m) => /not a valid manifest/.test(m))).toBe(true);
|
|
122
|
+
expect(r.rules.length).toBeGreaterThan(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("dedupes patterns within a tier", async () => {
|
|
126
|
+
await writeFile(
|
|
127
|
+
join(projectDir, ".claude", "gitignore.json"),
|
|
128
|
+
JSON.stringify({ ignore: ["foo", "foo", "bar"], track: [] }),
|
|
129
|
+
);
|
|
130
|
+
const r = await resolveGitignore(projectDir, { homeDir });
|
|
131
|
+
const fooRules = r.rules.filter((x) => x.pattern === "foo");
|
|
132
|
+
expect(fooRules.length).toBe(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does not mark same-action overrides as conflicts", async () => {
|
|
136
|
+
await writeFile(
|
|
137
|
+
join(homeDir, ".claude", "gitignore.json"),
|
|
138
|
+
JSON.stringify({ ignore: [".env"], track: [] }),
|
|
139
|
+
);
|
|
140
|
+
await writeFile(
|
|
141
|
+
join(projectDir, ".claude", "gitignore.json"),
|
|
142
|
+
JSON.stringify({ ignore: [".env"], track: [] }),
|
|
143
|
+
);
|
|
144
|
+
const r = await resolveGitignore(projectDir, { homeDir });
|
|
145
|
+
expect(r.conflicts.find((c) => c.pattern === ".env")).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("records conflict losers across multiple tiers", async () => {
|
|
149
|
+
// built-in ignores .claude/settings.local.json; global tracks it; project ignores it again
|
|
150
|
+
await writeFile(
|
|
151
|
+
join(homeDir, ".claude", "gitignore.json"),
|
|
152
|
+
JSON.stringify({ ignore: [], track: [".claude/settings.local.json"] }),
|
|
153
|
+
);
|
|
154
|
+
await writeFile(
|
|
155
|
+
join(projectDir, ".claude", "gitignore.json"),
|
|
156
|
+
JSON.stringify({ ignore: [".claude/settings.local.json"], track: [] }),
|
|
157
|
+
);
|
|
158
|
+
const r = await resolveGitignore(projectDir, { homeDir });
|
|
159
|
+
const winner = r.rules.find((x) => x.pattern === ".claude/settings.local.json");
|
|
160
|
+
expect(winner?.action).toBe("ignore");
|
|
161
|
+
expect(winner?.source).toBe("project");
|
|
162
|
+
const conflict = r.conflicts.find((c) => c.pattern === ".claude/settings.local.json");
|
|
163
|
+
expect(conflict?.winner).toBe("project");
|
|
164
|
+
expect(conflict?.losers.some((l) => l.tier === "global" && l.action === "track")).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
});
|