claudeup 6.2.1 → 6.3.2

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.
Files changed (150) hide show
  1. package/README.md +22 -0
  2. package/bin/claudeup.js +6 -1
  3. package/package.json +6 -6
  4. package/src/__tests__/alias-adopt.test.ts +331 -329
  5. package/src/__tests__/alias-parser.test.ts +384 -376
  6. package/src/__tests__/alias-shell-writer.test.ts +638 -625
  7. package/src/__tests__/alias-store.test.ts +143 -143
  8. package/src/__tests__/catalog-cache-store.test.ts +265 -265
  9. package/src/__tests__/catalog-notice.test.ts +123 -123
  10. package/src/__tests__/cli-apply-seams.test.ts +12 -8
  11. package/src/__tests__/cli-router.test.ts +132 -3
  12. package/src/__tests__/cli-update-view.test.ts +27 -8
  13. package/src/__tests__/community-staleness.test.ts +0 -2
  14. package/src/__tests__/component-badge.test.ts +4 -4
  15. package/src/__tests__/content-drift.test.ts +5 -1
  16. package/src/__tests__/conventions-integration.test.ts +774 -689
  17. package/src/__tests__/conventions-manager.test.ts +1216 -1152
  18. package/src/__tests__/doctor-bins.test.ts +14 -6
  19. package/src/__tests__/doctor.test.ts +438 -425
  20. package/src/__tests__/dual-write-prevention.test.ts +277 -282
  21. package/src/__tests__/enabled-not-installed.test.ts +71 -63
  22. package/src/__tests__/file-locking.test.ts +196 -196
  23. package/src/__tests__/gap-fill-versions.test.ts +318 -311
  24. package/src/__tests__/github-budget.test.ts +170 -164
  25. package/src/__tests__/gitignore-detector.test.ts +160 -152
  26. package/src/__tests__/gitignore-fixer.test.ts +285 -247
  27. package/src/__tests__/gitignore-prerun.test.ts +57 -57
  28. package/src/__tests__/gitignore-resolver.test.ts +173 -146
  29. package/src/__tests__/gitignore-service.test.ts +121 -119
  30. package/src/__tests__/install-command.test.ts +3 -3
  31. package/src/__tests__/install-plan.test.ts +13 -7
  32. package/src/__tests__/manifest.test.ts +16 -5
  33. package/src/__tests__/markdown-renderer.test.ts +0 -1
  34. package/src/__tests__/marketplace-badge.test.ts +111 -90
  35. package/src/__tests__/marketplace-version.test.ts +64 -61
  36. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  37. package/src/__tests__/plugin-requires.test.ts +18 -4
  38. package/src/__tests__/plugin-setup.test.ts +12 -12
  39. package/src/__tests__/profile-command.test.ts +1 -7
  40. package/src/__tests__/profile-materializer.test.ts +31 -8
  41. package/src/__tests__/profile-sync.test.ts +23 -5
  42. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  43. package/src/__tests__/resolve-executable.test.ts +1 -1
  44. package/src/__tests__/resolver.test.ts +40 -15
  45. package/src/__tests__/scope-action.test.ts +52 -50
  46. package/src/__tests__/scope-squares.test.tsx +4 -5
  47. package/src/__tests__/skill-install-files.test.ts +111 -103
  48. package/src/__tests__/symlink-manager.test.ts +27 -10
  49. package/src/__tests__/theme-env.test.ts +281 -0
  50. package/src/__tests__/theme-resolve.test.ts +380 -0
  51. package/src/__tests__/update-apply.test.ts +1 -3
  52. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  53. package/src/__tests__/version-snapshot.test.ts +181 -165
  54. package/src/cli/ansi.ts +23 -15
  55. package/src/cli/doctor.ts +10 -9
  56. package/src/cli/install.ts +3 -3
  57. package/src/cli/profile.ts +5 -4
  58. package/src/cli/prompt.ts +0 -1
  59. package/src/cli/router.ts +69 -2
  60. package/src/cli/update-view.ts +11 -6
  61. package/src/cli/update.ts +9 -4
  62. package/src/cli/upgrade.ts +6 -4
  63. package/src/data/alias-flags.ts +260 -260
  64. package/src/data/gitignore-defaults.ts +20 -20
  65. package/src/data/gitignore-reasons.ts +86 -92
  66. package/src/data/gitignore-templates.ts +14 -14
  67. package/src/data/predefined-profiles.ts +8 -9
  68. package/src/data/skill-repos.ts +43 -35
  69. package/src/main.tsx +29 -6
  70. package/src/opentui.d.ts +1 -3
  71. package/src/prerunner/index.ts +7 -8
  72. package/src/services/alias-settings.ts +20 -23
  73. package/src/services/alias-shell-writer.ts +800 -803
  74. package/src/services/alias-store.ts +77 -77
  75. package/src/services/claude-settings.ts +27 -18
  76. package/src/services/content-drift.ts +13 -3
  77. package/src/services/conventions-manager.ts +692 -617
  78. package/src/services/doctor.ts +464 -403
  79. package/src/services/gitignore-detector.ts +113 -103
  80. package/src/services/gitignore-fixer.ts +204 -189
  81. package/src/services/gitignore-prerun.ts +26 -26
  82. package/src/services/gitignore-resolver.ts +162 -144
  83. package/src/services/gitignore-service.ts +120 -117
  84. package/src/services/install-plan.ts +4 -4
  85. package/src/services/manifest.ts +1 -1
  86. package/src/services/marketplace-catalog-git.ts +5 -2
  87. package/src/services/marketplace-fetcher.ts +241 -235
  88. package/src/services/plugin-manager.ts +17 -20
  89. package/src/services/plugin-mcp-config.ts +2 -2
  90. package/src/services/plugin-requires.ts +9 -6
  91. package/src/services/plugin-setup.ts +7 -9
  92. package/src/services/plugin-version-check.ts +4 -4
  93. package/src/services/profile-materializer.ts +9 -5
  94. package/src/services/profile-sync.ts +12 -4
  95. package/src/services/profiles.ts +3 -3
  96. package/src/services/resolver.ts +10 -6
  97. package/src/services/settings-manager.ts +82 -46
  98. package/src/services/skills-manager.ts +11 -9
  99. package/src/services/symlink-manager.ts +2 -5
  100. package/src/services/toolchain.ts +3 -3
  101. package/src/services/update-cache.ts +1 -1
  102. package/src/services/update-engine.ts +4 -4
  103. package/src/services/update-plan.ts +9 -11
  104. package/src/services/version-snapshot.ts +88 -88
  105. package/src/types/gitignore.ts +28 -28
  106. package/src/ui/App.tsx +36 -36
  107. package/src/ui/adapters/settingsAdapter.ts +2 -2
  108. package/src/ui/components/CategoryHeader.tsx +1 -1
  109. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  110. package/src/ui/components/ScopeIndicator.tsx +3 -1
  111. package/src/ui/components/ScrollableList.tsx +2 -1
  112. package/src/ui/components/StyledText.tsx +1 -1
  113. package/src/ui/components/layout/FooterHints.tsx +5 -3
  114. package/src/ui/components/layout/Panel.tsx +2 -2
  115. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  116. package/src/ui/components/modals/InputModal.tsx +7 -1
  117. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  118. package/src/ui/components/modals/SelectModal.tsx +1 -1
  119. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  120. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  121. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  122. package/src/ui/hooks/index.ts +0 -1
  123. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  124. package/src/ui/hooks/useMismatchModal.ts +4 -10
  125. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  126. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  127. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  128. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  129. package/src/ui/renderers/profileRenderers.tsx +9 -8
  130. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  131. package/src/ui/renderers/skillRenderers.tsx +14 -14
  132. package/src/ui/screens/AliasScreen.tsx +31 -34
  133. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  134. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  135. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  136. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  137. package/src/ui/screens/McpScreen.tsx +10 -10
  138. package/src/ui/screens/PluginsScreen.tsx +17 -21
  139. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  140. package/src/ui/screens/SkillsScreen.tsx +16 -14
  141. package/src/ui/state/AppContext.tsx +4 -8
  142. package/src/ui/state/DimensionsContext.tsx +3 -2
  143. package/src/ui/theme-mode.ts +20 -38
  144. package/src/ui/theme-resolve.ts +148 -0
  145. package/src/utils/clipboard.ts +5 -5
  146. package/src/utils/command-utils.ts +2 -2
  147. package/src/utils/config-dir.ts +1 -3
  148. package/src/utils/file-locking.ts +88 -86
  149. package/src/ui/hooks/useAsyncData.ts +0 -127
  150. package/src/ui/state/AnimationContext.tsx +0 -76
