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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect violations of the resolved gitignore manifest against a working
|
|
3
|
+
* tree. Emits one Violation per offending path.
|
|
4
|
+
*
|
|
5
|
+
* Strategy:
|
|
6
|
+
* - For ignore rules: ask `git ls-files` whether the path (or anything
|
|
7
|
+
* under it for directory-style rules) is currently tracked. If so,
|
|
8
|
+
* it's "tracked-but-should-ignore" (destructive). Then check whether
|
|
9
|
+
* the existing .gitignore covers the path; if not, that's
|
|
10
|
+
* "missing-from-gitignore" (safe additive fix).
|
|
11
|
+
* - For track rules: ask `git check-ignore` whether the path is being
|
|
12
|
+
* excluded by the current .gitignore. If yes, "ignored-but-should-track"
|
|
13
|
+
* (destructive — must remove the .gitignore line). If the path exists
|
|
14
|
+
* on disk but isn't tracked and isn't ignored, "untracked-and-should-track".
|
|
15
|
+
*
|
|
16
|
+
* Glob expansion is intentionally limited: we treat patterns as literals
|
|
17
|
+
* with directory-suffix awareness ("foo/" matches anything under foo/).
|
|
18
|
+
* Full gitignore-syntax glob expansion is deferred — git itself handles
|
|
19
|
+
* the heavy lifting via ls-files / check-ignore.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import type { ResolvedManifest, Violation } from "../types/gitignore.js";
|
|
26
|
+
|
|
27
|
+
export async function detectViolations(
|
|
28
|
+
cwd: string,
|
|
29
|
+
resolved: ResolvedManifest,
|
|
30
|
+
): Promise<Violation[]> {
|
|
31
|
+
if (!(await isGitRepo(cwd))) return [];
|
|
32
|
+
|
|
33
|
+
const violations: Violation[] = [];
|
|
34
|
+
const trackedFiles = await listTrackedFiles(cwd);
|
|
35
|
+
const trackedSet = new Set(trackedFiles);
|
|
36
|
+
|
|
37
|
+
for (const rule of resolved.rules) {
|
|
38
|
+
if (rule.action === "ignore") {
|
|
39
|
+
const tracked = matchesTrackedFile(rule.pattern, trackedSet);
|
|
40
|
+
if (tracked.length > 0) {
|
|
41
|
+
for (const path of tracked) {
|
|
42
|
+
violations.push({
|
|
43
|
+
kind: "tracked-but-should-ignore",
|
|
44
|
+
path,
|
|
45
|
+
source: rule.source,
|
|
46
|
+
severity: "destructive",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
// Not tracked — check if .gitignore covers it. If not, suggest
|
|
51
|
+
// a safe additive fix (append to .gitignore).
|
|
52
|
+
const ignored = await pathIsIgnored(cwd, rule.pattern);
|
|
53
|
+
if (!ignored) {
|
|
54
|
+
violations.push({
|
|
55
|
+
kind: "missing-from-gitignore",
|
|
56
|
+
path: rule.pattern,
|
|
57
|
+
source: rule.source,
|
|
58
|
+
severity: "safe",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
// action === "track"
|
|
64
|
+
const ignored = await pathIsIgnored(cwd, rule.pattern);
|
|
65
|
+
if (ignored) {
|
|
66
|
+
violations.push({
|
|
67
|
+
kind: "ignored-but-should-track",
|
|
68
|
+
path: rule.pattern,
|
|
69
|
+
source: rule.source,
|
|
70
|
+
severity: "destructive",
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const isTracked = trackedSet.has(rule.pattern);
|
|
75
|
+
const existsOnDisk = existsSync(join(cwd, rule.pattern));
|
|
76
|
+
if (existsOnDisk && !isTracked) {
|
|
77
|
+
violations.push({
|
|
78
|
+
kind: "untracked-and-should-track",
|
|
79
|
+
path: rule.pattern,
|
|
80
|
+
source: rule.source,
|
|
81
|
+
severity: "destructive",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return violations;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Match a pattern against the tracked-files set. Directory-style patterns
|
|
92
|
+
* (trailing "/") match every tracked path under that prefix.
|
|
93
|
+
*/
|
|
94
|
+
function matchesTrackedFile(pattern: string, tracked: Set<string>): string[] {
|
|
95
|
+
if (pattern.endsWith("/")) {
|
|
96
|
+
const prefix = pattern;
|
|
97
|
+
return Array.from(tracked).filter((p) => p.startsWith(prefix));
|
|
98
|
+
}
|
|
99
|
+
if (pattern.includes("*")) {
|
|
100
|
+
// Very limited literal-prefix glob handling. Full expansion is git's job.
|
|
101
|
+
const star = pattern.indexOf("*");
|
|
102
|
+
const prefix = pattern.slice(0, star);
|
|
103
|
+
return Array.from(tracked).filter((p) => p.startsWith(prefix));
|
|
104
|
+
}
|
|
105
|
+
return tracked.has(pattern) ? [pattern] : [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function isGitRepo(cwd: string): Promise<boolean> {
|
|
109
|
+
try {
|
|
110
|
+
await runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function listTrackedFiles(cwd: string): Promise<string[]> {
|
|
118
|
+
try {
|
|
119
|
+
const out = await runGit(["ls-files"], cwd);
|
|
120
|
+
return out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
121
|
+
} catch {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function pathIsIgnored(cwd: string, path: string): Promise<boolean> {
|
|
127
|
+
try {
|
|
128
|
+
// git check-ignore exits 0 when path IS ignored, 1 when NOT, >1 on error.
|
|
129
|
+
await runGit(["check-ignore", "--quiet", "--", path], cwd);
|
|
130
|
+
return true;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// exit code 1 = not ignored (expected), anything else swallowed.
|
|
133
|
+
if (err instanceof GitError && err.code === 1) return false;
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
class GitError extends Error {
|
|
139
|
+
constructor(public code: number, public stderr: string) {
|
|
140
|
+
super(`git exited ${code}: ${stderr}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function runGit(args: string[], cwd: string): Promise<string> {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const proc = spawn("git", args, { cwd });
|
|
147
|
+
let stdout = "";
|
|
148
|
+
let stderr = "";
|
|
149
|
+
proc.stdout.on("data", (b) => { stdout += b.toString(); });
|
|
150
|
+
proc.stderr.on("data", (b) => { stderr += b.toString(); });
|
|
151
|
+
proc.on("error", reject);
|
|
152
|
+
proc.on("close", (code) => {
|
|
153
|
+
if (code === 0) resolve(stdout);
|
|
154
|
+
else reject(new GitError(code ?? -1, stderr));
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
@@ -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
|
+
}
|