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.
Files changed (41) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/gitignore-detector.test.ts +156 -0
  3. package/src/__tests__/gitignore-fixer.test.ts +197 -0
  4. package/src/__tests__/gitignore-prerun.test.ts +75 -0
  5. package/src/__tests__/gitignore-resolver.test.ts +166 -0
  6. package/src/__tests__/gitignore-service.test.ts +93 -0
  7. package/src/__tests__/useGitignoreModal.test.ts +71 -0
  8. package/src/data/gitignore-defaults.js +24 -0
  9. package/src/data/gitignore-defaults.ts +26 -0
  10. package/src/data/gitignore-templates.js +21 -0
  11. package/src/data/gitignore-templates.ts +25 -0
  12. package/src/data/marketplaces.js +17 -1
  13. package/src/data/marketplaces.ts +16 -1
  14. package/src/prerunner/index.js +17 -0
  15. package/src/prerunner/index.ts +20 -0
  16. package/src/services/gitignore-detector.js +155 -0
  17. package/src/services/gitignore-detector.ts +157 -0
  18. package/src/services/gitignore-fixer.js +171 -0
  19. package/src/services/gitignore-fixer.ts +198 -0
  20. package/src/services/gitignore-prerun.js +46 -0
  21. package/src/services/gitignore-prerun.ts +59 -0
  22. package/src/services/gitignore-resolver.js +143 -0
  23. package/src/services/gitignore-resolver.ts +190 -0
  24. package/src/services/gitignore-service.js +99 -0
  25. package/src/services/gitignore-service.ts +142 -0
  26. package/src/types/gitignore.js +6 -0
  27. package/src/types/gitignore.ts +68 -0
  28. package/src/types/index.ts +1 -0
  29. package/src/ui/App.js +47 -3
  30. package/src/ui/App.tsx +51 -2
  31. package/src/ui/components/TabBar.js +1 -0
  32. package/src/ui/components/TabBar.tsx +1 -0
  33. package/src/ui/hooks/useGitignoreModal.js +75 -0
  34. package/src/ui/hooks/useGitignoreModal.ts +97 -0
  35. package/src/ui/renderers/gitignoreRenderers.js +33 -0
  36. package/src/ui/renderers/gitignoreRenderers.tsx +70 -0
  37. package/src/ui/screens/GitignoreScreen.js +227 -0
  38. package/src/ui/screens/GitignoreScreen.tsx +364 -0
  39. package/src/ui/screens/index.js +1 -0
  40. package/src/ui/screens/index.ts +1 -0
  41. 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;
@@ -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
  });
@@ -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
  });
@@ -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) {
@@ -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
+ }