claudeup 4.15.1 → 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.
Files changed (38) 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/prerunner/index.js +17 -0
  13. package/src/prerunner/index.ts +20 -0
  14. package/src/services/gitignore-detector.js +155 -0
  15. package/src/services/gitignore-detector.ts +157 -0
  16. package/src/services/gitignore-fixer.js +171 -0
  17. package/src/services/gitignore-fixer.ts +198 -0
  18. package/src/services/gitignore-prerun.js +46 -0
  19. package/src/services/gitignore-prerun.ts +59 -0
  20. package/src/services/gitignore-resolver.js +143 -0
  21. package/src/services/gitignore-resolver.ts +190 -0
  22. package/src/services/gitignore-service.js +99 -0
  23. package/src/services/gitignore-service.ts +142 -0
  24. package/src/types/gitignore.js +6 -0
  25. package/src/types/gitignore.ts +68 -0
  26. package/src/ui/App.js +47 -3
  27. package/src/ui/App.tsx +51 -2
  28. package/src/ui/components/TabBar.js +1 -0
  29. package/src/ui/components/TabBar.tsx +1 -0
  30. package/src/ui/hooks/useGitignoreModal.js +75 -0
  31. package/src/ui/hooks/useGitignoreModal.ts +97 -0
  32. package/src/ui/renderers/gitignoreRenderers.js +33 -0
  33. package/src/ui/renderers/gitignoreRenderers.tsx +70 -0
  34. package/src/ui/screens/GitignoreScreen.js +227 -0
  35. package/src/ui/screens/GitignoreScreen.tsx +364 -0
  36. package/src/ui/screens/index.js +1 -0
  37. package/src/ui/screens/index.ts +1 -0
  38. package/src/ui/state/types.ts +4 -2
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Apply fixes for gitignore violations. Two flavors:
3
+ *
4
+ * - `applySafeFixes`: additive — appends missing patterns to .gitignore
5
+ * under a "# Added by claudeup" header. Idempotent (re-running is a no-op
6
+ * because patterns are already present).
7
+ *
8
+ * - `applyDestructiveFix`: index-mutating — runs `git rm --cached <path>`
9
+ * for tracked-but-should-ignore, `git add <path>` (after stripping
10
+ * gitignore lines) for ignored-but-should-track / untracked-and-should-track.
11
+ * Each call handles ONE violation; the caller is responsible for confirming
12
+ * each one with the user.
13
+ *
14
+ * `formatViolationSummary` is a pure helper for prerun warning + TUI.
15
+ */
16
+ import { readFile, writeFile, appendFile } from "node:fs/promises";
17
+ import { existsSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { spawn } from "node:child_process";
20
+ const CLAUDEUP_HEADER = "# Added by claudeup";
21
+ export async function applySafeFixes(cwd, violations) {
22
+ const safe = violations.filter((v) => v.severity === "safe" && v.kind === "missing-from-gitignore");
23
+ if (safe.length === 0) {
24
+ return { appended: [], alreadyPresent: [] };
25
+ }
26
+ const gitignorePath = join(cwd, ".gitignore");
27
+ const existing = existsSync(gitignorePath)
28
+ ? await readFile(gitignorePath, "utf8")
29
+ : "";
30
+ const lines = new Set(existing.split("\n").map((l) => l.trim()).filter(Boolean));
31
+ const appended = [];
32
+ const alreadyPresent = [];
33
+ for (const v of safe) {
34
+ if (lines.has(v.path)) {
35
+ alreadyPresent.push(v.path);
36
+ }
37
+ else {
38
+ appended.push(v.path);
39
+ lines.add(v.path);
40
+ }
41
+ }
42
+ if (appended.length === 0)
43
+ return { appended, alreadyPresent };
44
+ const block = [
45
+ "",
46
+ CLAUDEUP_HEADER,
47
+ ...appended,
48
+ "",
49
+ ].join("\n");
50
+ if (existing.length > 0 && !existing.endsWith("\n")) {
51
+ await appendFile(gitignorePath, "\n" + block);
52
+ }
53
+ else {
54
+ await appendFile(gitignorePath, block);
55
+ }
56
+ return { appended, alreadyPresent };
57
+ }
58
+ export async function applyDestructiveFix(cwd, violation) {
59
+ switch (violation.kind) {
60
+ case "tracked-but-should-ignore": {
61
+ await runGit(["rm", "--cached", "--", violation.path], cwd);
62
+ // Then make sure .gitignore has the entry so it stays out
63
+ await appendIfMissing(cwd, violation.path);
64
+ return {
65
+ applied: true,
66
+ message: `Removed ${violation.path} from index and added to .gitignore`,
67
+ };
68
+ }
69
+ case "ignored-but-should-track": {
70
+ await stripGitignoreLine(cwd, violation.path);
71
+ try {
72
+ await runGit(["add", "--", violation.path], cwd);
73
+ }
74
+ catch (err) {
75
+ return {
76
+ applied: false,
77
+ message: `Removed .gitignore line but git add failed: ${err instanceof Error ? err.message : String(err)}`,
78
+ };
79
+ }
80
+ return {
81
+ applied: true,
82
+ message: `Removed .gitignore line and staged ${violation.path}`,
83
+ };
84
+ }
85
+ case "untracked-and-should-track": {
86
+ await runGit(["add", "--", violation.path], cwd);
87
+ return { applied: true, message: `Staged ${violation.path}` };
88
+ }
89
+ case "missing-from-gitignore": {
90
+ // Treat as a safe append for symmetry — caller can route through here too.
91
+ await appendIfMissing(cwd, violation.path);
92
+ return { applied: true, message: `Added ${violation.path} to .gitignore` };
93
+ }
94
+ }
95
+ }
96
+ export function formatViolationSummary(violations) {
97
+ if (violations.length === 0)
98
+ return "No gitignore issues detected.";
99
+ const byKind = new Map();
100
+ for (const v of violations) {
101
+ const arr = byKind.get(v.kind) ?? [];
102
+ arr.push(v);
103
+ byKind.set(v.kind, arr);
104
+ }
105
+ const labels = {
106
+ "tracked-but-should-ignore": "tracked but should be ignored",
107
+ "ignored-but-should-track": "ignored but should be tracked",
108
+ "untracked-and-should-track": "exist but not tracked (should be)",
109
+ "missing-from-gitignore": "missing from .gitignore",
110
+ };
111
+ const lines = [];
112
+ for (const [kind, vs] of byKind.entries()) {
113
+ lines.push(` ${labels[kind]}: ${vs.length}`);
114
+ for (const v of vs.slice(0, 5)) {
115
+ lines.push(` - ${v.path}`);
116
+ }
117
+ if (vs.length > 5)
118
+ lines.push(` … and ${vs.length - 5} more`);
119
+ }
120
+ return lines.join("\n");
121
+ }
122
+ async function appendIfMissing(cwd, pattern) {
123
+ const gitignorePath = join(cwd, ".gitignore");
124
+ const existing = existsSync(gitignorePath)
125
+ ? await readFile(gitignorePath, "utf8")
126
+ : "";
127
+ const lines = existing.split("\n").map((l) => l.trim());
128
+ if (lines.includes(pattern))
129
+ return;
130
+ const block = (existing.length > 0 && !existing.endsWith("\n") ? "\n" : "") +
131
+ `${CLAUDEUP_HEADER}\n${pattern}\n`;
132
+ await appendFile(gitignorePath, block);
133
+ }
134
+ async function stripGitignoreLine(cwd, pattern) {
135
+ const gitignorePath = join(cwd, ".gitignore");
136
+ if (!existsSync(gitignorePath))
137
+ return;
138
+ const content = await readFile(gitignorePath, "utf8");
139
+ const filtered = content
140
+ .split("\n")
141
+ .filter((line) => line.trim() !== pattern)
142
+ .join("\n");
143
+ if (filtered !== content) {
144
+ await writeFile(gitignorePath, filtered);
145
+ }
146
+ }
147
+ class GitError extends Error {
148
+ code;
149
+ stderr;
150
+ constructor(code, stderr) {
151
+ super(`git exited ${code}: ${stderr}`);
152
+ this.code = code;
153
+ this.stderr = stderr;
154
+ }
155
+ }
156
+ function runGit(args, cwd) {
157
+ return new Promise((resolve, reject) => {
158
+ const proc = spawn("git", args, { cwd });
159
+ let stdout = "";
160
+ let stderr = "";
161
+ proc.stdout.on("data", (b) => { stdout += b.toString(); });
162
+ proc.stderr.on("data", (b) => { stderr += b.toString(); });
163
+ proc.on("error", reject);
164
+ proc.on("close", (code) => {
165
+ if (code === 0)
166
+ resolve(stdout);
167
+ else
168
+ reject(new GitError(code ?? -1, stderr));
169
+ });
170
+ });
171
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Apply fixes for gitignore violations. Two flavors:
3
+ *
4
+ * - `applySafeFixes`: additive — appends missing patterns to .gitignore
5
+ * under a "# Added by claudeup" header. Idempotent (re-running is a no-op
6
+ * because patterns are already present).
7
+ *
8
+ * - `applyDestructiveFix`: index-mutating — runs `git rm --cached <path>`
9
+ * for tracked-but-should-ignore, `git add <path>` (after stripping
10
+ * gitignore lines) for ignored-but-should-track / untracked-and-should-track.
11
+ * Each call handles ONE violation; the caller is responsible for confirming
12
+ * each one with the user.
13
+ *
14
+ * `formatViolationSummary` is a pure helper for prerun warning + TUI.
15
+ */
16
+
17
+ import { readFile, writeFile, appendFile } from "node:fs/promises";
18
+ import { existsSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { spawn } from "node:child_process";
21
+ import type { Violation } from "../types/gitignore.js";
22
+
23
+ const CLAUDEUP_HEADER = "# Added by claudeup";
24
+
25
+ export interface SafeFixResult {
26
+ appended: string[];
27
+ alreadyPresent: string[];
28
+ }
29
+
30
+ export async function applySafeFixes(
31
+ cwd: string,
32
+ violations: Violation[],
33
+ ): Promise<SafeFixResult> {
34
+ const safe = violations.filter(
35
+ (v) => v.severity === "safe" && v.kind === "missing-from-gitignore",
36
+ );
37
+ if (safe.length === 0) {
38
+ return { appended: [], alreadyPresent: [] };
39
+ }
40
+
41
+ const gitignorePath = join(cwd, ".gitignore");
42
+ const existing = existsSync(gitignorePath)
43
+ ? await readFile(gitignorePath, "utf8")
44
+ : "";
45
+ const lines = new Set(
46
+ existing.split("\n").map((l) => l.trim()).filter(Boolean),
47
+ );
48
+
49
+ const appended: string[] = [];
50
+ const alreadyPresent: string[] = [];
51
+ for (const v of safe) {
52
+ if (lines.has(v.path)) {
53
+ alreadyPresent.push(v.path);
54
+ } else {
55
+ appended.push(v.path);
56
+ lines.add(v.path);
57
+ }
58
+ }
59
+
60
+ if (appended.length === 0) return { appended, alreadyPresent };
61
+
62
+ const block = [
63
+ "",
64
+ CLAUDEUP_HEADER,
65
+ ...appended,
66
+ "",
67
+ ].join("\n");
68
+
69
+ if (existing.length > 0 && !existing.endsWith("\n")) {
70
+ await appendFile(gitignorePath, "\n" + block);
71
+ } else {
72
+ await appendFile(gitignorePath, block);
73
+ }
74
+
75
+ return { appended, alreadyPresent };
76
+ }
77
+
78
+ export interface DestructiveFixResult {
79
+ applied: boolean;
80
+ message: string;
81
+ }
82
+
83
+ export async function applyDestructiveFix(
84
+ cwd: string,
85
+ violation: Violation,
86
+ ): Promise<DestructiveFixResult> {
87
+ switch (violation.kind) {
88
+ case "tracked-but-should-ignore": {
89
+ await runGit(["rm", "--cached", "--", violation.path], cwd);
90
+ // Then make sure .gitignore has the entry so it stays out
91
+ await appendIfMissing(cwd, violation.path);
92
+ return {
93
+ applied: true,
94
+ message: `Removed ${violation.path} from index and added to .gitignore`,
95
+ };
96
+ }
97
+ case "ignored-but-should-track": {
98
+ await stripGitignoreLine(cwd, violation.path);
99
+ try {
100
+ await runGit(["add", "--", violation.path], cwd);
101
+ } catch (err) {
102
+ return {
103
+ applied: false,
104
+ message: `Removed .gitignore line but git add failed: ${
105
+ err instanceof Error ? err.message : String(err)
106
+ }`,
107
+ };
108
+ }
109
+ return {
110
+ applied: true,
111
+ message: `Removed .gitignore line and staged ${violation.path}`,
112
+ };
113
+ }
114
+ case "untracked-and-should-track": {
115
+ await runGit(["add", "--", violation.path], cwd);
116
+ return { applied: true, message: `Staged ${violation.path}` };
117
+ }
118
+ case "missing-from-gitignore": {
119
+ // Treat as a safe append for symmetry — caller can route through here too.
120
+ await appendIfMissing(cwd, violation.path);
121
+ return { applied: true, message: `Added ${violation.path} to .gitignore` };
122
+ }
123
+ }
124
+ }
125
+
126
+ export function formatViolationSummary(violations: Violation[]): string {
127
+ if (violations.length === 0) return "No gitignore issues detected.";
128
+
129
+ const byKind = new Map<string, Violation[]>();
130
+ for (const v of violations) {
131
+ const arr = byKind.get(v.kind) ?? [];
132
+ arr.push(v);
133
+ byKind.set(v.kind, arr);
134
+ }
135
+
136
+ const labels: Record<Violation["kind"], string> = {
137
+ "tracked-but-should-ignore": "tracked but should be ignored",
138
+ "ignored-but-should-track": "ignored but should be tracked",
139
+ "untracked-and-should-track":"exist but not tracked (should be)",
140
+ "missing-from-gitignore": "missing from .gitignore",
141
+ };
142
+
143
+ const lines: string[] = [];
144
+ for (const [kind, vs] of byKind.entries()) {
145
+ lines.push(` ${labels[kind as Violation["kind"]]}: ${vs.length}`);
146
+ for (const v of vs.slice(0, 5)) {
147
+ lines.push(` - ${v.path}`);
148
+ }
149
+ if (vs.length > 5) lines.push(` … and ${vs.length - 5} more`);
150
+ }
151
+ return lines.join("\n");
152
+ }
153
+
154
+ async function appendIfMissing(cwd: string, pattern: string): Promise<void> {
155
+ const gitignorePath = join(cwd, ".gitignore");
156
+ const existing = existsSync(gitignorePath)
157
+ ? await readFile(gitignorePath, "utf8")
158
+ : "";
159
+ const lines = existing.split("\n").map((l) => l.trim());
160
+ if (lines.includes(pattern)) return;
161
+ const block = (existing.length > 0 && !existing.endsWith("\n") ? "\n" : "") +
162
+ `${CLAUDEUP_HEADER}\n${pattern}\n`;
163
+ await appendFile(gitignorePath, block);
164
+ }
165
+
166
+ async function stripGitignoreLine(cwd: string, pattern: string): Promise<void> {
167
+ const gitignorePath = join(cwd, ".gitignore");
168
+ if (!existsSync(gitignorePath)) return;
169
+ const content = await readFile(gitignorePath, "utf8");
170
+ const filtered = content
171
+ .split("\n")
172
+ .filter((line) => line.trim() !== pattern)
173
+ .join("\n");
174
+ if (filtered !== content) {
175
+ await writeFile(gitignorePath, filtered);
176
+ }
177
+ }
178
+
179
+ class GitError extends Error {
180
+ constructor(public code: number, public stderr: string) {
181
+ super(`git exited ${code}: ${stderr}`);
182
+ }
183
+ }
184
+
185
+ function runGit(args: string[], cwd: string): Promise<string> {
186
+ return new Promise((resolve, reject) => {
187
+ const proc = spawn("git", args, { cwd });
188
+ let stdout = "";
189
+ let stderr = "";
190
+ proc.stdout.on("data", (b) => { stdout += b.toString(); });
191
+ proc.stderr.on("data", (b) => { stderr += b.toString(); });
192
+ proc.on("error", reject);
193
+ proc.on("close", (code) => {
194
+ if (code === 0) resolve(stdout);
195
+ else reject(new GitError(code ?? -1, stderr));
196
+ });
197
+ });
198
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Prerunner integration for the gitignore manager.
3
+ *
4
+ * Called from `prerunner/index.ts` (Step 0.9) on every claudeup invocation.
5
+ * Resolves the manifest, runs detection, and prints a concise warning when
6
+ * violations exist. Does NOT auto-apply fixes — the user must open the
7
+ * Gitignore tab and confirm. This mirrors the plugin-version-mismatch flow
8
+ * where prerun *warns* and the TUI provides the interactive fix dialog.
9
+ */
10
+ import { resolveGitignore } from "./gitignore-resolver.js";
11
+ import { detectViolations } from "./gitignore-detector.js";
12
+ import { formatViolationSummary } from "./gitignore-fixer.js";
13
+ export async function checkGitignore(cwd) {
14
+ const warnings = [];
15
+ try {
16
+ const resolved = await resolveGitignore(cwd, {
17
+ logger: (m) => warnings.push(m),
18
+ });
19
+ const violations = await detectViolations(cwd, resolved);
20
+ return {
21
+ violationCount: violations.length,
22
+ warnings,
23
+ };
24
+ }
25
+ catch (err) {
26
+ warnings.push(`[gitignore] check failed: ${err instanceof Error ? err.message : String(err)}`);
27
+ return { violationCount: 0, warnings };
28
+ }
29
+ }
30
+ /**
31
+ * One-line summary suitable for prerun stderr. Returns null when there's
32
+ * nothing to warn about.
33
+ */
34
+ export function formatPrerunWarning(result) {
35
+ if (result.violationCount === 0)
36
+ return null;
37
+ return `⚠ ${result.violationCount} gitignore issue(s) detected. Run: claudeup → Gitignore tab to review and fix`;
38
+ }
39
+ /**
40
+ * Verbose multi-line summary, used by the Gitignore tab when the user wants
41
+ * to know what's wrong before deciding what to do.
42
+ */
43
+ export async function describeViolations(cwd, violations) {
44
+ void cwd;
45
+ return formatViolationSummary(violations);
46
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Prerunner integration for the gitignore manager.
3
+ *
4
+ * Called from `prerunner/index.ts` (Step 0.9) on every claudeup invocation.
5
+ * Resolves the manifest, runs detection, and prints a concise warning when
6
+ * violations exist. Does NOT auto-apply fixes — the user must open the
7
+ * Gitignore tab and confirm. This mirrors the plugin-version-mismatch flow
8
+ * where prerun *warns* and the TUI provides the interactive fix dialog.
9
+ */
10
+
11
+ import { resolveGitignore } from "./gitignore-resolver.js";
12
+ import { detectViolations } from "./gitignore-detector.js";
13
+ import { formatViolationSummary } from "./gitignore-fixer.js";
14
+ import type { Violation } from "../types/gitignore.js";
15
+
16
+ export interface PrerunResult {
17
+ violationCount: number;
18
+ warnings: string[];
19
+ }
20
+
21
+ export async function checkGitignore(cwd: string): Promise<PrerunResult> {
22
+ const warnings: string[] = [];
23
+ try {
24
+ const resolved = await resolveGitignore(cwd, {
25
+ logger: (m) => warnings.push(m),
26
+ });
27
+ const violations = await detectViolations(cwd, resolved);
28
+ return {
29
+ violationCount: violations.length,
30
+ warnings,
31
+ };
32
+ } catch (err) {
33
+ warnings.push(
34
+ `[gitignore] check failed: ${err instanceof Error ? err.message : String(err)}`,
35
+ );
36
+ return { violationCount: 0, warnings };
37
+ }
38
+ }
39
+
40
+ /**
41
+ * One-line summary suitable for prerun stderr. Returns null when there's
42
+ * nothing to warn about.
43
+ */
44
+ export function formatPrerunWarning(result: PrerunResult): string | null {
45
+ if (result.violationCount === 0) return null;
46
+ return `⚠ ${result.violationCount} gitignore issue(s) detected. Run: claudeup → Gitignore tab to review and fix`;
47
+ }
48
+
49
+ /**
50
+ * Verbose multi-line summary, used by the Gitignore tab when the user wants
51
+ * to know what's wrong before deciding what to do.
52
+ */
53
+ export async function describeViolations(
54
+ cwd: string,
55
+ violations: Violation[],
56
+ ): Promise<string> {
57
+ void cwd;
58
+ return formatViolationSummary(violations);
59
+ }
@@ -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
+ }