shadow-git 0.1.0 → 1.0.3

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 (54) hide show
  1. package/.claude/settings.local.json +5 -1
  2. package/.temp/.sgit/index +0 -0
  3. package/.temp/.sgit/info/exclude +16 -7
  4. package/.temp/.sgit/objects/03/7f4e37a02573e40ab78b35dd4b442cc6110bae +0 -0
  5. package/.temp/.sgit/objects/2f/f0fd85dec20039eef6ff63ead905778a88843f +0 -0
  6. package/.temp/.sgit/objects/c3/2432f724b666f647fe21a1ce16535cd7693f74 +0 -0
  7. package/.temp/.sgit/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +0 -0
  8. package/.temp/.sgit/objects/ee/9dd32cca3b134623d184eb4de5805704c493e3 +0 -0
  9. package/.temp/.sgit.json +6 -2
  10. package/.temp/README.md +0 -3
  11. package/.temp/rec1/rec2/test +0 -0
  12. package/.temp/temp/shouldnt-track +0 -0
  13. package/.temp/test/1 +0 -0
  14. package/CLAUDE.md +10 -2
  15. package/README.md +17 -30
  16. package/dist/cli.js +810 -119
  17. package/package.json +1 -1
  18. package/src/cli.ts +50 -19
  19. package/src/commands/debug/gitignores.ts +28 -0
  20. package/src/commands/debug/index.ts +32 -0
  21. package/src/commands/track/add.ts +11 -0
  22. package/src/commands/track/clone.ts +213 -0
  23. package/src/commands/track/completion.ts +50 -15
  24. package/src/commands/track/destroy.ts +97 -19
  25. package/src/commands/track/ignore.ts +55 -0
  26. package/src/commands/track/index.ts +23 -1
  27. package/src/commands/track/init.ts +5 -2
  28. package/src/commands/track/sync.ts +1 -1
  29. package/src/commands/track/unignore.ts +50 -0
  30. package/src/commands/track/verify.ts +93 -20
  31. package/src/core/errors.ts +23 -2
  32. package/src/core/exclude.ts +40 -43
  33. package/src/core/gitignore.ts +242 -0
  34. package/src/core/hooks.ts +19 -0
  35. package/src/core/manifest.ts +33 -1
  36. package/src/core/paths.ts +21 -3
  37. package/src/core/repository.ts +17 -2
  38. package/tests/commands/track/add.test.ts +31 -1
  39. package/tests/commands/track/clone.test.ts +315 -0
  40. package/tests/commands/track/completion.test.ts +1 -1
  41. package/tests/commands/track/destroy.test.ts +68 -2
  42. package/tests/commands/track/ignore.test.ts +109 -0
  43. package/tests/commands/track/init.test.ts +27 -4
  44. package/tests/commands/track/remove.test.ts +1 -1
  45. package/tests/commands/track/sync.test.ts +3 -3
  46. package/tests/commands/track/unignore.test.ts +72 -0
  47. package/tests/commands/track/verify.test.ts +132 -1
  48. package/tests/core/exclude.test.ts +107 -19
  49. package/tests/core/gitignore.test.ts +561 -0
  50. package/tests/core/hooks.test.ts +34 -0
  51. package/tests/core/paths.test.ts +24 -0
  52. package/tests/core/repository.test.ts +39 -5
  53. package/tests/helpers/guard-child.mjs +125 -0
  54. package/tests/helpers.ts +37 -0
