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.
- 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/marketplaces.js +17 -1
- package/src/data/marketplaces.ts +16 -1
- 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/types/index.ts +1 -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,93 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile, mkdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
loadGitignoreState,
|
|
8
|
+
applyTemplate,
|
|
9
|
+
} from "../services/gitignore-service";
|
|
10
|
+
|
|
11
|
+
function git(cwd: string, ...args: string[]): void {
|
|
12
|
+
spawnSync("git", ["-c", "core.excludesFile=/dev/null", ...args], { cwd });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("loadGitignoreState", () => {
|
|
16
|
+
let projectDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
projectDir = await mkdtemp(join(tmpdir(), "gitig-svc-"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns resolved manifest + violations + warnings", async () => {
|
|
27
|
+
git(projectDir, "init", "-q");
|
|
28
|
+
const s = await loadGitignoreState(projectDir);
|
|
29
|
+
expect(s.resolved).toBeDefined();
|
|
30
|
+
expect(Array.isArray(s.resolved.rules)).toBe(true);
|
|
31
|
+
expect(Array.isArray(s.violations)).toBe(true);
|
|
32
|
+
expect(Array.isArray(s.warnings)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("applyTemplate", () => {
|
|
37
|
+
let projectDir: string;
|
|
38
|
+
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
projectDir = await mkdtemp(join(tmpdir(), "gitig-tpl-"));
|
|
41
|
+
await mkdir(join(projectDir, ".claude"), { recursive: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("creates project manifest and merges template entries", async () => {
|
|
49
|
+
const r = await applyTemplate(projectDir, "common-dev");
|
|
50
|
+
expect(r.added.ignore).toContain("node_modules/");
|
|
51
|
+
expect(r.added.ignore).toContain(".env");
|
|
52
|
+
expect(r.conflicts).toEqual([]);
|
|
53
|
+
|
|
54
|
+
const written = JSON.parse(
|
|
55
|
+
await readFile(join(projectDir, ".claude", "gitignore.json"), "utf8"),
|
|
56
|
+
);
|
|
57
|
+
expect(written.ignore).toContain("node_modules/");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not duplicate entries on second apply", async () => {
|
|
61
|
+
await applyTemplate(projectDir, "common-dev");
|
|
62
|
+
const r2 = await applyTemplate(projectDir, "common-dev");
|
|
63
|
+
expect(r2.added.ignore).toEqual([]);
|
|
64
|
+
expect(r2.added.track).toEqual([]);
|
|
65
|
+
|
|
66
|
+
const written = JSON.parse(
|
|
67
|
+
await readFile(join(projectDir, ".claude", "gitignore.json"), "utf8"),
|
|
68
|
+
);
|
|
69
|
+
const occurrences = written.ignore.filter((p: string) => p === "node_modules/").length;
|
|
70
|
+
expect(occurrences).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("reports conflicts when an existing pattern would flip action", async () => {
|
|
74
|
+
await writeFile(
|
|
75
|
+
join(projectDir, ".claude", "gitignore.json"),
|
|
76
|
+
JSON.stringify({ ignore: [], track: ["node_modules/"] }),
|
|
77
|
+
);
|
|
78
|
+
const r = await applyTemplate(projectDir, "common-dev");
|
|
79
|
+
expect(r.conflicts).toContain("node_modules/");
|
|
80
|
+
// node_modules/ should NOT have been added to ignore
|
|
81
|
+
const written = JSON.parse(
|
|
82
|
+
await readFile(join(projectDir, ".claude", "gitignore.json"), "utf8"),
|
|
83
|
+
);
|
|
84
|
+
expect(written.ignore).not.toContain("node_modules/");
|
|
85
|
+
expect(written.track).toContain("node_modules/");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("throws on unknown template name", async () => {
|
|
89
|
+
await expect(applyTemplate(projectDir, "does-not-exist")).rejects.toThrow(
|
|
90
|
+
/Unknown template/,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { buildGitignoreModal } from "../ui/hooks/useGitignoreModal";
|
|
3
|
+
|
|
4
|
+
describe("buildGitignoreModal", () => {
|
|
5
|
+
it("builds a select modal with three options", () => {
|
|
6
|
+
let dismissed = false;
|
|
7
|
+
let fixed = false;
|
|
8
|
+
let opened = false;
|
|
9
|
+
const modal = buildGitignoreModal({
|
|
10
|
+
violationCount: 5,
|
|
11
|
+
safeCount: 3,
|
|
12
|
+
onFixSafe: () => { fixed = true; },
|
|
13
|
+
onOpenTab: () => { opened = true; },
|
|
14
|
+
onDismiss: () => { dismissed = true; },
|
|
15
|
+
});
|
|
16
|
+
expect(modal.type).toBe("select");
|
|
17
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
18
|
+
expect(modal.options.length).toBe(3);
|
|
19
|
+
expect(modal.options[0].value).toBe("fix-safe");
|
|
20
|
+
expect(modal.options[1].value).toBe("open-tab");
|
|
21
|
+
expect(modal.options[2].value).toBe("dismiss");
|
|
22
|
+
expect(modal.options[0].label).toContain("3");
|
|
23
|
+
|
|
24
|
+
modal.onSelect("fix-safe");
|
|
25
|
+
expect(fixed).toBe(true);
|
|
26
|
+
|
|
27
|
+
modal.onSelect("open-tab");
|
|
28
|
+
expect(opened).toBe(true);
|
|
29
|
+
|
|
30
|
+
modal.onSelect("dismiss");
|
|
31
|
+
expect(dismissed).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("shows '(no safe-fixable entries)' label when safeCount is 0", () => {
|
|
35
|
+
const modal = buildGitignoreModal({
|
|
36
|
+
violationCount: 2,
|
|
37
|
+
safeCount: 0,
|
|
38
|
+
onFixSafe: () => {},
|
|
39
|
+
onOpenTab: () => {},
|
|
40
|
+
onDismiss: () => {},
|
|
41
|
+
});
|
|
42
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
43
|
+
expect(modal.options[0].label).toContain("no safe-fixable");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("includes violation count in the message", () => {
|
|
47
|
+
const modal = buildGitignoreModal({
|
|
48
|
+
violationCount: 7,
|
|
49
|
+
safeCount: 2,
|
|
50
|
+
onFixSafe: () => {},
|
|
51
|
+
onOpenTab: () => {},
|
|
52
|
+
onDismiss: () => {},
|
|
53
|
+
});
|
|
54
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
55
|
+
expect(modal.message).toContain("7");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("calls onCancel as dismiss", () => {
|
|
59
|
+
let dismissed = false;
|
|
60
|
+
const modal = buildGitignoreModal({
|
|
61
|
+
violationCount: 1,
|
|
62
|
+
safeCount: 0,
|
|
63
|
+
onFixSafe: () => {},
|
|
64
|
+
onOpenTab: () => {},
|
|
65
|
+
onDismiss: () => { dismissed = true; },
|
|
66
|
+
});
|
|
67
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
68
|
+
modal.onCancel();
|
|
69
|
+
expect(dismissed).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative built-in defaults. These ship inside claudeup and apply to
|
|
3
|
+
* every project unless overridden by a global or project manifest. Kept narrow
|
|
4
|
+
* on purpose — broader patterns (node_modules, .env, .DS_Store, …) live in the
|
|
5
|
+
* "common-dev" template which users opt into via `extends`.
|
|
6
|
+
*/
|
|
7
|
+
export const BUILTIN_DEFAULTS = {
|
|
8
|
+
ignore: [
|
|
9
|
+
".claude/settings.local.json",
|
|
10
|
+
".claude/scheduled_tasks.lock",
|
|
11
|
+
".claude/cache/",
|
|
12
|
+
".claude/.statusline-worktree-*",
|
|
13
|
+
"ai-docs/sessions/",
|
|
14
|
+
".mnemex/",
|
|
15
|
+
".claudemem/",
|
|
16
|
+
"gtd/sessions/",
|
|
17
|
+
".agents/",
|
|
18
|
+
],
|
|
19
|
+
track: [
|
|
20
|
+
".claude/settings.json",
|
|
21
|
+
".mcp.json",
|
|
22
|
+
"plugin.json",
|
|
23
|
+
],
|
|
24
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { GitignoreManifest } from "../types/gitignore.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Conservative built-in defaults. These ship inside claudeup and apply to
|
|
5
|
+
* every project unless overridden by a global or project manifest. Kept narrow
|
|
6
|
+
* on purpose — broader patterns (node_modules, .env, .DS_Store, …) live in the
|
|
7
|
+
* "common-dev" template which users opt into via `extends`.
|
|
8
|
+
*/
|
|
9
|
+
export const BUILTIN_DEFAULTS: GitignoreManifest = {
|
|
10
|
+
ignore: [
|
|
11
|
+
".claude/settings.local.json",
|
|
12
|
+
".claude/scheduled_tasks.lock",
|
|
13
|
+
".claude/cache/",
|
|
14
|
+
".claude/.statusline-worktree-*",
|
|
15
|
+
"ai-docs/sessions/",
|
|
16
|
+
".mnemex/",
|
|
17
|
+
".claudemem/",
|
|
18
|
+
"gtd/sessions/",
|
|
19
|
+
".agents/",
|
|
20
|
+
],
|
|
21
|
+
track: [
|
|
22
|
+
".claude/settings.json",
|
|
23
|
+
".mcp.json",
|
|
24
|
+
"plugin.json",
|
|
25
|
+
],
|
|
26
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in templates a user manifest can pull in via `extends`. Kept separate
|
|
3
|
+
* from BUILTIN_DEFAULTS so the active baseline stays minimal — these only
|
|
4
|
+
* apply when explicitly requested.
|
|
5
|
+
*/
|
|
6
|
+
export const BUILTIN_TEMPLATES = {
|
|
7
|
+
"common-dev": {
|
|
8
|
+
ignore: [
|
|
9
|
+
"node_modules/",
|
|
10
|
+
".env",
|
|
11
|
+
".env.local",
|
|
12
|
+
".DS_Store",
|
|
13
|
+
"dist/",
|
|
14
|
+
"build/",
|
|
15
|
+
"*.log",
|
|
16
|
+
"coverage/",
|
|
17
|
+
".cache/",
|
|
18
|
+
],
|
|
19
|
+
track: [],
|
|
20
|
+
},
|
|
21
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GitignoreManifest } from "../types/gitignore.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Opt-in templates a user manifest can pull in via `extends`. Kept separate
|
|
5
|
+
* from BUILTIN_DEFAULTS so the active baseline stays minimal — these only
|
|
6
|
+
* apply when explicitly requested.
|
|
7
|
+
*/
|
|
8
|
+
export const BUILTIN_TEMPLATES: Record<string, GitignoreManifest> = {
|
|
9
|
+
"common-dev": {
|
|
10
|
+
ignore: [
|
|
11
|
+
"node_modules/",
|
|
12
|
+
".env",
|
|
13
|
+
".env.local",
|
|
14
|
+
".DS_Store",
|
|
15
|
+
"dist/",
|
|
16
|
+
"build/",
|
|
17
|
+
"*.log",
|
|
18
|
+
"coverage/",
|
|
19
|
+
".cache/",
|
|
20
|
+
],
|
|
21
|
+
track: [],
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type BuiltinTemplateName = keyof typeof BUILTIN_TEMPLATES;
|
package/src/data/marketplaces.js
CHANGED
|
@@ -41,6 +41,15 @@ export const defaultMarketplaces = [
|
|
|
41
41
|
description: "Official Anthropic-managed directory of high quality Claude Code plugins",
|
|
42
42
|
official: true,
|
|
43
43
|
},
|
|
44
|
+
{
|
|
45
|
+
name: "superpowers",
|
|
46
|
+
displayName: "Superpowers",
|
|
47
|
+
source: {
|
|
48
|
+
source: "github",
|
|
49
|
+
repo: "obra/superpowers",
|
|
50
|
+
},
|
|
51
|
+
description: "Jesse Vincent's agentic skills framework and software development methodology",
|
|
52
|
+
},
|
|
44
53
|
{
|
|
45
54
|
name: "claude-code-plugins",
|
|
46
55
|
displayName: "Anthropic Deprecated",
|
|
@@ -50,6 +59,7 @@ export const defaultMarketplaces = [
|
|
|
50
59
|
},
|
|
51
60
|
description: "Legacy demo plugins from Anthropic (deprecated - use Official instead)",
|
|
52
61
|
official: true,
|
|
62
|
+
deprecated: true,
|
|
53
63
|
},
|
|
54
64
|
];
|
|
55
65
|
export function getMarketplaceByName(name) {
|
|
@@ -93,6 +103,7 @@ export function getAllMarketplaces(localMarketplaces) {
|
|
|
93
103
|
description: defaultMp?.description || local.description || "",
|
|
94
104
|
official: defaultMp?.official ?? repo.toLowerCase().includes("anthropics/"),
|
|
95
105
|
featured: defaultMp?.featured,
|
|
106
|
+
deprecated: defaultMp?.deprecated,
|
|
96
107
|
});
|
|
97
108
|
}
|
|
98
109
|
}
|
|
@@ -105,7 +116,7 @@ export function getAllMarketplaces(localMarketplaces) {
|
|
|
105
116
|
seenRepos.add(repo);
|
|
106
117
|
}
|
|
107
118
|
}
|
|
108
|
-
// Sort: Magus first, then alphabetically
|
|
119
|
+
// Sort: Magus first, deprecated last, then alphabetically
|
|
109
120
|
return Array.from(all.values()).sort((a, b) => {
|
|
110
121
|
// Magus (MadAppGang) always first
|
|
111
122
|
const aIsMag = a.source.repo?.toLowerCase().includes("madappgang/");
|
|
@@ -114,6 +125,11 @@ export function getAllMarketplaces(localMarketplaces) {
|
|
|
114
125
|
return -1;
|
|
115
126
|
if (!aIsMag && bIsMag)
|
|
116
127
|
return 1;
|
|
128
|
+
// Deprecated entries always last
|
|
129
|
+
if (a.deprecated && !b.deprecated)
|
|
130
|
+
return 1;
|
|
131
|
+
if (!a.deprecated && b.deprecated)
|
|
132
|
+
return -1;
|
|
117
133
|
// Then alphabetically by display name
|
|
118
134
|
return (a.displayName || a.name).localeCompare(b.displayName || b.name);
|
|
119
135
|
});
|
package/src/data/marketplaces.ts
CHANGED
|
@@ -49,6 +49,16 @@ export const defaultMarketplaces: Marketplace[] = [
|
|
|
49
49
|
"Official Anthropic-managed directory of high quality Claude Code plugins",
|
|
50
50
|
official: true,
|
|
51
51
|
},
|
|
52
|
+
{
|
|
53
|
+
name: "superpowers",
|
|
54
|
+
displayName: "Superpowers",
|
|
55
|
+
source: {
|
|
56
|
+
source: "github",
|
|
57
|
+
repo: "obra/superpowers",
|
|
58
|
+
},
|
|
59
|
+
description:
|
|
60
|
+
"Jesse Vincent's agentic skills framework and software development methodology",
|
|
61
|
+
},
|
|
52
62
|
{
|
|
53
63
|
name: "claude-code-plugins",
|
|
54
64
|
displayName: "Anthropic Deprecated",
|
|
@@ -59,6 +69,7 @@ export const defaultMarketplaces: Marketplace[] = [
|
|
|
59
69
|
description:
|
|
60
70
|
"Legacy demo plugins from Anthropic (deprecated - use Official instead)",
|
|
61
71
|
official: true,
|
|
72
|
+
deprecated: true,
|
|
62
73
|
},
|
|
63
74
|
];
|
|
64
75
|
|
|
@@ -113,6 +124,7 @@ export function getAllMarketplaces(
|
|
|
113
124
|
official:
|
|
114
125
|
defaultMp?.official ?? repo.toLowerCase().includes("anthropics/"),
|
|
115
126
|
featured: defaultMp?.featured,
|
|
127
|
+
deprecated: defaultMp?.deprecated,
|
|
116
128
|
});
|
|
117
129
|
}
|
|
118
130
|
}
|
|
@@ -126,13 +138,16 @@ export function getAllMarketplaces(
|
|
|
126
138
|
}
|
|
127
139
|
}
|
|
128
140
|
|
|
129
|
-
// Sort: Magus first, then alphabetically
|
|
141
|
+
// Sort: Magus first, deprecated last, then alphabetically
|
|
130
142
|
return Array.from(all.values()).sort((a, b) => {
|
|
131
143
|
// Magus (MadAppGang) always first
|
|
132
144
|
const aIsMag = a.source.repo?.toLowerCase().includes("madappgang/");
|
|
133
145
|
const bIsMag = b.source.repo?.toLowerCase().includes("madappgang/");
|
|
134
146
|
if (aIsMag && !bIsMag) return -1;
|
|
135
147
|
if (!aIsMag && bIsMag) return 1;
|
|
148
|
+
// Deprecated entries always last
|
|
149
|
+
if (a.deprecated && !b.deprecated) return 1;
|
|
150
|
+
if (!a.deprecated && b.deprecated) return -1;
|
|
136
151
|
// Then alphabetically by display name
|
|
137
152
|
return (a.displayName || a.name).localeCompare(b.displayName || b.name);
|
|
138
153
|
});
|
package/src/prerunner/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { recoverMarketplaceSettings, migrateMarketplaceRename, cleanupExtraKnown
|
|
|
8
8
|
import { checkPluginVersionMismatches, formatMismatchWarning, } from "../services/plugin-version-check.js";
|
|
9
9
|
import { updatePlugin, isClaudeAvailable } from "../services/claude-cli.js";
|
|
10
10
|
import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
|
|
11
|
+
import { checkGitignore, formatPrerunWarning as formatGitignoreWarning, } from "../services/gitignore-prerun.js";
|
|
11
12
|
const CONTINUITY_PLUGIN_SENTINEL = "tmux-claude-continuity";
|
|
12
13
|
const CONTINUITY_PLUGIN_SCRIPT = path.join(os.homedir(), ".tmux", "plugins", "tmux-claude-continuity", "scripts", "on_session_start.sh");
|
|
13
14
|
/**
|
|
@@ -139,6 +140,22 @@ export async function prerunClaude(claudeArgs, options = {}) {
|
|
|
139
140
|
catch {
|
|
140
141
|
// Non-fatal: mismatch detection is best-effort
|
|
141
142
|
}
|
|
143
|
+
// STEP 0.9: Detect gitignore manifest violations. Warn-only here — the
|
|
144
|
+
// fix dialog lives in the TUI Gitignore tab, mirroring the plugin
|
|
145
|
+
// version-mismatch flow (since destructive fixes need user confirmation).
|
|
146
|
+
try {
|
|
147
|
+
const cwd = process.cwd();
|
|
148
|
+
const result = await checkGitignore(cwd);
|
|
149
|
+
const warning = formatGitignoreWarning(result);
|
|
150
|
+
if (warning) {
|
|
151
|
+
console.warn("");
|
|
152
|
+
console.warn(warning);
|
|
153
|
+
console.warn("");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Non-fatal: gitignore detection is best-effort
|
|
158
|
+
}
|
|
142
159
|
// STEP 1: Check if we should update (time-based cache, or forced)
|
|
143
160
|
const shouldUpdate = options.force || (await cache.shouldCheckForUpdates());
|
|
144
161
|
if (options.force) {
|
package/src/prerunner/index.ts
CHANGED
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
} from "../services/plugin-version-check.js";
|
|
23
23
|
import { updatePlugin, isClaudeAvailable } from "../services/claude-cli.js";
|
|
24
24
|
import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
|
|
25
|
+
import {
|
|
26
|
+
checkGitignore,
|
|
27
|
+
formatPrerunWarning as formatGitignoreWarning,
|
|
28
|
+
} from "../services/gitignore-prerun.js";
|
|
25
29
|
|
|
26
30
|
export interface PrerunOptions {
|
|
27
31
|
force?: boolean; // Bypass cache and force update check
|
|
@@ -194,6 +198,22 @@ export async function prerunClaude(
|
|
|
194
198
|
// Non-fatal: mismatch detection is best-effort
|
|
195
199
|
}
|
|
196
200
|
|
|
201
|
+
// STEP 0.9: Detect gitignore manifest violations. Warn-only here — the
|
|
202
|
+
// fix dialog lives in the TUI Gitignore tab, mirroring the plugin
|
|
203
|
+
// version-mismatch flow (since destructive fixes need user confirmation).
|
|
204
|
+
try {
|
|
205
|
+
const cwd = process.cwd();
|
|
206
|
+
const result = await checkGitignore(cwd);
|
|
207
|
+
const warning = formatGitignoreWarning(result);
|
|
208
|
+
if (warning) {
|
|
209
|
+
console.warn("");
|
|
210
|
+
console.warn(warning);
|
|
211
|
+
console.warn("");
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// Non-fatal: gitignore detection is best-effort
|
|
215
|
+
}
|
|
216
|
+
|
|
197
217
|
// STEP 1: Check if we should update (time-based cache, or forced)
|
|
198
218
|
const shouldUpdate = options.force || (await cache.shouldCheckForUpdates());
|
|
199
219
|
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
export async function detectViolations(cwd, resolved) {
|
|
25
|
+
if (!(await isGitRepo(cwd)))
|
|
26
|
+
return [];
|
|
27
|
+
const violations = [];
|
|
28
|
+
const trackedFiles = await listTrackedFiles(cwd);
|
|
29
|
+
const trackedSet = new Set(trackedFiles);
|
|
30
|
+
for (const rule of resolved.rules) {
|
|
31
|
+
if (rule.action === "ignore") {
|
|
32
|
+
const tracked = matchesTrackedFile(rule.pattern, trackedSet);
|
|
33
|
+
if (tracked.length > 0) {
|
|
34
|
+
for (const path of tracked) {
|
|
35
|
+
violations.push({
|
|
36
|
+
kind: "tracked-but-should-ignore",
|
|
37
|
+
path,
|
|
38
|
+
source: rule.source,
|
|
39
|
+
severity: "destructive",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
// Not tracked — check if .gitignore covers it. If not, suggest
|
|
45
|
+
// a safe additive fix (append to .gitignore).
|
|
46
|
+
const ignored = await pathIsIgnored(cwd, rule.pattern);
|
|
47
|
+
if (!ignored) {
|
|
48
|
+
violations.push({
|
|
49
|
+
kind: "missing-from-gitignore",
|
|
50
|
+
path: rule.pattern,
|
|
51
|
+
source: rule.source,
|
|
52
|
+
severity: "safe",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
// action === "track"
|
|
59
|
+
const ignored = await pathIsIgnored(cwd, rule.pattern);
|
|
60
|
+
if (ignored) {
|
|
61
|
+
violations.push({
|
|
62
|
+
kind: "ignored-but-should-track",
|
|
63
|
+
path: rule.pattern,
|
|
64
|
+
source: rule.source,
|
|
65
|
+
severity: "destructive",
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const isTracked = trackedSet.has(rule.pattern);
|
|
70
|
+
const existsOnDisk = existsSync(join(cwd, rule.pattern));
|
|
71
|
+
if (existsOnDisk && !isTracked) {
|
|
72
|
+
violations.push({
|
|
73
|
+
kind: "untracked-and-should-track",
|
|
74
|
+
path: rule.pattern,
|
|
75
|
+
source: rule.source,
|
|
76
|
+
severity: "destructive",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return violations;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Match a pattern against the tracked-files set. Directory-style patterns
|
|
85
|
+
* (trailing "/") match every tracked path under that prefix.
|
|
86
|
+
*/
|
|
87
|
+
function matchesTrackedFile(pattern, tracked) {
|
|
88
|
+
if (pattern.endsWith("/")) {
|
|
89
|
+
const prefix = pattern;
|
|
90
|
+
return Array.from(tracked).filter((p) => p.startsWith(prefix));
|
|
91
|
+
}
|
|
92
|
+
if (pattern.includes("*")) {
|
|
93
|
+
// Very limited literal-prefix glob handling. Full expansion is git's job.
|
|
94
|
+
const star = pattern.indexOf("*");
|
|
95
|
+
const prefix = pattern.slice(0, star);
|
|
96
|
+
return Array.from(tracked).filter((p) => p.startsWith(prefix));
|
|
97
|
+
}
|
|
98
|
+
return tracked.has(pattern) ? [pattern] : [];
|
|
99
|
+
}
|
|
100
|
+
async function isGitRepo(cwd) {
|
|
101
|
+
try {
|
|
102
|
+
await runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function listTrackedFiles(cwd) {
|
|
110
|
+
try {
|
|
111
|
+
const out = await runGit(["ls-files"], cwd);
|
|
112
|
+
return out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function pathIsIgnored(cwd, path) {
|
|
119
|
+
try {
|
|
120
|
+
// git check-ignore exits 0 when path IS ignored, 1 when NOT, >1 on error.
|
|
121
|
+
await runGit(["check-ignore", "--quiet", "--", path], cwd);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
// exit code 1 = not ignored (expected), anything else swallowed.
|
|
126
|
+
if (err instanceof GitError && err.code === 1)
|
|
127
|
+
return false;
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
class GitError extends Error {
|
|
132
|
+
code;
|
|
133
|
+
stderr;
|
|
134
|
+
constructor(code, stderr) {
|
|
135
|
+
super(`git exited ${code}: ${stderr}`);
|
|
136
|
+
this.code = code;
|
|
137
|
+
this.stderr = stderr;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function runGit(args, cwd) {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const proc = spawn("git", args, { cwd });
|
|
143
|
+
let stdout = "";
|
|
144
|
+
let stderr = "";
|
|
145
|
+
proc.stdout.on("data", (b) => { stdout += b.toString(); });
|
|
146
|
+
proc.stderr.on("data", (b) => { stderr += b.toString(); });
|
|
147
|
+
proc.on("error", reject);
|
|
148
|
+
proc.on("close", (code) => {
|
|
149
|
+
if (code === 0)
|
|
150
|
+
resolve(stdout);
|
|
151
|
+
else
|
|
152
|
+
reject(new GitError(code ?? -1, stderr));
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|