@@ -1,12 +1,12 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
- import { mkdtemp, rm, writeFile, readFile, mkdir } from "node:fs/promises";
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { spawnSync } from "node:child_process";
3
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
5
  import { join } from "node:path";
5
- import { spawnSync } from "node:child_process";
6
6
  import {
7
- applySafeFixes,
8
- applyDestructiveFix,
9
- formatViolationSummary,
7
+ applyDestructiveFix,
8
+ applySafeFixes,
9
+ formatViolationSummary,
10
10
  } from "../services/gitignore-fixer";
11
11
  import type { Violation } from "../types/gitignore";
12
12
 
@@ -21,259 +21,297 @@ import type { Violation } from "../types/gitignore";
21
21
  // the cache expires. `marketplace-refresh.test.ts` already guards this the same
22
22
  // way; this file did not.
23
23
  const GIT_CFG = [
24
- "-c",
25
- "core.excludesFile=/dev/null",
26
- "-c",
27
- "commit.gpgsign=false",
28
- "-c",
29
- "init.defaultBranch=main",
24
+ "-c",
25
+ "core.excludesFile=/dev/null",
26
+ "-c",
27
+ "commit.gpgsign=false",
28
+ "-c",
29
+ "init.defaultBranch=main",
30
30
  ];
31
31
 