@@ -0,0 +1,561 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { writeFile, mkdir, readFile } from 'node:fs/promises';
3
+ import {
4
+ accessSync,
5
+ constants,
6
+ writeFileSync,
7
+ unlinkSync,
8
+ renameSync,
9
+ } from 'node:fs';
10
+ import { join, resolve } from 'node:path';
11
+ import { spawnSync, spawn } from 'node:child_process';
12
+ import { createTempGitRepo, removeTempDir } from '../helpers';
13
+ import { trackInit } from '../../src/commands/track/init';
14
+ import { trackAdd } from '../../src/commands/track/add';
15
+ import {
16
+ disableGitignores,
17
+ enableGitignores,
18
+ findStaleGitignores,
19
+ isGuardActive,
20
+ setupGitignoreGuard,
21
+ } from '../../src/core/gitignore';
22
+ import { StaleGitignoreError } from '../../src/core/errors';
23
+
24
+ const HELPER_SCRIPT = resolve('tests/helpers/guard-child.mjs');
25
+
26
+ function fileExists(path: string): boolean {
27
+ try {
28
+ accessSync(path, constants.F_OK);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ describe('gitignore manipulator', () => {
36
+ let tempDir: string;
37
+ const originalCwd = process.cwd();
38
+
39
+ beforeEach(async () => {
40
+ tempDir = await createTempGitRepo();
41
+ process.chdir(tempDir);
42
+ await trackInit();
43
+
44
+ // Create a tracked directory with a .gitignore inside
45
+ await mkdir(join(tempDir, 'secret'), { recursive: true });
46
+ await writeFile(join(tempDir, 'secret', '.gitignore'), 'local-only\n');
47
+ await writeFile(join(tempDir, 'secret', 'data.txt'), 'secret data');
48
+ await trackAdd(['secret']);
49
+ });
50
+
51
+ afterEach(async () => {
52
+ process.chdir(originalCwd);
53
+ await removeTempDir(tempDir);
54
+ });
55
+
56
+ // ---------------------------------------------------------------
57
+ // disableGitignores
58
+ // ---------------------------------------------------------------
59
+ describe('disableGitignores', () => {
60
+ it('renames .gitignore to .gitignore-sgit.bat in tracked directory', () => {
61
+ const disabled = disableGitignores(tempDir, ['secret/']);
62
+ expect(disabled).toContain('secret/.gitignore');
63
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(false);
64
+ expect(fileExists(join(tempDir, 'secret', '.gitignore-sgit.bat'))).toBe(
65
+ true,
66
+ );
67
+ });
68
+
69
+ it('handles nested .gitignore files in tracked directory', async () => {
70
+ await mkdir(join(tempDir, 'secret', 'sub'), { recursive: true });
71
+ await writeFile(
72
+ join(tempDir, 'secret', 'sub', '.gitignore'),
73
+ 'nested\n',
74
+ );
75
+
76
+ const disabled = disableGitignores(tempDir, ['secret/']);
77
+ expect(disabled).toContain('secret/.gitignore');
78
+ expect(disabled).toContain('secret/sub/.gitignore');
79
+ });
80
+
81
+ it('disables ancestor .gitignore for file entries', async () => {
82
+ // secret/data.txt is a file entry — its parent secret/ has a .gitignore
83
+ const disabled = disableGitignores(tempDir, ['secret/data.txt']);
84
+ expect(disabled).toContain('secret/.gitignore');
85
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(false);
86
+ });
87
+
88
+ it('disables root .gitignore for any manifest path', async () => {
89
+ await writeFile(join(tempDir, '.gitignore'), '*.log\n');
90
+ const disabled = disableGitignores(tempDir, ['secret/']);
91
+ expect(disabled).toContain('.gitignore');
92
+ expect(fileExists(join(tempDir, '.gitignore'))).toBe(false);
93
+ });
94
+
95
+ it('disables root .gitignore for root-level file entries', async () => {
96
+ await writeFile(join(tempDir, '.gitignore'), '*.log\n');
97
+ const disabled = disableGitignores(tempDir, ['README.md']);
98
+ expect(disabled).toContain('.gitignore');
99
+ });
100
+
101
+ it('disables all ancestor .gitignore files for deep file path', async () => {
102
+ await mkdir(join(tempDir, 'a', 'b', 'c'), { recursive: true });
103
+ await writeFile(join(tempDir, 'a', '.gitignore'), 'a-ignore\n');
104
+ await writeFile(join(tempDir, 'a', 'b', '.gitignore'), 'b-ignore\n');
105
+ await writeFile(join(tempDir, 'a', 'b', 'c', '.gitignore'), 'c-ignore\n');
106
+ await writeFile(join(tempDir, 'a', 'b', 'c', 'file.txt'), 'data');
107
+
108
+ const disabled = disableGitignores(tempDir, ['a/b/c/file.txt']);
109
+ expect(disabled).toContain('a/.gitignore');
110
+ expect(disabled).toContain('a/b/.gitignore');
111
+ expect(disabled).toContain('a/b/c/.gitignore');
112
+ });
113
+
114
+ it('ignores .gitignore files in unrelated directories', async () => {
115
+ await mkdir(join(tempDir, 'other'), { recursive: true });
116
+ await writeFile(join(tempDir, 'other', '.gitignore'), 'stuff\n');
117
+
118
+ const disabled = disableGitignores(tempDir, ['secret/']);
119
+ expect(disabled).not.toContain('other/.gitignore');
120
+ expect(fileExists(join(tempDir, 'other', '.gitignore'))).toBe(true);
121
+ });
122
+
123
+ it('does not affect sibling subdirectory .gitignore', async () => {
124
+ // test/1 is tracked but test/test2/.gitignore should NOT be affected
125
+ await mkdir(join(tempDir, 'test', 'test2'), { recursive: true });
126
+ await writeFile(join(tempDir, 'test', '.gitignore'), 'test-gi\n');
127
+ await writeFile(join(tempDir, 'test', 'test2', '.gitignore'), 'test2-gi\n');
128
+ await writeFile(join(tempDir, 'test', '1'), 'data');
129
+
130
+ const disabled = disableGitignores(tempDir, ['test/1']);
131
+ expect(disabled).toContain('test/.gitignore');
132
+ expect(disabled).not.toContain('test/test2/.gitignore');
133
+ expect(fileExists(join(tempDir, 'test', 'test2', '.gitignore'))).toBe(true);
134
+ });
135
+
136
+ it('returns empty array when no .gitignore files found', () => {
137
+ unlinkSync(join(tempDir, 'secret', '.gitignore'));
138
+ const disabled = disableGitignores(tempDir, ['secret/']);
139
+ expect(disabled).toEqual([]);
140
+ });
141
+
142
+ it('throws StaleGitignoreError when .gitignore-sgit.bat already exists', async () => {
143
+ await writeFile(
144
+ join(tempDir, 'secret', '.gitignore-sgit.bat'),
145
+ 'stale\n',
146
+ );
147
+ expect(() => disableGitignores(tempDir, ['secret/'])).toThrow(
148
+ StaleGitignoreError,
149
+ );
150
+ });
151
+
152
+ it('matches .temp folder scenario exactly', async () => {
153
+ // Reproduce the .temp manifest:
154
+ // ["temp.md", "README.md", "temp/should-track", "temp2/", "test/1", "rec1/rec2/test"]
155
+ //
156
+ // Should disable: root, temp/, temp2/, temp2/temp3/, test/, rec1/, rec1/rec2/
157
+ // Should NOT disable: iii/, test/test2/
158
+ await writeFile(join(tempDir, '.gitignore'), 'root-gi\n');
159
+ await writeFile(join(tempDir, 'temp.md'), 'data');
160
+ await writeFile(join(tempDir, 'README.md'), 'data');
161
+
162
+ await mkdir(join(tempDir, 'temp'), { recursive: true });
163
+ await writeFile(join(tempDir, 'temp', '.gitignore'), 'temp-gi\n');
164
+ await writeFile(join(tempDir, 'temp', 'should-track'), 'data');
165
+
166
+ await mkdir(join(tempDir, 'temp2', 'temp3'), { recursive: true });
167
+ await writeFile(join(tempDir, 'temp2', '.gitignore'), 'temp2-gi\n');
168
+ await writeFile(join(tempDir, 'temp2', 'temp3', '.gitignore'), 'temp3-gi\n');
169
+
170
+ await mkdir(join(tempDir, 'test', 'test2'), { recursive: true });
171
+ await writeFile(join(tempDir, 'test', '.gitignore'), 'test-gi\n');
172
+ await writeFile(join(tempDir, 'test', 'test2', '.gitignore'), 'test2-gi\n');
173
+ await writeFile(join(tempDir, 'test', '1'), 'data');
174
+
175
+ await mkdir(join(tempDir, 'rec1', 'rec2'), { recursive: true });
176
+ await writeFile(join(tempDir, 'rec1', '.gitignore'), 'rec1-gi\n');
177
+ await writeFile(join(tempDir, 'rec1', 'rec2', '.gitignore'), 'rec2-gi\n');
178
+ await writeFile(join(tempDir, 'rec1', 'rec2', 'test'), 'data');
179
+
180
+ await mkdir(join(tempDir, 'iii'), { recursive: true });
181
+ await writeFile(join(tempDir, 'iii', '.gitignore'), 'iii-gi\n');
182
+
183
+ const manifest = [
184
+ 'temp.md',
185
+ 'README.md',
186
+ 'temp/should-track',
187
+ 'temp2/',
188
+ 'test/1',
189
+ 'rec1/rec2/test',
190
+ ];
191
+
192
+ const disabled = disableGitignores(tempDir, manifest);
193
+
194
+ // SHOULD be disabled (7):
195
+ expect(disabled).toContain('.gitignore');
196
+ expect(disabled).toContain('temp/.gitignore');
197
+ expect(disabled).toContain('temp2/.gitignore');
198
+ expect(disabled).toContain('temp2/temp3/.gitignore');
199
+ expect(disabled).toContain('test/.gitignore');
200
+ expect(disabled).toContain('rec1/.gitignore');
201
+ expect(disabled).toContain('rec1/rec2/.gitignore');
202
+
203
+ // SHOULD NOT be disabled (2):
204
+ expect(disabled).not.toContain('iii/.gitignore');
205
+ expect(disabled).not.toContain('test/test2/.gitignore');
206
+ expect(fileExists(join(tempDir, 'iii', '.gitignore'))).toBe(true);
207
+ expect(fileExists(join(tempDir, 'test', 'test2', '.gitignore'))).toBe(true);
208
+ });
209
+ });
210
+
211
+ // ---------------------------------------------------------------
212
+ // enableGitignores
213
+ // ---------------------------------------------------------------
214
+ describe('enableGitignores', () => {
215
+ it('renames .gitignore-sgit.bat back to .gitignore', () => {
216
+ disableGitignores(tempDir, ['secret/']);
217
+ const enabled = enableGitignores(tempDir, ['secret/']);
218
+ expect(enabled).toHaveLength(1);
219
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(true);
220
+ expect(fileExists(join(tempDir, 'secret', '.gitignore-sgit.bat'))).toBe(
221
+ false,
222
+ );
223
+ });
224
+
225
+ it('restores ancestor .gitignore for file entries', async () => {
226
+ await writeFile(join(tempDir, '.gitignore'), 'root\n');
227
+ disableGitignores(tempDir, ['secret/data.txt']);
228
+ enableGitignores(tempDir, ['secret/data.txt']);
229
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(true);
230
+ expect(fileExists(join(tempDir, '.gitignore'))).toBe(true);
231
+ });
232
+
233
+ it('no-ops when no disabled files exist', () => {
234
+ const enabled = enableGitignores(tempDir, ['secret/']);
235
+ expect(enabled).toEqual([]);
236
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(true);
237
+ });
238
+
239
+ it('preserves .gitignore content through disable/enable cycle', async () => {
240
+ const original = await readFile(
241
+ join(tempDir, 'secret', '.gitignore'),
242
+ 'utf-8',
243
+ );
244
+ disableGitignores(tempDir, ['secret/']);
245
+ enableGitignores(tempDir, ['secret/']);
246
+ const restored = await readFile(
247
+ join(tempDir, 'secret', '.gitignore'),
248
+ 'utf-8',
249
+ );
250
+ expect(restored).toBe(original);
251
+ });
252
+ });
253
+
254
+ // ---------------------------------------------------------------
255
+ // findStaleGitignores
256
+ // ---------------------------------------------------------------
257
+ describe('findStaleGitignores', () => {
258
+ it('finds .gitignore-sgit.bat in tracked dirs', async () => {
259
+ await writeFile(
260
+ join(tempDir, 'secret', '.gitignore-sgit.bat'),
261
+ 'stale\n',
262
+ );
263
+ const stale = findStaleGitignores(tempDir, ['secret/']);
264
+ expect(stale).toEqual(['secret/.gitignore-sgit.bat']);
265
+ });
266
+
267
+ it('finds .gitignore-sgit.bat in ancestor dirs for file entries', async () => {
268
+ await writeFile(
269
+ join(tempDir, 'secret', '.gitignore-sgit.bat'),
270
+ 'stale\n',
271
+ );
272
+ const stale = findStaleGitignores(tempDir, ['secret/data.txt']);
273
+ expect(stale).toContain('secret/.gitignore-sgit.bat');
274
+ });
275
+
276
+ it('finds .gitignore-sgit.bat at root', async () => {
277
+ await writeFile(join(tempDir, '.gitignore-sgit.bat'), 'stale\n');
278
+ const stale = findStaleGitignores(tempDir, ['secret/']);
279
+ expect(stale).toContain('.gitignore-sgit.bat');
280
+ });
281
+
282
+ it('returns empty when no stale files exist', () => {
283
+ const stale = findStaleGitignores(tempDir, ['secret/']);
284
+ expect(stale).toEqual([]);
285
+ });
286
+ });
287
+
288
+ // ---------------------------------------------------------------
289
+ // isGuardActive
290
+ // ---------------------------------------------------------------
291
+ describe('isGuardActive', () => {
292
+ it('returns false when no lock file', () => {
293
+ expect(isGuardActive(tempDir)).toBe(false);
294
+ });
295
+
296
+ it('returns true when lock file has current PID', () => {
297
+ const lockPath = join(tempDir, '.sgit', '.gitignore-guard');
298
+ writeFileSync(lockPath, String(process.pid));
299
+ expect(isGuardActive(tempDir)).toBe(true);
300
+ unlinkSync(lockPath);
301
+ });
302
+
303
+ it('returns false when lock file has dead PID', () => {
304
+ const lockPath = join(tempDir, '.sgit', '.gitignore-guard');
305
+ writeFileSync(lockPath, '99999999');
306
+ expect(isGuardActive(tempDir)).toBe(false);
307
+ });
308
+ });
309
+
310
+ // ---------------------------------------------------------------
311
+ // setupGitignoreGuard — in-process tests
312
+ // ---------------------------------------------------------------
313
+ describe('setupGitignoreGuard', () => {
314
+ it('disables gitignores and returns cleanup function', () => {
315
+ const cleanup = setupGitignoreGuard(tempDir, ['secret/']);
316
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(false);
317
+ expect(fileExists(join(tempDir, 'secret', '.gitignore-sgit.bat'))).toBe(
318
+ true,
319
+ );
320
+ expect(fileExists(join(tempDir, '.sgit', '.gitignore-guard'))).toBe(true);
321
+
322
+ cleanup();
323
+
324
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(true);
325
+ expect(fileExists(join(tempDir, 'secret', '.gitignore-sgit.bat'))).toBe(
326
+ false,
327
+ );
328
+ expect(fileExists(join(tempDir, '.sgit', '.gitignore-guard'))).toBe(
329
+ false,
330
+ );
331
+ });
332
+
333
+ it('handles file entries with ancestor gitignores', async () => {
334
+ await writeFile(join(tempDir, '.gitignore'), 'root\n');
335
+ const cleanup = setupGitignoreGuard(tempDir, ['secret/data.txt']);
336
+
337
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(false);
338
+ expect(fileExists(join(tempDir, '.gitignore'))).toBe(false);
339
+
340
+ cleanup();
341
+
342
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(true);
343
+ expect(fileExists(join(tempDir, '.gitignore'))).toBe(true);
344
+ });
345
+
346
+ it('returns no-op when no gitignore files exist', () => {
347
+ unlinkSync(join(tempDir, 'secret', '.gitignore'));
348
+ const cleanup = setupGitignoreGuard(tempDir, ['secret/']);
349
+ expect(fileExists(join(tempDir, '.sgit', '.gitignore-guard'))).toBe(
350
+ false,
351
+ );
352
+ cleanup(); // should not throw
353
+ });
354
+
355
+ it('returns no-op for nested invocation (active lock)', () => {
356
+ const lockPath = join(tempDir, '.sgit', '.gitignore-guard');
357
+ writeFileSync(lockPath, String(process.pid));
358
+
359
+ const cleanup = setupGitignoreGuard(tempDir, ['secret/']);
360
+ // .gitignore should NOT be renamed
361
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(true);
362
+ cleanup();
363
+ // Original lock still there
364
+ expect(fileExists(lockPath)).toBe(true);
365
+ unlinkSync(lockPath);
366
+ });
367
+
368
+ it('auto-cleans stale .bat files from a crashed process', () => {
369
+ renameSync(
370
+ join(tempDir, 'secret', '.gitignore'),
371
+ join(tempDir, 'secret', '.gitignore-sgit.bat'),
372
+ );
373
+ writeFileSync(
374
+ join(tempDir, '.sgit', '.gitignore-guard'),
375
+ '99999999', // dead PID
376
+ );
377
+
378
+ const cleanup = setupGitignoreGuard(tempDir, ['secret/']);
379
+ // Guard should have restored stale .bat, then re-disabled
380
+ expect(fileExists(join(tempDir, 'secret', '.gitignore-sgit.bat'))).toBe(
381
+ true,
382
+ );
383
+ cleanup();
384
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(true);
385
+ });
386
+
387
+ it('cleanup is idempotent — calling twice does not throw', () => {
388
+ const cleanup = setupGitignoreGuard(tempDir, ['secret/']);
389
+ cleanup();
390
+ cleanup();
391
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(true);
392
+ });
393
+
394
+ it('restores gitignores when used in try/finally after error', () => {
395
+ const cleanup = setupGitignoreGuard(tempDir, ['secret/']);
396
+ try {
397
+ throw new Error('simulated failure');
398
+ } catch {
399
+ // error caught
400
+ } finally {
401
+ cleanup();
402
+ }
403
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(true);
404
+ expect(fileExists(join(tempDir, 'secret', '.gitignore-sgit.bat'))).toBe(
405
+ false,
406
+ );
407
+ });
408
+ });
409
+
410
+ // ---------------------------------------------------------------
411
+ // Process termination scenarios (child process tests)
412
+ // ---------------------------------------------------------------
413
+ describe('guard restores gitignores on process termination', () => {
414
+ it('restores gitignores on successful exit', () => {
415
+ const r = runChild(tempDir, 'success');
416
+ expect(r.status).toBe(0);
417
+ assertGitignoreRestored(tempDir);
418
+ });
419
+
420
+ it('restores gitignores on uncaught exception', () => {
421
+ const r = runChild(tempDir, 'throw');
422
+ expect(r.status).not.toBe(0);
423
+ assertGitignoreRestored(tempDir);
424
+ });
425
+
426
+ it('restores gitignores on process.exit(1)', () => {
427
+ const r = runChild(tempDir, 'exit1');
428
+ expect(r.status).toBe(1);
429
+ assertGitignoreRestored(tempDir);
430
+ });
431
+
432
+ it('restores gitignores on SIGINT (Ctrl+C)', () => {
433
+ runChild(tempDir, 'sigint');
434
+ assertGitignoreRestored(tempDir);
435
+ });
436
+
437
+ it('restores gitignores on SIGTERM', () => {
438
+ runChild(tempDir, 'sigterm');
439
+ assertGitignoreRestored(tempDir);
440
+ });
441
+
442
+ it('restores gitignores when killed externally with SIGTERM (timeout)', () => {
443
+ runChildWithExternalKill(tempDir, 'SIGTERM');
444
+ assertGitignoreRestored(tempDir);
445
+ });
446
+
447
+ it('restores gitignores on git error (non-zero exit from git)', () => {
448
+ const r = runChild(tempDir, 'git-error');
449
+ expect(r.status).not.toBe(0);
450
+ assertGitignoreRestored(tempDir);
451
+ });
452
+
453
+ it('restores gitignores on SgitError (thrown application error)', () => {
454
+ const r = runChild(tempDir, 'sgit-error');
455
+ expect(r.status).not.toBe(0);
456
+ assertGitignoreRestored(tempDir);
457
+ });
458
+ });
459
+
460
+ // ---------------------------------------------------------------
461
+ // .gitignore-sgit.bat must never persist
462
+ // ---------------------------------------------------------------
463
+ describe('.gitignore-sgit.bat must not exist after any sgit command', () => {
464
+ it('does not exist after successful guard lifecycle', () => {
465
+ const cleanup = setupGitignoreGuard(tempDir, ['secret/']);
466
+ cleanup();
467
+ expect(findStaleGitignores(tempDir, ['secret/'])).toEqual([]);
468
+ });
469
+
470
+ it('does not exist after guard cleanup from thrown error', () => {
471
+ const cleanup = setupGitignoreGuard(tempDir, ['secret/']);
472
+ try {
473
+ throw new Error('simulated failure');
474
+ } catch {
475
+ // error caught
476
+ } finally {
477
+ cleanup();
478
+ }
479
+ expect(findStaleGitignores(tempDir, ['secret/'])).toEqual([]);
480
+ });
481
+
482
+ it('disableGitignores throws when .gitignore-sgit.bat already exists', async () => {
483
+ await writeFile(
484
+ join(tempDir, 'secret', '.gitignore-sgit.bat'),
485
+ 'stale content\n',
486
+ );
487
+ expect(() => disableGitignores(tempDir, ['secret/'])).toThrow(
488
+ StaleGitignoreError,
489
+ );
490
+ expect(() => disableGitignores(tempDir, ['secret/'])).toThrow(
491
+ /stale .gitignore-sgit.bat/,
492
+ );
493
+ });
494
+
495
+ it('guard auto-recovers stale files then proceeds normally', () => {
496
+ renameSync(
497
+ join(tempDir, 'secret', '.gitignore'),
498
+ join(tempDir, 'secret', '.gitignore-sgit.bat'),
499
+ );
500
+ writeFileSync(
501
+ join(tempDir, '.sgit', '.gitignore-guard'),
502
+ '99999999',
503
+ );
504
+
505
+ const cleanup = setupGitignoreGuard(tempDir, ['secret/']);
506
+ cleanup();
507
+
508
+ expect(fileExists(join(tempDir, 'secret', '.gitignore'))).toBe(true);
509
+ expect(fileExists(join(tempDir, 'secret', '.gitignore-sgit.bat'))).toBe(
510
+ false,
511
+ );
512
+ expect(findStaleGitignores(tempDir, ['secret/'])).toEqual([]);
513
+ });
514
+ });
515
+ });
516
+
517
+ // --- Test helpers ---
518
+
519
+ function assertGitignoreRestored(root: string): void {
520
+ expect(fileExists(join(root, 'secret', '.gitignore'))).toBe(true);
521
+ expect(fileExists(join(root, 'secret', '.gitignore-sgit.bat'))).toBe(false);
522
+ expect(fileExists(join(root, '.sgit', '.gitignore-guard'))).toBe(false);
523
+ }
524
+
525
+ function runChild(
526
+ root: string,
527
+ exitMode: string,
528
+ ): { status: number | null; signal: string | null } {
529
+ const result = spawnSync('node', [HELPER_SCRIPT, root, exitMode], {
530
+ stdio: 'ignore',
531
+ timeout: 10_000,
532
+ env: { ...process.env, NODE_OPTIONS: '' },
533
+ });
534
+ return { status: result.status, signal: result.signal };
535
+ }
536
+
537
+ function runChildWithExternalKill(
538
+ root: string,
539
+ signal: NodeJS.Signals,
540
+ ): void {
541
+ const child = spawn('node', [HELPER_SCRIPT, root, 'timeout'], {
542
+ stdio: 'ignore',
543
+ env: { ...process.env, NODE_OPTIONS: '' },
544
+ });
545
+
546
+ spawnSync('sleep', ['0.3']);
547
+
548
+ if (child.pid) {
549
+ process.kill(child.pid, signal);
550
+ }
551
+
552
+ const deadline = Date.now() + 5000;
553
+ while (Date.now() < deadline) {
554
+ try {
555
+ process.kill(child.pid!, 0);
556
+ spawnSync('sleep', ['0.05']);
557
+ } catch {
558
+ break;
559
+ }
560
+ }
561
+ }
@@ -12,6 +12,7 @@ import { join } from 'node:path';
12
12
  import {
13
13
  installMainHook,
14
14
  installShadowHook,
15
+ installShadowSyncHooks,
15
16
  uninstallMainHook,
16
17
  getHookDir,
17
18
  } from '../../src/core/hooks';
@@ -154,6 +155,39 @@ describe('installShadowHook', () => {
154
155
  });
155
156
  });
