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.
- 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 +17 -30
- 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,72 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { trackInit } from '../../../src/commands/track/init';
|
|
5
|
+
import { trackAdd } from '../../../src/commands/track/add';
|
|
6
|
+
import { trackIgnore } from '../../../src/commands/track/ignore';
|
|
7
|
+
import { trackUnignore } from '../../../src/commands/track/unignore';
|
|
8
|
+
import { createTempGitRepo, removeTempDir } from '../../helpers';
|
|
9
|
+
|
|
10
|
+
describe('track unignore', () => {
|
|
11
|
+
let tempDir: string;
|
|
12
|
+
const originalCwd = process.cwd();
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
tempDir = await createTempGitRepo();
|
|
16
|
+
process.chdir(tempDir);
|
|
17
|
+
await trackInit();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
process.chdir(originalCwd);
|
|
22
|
+
await removeTempDir(tempDir);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('removes path from ignore list and syncs excludes', async () => {
|
|
26
|
+
await mkdir(join(tempDir, 'docs', 'cache'), { recursive: true });
|
|
27
|
+
await writeFile(join(tempDir, 'docs', 'readme.md'), 'hello');
|
|
28
|
+
await writeFile(join(tempDir, 'docs', 'cache', 'data.bin'), 'cached');
|
|
29
|
+
await trackAdd(['docs/']);
|
|
30
|
+
await trackIgnore(['docs/cache/']);
|
|
31
|
+
|
|
32
|
+
await trackUnignore(['docs/cache/']);
|
|
33
|
+
|
|
34
|
+
const manifest = JSON.parse(
|
|
35
|
+
await readFile(join(tempDir, '.sgit.json'), 'utf-8'),
|
|
36
|
+
);
|
|
37
|
+
expect(manifest.ignore).not.toContain('docs/cache/');
|
|
38
|
+
|
|
39
|
+
// Shadow exclude should no longer have the re-ignore
|
|
40
|
+
const shadowExclude = await readFile(
|
|
41
|
+
join(tempDir, '.sgit', 'info', 'exclude'),
|
|
42
|
+
'utf-8',
|
|
43
|
+
);
|
|
44
|
+
expect(shadowExclude).not.toContain('docs/cache/');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('errors if path is not in ignore list', async () => {
|
|
48
|
+
await mkdir(join(tempDir, 'docs'), { recursive: true });
|
|
49
|
+
await writeFile(join(tempDir, 'docs', 'readme.md'), 'hello');
|
|
50
|
+
await trackAdd(['docs/']);
|
|
51
|
+
|
|
52
|
+
await expect(trackUnignore(['docs/readme.md'])).rejects.toThrow(
|
|
53
|
+
'not in the ignore list',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('handles trailing slash matching', async () => {
|
|
58
|
+
await mkdir(join(tempDir, 'docs', 'cache'), { recursive: true });
|
|
59
|
+
await writeFile(join(tempDir, 'docs', 'readme.md'), 'hello');
|
|
60
|
+
await writeFile(join(tempDir, 'docs', 'cache', 'data.bin'), 'cached');
|
|
61
|
+
await trackAdd(['docs/']);
|
|
62
|
+
await trackIgnore(['docs/cache/']);
|
|
63
|
+
|
|
64
|
+
// Unignore without trailing slash should still match
|
|
65
|
+
await trackUnignore(['docs/cache']);
|
|
66
|
+
|
|
67
|
+
const manifest = JSON.parse(
|
|
68
|
+
await readFile(join(tempDir, '.sgit.json'), 'utf-8'),
|
|
69
|
+
);
|
|
70
|
+
expect(manifest.ignore).not.toContain('docs/cache/');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { writeFileSync, unlinkSync } from 'node:fs';
|
|
4
5
|
import { trackInit } from '../../../src/commands/track/init';
|
|
5
6
|
import { trackAdd } from '../../../src/commands/track/add';
|
|
6
7
|
import { verifyMain, verifyShadow } from '../../../src/commands/track/verify';
|
|
8
|
+
import { readManifest, writeManifest } from '../../../src/core/manifest';
|
|
7
9
|
import { execGit } from '../../../src/core/git';
|
|
8
10
|
import { createTempGitRepo, removeTempDir } from '../../helpers';
|
|
9
11
|
|
|
@@ -44,6 +46,46 @@ describe('track verify', () => {
|
|
|
44
46
|
expect(result.valid).toBe(true);
|
|
45
47
|
});
|
|
46
48
|
|
|
49
|
+
it('blocks stale .gitignore-sgit.bat when no guard is active', async () => {
|
|
50
|
+
await mkdir(join(tempDir, 'docs'), { recursive: true });
|
|
51
|
+
await writeFile(join(tempDir, 'docs', 'file.md'), 'content');
|
|
52
|
+
await trackAdd(['docs']);
|
|
53
|
+
|
|
54
|
+
// Create a stale .gitignore-sgit.bat
|
|
55
|
+
await writeFile(
|
|
56
|
+
join(tempDir, 'docs', '.gitignore-sgit.bat'),
|
|
57
|
+
'stale\n',
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const result = await verifyMain(tempDir);
|
|
61
|
+
expect(result.valid).toBe(false);
|
|
62
|
+
expect(
|
|
63
|
+
result.violations.some((v) => v.includes('.gitignore-sgit.bat')),
|
|
64
|
+
).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('skips stale .gitignore-sgit.bat check when guard is active', async () => {
|
|
68
|
+
await mkdir(join(tempDir, 'docs'), { recursive: true });
|
|
69
|
+
await writeFile(join(tempDir, 'docs', 'file.md'), 'content');
|
|
70
|
+
await trackAdd(['docs']);
|
|
71
|
+
|
|
72
|
+
writeFileSync(
|
|
73
|
+
join(tempDir, '.sgit', '.gitignore-guard'),
|
|
74
|
+
String(process.pid),
|
|
75
|
+
);
|
|
76
|
+
await writeFile(
|
|
77
|
+
join(tempDir, 'docs', '.gitignore-sgit.bat'),
|
|
78
|
+
'managed by guard\n',
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const result = await verifyMain(tempDir);
|
|
82
|
+
expect(
|
|
83
|
+
result.violations.some((v) => v.includes('.gitignore-sgit.bat')),
|
|
84
|
+
).toBe(false);
|
|
85
|
+
|
|
86
|
+
unlinkSync(join(tempDir, '.sgit', '.gitignore-guard'));
|
|
87
|
+
});
|
|
88
|
+
|
|
47
89
|
it('blocks file inside shadow-tracked directory', async () => {
|
|
48
90
|
await mkdir(join(tempDir, 'docs', '.internal'), { recursive: true });
|
|
49
91
|
await writeFile(
|
|
@@ -92,5 +134,94 @@ describe('track verify', () => {
|
|
|
92
134
|
const result = await verifyShadow(tempDir);
|
|
93
135
|
expect(result.valid).toBe(true);
|
|
94
136
|
});
|
|
137
|
+
|
|
138
|
+
it('blocks deletion of .sgit.json', async () => {
|
|
139
|
+
// Stage .sgit.json for deletion in shadow repo
|
|
140
|
+
await execGit(['rm', '--cached', '.sgit.json'], {
|
|
141
|
+
gitDir: join(tempDir, '.sgit'),
|
|
142
|
+
workTree: tempDir,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const result = await verifyShadow(tempDir);
|
|
146
|
+
expect(result.valid).toBe(false);
|
|
147
|
+
expect(result.violations.some((v) => v.includes('.sgit.json') && v.includes('cannot delete'))).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('blocks stale .gitignore-sgit.bat when no guard is active', async () => {
|
|
151
|
+
await mkdir(join(tempDir, 'docs'), { recursive: true });
|
|
152
|
+
await writeFile(join(tempDir, 'docs', 'file.md'), 'content');
|
|
153
|
+
await trackAdd(['docs']);
|
|
154
|
+
|
|
155
|
+
// Create a stale .gitignore-sgit.bat
|
|
156
|
+
await writeFile(
|
|
157
|
+
join(tempDir, 'docs', '.gitignore-sgit.bat'),
|
|
158
|
+
'stale\n',
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const result = await verifyShadow(tempDir);
|
|
162
|
+
expect(result.valid).toBe(false);
|
|
163
|
+
expect(
|
|
164
|
+
result.violations.some((v) => v.includes('.gitignore-sgit.bat')),
|
|
165
|
+
).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('skips stale .gitignore-sgit.bat check when guard is active', async () => {
|
|
169
|
+
await mkdir(join(tempDir, 'docs'), { recursive: true });
|
|
170
|
+
await writeFile(join(tempDir, 'docs', 'file.md'), 'content');
|
|
171
|
+
await trackAdd(['docs']);
|
|
172
|
+
|
|
173
|
+
// Simulate active guard: create lock with current PID + stale .bat
|
|
174
|
+
writeFileSync(
|
|
175
|
+
join(tempDir, '.sgit', '.gitignore-guard'),
|
|
176
|
+
String(process.pid),
|
|
177
|
+
);
|
|
178
|
+
await writeFile(
|
|
179
|
+
join(tempDir, 'docs', '.gitignore-sgit.bat'),
|
|
180
|
+
'managed by guard\n',
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const result = await verifyShadow(tempDir);
|
|
184
|
+
// Should not report .bat files as violations when guard is active
|
|
185
|
+
expect(
|
|
186
|
+
result.violations.some((v) => v.includes('.gitignore-sgit.bat')),
|
|
187
|
+
).toBe(false);
|
|
188
|
+
|
|
189
|
+
// Cleanup
|
|
190
|
+
unlinkSync(join(tempDir, '.sgit', '.gitignore-guard'));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('blocks invalid .sgit.json modifications', async () => {
|
|
194
|
+
const sgitJsonPath = join(tempDir, '.sgit.json');
|
|
195
|
+
const original = await readFile(sgitJsonPath, 'utf-8');
|
|
196
|
+
|
|
197
|
+
// Stage invalid JSON, then restore on disk so readManifest works
|
|
198
|
+
await writeFile(sgitJsonPath, 'not json{{{');
|
|
199
|
+
await execGit(['add', '-f', '.sgit.json'], {
|
|
200
|
+
gitDir: join(tempDir, '.sgit'),
|
|
201
|
+
workTree: tempDir,
|
|
202
|
+
});
|
|
203
|
+
await writeFile(sgitJsonPath, original);
|
|
204
|
+
|
|
205
|
+
const result = await verifyShadow(tempDir);
|
|
206
|
+
expect(result.valid).toBe(false);
|
|
207
|
+
expect(result.violations.some((v) => v.includes('invalid JSON'))).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('blocks .sgit.json missing paths array', async () => {
|
|
211
|
+
const sgitJsonPath = join(tempDir, '.sgit.json');
|
|
212
|
+
const original = await readFile(sgitJsonPath, 'utf-8');
|
|
213
|
+
|
|
214
|
+
// Stage JSON without paths, then restore on disk
|
|
215
|
+
await writeFile(sgitJsonPath, '{"version": 1}');
|
|
216
|
+
await execGit(['add', '-f', '.sgit.json'], {
|
|
217
|
+
gitDir: join(tempDir, '.sgit'),
|
|
218
|
+
workTree: tempDir,
|
|
219
|
+
});
|
|
220
|
+
await writeFile(sgitJsonPath, original);
|
|
221
|
+
|
|
222
|
+
const result = await verifyShadow(tempDir);
|
|
223
|
+
expect(result.valid).toBe(false);
|
|
224
|
+
expect(result.violations.some((v) => v.includes('missing "paths" array'))).toBe(true);
|
|
225
|
+
});
|
|
95
226
|
});
|
|
96
227
|
});
|
|
@@ -2,14 +2,17 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import {
|
|
3
3
|
generateMainExclude,
|
|
4
4
|
generateShadowExclude,
|
|
5
|
+
SGIT_BLOCK_START,
|
|
6
|
+
SGIT_BLOCK_END,
|
|
5
7
|
} from '../../src/core/exclude';
|
|
6
8
|
|
|
7
9
|
describe('generateMainExclude', () => {
|
|
8
10
|
it('adds sgit entries to empty file', () => {
|
|
9
11
|
const result = generateMainExclude('', []);
|
|
10
|
-
expect(result).toContain(
|
|
11
|
-
expect(result).toContain(
|
|
12
|
-
expect(result).toContain('
|
|
12
|
+
expect(result).toContain(SGIT_BLOCK_START);
|
|
13
|
+
expect(result).toContain(SGIT_BLOCK_END);
|
|
14
|
+
expect(result).toContain('.sgit\n');
|
|
15
|
+
expect(result).toContain('.sgit.json\n');
|
|
13
16
|
});
|
|
14
17
|
|
|
15
18
|
it('preserves existing non-sgit lines', () => {
|
|
@@ -17,45 +20,74 @@ describe('generateMainExclude', () => {
|
|
|
17
20
|
const result = generateMainExclude(existing, []);
|
|
18
21
|
expect(result).toContain('*.log');
|
|
19
22
|
expect(result).toContain('build/');
|
|
20
|
-
expect(result).toContain('
|
|
23
|
+
expect(result).toContain('.sgit\n');
|
|
21
24
|
});
|
|
22
25
|
|
|
23
26
|
it('replaces old sgit entries', () => {
|
|
24
|
-
const existing =
|
|
27
|
+
const existing = `user-entry\n${SGIT_BLOCK_START}\nold-path\n${SGIT_BLOCK_END}\n`;
|
|
25
28
|
const result = generateMainExclude(existing, ['new-path.md']);
|
|
26
29
|
expect(result).toContain('user-entry');
|
|
27
30
|
expect(result).not.toContain('old-path');
|
|
28
|
-
expect(result).toContain('
|
|
31
|
+
expect(result).toContain('new-path.md\n');
|
|
29
32
|
});
|
|
30
33
|
|
|
31
|
-
it('
|
|
34
|
+
it('strips legacy format on regeneration', () => {
|
|
35
|
+
const existing = 'user-entry\n# sgit\nold-path\n# sgit-managed (do not edit below this line)\n# sgit\nold2\n';
|
|
36
|
+
const result = generateMainExclude(existing, ['new.md']);
|
|
37
|
+
expect(result).toContain('user-entry');
|
|
38
|
+
expect(result).not.toContain('old-path');
|
|
39
|
+
expect(result).not.toContain('old2');
|
|
40
|
+
expect(result).toContain('new.md');
|
|
41
|
+
expect(result).not.toContain('# sgit\n');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('includes manifest paths', () => {
|
|
32
45
|
const result = generateMainExclude('', [
|
|
33
46
|
'docs/.internal/',
|
|
34
47
|
'notes/todo.md',
|
|
35
48
|
]);
|
|
36
|
-
expect(result).toContain('
|
|
37
|
-
expect(result).toContain('
|
|
49
|
+
expect(result).toContain('docs/.internal/\n');
|
|
50
|
+
expect(result).toContain('notes/todo.md\n');
|
|
38
51
|
});
|
|
39
52
|
|
|
40
|
-
it('
|
|
53
|
+
it('includes .gitignore-sgit.bat in managed block', () => {
|
|
54
|
+
const result = generateMainExclude('', ['notes.md']);
|
|
55
|
+
expect(result).toContain('.gitignore-sgit.bat\n');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('orders entries: .sgit, .sgit.json, .gitignore-sgit.bat, then manifest paths', () => {
|
|
41
59
|
const result = generateMainExclude('', ['z.txt', 'a.txt']);
|
|
42
60
|
const lines = result.split('\n');
|
|
43
61
|
const sgitLine = lines.indexOf('.sgit');
|
|
44
62
|
const sgitJsonLine = lines.indexOf('.sgit.json');
|
|
63
|
+
const batLine = lines.indexOf('.gitignore-sgit.bat');
|
|
45
64
|
const zLine = lines.indexOf('z.txt');
|
|
46
65
|
const aLine = lines.indexOf('a.txt');
|
|
47
66
|
expect(sgitLine).toBeLessThan(sgitJsonLine);
|
|
48
|
-
expect(sgitJsonLine).toBeLessThan(
|
|
49
|
-
expect(
|
|
67
|
+
expect(sgitJsonLine).toBeLessThan(batLine);
|
|
68
|
+
expect(batLine).toBeLessThan(zLine);
|
|
69
|
+
expect(batLine).toBeLessThan(aLine);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('has no per-line markers or extra comments inside block', () => {
|
|
73
|
+
const result = generateMainExclude('', ['notes.md']);
|
|
74
|
+
const lines = result.split('\n');
|
|
75
|
+
const startIdx = lines.indexOf(SGIT_BLOCK_START);
|
|
76
|
+
const endIdx = lines.indexOf(SGIT_BLOCK_END);
|
|
77
|
+
const blockLines = lines.slice(startIdx + 1, endIdx);
|
|
78
|
+
// Every line inside should be a plain entry, no comments
|
|
79
|
+
for (const line of blockLines) {
|
|
80
|
+
expect(line).not.toMatch(/^#/);
|
|
81
|
+
}
|
|
50
82
|
});
|
|
51
83
|
});
|
|
52
84
|
|
|
53
85
|
describe('generateShadowExclude', () => {
|
|
54
86
|
it('generates base pattern for empty manifest', () => {
|
|
55
87
|
const result = generateShadowExclude([]);
|
|
56
|
-
expect(result).toContain(
|
|
88
|
+
expect(result).toContain(SGIT_BLOCK_START);
|
|
89
|
+
expect(result).toContain(SGIT_BLOCK_END);
|
|
57
90
|
expect(result).toContain('*');
|
|
58
|
-
expect(result).toContain('# .sgit.json (always tracked)');
|
|
59
91
|
expect(result).toContain('!.sgit.json');
|
|
60
92
|
});
|
|
61
93
|
|
|
@@ -73,6 +105,22 @@ describe('generateShadowExclude', () => {
|
|
|
73
105
|
expect(lines).toContain('!docs/');
|
|
74
106
|
expect(lines).toContain('docs/*');
|
|
75
107
|
expect(lines).toContain('!docs/.internal/');
|
|
108
|
+
expect(lines).toContain('!docs/.internal/**');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('generates recursive pattern for root-level directory', () => {
|
|
112
|
+
const result = generateShadowExclude(['docs/']);
|
|
113
|
+
const lines = result.split('\n');
|
|
114
|
+
expect(lines).toContain('!docs/');
|
|
115
|
+
expect(lines).toContain('!docs/**');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('does not generate recursive pattern for file entries', () => {
|
|
119
|
+
const result = generateShadowExclude(['notes/todo.md']);
|
|
120
|
+
const lines = result.split('\n');
|
|
121
|
+
expect(lines).toContain('!notes/todo.md');
|
|
122
|
+
expect(lines).not.toContain('!notes/todo.md**');
|
|
123
|
+
expect(lines).not.toContain('!notes/todo.md/**');
|
|
76
124
|
});
|
|
77
125
|
|
|
78
126
|
it('generates deep ancestor chain', () => {
|
|
@@ -93,12 +141,10 @@ describe('generateShadowExclude', () => {
|
|
|
93
141
|
'docs/.drafts/notes.md',
|
|
94
142
|
]);
|
|
95
143
|
const lines = result.split('\n');
|
|
96
|
-
// !docs/ and docs/* should appear only once
|
|
97
144
|
const docsUnignore = lines.filter((l) => l === '!docs/');
|
|
98
145
|
const docsReignore = lines.filter((l) => l === 'docs/*');
|
|
99
146
|
expect(docsUnignore).toHaveLength(1);
|
|
100
147
|
expect(docsReignore).toHaveLength(1);
|
|
101
|
-
// Both targets should be present
|
|
102
148
|
expect(lines).toContain('!docs/.internal/');
|
|
103
149
|
expect(lines).toContain('!docs/.drafts/');
|
|
104
150
|
expect(lines).toContain('docs/.drafts/*');
|
|
@@ -109,12 +155,54 @@ describe('generateShadowExclude', () => {
|
|
|
109
155
|
const result = generateShadowExclude(['readme.md']);
|
|
110
156
|
const lines = result.split('\n');
|
|
111
157
|
expect(lines).toContain('!readme.md');
|
|
112
|
-
// No ancestor chain needed for root-level files
|
|
113
158
|
expect(lines).not.toContain('!./');
|
|
114
159
|
});
|
|
115
160
|
|
|
116
|
-
it('
|
|
161
|
+
it('re-ignores .gitignore-sgit.bat after allow-list', () => {
|
|
162
|
+
const result = generateShadowExclude(['docs/']);
|
|
163
|
+
const lines = result.split('\n');
|
|
164
|
+
const allowLine = lines.indexOf('!docs/**');
|
|
165
|
+
const batLine = lines.indexOf('.gitignore-sgit.bat');
|
|
166
|
+
expect(batLine).toBeGreaterThan(-1);
|
|
167
|
+
expect(batLine).toBeGreaterThan(allowLine);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('includes .gitignore-sgit.bat even for empty manifest', () => {
|
|
171
|
+
const result = generateShadowExclude([]);
|
|
172
|
+
const lines = result.split('\n');
|
|
173
|
+
expect(lines).toContain('.gitignore-sgit.bat');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('has no per-path comment lines', () => {
|
|
117
177
|
const result = generateShadowExclude(['notes/todo.md']);
|
|
118
|
-
expect(result).toContain('# notes/todo.md');
|
|
178
|
+
expect(result).not.toContain('# notes/todo.md');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('adds ignore paths as re-ignore rules', () => {
|
|
182
|
+
const result = generateShadowExclude(['temp2/'], ['temp2/cache/']);
|
|
183
|
+
const lines = result.split('\n');
|
|
184
|
+
expect(lines).toContain('!temp2/');
|
|
185
|
+
expect(lines).toContain('!temp2/**');
|
|
186
|
+
expect(lines).toContain('temp2/cache/');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('places ignore rules after un-ignore and before .gitignore-sgit.bat', () => {
|
|
190
|
+
const result = generateShadowExclude(['docs/'], ['docs/cache/']);
|
|
191
|
+
const lines = result.split('\n');
|
|
192
|
+
const unignoreLine = lines.indexOf('!docs/**');
|
|
193
|
+
const ignoreLine = lines.indexOf('docs/cache/');
|
|
194
|
+
const batLine = lines.indexOf('.gitignore-sgit.bat');
|
|
195
|
+
expect(ignoreLine).toBeGreaterThan(unignoreLine);
|
|
196
|
+
expect(ignoreLine).toBeLessThan(batLine);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('handles multiple ignore paths', () => {
|
|
200
|
+
const result = generateShadowExclude(
|
|
201
|
+
['temp2/'],
|
|
202
|
+
['temp2/cache/', 'temp2/logs/'],
|
|
203
|
+
);
|
|
204
|
+
const lines = result.split('\n');
|
|
205
|
+
expect(lines).toContain('temp2/cache/');
|
|
206
|
+
expect(lines).toContain('temp2/logs/');
|
|
119
207
|
});
|
|
120
208
|
});
|