32
- function git(cwd: string, ...args: string[]): { stdout: string; status: number } {
33
- const r = spawnSync("git", [...GIT_CFG, ...args], {
34
- cwd,
35
- encoding: "utf8",
36
- });
37
- return { stdout: r.stdout, status: r.status ?? -1 };
32
+ function git(
33
+ cwd: string,
34
+ ...args: string[]
35
+ ): { stdout: string; status: number } {
36
+ const r = spawnSync("git", [...GIT_CFG, ...args], {
37
+ cwd,
38
+ encoding: "utf8",
39
+ });
40
+ return { stdout: r.stdout, status: r.status ?? -1 };
38
41
  }
39
42
 
40
43
  async function makeRepo(): Promise<string> {
41
- const dir = await mkdtemp(join(tmpdir(), "gitig-fix-"));
42
- git(dir, "init", "-q");
43
- git(dir, "config", "user.email", "t@t");
44
- git(dir, "config", "user.name", "t");
45
- return dir;
44
+ const dir = await mkdtemp(join(tmpdir(), "gitig-fix-"));
45
+ git(dir, "init", "-q");
46
+ git(dir, "config", "user.email", "t@t");
47
+ git(dir, "config", "user.name", "t");
48
+ return dir;
46
49
  }
47
50
 
48
51
  describe("applySafeFixes", () => {
49
- let repo: string;
50
-
51
- beforeEach(async () => {
52
- repo = await makeRepo();
53
- });
54
-
55
- afterEach(async () => {
56
- await rm(repo, { recursive: true, force: true });
57
- });
58
-
59
- it("appends missing patterns to .gitignore under a header", async () => {
60
- const violations: Violation[] = [
61
- { kind: "missing-from-gitignore", path: ".env", source: "builtin", severity: "safe" },
62
- { kind: "missing-from-gitignore", path: ".mnemex/", source: "builtin", severity: "safe" },
63
- ];
64
- const r = await applySafeFixes(repo, violations);
65
- expect(r.appended).toEqual([".env", ".mnemex/"]);
66
- expect(r.alreadyPresent).toEqual([]);
67
-
68
- const content = await readFile(join(repo, ".gitignore"), "utf8");
69
- expect(content).toContain(".env");
70
- expect(content).toContain(".mnemex/");
71
- expect(content).toContain("# Added by claudeup");
72
- expect(content).toContain("Environment files commonly contain");
73
- expect(content).toContain("Local memory/index data");
74
- });
75
-
76
- it("creates .gitignore if absent", async () => {
77
- const r = await applySafeFixes(repo, [
78
- { kind: "missing-from-gitignore", path: "foo", source: "builtin", severity: "safe" },
79
- ]);
80
- expect(r.appended).toEqual(["foo"]);
81
- const content = await readFile(join(repo, ".gitignore"), "utf8");
82
- expect(content).toMatch(/foo/);
83
- });
84
-
85
- it("is idempotent — re-running does not duplicate entries", async () => {
86
- const v: Violation = {
87
- kind: "missing-from-gitignore",
88
- path: "dup",
89
- source: "builtin",
90
- severity: "safe",
91
- };
92
- await applySafeFixes(repo, [v]);
93
- const second = await applySafeFixes(repo, [v]);
94
- expect(second.appended).toEqual([]);
95
- expect(second.alreadyPresent).toEqual(["dup"]);
96
- const content = await readFile(join(repo, ".gitignore"), "utf8");
97
- expect(content.match(/^dup$/gm)?.length).toBe(1);
98
- });
99
-
100
- it("ignores destructive violations", async () => {
101
- const r = await applySafeFixes(repo, [
102
- {
103
- kind: "tracked-but-should-ignore",
104
- path: "secret",
105
- source: "builtin",
106
- severity: "destructive",
107
- },
108
- ]);
109
- expect(r.appended).toEqual([]);
110
- });
111
-
112
- it("preserves existing .gitignore content", async () => {
113
- await writeFile(join(repo, ".gitignore"), "node_modules/\n.DS_Store\n");
114
- await applySafeFixes(repo, [
115
- { kind: "missing-from-gitignore", path: ".env", source: "builtin", severity: "safe" },
116
- ]);
117
- const content = await readFile(join(repo, ".gitignore"), "utf8");
118
- expect(content).toContain("node_modules/");
119
- expect(content).toContain(".DS_Store");
120
- expect(content).toContain(".env");
121
- });
52
+ let repo: string;
53
+
54
+ beforeEach(async () => {
55
+ repo = await makeRepo();
56
+ });
57
+
58
+ afterEach(async () => {
59
+ await rm(repo, { recursive: true, force: true });
60
+ });
61
+
62
+ it("appends missing patterns to .gitignore under a header", async () => {
63
+ const violations: Violation[] = [
64
+ {
65
+ kind: "missing-from-gitignore",
66
+ path: ".env",
67
+ source: "builtin",
68
+ severity: "safe",
69
+ },
70
+ {
71
+ kind: "missing-from-gitignore",
72
+ path: ".mnemex/",
73
+ source: "builtin",
74
+ severity: "safe",
75
+ },
76
+ ];
77
+ const r = await applySafeFixes(repo, violations);
78
+ expect(r.appended).toEqual([".env", ".mnemex/"]);
79
+ expect(r.alreadyPresent).toEqual([]);
80
+
81
+ const content = await readFile(join(repo, ".gitignore"), "utf8");
82
+ expect(content).toContain(".env");
83
+ expect(content).toContain(".mnemex/");
84
+ expect(content).toContain("# Added by claudeup");
85
+ expect(content).toContain("Environment files commonly contain");
86
+ expect(content).toContain("Local memory/index data");
87
+ });
88
+
89
+ it("creates .gitignore if absent", async () => {
90
+ const r = await applySafeFixes(repo, [
91
+ {
92
+ kind: "missing-from-gitignore",
93
+ path: "foo",
94
+ source: "builtin",
95
+ severity: "safe",
96
+ },
97
+ ]);
98
+ expect(r.appended).toEqual(["foo"]);
99
+ const content = await readFile(join(repo, ".gitignore"), "utf8");
100
+ expect(content).toMatch(/foo/);
101
+ });
102
+
103
+ it("is idempotent — re-running does not duplicate entries", async () => {
104
+ const v: Violation = {
105
+ kind: "missing-from-gitignore",
106
+ path: "dup",
107
+ source: "builtin",
108
+ severity: "safe",
109
+ };
110
+ await applySafeFixes(repo, [v]);
111
+ const second = await applySafeFixes(repo, [v]);
112
+ expect(second.appended).toEqual([]);
113
+ expect(second.alreadyPresent).toEqual(["dup"]);
114
+ const content = await readFile(join(repo, ".gitignore"), "utf8");
115
+ expect(content.match(/^dup$/gm)?.length).toBe(1);
116
+ });
117
+
118
+ it("ignores destructive violations", async () => {
119
+ const r = await applySafeFixes(repo, [
120
+ {
121
+ kind: "tracked-but-should-ignore",
122
+ path: "secret",
123
+ source: "builtin",
124
+ severity: "destructive",
125
+ },
126
+ ]);
127
+ expect(r.appended).toEqual([]);
128
+ });
129
+
130
+ it("preserves existing .gitignore content", async () => {
131
+ await writeFile(join(repo, ".gitignore"), "node_modules/\n.DS_Store\n");
132
+ await applySafeFixes(repo, [
133
+ {
134
+ kind: "missing-from-gitignore",
135
+ path: ".env",
136
+ source: "builtin",
137
+ severity: "safe",
138
+ },
139
+ ]);
140
+ const content = await readFile(join(repo, ".gitignore"), "utf8");
141
+ expect(content).toContain("node_modules/");
142
+ expect(content).toContain(".DS_Store");
143
+ expect(content).toContain(".env");
144
+ });
122
145
  });
123
146
 
124
147
  describe("applyDestructiveFix", () => {
125
- let repo: string;
126
-
127
- beforeEach(async () => {
128
- repo = await makeRepo();
129
- });
130
-
131
- afterEach(async () => {
132
- await rm(repo, { recursive: true, force: true });
133
- });
134
-
135
- it("removes tracked file from index and adds to .gitignore", async () => {
136
- await writeFile(join(repo, "secret"), "x");
137
- git(repo, "add", "secret");
138
- git(repo, "commit", "-q", "-m", "add");
139
-
140
- const r = await applyDestructiveFix(repo, {
141
- kind: "tracked-but-should-ignore",
142
- path: "secret",
143
- source: "builtin",
144
- severity: "destructive",
145
- });
146
- expect(r.applied).toBe(true);
147
-
148
- const ls = git(repo, "ls-files");
149
- expect(ls.stdout.includes("secret")).toBe(false);
150
- const ignore = await readFile(join(repo, ".gitignore"), "utf8");
151
- expect(ignore).toContain("secret");
152
- });
153
-
154
- it("strips .gitignore line and stages file for ignored-but-should-track", async () => {
155
- await writeFile(join(repo, ".mcp.json"), "{}");
156
- await writeFile(join(repo, ".gitignore"), ".mcp.json\nnode_modules/\n");
157
-
158
- const r = await applyDestructiveFix(repo, {
159
- kind: "ignored-but-should-track",
160
- path: ".mcp.json",
161
- source: "builtin",
162
- severity: "destructive",
163
- });
164
- expect(r.applied).toBe(true);
165
-
166
- const ignore = await readFile(join(repo, ".gitignore"), "utf8");
167
- expect(ignore).not.toMatch(/^\.mcp\.json$/m);
168
- expect(ignore).toContain("node_modules/");
169
-
170
- const ls = git(repo, "ls-files");
171
- // staged but not yet committed; ls-files only shows index
172
- expect(ls.stdout).toContain(".mcp.json");
173
- });
174
-
175
- it("adds unignore exceptions when a parent rule ignores a tracked path", async () => {
176
- await mkdir(join(repo, ".claude"), { recursive: true });
177
- await writeFile(join(repo, ".claude", "settings.json"), "{}");
178
- await writeFile(join(repo, ".gitignore"), "/*\n");
179
-
180
- const r = await applyDestructiveFix(repo, {
181
- kind: "ignored-but-should-track",
182
- path: ".claude/settings.json",
183
- source: "builtin",
184
- severity: "destructive",
185
- });
186
- expect(r.applied).toBe(true);
187
-
188
- const ignore = await readFile(join(repo, ".gitignore"), "utf8");
189
- expect(ignore).toContain("!/.claude/");
190
- expect(ignore).toContain("!/.claude/settings.json");
191
- expect(ignore).toContain("Team Claude settings should be committed");
192
-
193
- const ls = git(repo, "ls-files");
194
- expect(ls.stdout).toContain(".claude/settings.json");
195
- });
196
-
197
- it("does not fail when making an absent tracked path trackable", async () => {
198
- await writeFile(join(repo, ".gitignore"), "/*\n");
199
-
200
- const r = await applyDestructiveFix(repo, {
201
- kind: "ignored-but-should-track",
202
- path: "plugin.json",
203
- source: "builtin",
204
- severity: "destructive",
205
- });
206
- expect(r.applied).toBe(true);
207
- expect(r.message).toContain("does not exist yet");
208
-
209
- const ignore = await readFile(join(repo, ".gitignore"), "utf8");
210
- expect(ignore).toContain("!/plugin.json");
211
- });
212
-
213
- it("uses the managed rule pattern when untracking child violations", async () => {
214
- await mkdir(join(repo, "logs"), { recursive: true });
215
- await writeFile(join(repo, "logs", "app.log"), "x");
216
- git(repo, "add", "logs/app.log");
217
- git(repo, "commit", "-q", "-m", "add log");
218
-
219
- const r = await applyDestructiveFix(
220
- repo,
221
- {
222
- kind: "tracked-but-should-ignore",
223
- path: "logs/app.log",
224
- source: "builtin",
225
- severity: "destructive",
226
- },
227
- "logs/",
228
- );
229
- expect(r.applied).toBe(true);
230
-
231
- const ignore = await readFile(join(repo, ".gitignore"), "utf8");
232
- expect(ignore).toContain("logs/");
233
- expect(ignore).not.toMatch(/^logs\/app\.log$/m);
234
- });
235
-
236
- it("stages an untracked file", async () => {
237
- await writeFile(join(repo, ".mcp.json"), "{}");
238
- const r = await applyDestructiveFix(repo, {
239
- kind: "untracked-and-should-track",
240
- path: ".mcp.json",
241
- source: "builtin",
242
- severity: "destructive",
243
- });
244
- expect(r.applied).toBe(true);
245
- expect(git(repo, "ls-files").stdout).toContain(".mcp.json");
246
- });
148
+ let repo: string;
149
+
150
+ beforeEach(async () => {
151
+ repo = await makeRepo();
152
+ });
153
+
154
+ afterEach(async () => {
155
+ await rm(repo, { recursive: true, force: true });
156
+ });
157
+
158
+ it("removes tracked file from index and adds to .gitignore", async () => {
159
+ await writeFile(join(repo, "secret"), "x");
160
+ git(repo, "add", "secret");
161
+ git(repo, "commit", "-q", "-m", "add");
162
+
163
+ const r = await applyDestructiveFix(repo, {
164
+ kind: "tracked-but-should-ignore",
165
+ path: "secret",
166
+ source: "builtin",
167
+ severity: "destructive",
168
+ });
169
+ expect(r.applied).toBe(true);
170
+
171
+ const ls = git(repo, "ls-files");
172
+ expect(ls.stdout.includes("secret")).toBe(false);
173
+ const ignore = await readFile(join(repo, ".gitignore"), "utf8");
174
+ expect(ignore).toContain("secret");
175
+ });
176
+
177
+ it("strips .gitignore line and stages file for ignored-but-should-track", async () => {
178
+ await writeFile(join(repo, ".mcp.json"), "{}");
179
+ await writeFile(join(repo, ".gitignore"), ".mcp.json\nnode_modules/\n");
180
+
181
+ const r = await applyDestructiveFix(repo, {
182
+ kind: "ignored-but-should-track",
183
+ path: ".mcp.json",
184
+ source: "builtin",
185
+ severity: "destructive",
186
+ });
187
+ expect(r.applied).toBe(true);
188
+
189
+ const ignore = await readFile(join(repo, ".gitignore"), "utf8");
190
+ expect(ignore).not.toMatch(/^\.mcp\.json$/m);
191
+ expect(ignore).toContain("node_modules/");
192
+
193
+ const ls = git(repo, "ls-files");
194
+ // staged but not yet committed; ls-files only shows index
195
+ expect(ls.stdout).toContain(".mcp.json");
196
+ });
197
+
198
+ it("adds unignore exceptions when a parent rule ignores a tracked path", async () => {
199
+ await mkdir(join(repo, ".claude"), { recursive: true });
200
+ await writeFile(join(repo, ".claude", "settings.json"), "{}");
201
+ await writeFile(join(repo, ".gitignore"), "/*\n");
202
+
203
+ const r = await applyDestructiveFix(repo, {
204
+ kind: "ignored-but-should-track",
205
+ path: ".claude/settings.json",
206
+ source: "builtin",
207
+ severity: "destructive",
208
+ });
209
+ expect(r.applied).toBe(true);
210
+
211
+ const ignore = await readFile(join(repo, ".gitignore"), "utf8");
212
+ expect(ignore).toContain("!/.claude/");
213
+ expect(ignore).toContain("!/.claude/settings.json");
214
+ expect(ignore).toContain("Team Claude settings should be committed");
215
+
216
+ const ls = git(repo, "ls-files");
217
+ expect(ls.stdout).toContain(".claude/settings.json");
218
+ });
219
+
220
+ it("does not fail when making an absent tracked path trackable", async () => {
221
+ await writeFile(join(repo, ".gitignore"), "/*\n");
222
+
223
+ const r = await applyDestructiveFix(repo, {
224
+ kind: "ignored-but-should-track",
225
+ path: "plugin.json",
226
+ source: "builtin",
227
+ severity: "destructive",
228
+ });
229
+ expect(r.applied).toBe(true);
230
+ expect(r.message).toContain("does not exist yet");
231
+
232
+ const ignore = await readFile(join(repo, ".gitignore"), "utf8");
233
+ expect(ignore).toContain("!/plugin.json");
234
+ });
235
+
236
+ it("uses the managed rule pattern when untracking child violations", async () => {
237
+ await mkdir(join(repo, "logs"), { recursive: true });
238
+ await writeFile(join(repo, "logs", "app.log"), "x");
239
+ git(repo, "add", "logs/app.log");
240
+ git(repo, "commit", "-q", "-m", "add log");
241
+
242
+ const r = await applyDestructiveFix(
243
+ repo,
244
+ {
245
+ kind: "tracked-but-should-ignore",
246
+ path: "logs/app.log",
247
+ source: "builtin",
248
+ severity: "destructive",
249
+ },
250
+ "logs/",
251
+ );
252
+ expect(r.applied).toBe(true);
253
+
254
+ const ignore = await readFile(join(repo, ".gitignore"), "utf8");
255
+ expect(ignore).toContain("logs/");
256
+ expect(ignore).not.toMatch(/^logs\/app\.log$/m);
257
+ });
258
+
259
+ it("stages an untracked file", async () => {
260
+ await writeFile(join(repo, ".mcp.json"), "{}");
261
+ const r = await applyDestructiveFix(repo, {
262
+ kind: "untracked-and-should-track",
263
+ path: ".mcp.json",
264
+ source: "builtin",
265
+ severity: "destructive",
266
+ });
267
+ expect(r.applied).toBe(true);
268
+ expect(git(repo, "ls-files").stdout).toContain(".mcp.json");
269
+ });
247
270
  });
248
271
 
249
272
  describe("formatViolationSummary", () => {
250
- it("returns friendly message when empty", () => {
251
- expect(formatViolationSummary([])).toMatch(/No gitignore issues/);
252
- });
253
-
254
- it("groups by kind with counts and sample paths", () => {
255
- const summary = formatViolationSummary([
256
- { kind: "missing-from-gitignore", path: "a", source: "builtin", severity: "safe" },
257
- { kind: "missing-from-gitignore", path: "b", source: "builtin", severity: "safe" },
258
- { kind: "tracked-but-should-ignore", path: "c", source: "project", severity: "destructive" },
259
- ]);
260
- expect(summary).toContain("missing from .gitignore: 2");
261
- expect(summary).toContain("tracked but should be ignored: 1");
262
- expect(summary).toContain("- a");
263
- expect(summary).toContain("- c");
264
- });
265
-
266
- it("truncates long lists with `… and N more`", () => {
267
- const vs: Violation[] = [];
268
- for (let i = 0; i < 8; i++) {
269
- vs.push({
270
- kind: "missing-from-gitignore",
271
- path: `p${i}`,
272
- source: "builtin",
273
- severity: "safe",
274
- });
275
- }
276
- const summary = formatViolationSummary(vs);
277
- expect(summary).toContain("and 3 more");
278
- });
273
+ it("returns friendly message when empty", () => {
274
+ expect(formatViolationSummary([])).toMatch(/No gitignore issues/);
275
+ });
276
+
277
+ it("groups by kind with counts and sample paths", () => {
278
+ const summary = formatViolationSummary([
279
+ {
280
+ kind: "missing-from-gitignore",
281
+ path: "a",
282
+ source: "builtin",
283
+ severity: "safe",
284
+ },
285
+ {
286
+ kind: "missing-from-gitignore",
287
+ path: "b",
288
+ source: "builtin",
289
+ severity: "safe",
290
+ },
291
+ {
292
+ kind: "tracked-but-should-ignore",
293
+ path: "c",
294
+ source: "project",
295
+ severity: "destructive",
296
+ },
297
+ ]);
298
+ expect(summary).toContain("missing from .gitignore: 2");
299
+ expect(summary).toContain("tracked but should be ignored: 1");
300
+ expect(summary).toContain("- a");
301
+ expect(summary).toContain("- c");
302
+ });
303
+
304
+ it("truncates long lists with `… and N more`", () => {
305
+ const vs: Violation[] = [];
306
+ for (let i = 0; i < 8; i++) {
307
+ vs.push({
308
+ kind: "missing-from-gitignore",
309
+ path: `p${i}`,
310
+ source: "builtin",
311
+ severity: "safe",
312
+ });
313
+ }
314
+ const summary = formatViolationSummary(vs);
315
+ expect(summary).toContain("and 3 more");
316
+ });
279
317
  });