shadow-git 0.1.0 → 1.0.2
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/.claude/settings.local.json +5 -1
- package/.temp/.sgit/index +0 -0
- package/.temp/.sgit/info/exclude +16 -7
- package/.temp/.sgit/objects/03/7f4e37a02573e40ab78b35dd4b442cc6110bae +0 -0
- package/.temp/.sgit/objects/2f/f0fd85dec20039eef6ff63ead905778a88843f +0 -0
- package/.temp/.sgit/objects/c3/2432f724b666f647fe21a1ce16535cd7693f74 +0 -0
- package/.temp/.sgit/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +0 -0
- package/.temp/.sgit/objects/ee/9dd32cca3b134623d184eb4de5805704c493e3 +0 -0
- package/.temp/.sgit.json +6 -2
- package/.temp/README.md +0 -3
- package/.temp/rec1/rec2/test +0 -0
- package/.temp/temp/shouldnt-track +0 -0
- package/.temp/test/1 +0 -0
- package/CLAUDE.md +10 -2
- package/README.md +16 -29
- package/dist/cli.js +810 -119
- package/package.json +1 -1
- package/src/cli.ts +50 -19
- package/src/commands/debug/gitignores.ts +28 -0
- package/src/commands/debug/index.ts +32 -0
- package/src/commands/track/add.ts +11 -0
- package/src/commands/track/clone.ts +213 -0
- package/src/commands/track/completion.ts +50 -15
- package/src/commands/track/destroy.ts +97 -19
- package/src/commands/track/ignore.ts +55 -0
- package/src/commands/track/index.ts +23 -1
- package/src/commands/track/init.ts +5 -2
- package/src/commands/track/sync.ts +1 -1
- package/src/commands/track/unignore.ts +50 -0
- package/src/commands/track/verify.ts +93 -20
- package/src/core/errors.ts +23 -2
- package/src/core/exclude.ts +40 -43
- package/src/core/gitignore.ts +242 -0
- package/src/core/hooks.ts +19 -0
- package/src/core/manifest.ts +33 -1
- package/src/core/paths.ts +21 -3
- package/src/core/repository.ts +17 -2
- package/tests/commands/track/add.test.ts +31 -1
- package/tests/commands/track/clone.test.ts +315 -0
- package/tests/commands/track/completion.test.ts +1 -1
- package/tests/commands/track/destroy.test.ts +68 -2
- package/tests/commands/track/ignore.test.ts +109 -0
- package/tests/commands/track/init.test.ts +27 -4
- package/tests/commands/track/remove.test.ts +1 -1
- package/tests/commands/track/sync.test.ts +3 -3
- package/tests/commands/track/unignore.test.ts +72 -0
- package/tests/commands/track/verify.test.ts +132 -1
- package/tests/core/exclude.test.ts +107 -19
- package/tests/core/gitignore.test.ts +561 -0
- package/tests/core/hooks.test.ts +34 -0
- package/tests/core/paths.test.ts +24 -0
- package/tests/core/repository.test.ts +39 -5
- package/tests/helpers/guard-child.mjs +125 -0
- 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
|
+
}
|
package/tests/core/hooks.test.ts
CHANGED
|
@@ -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
|
|
package/tests/core/paths.test.ts
CHANGED
|
@@ -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
|
});
|