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.
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 +16 -29
  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,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('# sgit-managed (do not edit below this line)');
11
- expect(result).toContain('# sgit\n.sgit\n');
12
- expect(result).toContain('# sgit\n.sgit.json\n');
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('# sgit\n.sgit\n');
23
+ expect(result).toContain('.sgit\n');
21
24
  });
22
25
 
23
26
  it('replaces old sgit entries', () => {
24
- const existing = 'user-entry\n# sgit\nold-path\n';
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('# sgit\nnew-path.md\n');
31
+ expect(result).toContain('new-path.md\n');
29
32
  });
30
33
 
31
- it('includes manifest paths with sgit marker', () => {
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('# sgit\ndocs/.internal/\n');
37
- expect(result).toContain('# sgit\nnotes/todo.md\n');
49
+ expect(result).toContain('docs/.internal/\n');
50
+ expect(result).toContain('notes/todo.md\n');
38
51
  });
39
52
 
40
- it('orders entries: .sgit, .sgit.json, then manifest paths', () => {
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(zLine);
49
- expect(sgitJsonLine).toBeLessThan(aLine);
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('# Exclude everything by default');
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('includes comment before each tracked path section', () => {
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
  });