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,315 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ readFile,
4
+ writeFile,
5
+ mkdir,
6
+ access,
7
+ readdir,
8
+ } from 'node:fs/promises';
9
+ import { constants } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { execFile } from 'node:child_process';
12
+ import { promisify } from 'node:util';
13
+ import { trackInit } from '../../../src/commands/track/init';
14
+ import { trackClone } from '../../../src/commands/track/clone';
15
+ import { execGit } from '../../../src/core/git';
16
+ import {
17
+ createTempGitRepo,
18
+ createBareRemoteRepo,
19
+ removeTempDir,
20
+ } from '../../helpers';
21
+
22
+ const execFileAsync = promisify(execFile);
23
+
24
+ describe('track clone', () => {
25
+ let tempDir: string;
26
+ let remoteDir: string;
27
+ const originalCwd = process.cwd();
28
+
29
+ beforeEach(async () => {
30
+ tempDir = await createTempGitRepo();
31
+ process.chdir(tempDir);
32
+ });
33
+
34
+ afterEach(async () => {
35
+ process.chdir(originalCwd);
36
+ if (tempDir) await removeTempDir(tempDir);
37
+ if (remoteDir) await removeTempDir(remoteDir);
38
+ });
39
+
40
+ it('clones shadow repository with no conflicts', async () => {
41
+ remoteDir = await createBareRemoteRepo({
42
+ 'CLAUDE.md': '# Claude config',
43
+ '.claude/settings.json': '{"key": "value"}',
44
+ });
45
+
46
+ await trackClone(remoteDir);
47
+
48
+ // .sgit directory exists
49
+ await access(join(tempDir, '.sgit'), constants.F_OK);
50
+
51
+ // Config correct
52
+ const bare = await execGit(['config', 'core.bare'], {
53
+ gitDir: join(tempDir, '.sgit'),
54
+ });
55
+ expect(bare).toBe('false');
56
+
57
+ const worktree = await execGit(['config', 'core.worktree'], {
58
+ gitDir: join(tempDir, '.sgit'),
59
+ });
60
+ expect(worktree).toBe(tempDir);
61
+
62
+ // .sgit.json on disk
63
+ const manifest = JSON.parse(
64
+ await readFile(join(tempDir, '.sgit.json'), 'utf-8'),
65
+ );
66
+ expect(manifest.paths).toContain('CLAUDE.md');
67
+ expect(manifest.paths).toContain('.claude/settings.json');
68
+
69
+ // Files checked out
70
+ expect(await readFile(join(tempDir, 'CLAUDE.md'), 'utf-8')).toBe(
71
+ '# Claude config',
72
+ );
73
+ expect(
74
+ await readFile(join(tempDir, '.claude', 'settings.json'), 'utf-8'),
75
+ ).toBe('{"key": "value"}');
76
+
77
+ // Exclude files correct
78
+ const mainExclude = await readFile(
79
+ join(tempDir, '.git', 'info', 'exclude'),
80
+ 'utf-8',
81
+ );
82
+ expect(mainExclude).toContain('CLAUDE.md');
83
+ expect(mainExclude).toContain('.sgit\n');
84
+
85
+ const shadowExclude = await readFile(
86
+ join(tempDir, '.sgit', 'info', 'exclude'),
87
+ 'utf-8',
88
+ );
89
+ expect(shadowExclude).toContain('!CLAUDE.md');
90
+
91
+ // Pre-commit hooks installed
92
+ const mainHook = await readFile(
93
+ join(tempDir, '.git', 'hooks', 'pre-commit.d', 'sgit'),
94
+ 'utf-8',
95
+ );
96
+ expect(mainHook).toContain('sgit track verify --main');
97
+
98
+ const shadowPreCommit = await readFile(
99
+ join(tempDir, '.sgit', 'hooks', 'pre-commit'),
100
+ 'utf-8',
101
+ );
102
+ expect(shadowPreCommit).toContain('sgit track verify --shadow');
103
+
104
+ // Sync hooks installed
105
+ const postMerge = await readFile(
106
+ join(tempDir, '.sgit', 'hooks', 'post-merge'),
107
+ 'utf-8',
108
+ );
109
+ expect(postMerge).toContain('sgit track sync');
110
+
111
+ const postCheckout = await readFile(
112
+ join(tempDir, '.sgit', 'hooks', 'post-checkout'),
113
+ 'utf-8',
114
+ );
115
+ expect(postCheckout).toContain('sgit track sync');
116
+ });
117
+
118
+ it('errors if shadow repo already exists', async () => {
119
+ remoteDir = await createBareRemoteRepo({ 'CLAUDE.md': 'content' });
120
+ await trackInit();
121
+
122
+ await expect(trackClone(remoteDir)).rejects.toThrow('already initialized');
123
+ });
124
+
125
+ it('errors when git identity is not configured', async () => {
126
+ remoteDir = await createBareRemoteRepo({ 'CLAUDE.md': 'content' });
127
+ await execFileAsync('git', ['-C', tempDir, 'config', '--unset', 'user.name']);
128
+ await expect(trackClone(remoteDir)).rejects.toThrow('git user identity not configured');
129
+ });
130
+
131
+ it('errors outside git repo', async () => {
132
+ const { mkdtemp, realpath, rm } = await import('node:fs/promises');
133
+ const { tmpdir } = await import('node:os');
134
+ const nonGitRaw = await mkdtemp(join(tmpdir(), 'sgit-test-nogit-'));
135
+ const nonGit = await realpath(nonGitRaw);
136
+ process.chdir(nonGit);
137
+ remoteDir = await createBareRemoteRepo({ 'CLAUDE.md': 'content' });
138
+
139
+ await expect(trackClone(remoteDir)).rejects.toThrow();
140
+
141
+ process.chdir(tempDir);
142
+ await rm(nonGit, { recursive: true });
143
+ });
144
+
145
+ it('resolves conflicts with option 1 (keep sgit)', async () => {
146
+ remoteDir = await createBareRemoteRepo({ 'CLAUDE.md': '# From sgit' });
147
+
148
+ // Create conflicting file
149
+ await writeFile(join(tempDir, 'CLAUDE.md'), '# Local version');
150
+
151
+ await trackClone(remoteDir, { onConflict: 1 });
152
+
153
+ // sgit version should win
154
+ expect(await readFile(join(tempDir, 'CLAUDE.md'), 'utf-8')).toBe(
155
+ '# From sgit',
156
+ );
157
+ });
158
+
159
+ it('resolves conflicts with option 2 (keep originals)', async () => {
160
+ remoteDir = await createBareRemoteRepo({ 'CLAUDE.md': '# From sgit' });
161
+
162
+ await writeFile(join(tempDir, 'CLAUDE.md'), '# Local version');
163
+
164
+ await trackClone(remoteDir, { onConflict: 2 });
165
+
166
+ // Local version kept
167
+ expect(await readFile(join(tempDir, 'CLAUDE.md'), 'utf-8')).toBe(
168
+ '# Local version',
169
+ );
170
+
171
+ // sgit status should show it as modified
172
+ const status = await execGit(['status', '--porcelain'], {
173
+ gitDir: join(tempDir, '.sgit'),
174
+ workTree: tempDir,
175
+ });
176
+ expect(status).toContain('CLAUDE.md');
177
+ });
178
+
179
+ it('resolves conflicts with option 3 (rename originals)', async () => {
180
+ remoteDir = await createBareRemoteRepo({ 'CLAUDE.md': '# From sgit' });
181
+
182
+ await writeFile(join(tempDir, 'CLAUDE.md'), '# Local version');
183
+
184
+ await trackClone(remoteDir, { onConflict: 3 });
185
+
186
+ // sgit version checked out
187
+ expect(await readFile(join(tempDir, 'CLAUDE.md'), 'utf-8')).toBe(
188
+ '# From sgit',
189
+ );
190
+
191
+ // Original renamed with suffix pattern
192
+ const files = await readdir(tempDir);
193
+ const renamed = files.find((f) =>
194
+ /^CLAUDE-original~[0-9a-f]{4}\.md$/.test(f),
195
+ );
196
+ expect(renamed).toBeDefined();
197
+ expect(await readFile(join(tempDir, renamed!), 'utf-8')).toBe(
198
+ '# Local version',
199
+ );
200
+ });
201
+
202
+ it('handles directory-level conflicts', async () => {
203
+ remoteDir = await createBareRemoteRepo({
204
+ '.claude/settings.json': '{"from": "sgit"}',
205
+ });
206
+
207
+ // Create conflicting file inside directory
208
+ await mkdir(join(tempDir, '.claude'), { recursive: true });
209
+ await writeFile(
210
+ join(tempDir, '.claude', 'settings.json'),
211
+ '{"from": "local"}',
212
+ );
213
+
214
+ await trackClone(remoteDir, { onConflict: 1 });
215
+
216
+ // sgit version should be present
217
+ expect(
218
+ await readFile(join(tempDir, '.claude', 'settings.json'), 'utf-8'),
219
+ ).toBe('{"from": "sgit"}');
220
+ });
221
+
222
+ it('generates correct rename suffix format', async () => {
223
+ remoteDir = await createBareRemoteRepo({
224
+ 'file.txt': 'content',
225
+ 'noext': 'content',
226
+ 'path/to/deep.md': 'content',
227
+ });
228
+
229
+ await writeFile(join(tempDir, 'file.txt'), 'local');
230
+ await writeFile(join(tempDir, 'noext'), 'local');
231
+ await mkdir(join(tempDir, 'path', 'to'), { recursive: true });
232
+ await writeFile(join(tempDir, 'path', 'to', 'deep.md'), 'local');
233
+
234
+ await trackClone(remoteDir, { onConflict: 3 });
235
+
236
+ // Check suffix patterns
237
+ const rootFiles = await readdir(tempDir);
238
+ expect(
239
+ rootFiles.some((f) => /^file-original~[0-9a-f]{4}\.txt$/.test(f)),
240
+ ).toBe(true);
241
+ expect(
242
+ rootFiles.some((f) => /^noext-original~[0-9a-f]{4}$/.test(f)),
243
+ ).toBe(true);
244
+
245
+ const deepFiles = await readdir(join(tempDir, 'path', 'to'));
246
+ expect(
247
+ deepFiles.some((f) => /^deep-original~[0-9a-f]{4}\.md$/.test(f)),
248
+ ).toBe(true);
249
+ });
250
+
251
+ it('installs post-merge and post-checkout hooks', async () => {
252
+ remoteDir = await createBareRemoteRepo({ 'CLAUDE.md': 'content' });
253
+
254
+ await trackClone(remoteDir);
255
+
256
+ const postMerge = await readFile(
257
+ join(tempDir, '.sgit', 'hooks', 'post-merge'),
258
+ 'utf-8',
259
+ );
260
+ expect(postMerge).toContain('#!/bin/sh');
261
+ expect(postMerge).toContain('sgit track sync');
262
+ await access(join(tempDir, '.sgit', 'hooks', 'post-merge'), constants.X_OK);
263
+
264
+ const postCheckout = await readFile(
265
+ join(tempDir, '.sgit', 'hooks', 'post-checkout'),
266
+ 'utf-8',
267
+ );
268
+ expect(postCheckout).toContain('#!/bin/sh');
269
+ expect(postCheckout).toContain('sgit track sync');
270
+ await access(
271
+ join(tempDir, '.sgit', 'hooks', 'post-checkout'),
272
+ constants.X_OK,
273
+ );
274
+ });
275
+
276
+ it('handles clone with empty manifest', async () => {
277
+ remoteDir = await createBareRemoteRepo({});
278
+
279
+ await trackClone(remoteDir);
280
+
281
+ const manifest = JSON.parse(
282
+ await readFile(join(tempDir, '.sgit.json'), 'utf-8'),
283
+ );
284
+ expect(manifest.paths).toEqual([]);
285
+ });
286
+
287
+ it('preserves existing pre-commit hook', async () => {
288
+ remoteDir = await createBareRemoteRepo({ 'CLAUDE.md': 'content' });
289
+
290
+ const hookDir = join(tempDir, '.git', 'hooks');
291
+ await mkdir(hookDir, { recursive: true });
292
+ await writeFile(join(hookDir, 'pre-commit'), '#!/bin/sh\necho original', {
293
+ mode: 0o755,
294
+ });
295
+
296
+ await trackClone(remoteDir);
297
+
298
+ const original = await readFile(
299
+ join(hookDir, 'pre-commit.d', 'original'),
300
+ 'utf-8',
301
+ );
302
+ expect(original).toContain('echo original');
303
+ });
304
+
305
+ it('has remote origin configured for push/pull', async () => {
306
+ remoteDir = await createBareRemoteRepo({ 'CLAUDE.md': 'content' });
307
+
308
+ await trackClone(remoteDir);
309
+
310
+ const remoteUrl = await execGit(['config', 'remote.origin.url'], {
311
+ gitDir: join(tempDir, '.sgit'),
312
+ });
313
+ expect(remoteUrl).toBe(remoteDir);
314
+ });
315
+ });
@@ -16,7 +16,7 @@ describe('completion scripts', () => {
16
16
  expect(script).toContain('complete -F _sgit sgit');
17
17
  expect(script).toContain('_init_completion');
18
18
  expect(script).toContain('git --list-cmds=main,others');
19
- expect(script).toContain('init add remove list sync verify destroy completion');
19
+ expect(script).toContain('init clone add remove list sync verify ignore unignore destroy completion');
20
20
  expect(script).toContain('--adopt');
21
21
  expect(script).toContain('sgit track list');
22
22
  expect(script).toContain('__git_wrap__git_main');
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { trackInit } from '../../../src/commands/track/init';
6
6
  import { trackAdd } from '../../../src/commands/track/add';
7
7
  import { trackDestroy } from '../../../src/commands/track/destroy';
8
+ import { execGit } from '../../../src/core/git';
8
9
  import { createTempGitRepo, removeTempDir } from '../../helpers';
9
10
 
10
11
  describe('track destroy', () => {
@@ -43,21 +44,86 @@ describe('track destroy', () => {
43
44
  await trackDestroy({ force: true });
44
45
 
45
46
  const after = await readFile(excludePath, 'utf-8');
46
- expect(after).not.toContain('# sgit\n');
47
+ expect(after).not.toContain('# sgit-managed');
47
48
  expect(after).not.toContain('.sgit');
48
49
  });
49
50
 
50
- it('keeps previously tracked files on disk', async () => {
51
+ it('deletes tracked files by default', async () => {
51
52
  await writeFile(join(tempDir, 'notes.md'), 'keep me');
52
53
  await trackAdd(['notes.md']);
54
+ await execGit(['commit', '-m', 'add notes'], {
55
+ gitDir: join(tempDir, '.sgit'),
56
+ workTree: tempDir,
57
+ });
53
58
 
54
59
  await trackDestroy({ force: true });
55
60
 
61
+ // File should be gone
62
+ await expect(
63
+ access(join(tempDir, 'notes.md'), constants.F_OK),
64
+ ).rejects.toThrow();
65
+ });
66
+
67
+ it('keeps tracked files with --keep-files', async () => {
68
+ await writeFile(join(tempDir, 'notes.md'), 'keep me');
69
+ await trackAdd(['notes.md']);
70
+ await execGit(['commit', '-m', 'add notes'], {
71
+ gitDir: join(tempDir, '.sgit'),
72
+ workTree: tempDir,
73
+ });
74
+
75
+ await trackDestroy({ force: true, keepFiles: true });
76
+
56
77
  // File remains
57
78
  const content = await readFile(join(tempDir, 'notes.md'), 'utf-8');
58
79
  expect(content).toBe('keep me');
59
80
  });
60
81
 
82
+ it('deletes files in nested directories', async () => {
83
+ await mkdir(join(tempDir, 'docs', 'internal'), { recursive: true });
84
+ await writeFile(join(tempDir, 'docs', 'internal', 'notes.md'), 'hello');
85
+ await trackAdd(['docs/']);
86
+ await execGit(['commit', '-m', 'add docs'], {
87
+ gitDir: join(tempDir, '.sgit'),
88
+ workTree: tempDir,
89
+ });
90
+
91
+ await trackDestroy({ force: true });
92
+
93
+ // File and directory should be gone
94
+ await expect(
95
+ access(join(tempDir, 'docs', 'internal', 'notes.md'), constants.F_OK),
96
+ ).rejects.toThrow();
97
+ await expect(
98
+ access(join(tempDir, 'docs'), constants.F_OK),
99
+ ).rejects.toThrow();
100
+ });
101
+
102
+ it('does not delete non-sgit files in parent directories', async () => {
103
+ await mkdir(join(tempDir, 'docs'), { recursive: true });
104
+ await writeFile(join(tempDir, 'docs', 'tracked.md'), 'sgit');
105
+ await writeFile(join(tempDir, 'docs', 'unrelated.txt'), 'keep');
106
+ await trackAdd(['docs/tracked.md']);
107
+ await execGit(['commit', '-m', 'add tracked'], {
108
+ gitDir: join(tempDir, '.sgit'),
109
+ workTree: tempDir,
110
+ });
111
+
112
+ await trackDestroy({ force: true });
113
+
114
+ // Tracked file gone
115
+ await expect(
116
+ access(join(tempDir, 'docs', 'tracked.md'), constants.F_OK),
117
+ ).rejects.toThrow();
118
+
119
+ // Non-sgit file remains
120
+ const content = await readFile(join(tempDir, 'docs', 'unrelated.txt'), 'utf-8');
121
+ expect(content).toBe('keep');
122
+
123
+ // Directory remains (not empty)
124
+ await access(join(tempDir, 'docs'), constants.F_OK);
125
+ });
126
+
61
127
  it('restores original pre-commit hook', async () => {
62
128
  // Reinit with a pre-existing hook
63
129
  process.chdir(originalCwd);
@@ -0,0 +1,109 @@
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 { execGit } from '../../../src/core/git';
8
+ import { createTempGitRepo, removeTempDir } from '../../helpers';
9
+
10
+ describe('track ignore', () => {
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('adds path to 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
+
31
+ await trackIgnore(['docs/cache/']);
32
+
33
+ const manifest = JSON.parse(
34
+ await readFile(join(tempDir, '.sgit.json'), 'utf-8'),
35
+ );
36
+ expect(manifest.ignore).toContain('docs/cache/');
37
+
38
+ // Shadow exclude should re-ignore the path
39
+ const shadowExclude = await readFile(
40
+ join(tempDir, '.sgit', 'info', 'exclude'),
41
+ 'utf-8',
42
+ );
43
+ expect(shadowExclude).toContain('docs/cache/');
44
+ });
45
+
46
+ it('errors if path is not covered by tracked paths', async () => {
47
+ await mkdir(join(tempDir, 'untracked'));
48
+ await writeFile(join(tempDir, 'untracked', 'file.txt'), 'hello');
49
+
50
+ await expect(trackIgnore(['untracked/file.txt'])).rejects.toThrow(
51
+ 'not tracked by sgit',
52
+ );
53
+ });
54
+
55
+ it('skips already-ignored paths', async () => {
56
+ await mkdir(join(tempDir, 'docs', 'cache'), { recursive: true });
57
+ await writeFile(join(tempDir, 'docs', 'readme.md'), 'hello');
58
+ await writeFile(join(tempDir, 'docs', 'cache', 'data.bin'), 'cached');
59
+ await trackAdd(['docs/']);
60
+
61
+ await trackIgnore(['docs/cache/']);
62
+ await trackIgnore(['docs/cache/']);
63
+
64
+ const manifest = JSON.parse(
65
+ await readFile(join(tempDir, '.sgit.json'), 'utf-8'),
66
+ );
67
+ const cacheEntries = manifest.ignore.filter(
68
+ (p: string) => p === 'docs/cache/',
69
+ );
70
+ expect(cacheEntries).toHaveLength(1);
71
+ });
72
+
73
+ it('untracks ignored file from shadow index', async () => {
74
+ await mkdir(join(tempDir, 'docs'), { recursive: true });
75
+ await writeFile(join(tempDir, 'docs', 'secret.md'), 'secret');
76
+ await writeFile(join(tempDir, 'docs', 'readme.md'), 'hello');
77
+ await trackAdd(['docs/']);
78
+
79
+ // File should be in shadow index
80
+ const sgitDir = join(tempDir, '.sgit');
81
+ await execGit(['add', '-f', 'docs/secret.md'], {
82
+ gitDir: sgitDir,
83
+ workTree: tempDir,
84
+ });
85
+
86
+ await trackIgnore(['docs/secret.md']);
87
+
88
+ // File should no longer be in shadow index
89
+ const files = await execGit(['ls-files'], {
90
+ gitDir: sgitDir,
91
+ workTree: tempDir,
92
+ });
93
+ expect(files).not.toContain('docs/secret.md');
94
+ });
95
+
96
+ it('handles directory ignores', async () => {
97
+ await mkdir(join(tempDir, 'docs', 'tmp', 'nested'), { recursive: true });
98
+ await writeFile(join(tempDir, 'docs', 'readme.md'), 'hello');
99
+ await writeFile(join(tempDir, 'docs', 'tmp', 'nested', 'file.txt'), 'tmp');
100
+ await trackAdd(['docs/']);
101
+
102
+ await trackIgnore(['docs/tmp/']);
103
+
104
+ const manifest = JSON.parse(
105
+ await readFile(join(tempDir, '.sgit.json'), 'utf-8'),
106
+ );
107
+ expect(manifest.ignore).toContain('docs/tmp/');
108
+ });
109
+ });
@@ -8,10 +8,14 @@ import {
8
8
  } from 'node:fs/promises';
9
9
  import { constants } from 'node:fs';
10
10
  import { join } from 'node:path';
11
+ import { execFile } from 'node:child_process';
12
+ import { promisify } from 'node:util';
11
13
  import { trackInit } from '../../../src/commands/track/init';
12
14
  import { execGit } from '../../../src/core/git';
13
15
  import { createTempGitRepo, removeTempDir } from '../../helpers';
14
16
 
17
+ const execFileAsync = promisify(execFile);
18
+
15
19
  describe('track init', () => {
16
20
  let tempDir: string;
17
21
  const originalCwd = process.cwd();
@@ -47,15 +51,15 @@ describe('track init', () => {
47
51
  const manifest = JSON.parse(
48
52
  await readFile(join(tempDir, '.sgit.json'), 'utf-8'),
49
53
  );
50
- expect(manifest).toEqual({ paths: [] });
54
+ expect(manifest).toEqual({ paths: [], ignore: [] });
51
55
 
52
56
  // .git/info/exclude has sgit entries
53
57
  const mainExclude = await readFile(
54
58
  join(tempDir, '.git', 'info', 'exclude'),
55
59
  'utf-8',
56
60
  );
57
- expect(mainExclude).toContain('# sgit\n.sgit\n');
58
- expect(mainExclude).toContain('# sgit\n.sgit.json\n');
61
+ expect(mainExclude).toContain('.sgit\n');
62
+ expect(mainExclude).toContain('.sgit.json\n');
59
63
 
60
64
  // .sgit/info/exclude has base pattern
61
65
  const shadowExclude = await readFile(
@@ -78,6 +82,19 @@ describe('track init', () => {
78
82
  );
79
83
  expect(shadowHook).toContain('sgit track verify --shadow');
80
84
 
85
+ // Sync hooks installed
86
+ const postMerge = await readFile(
87
+ join(tempDir, '.sgit', 'hooks', 'post-merge'),
88
+ 'utf-8',
89
+ );
90
+ expect(postMerge).toContain('sgit track sync');
91
+
92
+ const postCheckout = await readFile(
93
+ join(tempDir, '.sgit', 'hooks', 'post-checkout'),
94
+ 'utf-8',
95
+ );
96
+ expect(postCheckout).toContain('sgit track sync');
97
+
81
98
  // Initial commit with .sgit.json
82
99
  const log = await execGit(['log', '--oneline'], {
83
100
  gitDir: join(tempDir, '.sgit'),
@@ -110,6 +127,11 @@ describe('track init', () => {
110
127
  await rm(nonGit, { recursive: true });
111
128
  });
112
129
 
130
+ it('errors when git identity is not configured', async () => {
131
+ await execFileAsync('git', ['-C', tempDir, 'config', '--unset', 'user.name']);
132
+ await expect(trackInit()).rejects.toThrow('git user identity not configured');
133
+ });
134
+
113
135
  it('preserves existing pre-commit hook', async () => {
114
136
  const hookDir = join(tempDir, '.git', 'hooks');
115
137
  await mkdir(hookDir, { recursive: true });
@@ -141,6 +163,7 @@ describe('track init', () => {
141
163
  );
142
164
  expect(exclude).toContain('*.log');
143
165
  expect(exclude).toContain('build/');
144
- expect(exclude).toContain('# sgit\n.sgit\n');
166
+ expect(exclude).toContain('# sgit-managed\n');
167
+ expect(exclude).toContain('.sgit\n');
145
168
  });
146
169
  });
@@ -47,7 +47,7 @@ describe('track remove', () => {
47
47
  join(tempDir, '.git', 'info', 'exclude'),
48
48
  'utf-8',
49
49
  );
50
- expect(mainExclude).not.toContain('# sgit\nnotes.md\n');
50
+ expect(mainExclude).not.toContain('\nnotes.md\n');
51
51
  });
52
52
 
53
53
  it('removes a tracked directory', async () => {
@@ -40,8 +40,8 @@ describe('track sync', () => {
40
40
  join(tempDir, '.git', 'info', 'exclude'),
41
41
  'utf-8',
42
42
  );
43
- expect(mainExclude).toContain('# sgit\n.sgit\n');
44
- expect(mainExclude).toContain('# sgit\nnotes/todo.md\n');
43
+ expect(mainExclude).toContain('# sgit-managed\n');
44
+ expect(mainExclude).toContain('notes/todo.md\n');
45
45
 
46
46
  // Verify .sgit/info/exclude
47
47
  const shadowExclude = await readFile(
@@ -71,7 +71,7 @@ describe('track sync', () => {
71
71
  expect(mainExclude).toContain('*.log');
72
72
  expect(mainExclude).toContain('build/');
73
73
  expect(mainExclude).not.toContain('garbage');
74
- expect(mainExclude).toContain('# sgit\n.sgit\n');
74
+ expect(mainExclude).toContain('# sgit-managed\n');
75
75
  });
76
76
 
77
77
  it('warns for missing manifest paths', async () => {