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,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;
|
|
@@ -47,6 +47,19 @@ export const SETTINGS_CATALOG = [
|
|
|
47
47
|
type: "boolean",
|
|
48
48
|
storage: { type: "setting", key: "enableAllProjectMcpServers" },
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
id: "teammate-mode",
|
|
52
|
+
name: "Teammate Display Mode",
|
|
53
|
+
description: "Set how agent team teammates display: auto picks split panes in tmux/iTerm2, in-process otherwise",
|
|
54
|
+
category: "recommended",
|
|
55
|
+
type: "select",
|
|
56
|
+
options: [
|
|
57
|
+
{ label: "Auto", value: "auto" },
|
|
58
|
+
{ label: "In-process", value: "in-process" },
|
|
59
|
+
{ label: "tmux", value: "tmux" },
|
|
60
|
+
],
|
|
61
|
+
storage: { type: "setting", key: "teammateMode" },
|
|
62
|
+
},
|
|
50
63
|
// ═══════ AGENTS & TEAMS ═══════
|
|
51
64
|
{
|
|
52
65
|
id: "team-name",
|
|
@@ -64,19 +77,6 @@ export const SETTINGS_CATALOG = [
|
|
|
64
77
|
type: "boolean",
|
|
65
78
|
storage: { type: "env", key: "CLAUDE_CODE_PLAN_MODE_REQUIRED" },
|
|
66
79
|
},
|
|
67
|
-
{
|
|
68
|
-
id: "teammate-mode",
|
|
69
|
-
name: "Teammate Display Mode",
|
|
70
|
-
description: "How agent team teammates display: auto picks split panes in tmux/iTerm2, in-process otherwise",
|
|
71
|
-
category: "agents",
|
|
72
|
-
type: "select",
|
|
73
|
-
options: [
|
|
74
|
-
{ label: "Auto", value: "auto" },
|
|
75
|
-
{ label: "In-process", value: "in-process" },
|
|
76
|
-
{ label: "tmux", value: "tmux" },
|
|
77
|
-
],
|
|
78
|
-
storage: { type: "setting", key: "teammateMode" },
|
|
79
|
-
},
|
|
80
80
|
{
|
|
81
81
|
id: "subagent-model",
|
|
82
82
|
name: "Subagent Model",
|
|
@@ -78,6 +78,20 @@ export const SETTINGS_CATALOG: SettingDefinition[] = [
|
|
|
78
78
|
type: "boolean",
|
|
79
79
|
storage: { type: "setting", key: "enableAllProjectMcpServers" },
|
|
80
80
|
},
|
|
81
|
+
{
|
|
82
|
+
id: "teammate-mode",
|
|
83
|
+
name: "Teammate Display Mode",
|
|
84
|
+
description:
|
|
85
|
+
"Set how agent team teammates display: auto picks split panes in tmux/iTerm2, in-process otherwise",
|
|
86
|
+
category: "recommended",
|
|
87
|
+
type: "select",
|
|
88
|
+
options: [
|
|
89
|
+
{ label: "Auto", value: "auto" },
|
|
90
|
+
{ label: "In-process", value: "in-process" },
|
|
91
|
+
{ label: "tmux", value: "tmux" },
|
|
92
|
+
],
|
|
93
|
+
storage: { type: "setting", key: "teammateMode" },
|
|
94
|
+
},
|
|
81
95
|
|
|
82
96
|
// ═══════ AGENTS & TEAMS ═══════
|
|
83
97
|
{
|
|
@@ -98,20 +112,6 @@ export const SETTINGS_CATALOG: SettingDefinition[] = [
|
|
|
98
112
|
type: "boolean",
|
|
99
113
|
storage: { type: "env", key: "CLAUDE_CODE_PLAN_MODE_REQUIRED" },
|
|
100
114
|
},
|
|
101
|
-
{
|
|
102
|
-
id: "teammate-mode",
|
|
103
|
-
name: "Teammate Display Mode",
|
|
104
|
-
description:
|
|
105
|
-
"How agent team teammates display: auto picks split panes in tmux/iTerm2, in-process otherwise",
|
|
106
|
-
category: "agents",
|
|
107
|
-
type: "select",
|
|
108
|
-
options: [
|
|
109
|
-
{ label: "Auto", value: "auto" },
|
|
110
|
-
{ label: "In-process", value: "in-process" },
|
|
111
|
-
{ label: "tmux", value: "tmux" },
|
|
112
|
-
],
|
|
113
|
-
storage: { type: "setting", key: "teammateMode" },
|
|
114
|
-
},
|
|
115
115
|
{
|
|
116
116
|
id: "subagent-model",
|
|
117
117
|
name: "Subagent Model",
|
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
|
+
}
|