skillrepo 4.18.0 → 4.18.1

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/README.md CHANGED
@@ -662,9 +662,11 @@ Skills land at one of two project paths, depending on the agent:
662
662
  | Cursor / Windsurf / Gemini CLI / Codex CLI / Cline / GitHub Copilot | `.agents/skills/<name>/` | `~/.agents/skills/<name>/` |
663
663
  | Windsurf (personal-scope override) | — | `~/.codeium/windsurf/skills/<name>/` |
664
664
 
665
- The CLI keeps `.claude/skills/` and `.agents/skills/` listed in the
666
- SkillRepo section of your project `.gitignore`, re-checked on every
667
- sync — skills are a per-developer cache, not committed content. The `--agent` flag overrides detection (e.g.
665
+ The CLI keeps `.claude/skills/` and `.agents/skills/` gitignored,
666
+ re-checked on every sync: an entry is added to the SkillRepo section of
667
+ your project `.gitignore` only when no existing rule already ignores
668
+ that path (a repo that ignores `.claude/` gets no `.claude/skills/`
669
+ line) — skills are a per-developer cache, not committed content. The `--agent` flag overrides detection (e.g.
668
670
  `--agent claude,agents` writes both paths). See
669
671
  [`docs/vendor-paths.md`](docs/vendor-paths.md) for primary-source
670
672
  citations on each agent's read paths.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.18.0",
3
+ "version": "4.18.1",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library, synced across your dev environment",
5
5
  "type": "module",
6
6
  "bin": {
@@ -443,7 +443,8 @@ export async function runInit(argv, io = {}, deps = {}) {
443
443
  `.gitignore updated (added: ${gitignoreResult.added.join(", ")})`,
444
444
  );
445
445
  }
446
- // "skipped" → all entries already present, no output needed
446
+ // "skipped" → every entry already present (or, for the skill roots,
447
+ // already ignored by an existing rule — #2903), no output needed
447
448
  } catch (err) {
448
449
  p.warning(
449
450
  `Could not update .gitignore: ${err?.message ?? String(err)}. ` +
@@ -1077,7 +1078,7 @@ async function runDetectionPicker({ yes, json, stdout, detections }) {
1077
1078
  " .agents/skills/ (for Cursor, Windsurf, Gemini CLI, Codex CLI, " +
1078
1079
  "Cline, Copilot, and others)\n" +
1079
1080
  "\n" +
1080
- " Both paths are added to .gitignore — skills are a per-developer " +
1081
+ " Both paths are gitignored — skills are a per-developer " +
1081
1082
  "cache, not committed.\n\n",
1082
1083
  );
1083
1084
  }
@@ -466,11 +466,13 @@ export function writeSkillDir(skill, options = {}) {
466
466
  const targets = placementTargetsFor(options);
467
467
 
468
468
  // Pre-flight: if any target writes to a project-scope managed root
469
- // (.claude/skills/, .agents/skills/), ensure the root's blanket
470
- // ignore is present in the CLI's marked .gitignore section before
471
- // starting any writes (#2361 — heal on every sync, both roots; the
469
+ // (.claude/skills/, .agents/skills/), ensure the root is gitignored
470
+ // before starting any writes — the blanket entry lands in the CLI's
471
+ // marked .gitignore section only when no existing rule already covers
472
+ // the root (#2903: a repo ignoring `.claude/` gets no redundant
473
+ // `.claude/skills/` line). #2361 — heal on every sync, both roots; the
472
474
  // pre-4.10.0 code covered only .agents/skills/ under a separate
473
- // header). A failure here is recoverable (user fixes their
475
+ // header. A failure here is recoverable (user fixes their
474
476
  // .gitignore and re-runs) because nothing has hit disk yet. A
475
477
  // failure here AFTER a successful write to an earlier target would
476
478
  // leave the user with a half-applied state and an error message
@@ -14,13 +14,21 @@
14
14
  * surfaced by the sync-time foreign-content scan (`foreign-content.mjs`)
15
15
  * — never something the ignore machinery accommodates.
16
16
  *
17
- * Idempotent: entries already present anywhere in the file are skipped;
18
- * missing entries are inserted INTO the existing marked section when one
19
- * exists (never a duplicate header), else appended as a new section.
20
- * Every call also heals the retired pre-4.10.0 sync-time section shape
21
- * (see `healLegacySection`).
17
+ * Idempotent: entries already present anywhere in the file are skipped.
18
+ * The managed-root entries are also skipped when an existing ignore rule
19
+ * already covers them — asked of git itself via `git check-ignore`
20
+ * (#2903), so a repo that ignores `.claude/` never gets a redundant
21
+ * `.claude/skills/` line; the init-only entries stay on the literal
22
+ * scan (see `isCoverageProbed` for why). Genuinely missing entries are
23
+ * inserted INTO the existing marked section when one exists (never a
24
+ * duplicate header), else appended as a new section. Nothing is written
25
+ * when nothing is missing. Every call also heals the retired pre-4.10.0
26
+ * sync-time section shape (see `healLegacySection`).
22
27
  */
23
28
 
29
+ import { spawnSync } from "node:child_process";
30
+ import { dirname } from "node:path";
31
+
24
32
  import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
25
33
  import { gitignorePath } from "./paths.mjs";
26
34
  import { diskError } from "./errors.mjs";
@@ -79,12 +87,18 @@ export function ensureManagedRootIgnores(targets, options = {}) {
79
87
  }
80
88
 
81
89
  /**
82
- * Core ensure: required entries end up line-exact in the file, inside
83
- * the CLI's marked section, with the legacy sync-time section healed
84
- * away. Single writer for every gitignore mutation the CLI performs.
90
+ * Core ensure: every required entry ends up ignored — either it is
91
+ * already present line-exact somewhere in the file, an existing ignore
92
+ * rule already covers it (managed roots only — `git check-ignore`, see
93
+ * `isCoverageProbed` / `isCoveredByGit`), or it is written into the
94
+ * CLI's marked section — with the legacy sync-time section healed away.
95
+ * Single writer for every gitignore mutation the CLI performs; at most
96
+ * ONE write per call.
85
97
  *
86
- * Returns `"skipped"` if every required entry is already present and no
87
- * legacy section needed healing, `"created"` if the file didn't exist,
98
+ * Returns `"skipped"` if nothing needed writing (every required entry
99
+ * present or covered, no legacy section to heal) — the file is not
100
+ * touched at all in that case, not even for a comment — `"created"` if
101
+ * the file didn't exist and at least one entry needed writing,
88
102
  * `"updated"` otherwise.
89
103
  *
90
104
  * @param {string[]} requiredEntries
@@ -107,28 +121,52 @@ export function ensureGitignoreEntries(requiredEntries, options = {}) {
107
121
  });
108
122
  }
109
123
 
124
+ const dir = dirname(filePath);
125
+
110
126
  if (existing === null) {
111
- // Fresh .gitignore — write all required entries as one section.
112
- const content =
113
- GITIGNORE_SECTION_HEADER + "\n" + requiredEntries.join("\n") + "\n";
127
+ // Fresh .gitignore — one section holding every entry that still
128
+ // needs a line: the init-only entries always, a managed root only
129
+ // when no other ignore source (a nested .gitignore, info/exclude,
130
+ // a global excludes file) already covers it. Nothing to add →
131
+ // nothing to create.
132
+ const toAdd = dropCoveredEntries(requiredEntries, dir);
133
+ if (toAdd.length === 0) {
134
+ return { path: ".gitignore", action: "skipped", added: [] };
135
+ }
136
+ const content = GITIGNORE_SECTION_HEADER + "\n" + toAdd.join("\n") + "\n";
114
137
  writeSectionFile(filePath, content);
115
- return {
116
- path: ".gitignore",
117
- action: "created",
118
- added: [...requiredEntries],
119
- };
138
+ return { path: ".gitignore", action: "created", added: toAdd };
120
139
  }
121
140
 
122
141
  // Preserve the original line-ending style throughout.
123
142
  const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
124
143
 
125
- const { lines, healed } = healLegacySection(existing.split(/\r?\n/));
144
+ const { lines, healed, removed } = healLegacySection(
145
+ existing.split(/\r?\n/),
146
+ );
126
147
 
127
148
  // Line-exact match check. `.env.local` as a line is distinct from
128
149
  // `.env.local.backup` or a comment `# .env.local` — compare trimmed
129
- // lines so we match the entry literally.
150
+ // lines so we match the entry literally. A managed-root entry not
151
+ // present line-exact may still be covered by a broader rule
152
+ // (`.claude/` covers `.claude/skills/`); git is the authority on that
153
+ // — except for the line the heal just removed, which git still sees
154
+ // on disk (`removed`, see `dropCoveredEntries`).
155
+ //
156
+ // Whatever the heal removed is re-homed even when this call did not
157
+ // ask for that root (a Claude-only sync in a repo still carrying the
158
+ // legacy `.agents/skills/` line): the heal migrates protection, never
159
+ // drops it. Before #2903 that line was silently lost in this case.
160
+ const wanted =
161
+ removed.length > 0
162
+ ? [...new Set([...requiredEntries, ...removed])]
163
+ : requiredEntries;
130
164
  const trimmed = lines.map((l) => l.trim());
131
- const missing = requiredEntries.filter((entry) => !trimmed.includes(entry));
165
+ const missing = dropCoveredEntries(
166
+ wanted.filter((entry) => !trimmed.includes(entry)),
167
+ dir,
168
+ removed,
169
+ );
132
170
 
133
171
  if (missing.length === 0 && !healed) {
134
172
  return {
@@ -195,6 +233,125 @@ function writeSectionFile(filePath, content) {
195
233
  hint: GITIGNORE_HINT,
196
234
  });
197
235
  }
236
+ forgetCoverage(dirname(filePath));
237
+ }
238
+
239
+ /**
240
+ * Per-process memo of `isCoveredByGit` answers, keyed by
241
+ * `<dir>\0<entry>`. The sync pre-flight runs once per written skill, so
242
+ * without it a sync writing N skills would spawn git N times for every
243
+ * entry that is covered-but-not-literal (~10 ms each). Every write this
244
+ * module makes to a directory's .gitignore forgets that directory's
245
+ * answers (`forgetCoverage`), so a memoized answer never outlives the
246
+ * file state it was computed from. Anything else editing the file
247
+ * mid-process is outside this module's contract — this is the only
248
+ * writer of that section.
249
+ */
250
+ const coverageMemo = new Map();
251
+
252
+ /**
253
+ * Forget every memoized answer for `dir`. Called after each write.
254
+ *
255
+ * @param {string} dir
256
+ */
257
+ function forgetCoverage(dir) {
258
+ const prefix = `${dir}\0`;
259
+ for (const key of coverageMemo.keys()) {
260
+ if (key.startsWith(prefix)) coverageMemo.delete(key);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Only the managed skill roots consult git for coverage. They are
266
+ * per-checkout cache directories the CLI alone populates, so any ignore
267
+ * source git honours in THIS checkout (the repo's .gitignore files,
268
+ * `.git/info/exclude`, the global excludes file) makes an entry
269
+ * redundant here, and every other developer's own sync heals their own
270
+ * checkout. The init-only entries are a different contract: `.env.local`
271
+ * holds a live access key and `.claude/settings.local.json` per-developer
272
+ * settings, and their line in the TRACKED .gitignore is what protects
273
+ * every clone — it must not be withheld because the one developer running
274
+ * `init` happens to have a personal global excludes rule that covers it.
275
+ * They stay on the literal scan, exactly as before #2903.
276
+ *
277
+ * Considered and rejected for them: probing every entry but counting
278
+ * only in-tree `.gitignore` sources (parse `git check-ignore -v -z` for
279
+ * the matching rule's source). It would also quiet the one-time,
280
+ * init-only redundancy when a TRACKED `.env*` rule already covers
281
+ * `.env.local`, at the cost of output parsing and a second probe shape
282
+ * for a cosmetic gain. Revisit if that redundancy ever matters.
283
+ *
284
+ * @param {string} entry
285
+ * @returns {boolean}
286
+ */
287
+ function isCoverageProbed(entry) {
288
+ return Object.values(MANAGED_ROOT_ENTRIES).includes(entry);
289
+ }
290
+
291
+ /**
292
+ * Ask git whether `entry` is already ignored in the work tree that owns
293
+ * the `.gitignore` at `dir` — the authoritative answer to "would adding
294
+ * this line be redundant?", honouring every ignore source git does
295
+ * (that `.gitignore`, nested ones, `.git/info/exclude`, the global
296
+ * excludes file). Per-checkout sources count deliberately for the
297
+ * entries this is asked about — see `isCoverageProbed`.
298
+ *
299
+ * `--no-index`: pattern coverage regardless of whether a tracked cache
300
+ * happens to sit under the root (#2361's tracked-cache case) — a rule
301
+ * that is already there is still redundant to add. The trailing slash
302
+ * on directory entries is load-bearing: `git check-ignore .agents/skills`
303
+ * (no slash) does NOT match an `.agents/skills/` rule while the
304
+ * directory does not exist yet, and on a first sync it does not. The
305
+ * probe runs WITH `dir` as its cwd because git resolves the path against
306
+ * its cwd — asked from a subdirectory, an unanchored `.claude/` rule
307
+ * would match `sub/.claude/skills/` and an anchored `/.claude/` would
308
+ * not, both wrong answers about the root.
309
+ *
310
+ * @param {string} dir - Directory holding the managed `.gitignore`.
311
+ * @param {string} entry - A `GITIGNORE_REQUIRED_ENTRIES` line.
312
+ * @returns {boolean | null} `true` covered, `false` not covered, `null`
313
+ * no signal — git absent (`spawnSync` reports ENOENT via
314
+ * `error`, it does not throw), not a work tree (exit 128), or
315
+ * the probe timed out. Callers fall back to the literal scan.
316
+ */
317
+ function isCoveredByGit(dir, entry) {
318
+ const key = `${dir}\0${entry}`;
319
+ if (coverageMemo.has(key)) return coverageMemo.get(key);
320
+ const probe = spawnSync(
321
+ "git",
322
+ ["check-ignore", "-q", "--no-index", "--", entry],
323
+ { cwd: dir, stdio: "ignore", windowsHide: true, timeout: 5000 },
324
+ );
325
+ let covered = null;
326
+ if (!probe.error && probe.status === 0) covered = true;
327
+ else if (!probe.error && probe.status === 1) covered = false;
328
+ coverageMemo.set(key, covered);
329
+ return covered;
330
+ }
331
+
332
+ /**
333
+ * Drop the managed-root entries git reports as already ignored. Entries
334
+ * that are not probed (`isCoverageProbed`) and entries in `exempt` stay.
335
+ * `exempt` names the line a pending legacy heal removed from the
336
+ * in-memory lines but not yet from disk: git would report that entry
337
+ * covered by the very line about to disappear, so it is re-homed
338
+ * unconditionally — the heal's whole purpose. With no signal from git
339
+ * (`null`) the literal scan the caller already ran is the only evidence,
340
+ * so the entry stays — outside a git work tree nothing can be committed
341
+ * anyway, and the pre-#2903 behaviour is preserved exactly.
342
+ *
343
+ * @param {string[]} entries - Entries not present line-exact in the file.
344
+ * @param {string} dir
345
+ * @param {string[]} [exempt]
346
+ * @returns {string[]}
347
+ */
348
+ function dropCoveredEntries(entries, dir, exempt = []) {
349
+ return entries.filter(
350
+ (entry) =>
351
+ !isCoverageProbed(entry) ||
352
+ exempt.includes(entry) ||
353
+ isCoveredByGit(dir, entry) !== true,
354
+ );
198
355
  }
199
356
 
200
357
  /**
@@ -210,20 +367,29 @@ function writeSectionFile(filePath, content) {
210
367
  * legacy header — survives verbatim.
211
368
  *
212
369
  * @param {string[]} lines
213
- * @returns {{ lines: string[]; healed: boolean }}
370
+ * @returns {{ lines: string[]; healed: boolean; removed: string[] }}
371
+ * `removed` names the entry line dropped (at most the
372
+ * `.agents/skills/` root) so the caller re-homes it without
373
+ * asking git, which still sees it on disk.
214
374
  */
215
375
  function healLegacySection(lines) {
216
376
  const headerIdx = lines.findIndex(
217
377
  (l) => l.trim() === LEGACY_GITIGNORE_SECTION_HEADER,
218
378
  );
219
- if (headerIdx === -1) return { lines, healed: false };
379
+ if (headerIdx === -1) return { lines, healed: false, removed: [] };
220
380
 
221
381
  let end = headerIdx + 1;
222
- if (end < lines.length && lines[end].trim() === ".agents/skills/") {
382
+ const removed = [];
383
+ if (
384
+ end < lines.length &&
385
+ lines[end].trim() === MANAGED_ROOT_ENTRIES.agentsProject
386
+ ) {
387
+ removed.push(MANAGED_ROOT_ENTRIES.agentsProject);
223
388
  end += 1;
224
389
  }
225
390
  return {
226
391
  lines: [...lines.slice(0, headerIdx), ...lines.slice(end)],
227
392
  healed: true,
393
+ removed,
228
394
  };
229
395
  }
@@ -14,8 +14,13 @@
14
14
  * entries through the same core, so every CLI-authored gitignore line
15
15
  * lives in the one marked section the uninstall remover strips (#2361).
16
16
  *
17
- * Idempotent: entries already present are skipped; a legacy sync-time
18
- * section is healed into the marked section as a side effect.
17
+ * Idempotent: entries already present are skipped, and so are the two
18
+ * skill-root entries when an ignore rule git honours already covers them
19
+ * (`git check-ignore`, #2903). The always-added entries never take that
20
+ * shortcut — `.env.local` holds a live key, and its line in the TRACKED
21
+ * .gitignore is what protects every clone (see `isCoverageProbed` in
22
+ * `../gitignore-section.mjs`). A legacy sync-time section is healed into
23
+ * the marked section as a side effect.
19
24
  */
20
25
 
21
26
  import { placementTargetsFor } from "../file-write.mjs";
@@ -64,11 +69,13 @@ function entriesFor(options) {
64
69
  }
65
70
 
66
71
  /**
67
- * Ensure the init-required paths are in .gitignore. Creates the file
68
- * if it doesn't exist. Idempotent — returns `"skipped"` if every
69
- * required entry is already present and no legacy section needed
70
- * healing, `"created"` if the file didn't exist, `"updated"` if it did
71
- * and at least one entry was missing (or a legacy section was healed).
72
+ * Ensure the init-required paths are gitignored. Creates the file if it
73
+ * doesn't exist and something needs writing. Idempotent — returns
74
+ * `"skipped"` if every required entry is already present (or, for the
75
+ * skill roots only, covered by an existing ignore rule) and no legacy
76
+ * section needed healing, `"created"` if the file didn't exist,
77
+ * `"updated"` if it did and at least one entry was missing (or a legacy
78
+ * section was healed).
72
79
  *
73
80
  * @param {{ vendors?: string[], global?: boolean }} [options]
74
81
  * @returns {{ path: string; action: "created" | "updated" | "skipped"; added: string[] }}
@@ -30,9 +30,12 @@ import {
30
30
  readFileSync,
31
31
  readdirSync,
32
32
  chmodSync,
33
+ statSync,
34
+ utimesSync,
33
35
  } from "node:fs";
34
36
  import { join } from "node:path";
35
37
  import { tmpdir } from "node:os";
38
+ import { execFileSync } from "node:child_process";
36
39
 
37
40
  import {
38
41
  validateFilePath,
@@ -1444,3 +1447,44 @@ describe("cleanupOrphans", () => {
1444
1447
  assert.match(result.cleaned[0], /ghost\.old$/);
1445
1448
  });
1446
1449
  });
1450
+
1451
+ // ── .gitignore pre-flight honours existing ignore rules (#2903) ─────────
1452
+ //
1453
+ // The sync path (SessionStart hook → update → writeSkillDir) is where the
1454
+ // redundant `.claude/skills/` line was observed: a repo already ignoring
1455
+ // `.claude/` had the entry appended on every fresh checkout. Real git
1456
+ // repository so `git check-ignore` is exercised, not mocked.
1457
+
1458
+ describe("writeSkillDir — .gitignore pre-flight honours existing ignore rules (#2903)", () => {
1459
+ beforeEach(() => {
1460
+ setupSandbox();
1461
+ execFileSync("git", ["init", "-q"], { cwd: process.cwd(), stdio: "ignore" });
1462
+ });
1463
+ afterEach(teardownSandbox);
1464
+
1465
+ it("a repo that already ignores both roots keeps its .gitignore byte-identical across a sync write", () => {
1466
+ const gi = join(process.cwd(), ".gitignore");
1467
+ const original = "# team rules\n.claude/\n.agents/\n";
1468
+ writeFileSync(gi, original);
1469
+ const frozen = new Date("2001-02-03T04:05:06Z");
1470
+ utimesSync(gi, frozen, frozen);
1471
+
1472
+ const { written } = writeSkillDir(minimalSkill(), { vendors: ["claudeCode", "cursor"] });
1473
+
1474
+ assert.equal(written.length, 2, "both project roots written");
1475
+ assert.equal(readFileSync(gi, "utf-8"), original);
1476
+ assert.equal(statSync(gi).mtimeMs, frozen.getTime(), ".gitignore must not be rewritten");
1477
+ });
1478
+
1479
+ it("a repo that ignores only .claude/ gets .agents/skills/ and nothing else", () => {
1480
+ const gi = join(process.cwd(), ".gitignore");
1481
+ writeFileSync(gi, ".claude/\n");
1482
+
1483
+ writeSkillDir(minimalSkill(), { vendors: ["claudeCode", "cursor"] });
1484
+
1485
+ assert.equal(
1486
+ readFileSync(gi, "utf-8"),
1487
+ ".claude/\n\n# SkillRepo CLI (added by `skillrepo init`)\n.agents/skills/\n",
1488
+ );
1489
+ });
1490
+ });
@@ -10,9 +10,19 @@
10
10
 
11
11
  import { describe, it, beforeEach, afterEach } from "node:test";
12
12
  import assert from "node:assert/strict";
13
- import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
14
- import { join } from "node:path";
13
+ import {
14
+ mkdtempSync,
15
+ mkdirSync,
16
+ writeFileSync,
17
+ readFileSync,
18
+ rmSync,
19
+ existsSync,
20
+ statSync,
21
+ utimesSync,
22
+ } from "node:fs";
23
+ import { join, dirname } from "node:path";
15
24
  import { tmpdir } from "node:os";
25
+ import { execFileSync, spawnSync } from "node:child_process";
16
26
 
17
27
  import {
18
28
  ensureGitignoreEntries,
@@ -23,6 +33,7 @@ import {
23
33
  GITIGNORE_SECTION_HEADER,
24
34
  LEGACY_GITIGNORE_SECTION_HEADER,
25
35
  } from "../../lib/artifact-registry.mjs";
36
+ import { isolatePathEnv } from "../helpers/path-isolation.mjs";
26
37
 
27
38
  let originalCwd;
28
39
  let tempDir;
@@ -150,6 +161,22 @@ describe("ensureGitignoreEntries — legacy sync-time section heal", () => {
150
161
  assert.equal(matches.length, 1);
151
162
  });
152
163
 
164
+ it("re-homes the legacy .agents/skills/ line even when the caller did not ask for that root (Claude-only sync)", () => {
165
+ // Before #2903 a claudeProject-only call healed the legacy section
166
+ // and silently dropped the `.agents/skills/` protection it removed.
167
+ writeFileSync(
168
+ join(tempDir, ".gitignore"),
169
+ `node_modules/\n\n${LEGACY_GITIGNORE_SECTION_HEADER}\n.agents/skills/\n`,
170
+ );
171
+ const result = ensureGitignoreEntries([".claude/skills/"]);
172
+ assert.equal(result.action, "updated");
173
+ assert.deepEqual(result.added, [".claude/skills/", ".agents/skills/"]);
174
+ assert.equal(
175
+ gitignore(),
176
+ `node_modules/\n\n${GITIGNORE_SECTION_HEADER}\n.claude/skills/\n.agents/skills/\n`,
177
+ );
178
+ });
179
+
153
180
  it("never removes user content sitting under the legacy header", () => {
154
181
  writeFileSync(
155
182
  join(tempDir, ".gitignore"),
@@ -211,3 +238,243 @@ describe("ensureManagedRootIgnores — target mapping", () => {
211
238
  ]);
212
239
  });
213
240
  });
241
+
242
+ // ── Existing ignore rules (#2903) ───────────────────────────────────────
243
+ //
244
+ // The literal scan above cannot see that `.claude/` already ignores
245
+ // `.claude/skills/`; git can. These run against REAL repositories so the
246
+ // probe's semantics (trailing slash, --no-index, cwd anchoring, every
247
+ // ignore source git honours) are exercised end-to-end, not mocked.
248
+
249
+ function git(args, cwd) {
250
+ execFileSync("git", args, { cwd, stdio: "ignore" });
251
+ }
252
+
253
+ /** A fixed, obviously-old mtime: proves "no write happened" without racing the clock. */
254
+ const FROZEN_MTIME = new Date("2001-02-03T04:05:06Z");
255
+
256
+ function freezeMtime(file) {
257
+ utimesSync(file, FROZEN_MTIME, FROZEN_MTIME);
258
+ }
259
+
260
+ /** What git itself says about a path in `cwd` — the property the writer must guarantee. */
261
+ function gitIgnores(entry, cwd) {
262
+ return (
263
+ spawnSync("git", ["check-ignore", "-q", "--no-index", "--", entry], {
264
+ cwd,
265
+ stdio: "ignore",
266
+ }).status === 0
267
+ );
268
+ }
269
+
270
+ describe("ensureGitignoreEntries — existing ignore rules are honoured (#2903)", () => {
271
+ beforeEach(() => {
272
+ git(["init", "-q"], tempDir);
273
+ });
274
+
275
+ it("appends only the entry no existing rule covers (.claude/ already covers .claude/skills/)", () => {
276
+ writeFileSync(join(tempDir, ".gitignore"), "node_modules/\n.claude/\n");
277
+ const result = ensureGitignoreEntries([".claude/skills/", ".agents/skills/"]);
278
+ assert.equal(result.action, "updated");
279
+ assert.deepEqual(result.added, [".agents/skills/"]);
280
+ assert.equal(
281
+ gitignore(),
282
+ `node_modules/\n.claude/\n\n${GITIGNORE_SECTION_HEADER}\n.agents/skills/\n`,
283
+ );
284
+ // Both roots end up ignored — one by the user's rule, one by ours.
285
+ assert.ok(gitIgnores(".claude/skills/", tempDir));
286
+ assert.ok(gitIgnores(".agents/skills/", tempDir));
287
+ });
288
+
289
+ it("does not touch the file at all when every entry is already covered — no comment-only write", () => {
290
+ const file = join(tempDir, ".gitignore");
291
+ const original = "# team rules\n.claude/\n.agents/\n";
292
+ writeFileSync(file, original);
293
+ freezeMtime(file);
294
+ const result = ensureGitignoreEntries([".claude/skills/", ".agents/skills/"]);
295
+ assert.deepEqual(result, { path: ".gitignore", action: "skipped", added: [] });
296
+ assert.equal(readFileSync(file, "utf-8"), original, "byte-identical");
297
+ assert.equal(
298
+ statSync(file).mtimeMs,
299
+ FROZEN_MTIME.getTime(),
300
+ "the file must not be rewritten, not even with identical bytes",
301
+ );
302
+ });
303
+
304
+ it("memoizes probe answers per process — a second call needs no git at all", () => {
305
+ writeFileSync(join(tempDir, ".gitignore"), ".claude/\n.agents/\n");
306
+ const first = ensureGitignoreEntries([".claude/skills/", ".agents/skills/"]);
307
+ assert.equal(first.action, "skipped");
308
+ // With git unreachable a fresh probe would return "no signal" and
309
+ // the literal scan would append both roots; the memo answers instead.
310
+ // (Nothing was written by the first call, so nothing cleared it.)
311
+ const restorePath = isolatePathEnv();
312
+ try {
313
+ const second = ensureGitignoreEntries([".claude/skills/", ".agents/skills/"]);
314
+ assert.deepEqual(second, { path: ".gitignore", action: "skipped", added: [] });
315
+ assert.equal(gitignore(), ".claude/\n.agents/\n");
316
+ } finally {
317
+ restorePath();
318
+ }
319
+ });
320
+
321
+ it("creates the file with every entry when no rule covers them", () => {
322
+ const result = ensureGitignoreEntries([".claude/skills/", ".agents/skills/"]);
323
+ assert.equal(result.action, "created");
324
+ assert.deepEqual(result.added, [".claude/skills/", ".agents/skills/"]);
325
+ assert.equal(
326
+ gitignore(),
327
+ `${GITIGNORE_SECTION_HEADER}\n.claude/skills/\n.agents/skills/\n`,
328
+ );
329
+ });
330
+
331
+ it("is idempotent — the run after a partial append changes nothing", () => {
332
+ const file = join(tempDir, ".gitignore");
333
+ writeFileSync(file, ".claude/\n");
334
+ const first = ensureGitignoreEntries([".claude/skills/", ".agents/skills/"]);
335
+ assert.equal(first.action, "updated");
336
+ const afterFirst = readFileSync(file, "utf-8");
337
+ freezeMtime(file);
338
+ const second = ensureGitignoreEntries([".claude/skills/", ".agents/skills/"]);
339
+ assert.deepEqual(second, { path: ".gitignore", action: "skipped", added: [] });
340
+ assert.equal(readFileSync(file, "utf-8"), afterFirst);
341
+ assert.equal(statSync(file).mtimeMs, FROZEN_MTIME.getTime());
342
+ });
343
+
344
+ it("a fresh .gitignore honours coverage from a nested .gitignore", () => {
345
+ mkdirSync(join(tempDir, ".claude"));
346
+ writeFileSync(join(tempDir, ".claude", ".gitignore"), "skills/\n");
347
+ const result = ensureGitignoreEntries([".claude/skills/", ".agents/skills/"]);
348
+ assert.equal(result.action, "created");
349
+ assert.deepEqual(result.added, [".agents/skills/"]);
350
+ assert.equal(gitignore(), `${GITIGNORE_SECTION_HEADER}\n.agents/skills/\n`);
351
+ });
352
+
353
+ it("creates nothing when every entry is covered by .git/info/exclude", () => {
354
+ writeFileSync(join(tempDir, ".git", "info", "exclude"), ".claude/\n.agents/\n");
355
+ const result = ensureGitignoreEntries([".claude/skills/", ".agents/skills/"]);
356
+ assert.deepEqual(result, { path: ".gitignore", action: "skipped", added: [] });
357
+ assert.ok(!existsSync(join(tempDir, ".gitignore")), "no file may be created");
358
+ });
359
+
360
+ it("the init-only entries stay on the literal scan even when a tracked rule covers them", () => {
361
+ // `.env.local` holds a live access key: its line in the TRACKED
362
+ // .gitignore protects every clone, so a covering rule never
363
+ // withholds it. Only the managed skill roots consult git.
364
+ writeFileSync(join(tempDir, ".gitignore"), ".env*\n.claude/\n");
365
+ const result = ensureGitignoreEntries([
366
+ ".env.local",
367
+ ".claude/settings.local.json",
368
+ ".claude/skills/",
369
+ ]);
370
+ assert.equal(result.action, "updated");
371
+ assert.deepEqual(result.added, [".env.local", ".claude/settings.local.json"]);
372
+ assert.equal(
373
+ gitignore(),
374
+ `.env*\n.claude/\n\n${GITIGNORE_SECTION_HEADER}\n.env.local\n.claude/settings.local.json\n`,
375
+ );
376
+ });
377
+
378
+ it("a personal ignore source never withholds the credential entry (.env.local covered by .git/info/exclude)", () => {
379
+ writeFileSync(join(tempDir, ".git", "info", "exclude"), ".env.local\n.claude/\n");
380
+ writeFileSync(join(tempDir, ".gitignore"), "node_modules/\n");
381
+ const result = ensureGitignoreEntries([".env.local", ".claude/skills/"]);
382
+ assert.equal(result.action, "updated");
383
+ assert.deepEqual(result.added, [".env.local"], "the skill root is per-checkout, the key is not");
384
+ });
385
+
386
+ it("heals the legacy section AND re-homes .agents/skills/ — the removed legacy line is not mistaken for coverage", () => {
387
+ writeFileSync(
388
+ join(tempDir, ".gitignore"),
389
+ `.claude/\n\n${LEGACY_GITIGNORE_SECTION_HEADER}\n.agents/skills/\n`,
390
+ );
391
+ const result = ensureGitignoreEntries([".claude/skills/", ".agents/skills/"]);
392
+ assert.equal(result.action, "updated");
393
+ assert.deepEqual(result.added, [".agents/skills/"]);
394
+ const content = gitignore();
395
+ assert.ok(!content.includes(LEGACY_GITIGNORE_SECTION_HEADER));
396
+ assert.equal(content, `.claude/\n\n${GITIGNORE_SECTION_HEADER}\n.agents/skills/\n`);
397
+ assert.ok(
398
+ gitIgnores(".agents/skills/", tempDir),
399
+ "the heal must never drop the root's only protection",
400
+ );
401
+ });
402
+
403
+ it("heal re-homes .agents/skills/ in ONE write even when another rule covers it (git still sees the legacy line on disk)", () => {
404
+ const file = join(tempDir, ".gitignore");
405
+ writeFileSync(
406
+ file,
407
+ `.claude/\n.agents/\n\n${LEGACY_GITIGNORE_SECTION_HEADER}\n.agents/skills/\n`,
408
+ );
409
+ const result = ensureGitignoreEntries([".claude/skills/", ".agents/skills/"]);
410
+ assert.deepEqual(result, {
411
+ path: ".gitignore",
412
+ action: "updated",
413
+ added: [".agents/skills/"],
414
+ });
415
+ // The heal and the re-home land together — never a healed-but-
416
+ // unprotected intermediate file; `.claude/skills/` stays covered
417
+ // by `.claude/` and is not added.
418
+ assert.equal(
419
+ readFileSync(file, "utf-8"),
420
+ `.claude/\n.agents/\n\n${GITIGNORE_SECTION_HEADER}\n.agents/skills/\n`,
421
+ );
422
+ });
423
+
424
+ it("heal with nothing to re-home writes the healed file once and reports updated", () => {
425
+ // Legacy header WITHOUT its `.agents/skills/` line: nothing was
426
+ // removed, so both roots go through the probe and are covered.
427
+ writeFileSync(
428
+ join(tempDir, ".gitignore"),
429
+ `.claude/\n.agents/\n\n${LEGACY_GITIGNORE_SECTION_HEADER}\nmy-own-line/\n`,
430
+ );
431
+ const result = ensureGitignoreEntries([".claude/skills/", ".agents/skills/"]);
432
+ assert.deepEqual(result, { path: ".gitignore", action: "updated", added: [] });
433
+ assert.equal(gitignore(), ".claude/\n.agents/\n\nmy-own-line/\n");
434
+ });
435
+
436
+ it("probes relative to the .gitignore's own directory, not the process cwd (baseDir anchoring)", () => {
437
+ // An ANCHORED rule at the repo root matches `.claude/skills/` only
438
+ // when the question is asked from the root. A probe run from `sub/`
439
+ // would ask about `sub/.claude/skills/`, hear "not ignored", and
440
+ // append a redundant line.
441
+ writeFileSync(join(tempDir, ".gitignore"), "/.claude/\n");
442
+ const sub = join(tempDir, "sub");
443
+ mkdirSync(sub);
444
+ process.cwd = () => sub;
445
+ const result = ensureGitignoreEntries([".claude/skills/"], { baseDir: tempDir });
446
+ assert.deepEqual(result, { path: ".gitignore", action: "skipped", added: [] });
447
+ assert.equal(gitignore(), "/.claude/\n");
448
+ });
449
+ });
450
+
451
+ describe("ensureGitignoreEntries — literal-scan fallback when git has no answer (#2903)", () => {
452
+ it("outside a git work tree a broader rule is not recognised (pre-#2903 behaviour, unchanged)", () => {
453
+ // Fence repository discovery at the temp dir's parent so this test
454
+ // cannot accidentally find a repository above the temp tree.
455
+ const prior = process.env.GIT_CEILING_DIRECTORIES;
456
+ process.env.GIT_CEILING_DIRECTORIES = dirname(tempDir);
457
+ try {
458
+ writeFileSync(join(tempDir, ".gitignore"), ".claude/\n");
459
+ const result = ensureGitignoreEntries([".claude/skills/"]);
460
+ assert.equal(result.action, "updated");
461
+ assert.deepEqual(result.added, [".claude/skills/"]);
462
+ } finally {
463
+ if (prior === undefined) delete process.env.GIT_CEILING_DIRECTORIES;
464
+ else process.env.GIT_CEILING_DIRECTORIES = prior;
465
+ }
466
+ });
467
+
468
+ it("with no git binary on PATH the literal scan decides", () => {
469
+ git(["init", "-q"], tempDir);
470
+ writeFileSync(join(tempDir, ".gitignore"), ".claude/\n");
471
+ const restorePath = isolatePathEnv();
472
+ try {
473
+ const result = ensureGitignoreEntries([".claude/skills/"]);
474
+ assert.equal(result.action, "updated");
475
+ assert.deepEqual(result.added, [".claude/skills/"]);
476
+ } finally {
477
+ restorePath();
478
+ }
479
+ });
480
+ });
@@ -21,6 +21,7 @@ import assert from "node:assert/strict";
21
21
  import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
22
22
  import { join } from "node:path";
23
23
  import { tmpdir } from "node:os";
24
+ import { execFileSync } from "node:child_process";
24
25
 
25
26
  import { mergeGitignore } from "../../lib/mergers/gitignore.mjs";
26
27
 
@@ -186,3 +187,50 @@ describe("mergeGitignore — line-exact matching", () => {
186
187
  assert.equal(result.action, "skipped");
187
188
  });
188
189
  });
190
+
191
+ // ── Existing ignore rules (#2903) ───────────────────────────────────────
192
+
193
+ describe("mergeGitignore — existing ignore rules are honoured for the skill roots (#2903)", () => {
194
+ it("in a repo that already ignores .claude/, init adds .agents/skills/ but never .claude/skills/", () => {
195
+ execFileSync("git", ["init", "-q"], { cwd: tempDir, stdio: "ignore" });
196
+ writeFileSync(join(tempDir, ".gitignore"), "node_modules/\n.claude/\n");
197
+
198
+ const result = mergeGitignore({ vendors: ["claudeCode", "cursor"] });
199
+
200
+ assert.equal(result.action, "updated");
201
+ // The init-only entries are literal-scan (a tracked-file contract,
202
+ // see isCoverageProbed); the skill roots consult git.
203
+ assert.deepEqual(result.added, [
204
+ ".env.local",
205
+ ".claude/settings.local.json",
206
+ ".agents/skills/",
207
+ ]);
208
+ assert.equal(
209
+ readFileSync(join(tempDir, ".gitignore"), "utf-8"),
210
+ "node_modules/\n.claude/\n\n# SkillRepo CLI (added by `skillrepo init`)\n" +
211
+ ".env.local\n.claude/settings.local.json\n.agents/skills/\n",
212
+ );
213
+ });
214
+
215
+ it("in a repo that already ignores both skill roots and lists the init-only entries, the file is left byte-identical", () => {
216
+ execFileSync("git", ["init", "-q"], { cwd: tempDir, stdio: "ignore" });
217
+ const original = ".env.local\n.claude/settings.local.json\n.claude/\n.agents/\n";
218
+ writeFileSync(join(tempDir, ".gitignore"), original);
219
+
220
+ const result = mergeGitignore({ vendors: ["claudeCode", "cursor"] });
221
+
222
+ assert.deepEqual(result, { path: ".gitignore", action: "skipped", added: [] });
223
+ assert.equal(readFileSync(join(tempDir, ".gitignore"), "utf-8"), original);
224
+ });
225
+
226
+ it("a personal global excludes rule never withholds the credential entry", () => {
227
+ execFileSync("git", ["init", "-q"], { cwd: tempDir, stdio: "ignore" });
228
+ writeFileSync(join(tempDir, ".git", "info", "exclude"), ".env*\n.claude/\n.agents/\n");
229
+ writeFileSync(join(tempDir, ".gitignore"), "node_modules/\n");
230
+
231
+ const result = mergeGitignore({ vendors: ["claudeCode", "cursor"] });
232
+
233
+ assert.equal(result.action, "updated");
234
+ assert.deepEqual(result.added, [".env.local", ".claude/settings.local.json"]);
235
+ });
236
+ });