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 +5 -3
- package/package.json +1 -1
- package/src/commands/init.mjs +3 -2
- package/src/lib/file-write.mjs +6 -4
- package/src/lib/gitignore-section.mjs +190 -24
- package/src/lib/mergers/gitignore.mjs +14 -7
- package/src/test/lib/file-write.test.mjs +44 -0
- package/src/test/lib/gitignore-section.test.mjs +269 -2
- package/src/test/mergers/gitignore.test.mjs +48 -0
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/`
|
|
666
|
-
|
|
667
|
-
|
|
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
package/src/commands/init.mjs
CHANGED
|
@@ -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" →
|
|
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
|
|
1081
|
+
" Both paths are gitignored — skills are a per-developer " +
|
|
1081
1082
|
"cache, not committed.\n\n",
|
|
1082
1083
|
);
|
|
1083
1084
|
}
|
package/src/lib/file-write.mjs
CHANGED
|
@@ -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
|
|
470
|
-
//
|
|
471
|
-
//
|
|
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
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
87
|
-
* legacy section
|
|
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 —
|
|
112
|
-
|
|
113
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
18
|
-
*
|
|
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
|
|
68
|
-
*
|
|
69
|
-
* required entry is already present
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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 {
|
|
14
|
-
|
|
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
|
+
});
|