claudeup 4.15.1 → 4.17.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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/gitignore-detector.test.ts +156 -0
  3. package/src/__tests__/gitignore-fixer.test.ts +197 -0
  4. package/src/__tests__/gitignore-prerun.test.ts +75 -0
  5. package/src/__tests__/gitignore-resolver.test.ts +166 -0
  6. package/src/__tests__/gitignore-service.test.ts +93 -0
  7. package/src/__tests__/useGitignoreModal.test.ts +71 -0
  8. package/src/data/gitignore-defaults.js +24 -0
  9. package/src/data/gitignore-defaults.ts +26 -0
  10. package/src/data/gitignore-templates.js +21 -0
  11. package/src/data/gitignore-templates.ts +25 -0
  12. package/src/data/marketplaces.js +17 -1
  13. package/src/data/marketplaces.ts +16 -1
  14. package/src/prerunner/index.js +17 -0
  15. package/src/prerunner/index.ts +20 -0
  16. package/src/services/gitignore-detector.js +155 -0
  17. package/src/services/gitignore-detector.ts +157 -0
  18. package/src/services/gitignore-fixer.js +171 -0
  19. package/src/services/gitignore-fixer.ts +198 -0
  20. package/src/services/gitignore-prerun.js +46 -0
  21. package/src/services/gitignore-prerun.ts +59 -0
  22. package/src/services/gitignore-resolver.js +143 -0
  23. package/src/services/gitignore-resolver.ts +190 -0
  24. package/src/services/gitignore-service.js +99 -0
  25. package/src/services/gitignore-service.ts +142 -0
  26. package/src/types/gitignore.js +6 -0
  27. package/src/types/gitignore.ts +68 -0
  28. package/src/types/index.ts +1 -0
  29. package/src/ui/App.js +47 -3
  30. package/src/ui/App.tsx +51 -2
  31. package/src/ui/components/TabBar.js +1 -0
  32. package/src/ui/components/TabBar.tsx +1 -0
  33. package/src/ui/hooks/useGitignoreModal.js +75 -0
  34. package/src/ui/hooks/useGitignoreModal.ts +97 -0
  35. package/src/ui/renderers/gitignoreRenderers.js +33 -0
  36. package/src/ui/renderers/gitignoreRenderers.tsx +70 -0
  37. package/src/ui/screens/GitignoreScreen.js +227 -0
  38. package/src/ui/screens/GitignoreScreen.tsx +364 -0
  39. package/src/ui/screens/index.js +1 -0
  40. package/src/ui/screens/index.ts +1 -0
  41. package/src/ui/state/types.ts +4 -2
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Three-tier gitignore manifest resolver.
3
+ *
4
+ * Precedence (lowest → highest, last-wins):
5
+ * 1. Built-in defaults (shipped with claudeup)
6
+ * 2. Global manifest at ~/.claude/gitignore.json (team-level, manually shared)
7
+ * 3. Project manifest at <cwd>/.claude/gitignore.json
8
+ *
9
+ * `extends` references are resolved against BUILTIN_TEMPLATES. Within a single
10
+ * tier, the same pattern cannot appear in both `ignore` and `track` — that
11
+ * throws. Across tiers, the higher-precedence tier wins; losers are recorded
12
+ * in `conflicts` so the TUI can show "global says ignore .env, project
13
+ * overrides to track."
14
+ */
15
+ import { readFile } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import { existsSync } from "node:fs";
19
+ import { BUILTIN_DEFAULTS } from "../data/gitignore-defaults.js";
20
+ import { BUILTIN_TEMPLATES } from "../data/gitignore-templates.js";
21
+ const TIER_ORDER = ["builtin", "global", "project"];
22
+ export async function resolveGitignore(projectCwd, opts = {}) {
23
+ const home = opts.homeDir ?? homedir();
24
+ const log = opts.logger ?? (() => { });
25
+ const tierManifests = new Map();
26
+ // 1. Built-in (always present)
27
+ tierManifests.set("builtin", BUILTIN_DEFAULTS);
28
+ // 2. Global ~/.claude/gitignore.json
29
+ const globalPath = join(home, ".claude", "gitignore.json");
30
+ const globalManifest = await readManifest(globalPath, log);
31
+ if (globalManifest)
32
+ tierManifests.set("global", globalManifest);
33
+ // 3. Project .claude/gitignore.json
34
+ const projectPath = join(projectCwd, ".claude", "gitignore.json");
35
+ const projectManifest = await readManifest(projectPath, log);
36
+ if (projectManifest)
37
+ tierManifests.set("project", projectManifest);
38
+ // Apply `extends` for each tier (templates count as part of their tier)
39
+ for (const tier of TIER_ORDER) {
40
+ const m = tierManifests.get(tier);
41
+ if (!m?.extends?.length)
42
+ continue;
43
+ const merged = {
44
+ ignore: [...m.ignore],
45
+ track: [...m.track],
46
+ };
47
+ for (const tplName of m.extends) {
48
+ const tpl = BUILTIN_TEMPLATES[tplName];
49
+ if (!tpl) {
50
+ log(`[gitignore] unknown template "${tplName}" in ${tier} manifest`);
51
+ continue;
52
+ }
53
+ merged.ignore.push(...tpl.ignore);
54
+ merged.track.push(...tpl.track);
55
+ }
56
+ tierManifests.set(tier, merged);
57
+ }
58
+ // Validate intra-tier consistency
59
+ for (const [tier, m] of tierManifests.entries()) {
60
+ const ig = new Set(m.ignore);
61
+ for (const t of m.track) {
62
+ if (ig.has(t)) {
63
+ throw new Error(`Intra-tier conflict in ${tier} manifest: "${t}" is in both ignore and track`);
64
+ }
65
+ }
66
+ }
67
+ // Merge across tiers — last-wins, track conflicts
68
+ const ruleByPattern = new Map();
69
+ for (const tier of TIER_ORDER) {
70
+ const m = tierManifests.get(tier);
71
+ if (!m)
72
+ continue;
73
+ for (const p of dedupe(m.ignore)) {
74
+ pushRule(ruleByPattern, p, "ignore", tier);
75
+ }
76
+ for (const p of dedupe(m.track)) {
77
+ pushRule(ruleByPattern, p, "track", tier);
78
+ }
79
+ }
80
+ const rules = [];
81
+ const conflicts = [];
82
+ for (const [pattern, entry] of ruleByPattern.entries()) {
83
+ rules.push({ pattern, action: entry.action, source: entry.source });
84
+ const losers = entry.history.filter((h) => h.tier !== entry.source || h.action !== entry.action);
85
+ if (losers.length > 0 && hasActionDisagreement(entry.history)) {
86
+ conflicts.push({
87
+ pattern,
88
+ winner: entry.source,
89
+ losers: losers.filter((l) => l.action !== entry.action),
90
+ });
91
+ }
92
+ }
93
+ return { rules, conflicts };
94
+ }
95
+ function pushRule(map, pattern, action, tier) {
96
+ const existing = map.get(pattern);
97
+ if (existing) {
98
+ existing.history.push({ tier, action });
99
+ existing.action = action;
100
+ existing.source = tier;
101
+ }
102
+ else {
103
+ map.set(pattern, { action, source: tier, history: [{ tier, action }] });
104
+ }
105
+ }
106
+ function hasActionDisagreement(history) {
107
+ const actions = new Set(history.map((h) => h.action));
108
+ return actions.size > 1;
109
+ }
110
+ function dedupe(xs) {
111
+ return Array.from(new Set(xs));
112
+ }
113
+ async function readManifest(path, log) {
114
+ if (!existsSync(path))
115
+ return null;
116
+ try {
117
+ const raw = await readFile(path, "utf8");
118
+ const parsed = JSON.parse(raw);
119
+ if (!isManifest(parsed)) {
120
+ log(`[gitignore] ${path} is not a valid manifest (expected { ignore: string[], track: string[] })`);
121
+ return null;
122
+ }
123
+ return parsed;
124
+ }
125
+ catch (err) {
126
+ log(`[gitignore] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
127
+ return null;
128
+ }
129
+ }
130
+ function isManifest(x) {
131
+ if (!x || typeof x !== "object")
132
+ return false;
133
+ const m = x;
134
+ if (!Array.isArray(m.ignore) || !m.ignore.every((p) => typeof p === "string"))
135
+ return false;
136
+ if (!Array.isArray(m.track) || !m.track.every((p) => typeof p === "string"))
137
+ return false;
138
+ if (m.extends !== undefined) {
139
+ if (!Array.isArray(m.extends) || !m.extends.every((p) => typeof p === "string"))
140
+ return false;
141
+ }
142
+ return true;
143
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Three-tier gitignore manifest resolver.
3
+ *
4
+ * Precedence (lowest → highest, last-wins):
5
+ * 1. Built-in defaults (shipped with claudeup)
6
+ * 2. Global manifest at ~/.claude/gitignore.json (team-level, manually shared)
7
+ * 3. Project manifest at <cwd>/.claude/gitignore.json
8
+ *
9
+ * `extends` references are resolved against BUILTIN_TEMPLATES. Within a single
10
+ * tier, the same pattern cannot appear in both `ignore` and `track` — that
11
+ * throws. Across tiers, the higher-precedence tier wins; losers are recorded
12
+ * in `conflicts` so the TUI can show "global says ignore .env, project
13
+ * overrides to track."
14
+ */
15
+
16
+ import { readFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { existsSync } from "node:fs";
20
+ import type {
21
+ GitignoreManifest,
22
+ ResolvedManifest,
23
+ ResolvedRule,
24
+ ResolvedConflict,
25
+ Tier,
26
+ RuleAction,
27
+ } from "../types/gitignore.js";
28
+ import { BUILTIN_DEFAULTS } from "../data/gitignore-defaults.js";
29
+ import { BUILTIN_TEMPLATES } from "../data/gitignore-templates.js";
30
+
31
+ export interface ResolveOptions {
32
+ /** Override $HOME (test seam). */
33
+ homeDir?: string;
34
+ /** Optional logger for non-fatal warnings (malformed JSON, missing template). */
35
+ logger?: (msg: string) => void;
36
+ }
37
+
38
+ const TIER_ORDER: Tier[] = ["builtin", "global", "project"];
39
+
40
+ export async function resolveGitignore(
41
+ projectCwd: string,
42
+ opts: ResolveOptions = {},
43
+ ): Promise<ResolvedManifest> {
44
+ const home = opts.homeDir ?? homedir();
45
+ const log = opts.logger ?? (() => {});
46
+
47
+ const tierManifests = new Map<Tier, GitignoreManifest>();
48
+
49
+ // 1. Built-in (always present)
50
+ tierManifests.set("builtin", BUILTIN_DEFAULTS);
51
+
52
+ // 2. Global ~/.claude/gitignore.json
53
+ const globalPath = join(home, ".claude", "gitignore.json");
54
+ const globalManifest = await readManifest(globalPath, log);
55
+ if (globalManifest) tierManifests.set("global", globalManifest);
56
+
57
+ // 3. Project .claude/gitignore.json
58
+ const projectPath = join(projectCwd, ".claude", "gitignore.json");
59
+ const projectManifest = await readManifest(projectPath, log);
60
+ if (projectManifest) tierManifests.set("project", projectManifest);
61
+
62
+ // Apply `extends` for each tier (templates count as part of their tier)
63
+ for (const tier of TIER_ORDER) {
64
+ const m = tierManifests.get(tier);
65
+ if (!m?.extends?.length) continue;
66
+ const merged: GitignoreManifest = {
67
+ ignore: [...m.ignore],
68
+ track: [...m.track],
69
+ };
70
+ for (const tplName of m.extends) {
71
+ const tpl = BUILTIN_TEMPLATES[tplName];
72
+ if (!tpl) {
73
+ log(`[gitignore] unknown template "${tplName}" in ${tier} manifest`);
74
+ continue;
75
+ }
76
+ merged.ignore.push(...tpl.ignore);
77
+ merged.track.push(...tpl.track);
78
+ }
79
+ tierManifests.set(tier, merged);
80
+ }
81
+
82
+ // Validate intra-tier consistency
83
+ for (const [tier, m] of tierManifests.entries()) {
84
+ const ig = new Set(m.ignore);
85
+ for (const t of m.track) {
86
+ if (ig.has(t)) {
87
+ throw new Error(
88
+ `Intra-tier conflict in ${tier} manifest: "${t}" is in both ignore and track`,
89
+ );
90
+ }
91
+ }
92
+ }
93
+
94
+ // Merge across tiers — last-wins, track conflicts
95
+ const ruleByPattern = new Map<
96
+ string,
97
+ { action: RuleAction; source: Tier; history: Array<{ tier: Tier; action: RuleAction }> }
98
+ >();
99
+
100
+ for (const tier of TIER_ORDER) {
101
+ const m = tierManifests.get(tier);
102
+ if (!m) continue;
103
+ for (const p of dedupe(m.ignore)) {
104
+ pushRule(ruleByPattern, p, "ignore", tier);
105
+ }
106
+ for (const p of dedupe(m.track)) {
107
+ pushRule(ruleByPattern, p, "track", tier);
108
+ }
109
+ }
110
+
111
+ const rules: ResolvedRule[] = [];
112
+ const conflicts: ResolvedConflict[] = [];
113
+
114
+ for (const [pattern, entry] of ruleByPattern.entries()) {
115
+ rules.push({ pattern, action: entry.action, source: entry.source });
116
+
117
+ const losers = entry.history.filter(
118
+ (h) => h.tier !== entry.source || h.action !== entry.action,
119
+ );
120
+ if (losers.length > 0 && hasActionDisagreement(entry.history)) {
121
+ conflicts.push({
122
+ pattern,
123
+ winner: entry.source,
124
+ losers: losers.filter((l) => l.action !== entry.action),
125
+ });
126
+ }
127
+ }
128
+
129
+ return { rules, conflicts };
130
+ }
131
+
132
+ function pushRule(
133
+ map: Map<
134
+ string,
135
+ { action: RuleAction; source: Tier; history: Array<{ tier: Tier; action: RuleAction }> }
136
+ >,
137
+ pattern: string,
138
+ action: RuleAction,
139
+ tier: Tier,
140
+ ): void {
141
+ const existing = map.get(pattern);
142
+ if (existing) {
143
+ existing.history.push({ tier, action });
144
+ existing.action = action;
145
+ existing.source = tier;
146
+ } else {
147
+ map.set(pattern, { action, source: tier, history: [{ tier, action }] });
148
+ }
149
+ }
150
+
151
+ function hasActionDisagreement(
152
+ history: Array<{ tier: Tier; action: RuleAction }>,
153
+ ): boolean {
154
+ const actions = new Set(history.map((h) => h.action));
155
+ return actions.size > 1;
156
+ }
157
+
158
+ function dedupe(xs: string[]): string[] {
159
+ return Array.from(new Set(xs));
160
+ }
161
+
162
+ async function readManifest(
163
+ path: string,
164
+ log: (msg: string) => void,
165
+ ): Promise<GitignoreManifest | null> {
166
+ if (!existsSync(path)) return null;
167
+ try {
168
+ const raw = await readFile(path, "utf8");
169
+ const parsed = JSON.parse(raw);
170
+ if (!isManifest(parsed)) {
171
+ log(`[gitignore] ${path} is not a valid manifest (expected { ignore: string[], track: string[] })`);
172
+ return null;
173
+ }
174
+ return parsed;
175
+ } catch (err) {
176
+ log(`[gitignore] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
177
+ return null;
178
+ }
179
+ }
180
+
181
+ function isManifest(x: unknown): x is GitignoreManifest {
182
+ if (!x || typeof x !== "object") return false;
183
+ const m = x as Record<string, unknown>;
184
+ if (!Array.isArray(m.ignore) || !m.ignore.every((p) => typeof p === "string")) return false;
185
+ if (!Array.isArray(m.track) || !m.track.every((p) => typeof p === "string")) return false;
186
+ if (m.extends !== undefined) {
187
+ if (!Array.isArray(m.extends) || !m.extends.every((p) => typeof p === "string")) return false;
188
+ }
189
+ return true;
190
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * TUI-facing wrapper around the gitignore core services. Loads everything
3
+ * the Gitignore screen needs in one call, and exposes a small set of
4
+ * action helpers (apply safe fixes, apply one destructive fix, apply a
5
+ * built-in template).
6
+ */
7
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
8
+ import { existsSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { resolveGitignore } from "./gitignore-resolver.js";
11
+ import { detectViolations } from "./gitignore-detector.js";
12
+ import { applySafeFixes as coreApplySafeFixes, applyDestructiveFix as coreApplyDestructiveFix, } from "./gitignore-fixer.js";
13
+ import { BUILTIN_TEMPLATES } from "../data/gitignore-templates.js";
14
+ export async function loadGitignoreState(cwd) {
15
+ const warnings = [];
16
+ const resolved = await resolveGitignore(cwd, {
17
+ logger: (m) => warnings.push(m),
18
+ });
19
+ const violations = await detectViolations(cwd, resolved);
20
+ return { resolved, violations, warnings };
21
+ }
22
+ export async function applyAllSafeFixes(cwd, violations) {
23
+ return coreApplySafeFixes(cwd, violations);
24
+ }
25
+ export async function applyDestructiveFix(cwd, violation) {
26
+ return coreApplyDestructiveFix(cwd, violation);
27
+ }
28
+ /**
29
+ * Merge a built-in template into the project manifest at .claude/gitignore.json.
30
+ * Conflicts (a pattern that already exists with the opposite action) are
31
+ * skipped and reported back to the caller.
32
+ */
33
+ export async function applyTemplate(cwd, templateName) {
34
+ const tpl = BUILTIN_TEMPLATES[templateName];
35
+ if (!tpl)
36
+ throw new Error(`Unknown template: ${templateName}`);
37
+ const projectManifestPath = join(cwd, ".claude", "gitignore.json");
38
+ const existing = await readManifestOrEmpty(projectManifestPath);
39
+ const existingIgnore = new Set(existing.ignore);
40
+ const existingTrack = new Set(existing.track);
41
+ const conflicts = [];
42
+ const newIgnore = [];
43
+ const newTrack = [];
44
+ for (const p of tpl.ignore) {
45
+ if (existingTrack.has(p)) {
46
+ conflicts.push(p);
47
+ continue;
48
+ }
49
+ if (!existingIgnore.has(p)) {
50
+ newIgnore.push(p);
51
+ existingIgnore.add(p);
52
+ }
53
+ }
54
+ for (const p of tpl.track) {
55
+ if (existingIgnore.has(p)) {
56
+ conflicts.push(p);
57
+ continue;
58
+ }
59
+ if (!existingTrack.has(p)) {
60
+ newTrack.push(p);
61
+ existingTrack.add(p);
62
+ }
63
+ }
64
+ const merged = {
65
+ ignore: [...existing.ignore, ...newIgnore],
66
+ track: [...existing.track, ...newTrack],
67
+ extends: existing.extends,
68
+ };
69
+ await mkdir(dirname(projectManifestPath), { recursive: true });
70
+ await writeFile(projectManifestPath, JSON.stringify(merged, null, 2) + "\n");
71
+ return {
72
+ appliedTo: projectManifestPath,
73
+ added: { ignore: newIgnore, track: newTrack },
74
+ conflicts,
75
+ };
76
+ }
77
+ async function readManifestOrEmpty(path) {
78
+ if (!existsSync(path))
79
+ return { ignore: [], track: [] };
80
+ try {
81
+ const raw = await readFile(path, "utf8");
82
+ const parsed = JSON.parse(raw);
83
+ if (!isManifest(parsed))
84
+ return { ignore: [], track: [] };
85
+ return parsed;
86
+ }
87
+ catch {
88
+ return { ignore: [], track: [] };
89
+ }
90
+ }
91
+ function isManifest(x) {
92
+ if (!x || typeof x !== "object")
93
+ return false;
94
+ const m = x;
95
+ return (Array.isArray(m.ignore) &&
96
+ m.ignore.every((p) => typeof p === "string") &&
97
+ Array.isArray(m.track) &&
98
+ m.track.every((p) => typeof p === "string"));
99
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * TUI-facing wrapper around the gitignore core services. Loads everything
3
+ * the Gitignore screen needs in one call, and exposes a small set of
4
+ * action helpers (apply safe fixes, apply one destructive fix, apply a
5
+ * built-in template).
6
+ */
7
+
8
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
9
+ import { existsSync } from "node:fs";
10
+ import { join, dirname } from "node:path";
11
+ import { resolveGitignore } from "./gitignore-resolver.js";
12
+ import { detectViolations } from "./gitignore-detector.js";
13
+ import {
14
+ applySafeFixes as coreApplySafeFixes,
15
+ applyDestructiveFix as coreApplyDestructiveFix,
16
+ type SafeFixResult,
17
+ type DestructiveFixResult,
18
+ } from "./gitignore-fixer.js";
19
+ import { BUILTIN_TEMPLATES } from "../data/gitignore-templates.js";
20
+ import type {
21
+ GitignoreManifest,
22
+ ResolvedManifest,
23
+ Violation,
24
+ } from "../types/gitignore.js";
25
+
26
+ export interface GitignoreState {
27
+ resolved: ResolvedManifest;
28
+ violations: Violation[];
29
+ /** Logger lines from resolution (malformed JSON, missing template, etc). */
30
+ warnings: string[];
31
+ }
32
+
33
+ export async function loadGitignoreState(cwd: string): Promise<GitignoreState> {
34
+ const warnings: string[] = [];
35
+ const resolved = await resolveGitignore(cwd, {
36
+ logger: (m) => warnings.push(m),
37
+ });
38
+ const violations = await detectViolations(cwd, resolved);
39
+ return { resolved, violations, warnings };
40
+ }
41
+
42
+ export async function applyAllSafeFixes(
43
+ cwd: string,
44
+ violations: Violation[],
45
+ ): Promise<SafeFixResult> {
46
+ return coreApplySafeFixes(cwd, violations);
47
+ }
48
+
49
+ export async function applyDestructiveFix(
50
+ cwd: string,
51
+ violation: Violation,
52
+ ): Promise<DestructiveFixResult> {
53
+ return coreApplyDestructiveFix(cwd, violation);
54
+ }
55
+
56
+ export interface ApplyTemplateResult {
57
+ appliedTo: string;
58
+ added: { ignore: string[]; track: string[] };
59
+ conflicts: string[];
60
+ }
61
+
62
+ /**
63
+ * Merge a built-in template into the project manifest at .claude/gitignore.json.
64
+ * Conflicts (a pattern that already exists with the opposite action) are
65
+ * skipped and reported back to the caller.
66
+ */
67
+ export async function applyTemplate(
68
+ cwd: string,
69
+ templateName: string,
70
+ ): Promise<ApplyTemplateResult> {
71
+ const tpl = BUILTIN_TEMPLATES[templateName];
72
+ if (!tpl) throw new Error(`Unknown template: ${templateName}`);
73
+
74
+ const projectManifestPath = join(cwd, ".claude", "gitignore.json");
75
+ const existing = await readManifestOrEmpty(projectManifestPath);
76
+
77
+ const existingIgnore = new Set(existing.ignore);
78
+ const existingTrack = new Set(existing.track);
79
+
80
+ const conflicts: string[] = [];
81
+ const newIgnore: string[] = [];
82
+ const newTrack: string[] = [];
83
+
84
+ for (const p of tpl.ignore) {
85
+ if (existingTrack.has(p)) {
86
+ conflicts.push(p);
87
+ continue;
88
+ }
89
+ if (!existingIgnore.has(p)) {
90
+ newIgnore.push(p);
91
+ existingIgnore.add(p);
92
+ }
93
+ }
94
+ for (const p of tpl.track) {
95
+ if (existingIgnore.has(p)) {
96
+ conflicts.push(p);
97
+ continue;
98
+ }
99
+ if (!existingTrack.has(p)) {
100
+ newTrack.push(p);
101
+ existingTrack.add(p);
102
+ }
103
+ }
104
+
105
+ const merged: GitignoreManifest = {
106
+ ignore: [...existing.ignore, ...newIgnore],
107
+ track: [...existing.track, ...newTrack],
108
+ extends: existing.extends,
109
+ };
110
+
111
+ await mkdir(dirname(projectManifestPath), { recursive: true });
112
+ await writeFile(projectManifestPath, JSON.stringify(merged, null, 2) + "\n");
113
+
114
+ return {
115
+ appliedTo: projectManifestPath,
116
+ added: { ignore: newIgnore, track: newTrack },
117
+ conflicts,
118
+ };
119
+ }
120
+
121
+ async function readManifestOrEmpty(path: string): Promise<GitignoreManifest> {
122
+ if (!existsSync(path)) return { ignore: [], track: [] };
123
+ try {
124
+ const raw = await readFile(path, "utf8");
125
+ const parsed = JSON.parse(raw);
126
+ if (!isManifest(parsed)) return { ignore: [], track: [] };
127
+ return parsed;
128
+ } catch {
129
+ return { ignore: [], track: [] };
130
+ }
131
+ }
132
+
133
+ function isManifest(x: unknown): x is GitignoreManifest {
134
+ if (!x || typeof x !== "object") return false;
135
+ const m = x as Record<string, unknown>;
136
+ return (
137
+ Array.isArray(m.ignore) &&
138
+ m.ignore.every((p) => typeof p === "string") &&
139
+ Array.isArray(m.track) &&
140
+ m.track.every((p) => typeof p === "string")
141
+ );
142
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Types for the gitignore manager: a three-tier (built-in → global → project)
3
+ * manifest system that declares which paths must be ignored and which must
4
+ * stay tracked. Last-wins on conflict across tiers; intra-tier conflicts throw.
5
+ */
6
+ export {};
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Types for the gitignore manager: a three-tier (built-in → global → project)
3
+ * manifest system that declares which paths must be ignored and which must
4
+ * stay tracked. Last-wins on conflict across tiers; intra-tier conflicts throw.
5
+ */
6
+
7
+ export type RuleAction = "ignore" | "track";
8
+
9
+ export type Tier = "builtin" | "global" | "project";
10
+
11
+ /** A user-authored manifest, deserialized from JSON. */
12
+ export interface GitignoreManifest {
13
+ /** Paths or globs that must be excluded from git. */
14
+ ignore: string[];
15
+ /** Paths that must remain tracked even if a parent is in ignore. */
16
+ track: string[];
17
+ /** Optional template names to merge in (e.g. "common-dev"). */
18
+ extends?: string[];
19
+ }
20
+
21
+ /** A single rule after tier resolution. */
22
+ export interface ResolvedRule {
23
+ pattern: string;
24
+ action: RuleAction;
25
+ source: Tier;
26
+ }
27
+
28
+ /** When two tiers disagree on a pattern, the higher-precedence tier wins. */
29
+ export interface ResolvedConflict {
30
+ pattern: string;
31
+ winner: Tier;
32
+ losers: Array<{ tier: Tier; action: RuleAction }>;
33
+ }
34
+
35
+ export interface ResolvedManifest {
36
+ rules: ResolvedRule[];
37
+ conflicts: ResolvedConflict[];
38
+ }
39
+
40
+ /**
41
+ * Categorized violation state. The detector inspects the working tree against
42
+ * the resolved manifest and emits one of these per offending path.
43
+ *
44
+ * - tracked-but-should-ignore: path is in git index, manifest says ignore
45
+ * - ignored-but-should-track: path matches .gitignore, manifest says track
46
+ * - untracked-and-should-track: path exists but neither tracked nor ignored;
47
+ * manifest says it should be tracked
48
+ * - missing-from-gitignore: path is in manifest.ignore but no .gitignore line
49
+ * covers it (and the path isn't currently tracked)
50
+ */
51
+ export type ViolationKind =
52
+ | "tracked-but-should-ignore"
53
+ | "ignored-but-should-track"
54
+ | "untracked-and-should-track"
55
+ | "missing-from-gitignore";
56
+
57
+ export interface Violation {
58
+ kind: ViolationKind;
59
+ path: string;
60
+ /** Which tier asserted the rule that's being violated. */
61
+ source: Tier;
62
+ /**
63
+ * "safe" — fix is purely additive (append a .gitignore line)
64
+ * "destructive" — fix mutates git index (`git rm --cached`, `git add`,
65
+ * removing existing .gitignore lines)
66
+ */
67
+ severity: "safe" | "destructive";
68
+ }
@@ -41,6 +41,7 @@ export interface Marketplace {
41
41
  description: string;
42
42
  official?: boolean;
43
43
  featured?: boolean; // Featured marketplaces have plugins fetched by default (like official)
44
+ deprecated?: boolean; // Deprecated marketplaces are sorted to the bottom of the list
44
45
  }
45
46
 
46
47
  export interface DiscoveredMarketplace {