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
|
@@ -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,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
|
+
}
|