156
157
 
158
+ describe('installShadowSyncHooks', () => {
159
+ let root: string;
160
+
161
+ beforeEach(async () => {
162
+ root = await createTempGitRepo();
163
+ });
164
+
165
+ afterEach(async () => {
166
+ await removeTempDir(root);
167
+ });
168
+
169
+ it('creates post-merge and post-checkout hooks', async () => {
170
+ const sgitDir = join(root, '.sgit');
171
+ await mkdir(sgitDir, { recursive: true });
172
+
173
+ await installShadowSyncHooks(sgitDir);
174
+
175
+ const postMerge = await readFile(
176
+ join(sgitDir, 'hooks', 'post-merge'),
177
+ 'utf-8',
178
+ );
179
+ expect(postMerge).toContain('sgit track sync');
180
+ await access(join(sgitDir, 'hooks', 'post-merge'), constants.X_OK);
181
+
182
+ const postCheckout = await readFile(
183
+ join(sgitDir, 'hooks', 'post-checkout'),
184
+ 'utf-8',
185
+ );
186
+ expect(postCheckout).toContain('sgit track sync');
187
+ await access(join(sgitDir, 'hooks', 'post-checkout'), constants.X_OK);
188
+ });
189
+ });
190
+
157
191
  describe('uninstallMainHook', () => {
158
192
  let root: string;
159
193
 
@@ -110,4 +110,28 @@ describe('isPathCoveredByManifest', () => {
110
110
  it('returns false for empty manifest', () => {
111
111
  expect(isPathCoveredByManifest('anything.md', [])).toBe(false);
112
112
  });
113
+
114
+ it('returns false for ignored exact path', () => {
115
+ expect(
116
+ isPathCoveredByManifest('docs/cache.json', ['docs/'], ['docs/cache.json']),
117
+ ).toBe(false);
118
+ });
119
+
120
+ it('returns false for file inside ignored directory', () => {
121
+ expect(
122
+ isPathCoveredByManifest('docs/cache/data.bin', ['docs/'], ['docs/cache/']),
123
+ ).toBe(false);
124
+ });
125
+
126
+ it('returns true for non-ignored file in tracked directory', () => {
127
+ expect(
128
+ isPathCoveredByManifest('docs/readme.md', ['docs/'], ['docs/cache/']),
129
+ ).toBe(true);
130
+ });
131
+
132
+ it('ignore has no effect on untracked paths', () => {
133
+ expect(
134
+ isPathCoveredByManifest('src/main.ts', ['docs/'], ['src/main.ts']),
135
+ ).toBe(false);
136
+ });
113
137
  });