shadow-git 0.1.0

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 (80) hide show
  1. package/.claude/settings.local.json +39 -0
  2. package/.temp/.sgit/COMMIT_EDITMSG +1 -0
  3. package/.temp/.sgit/HEAD +1 -0
  4. package/.temp/.sgit/config +8 -0
  5. package/.temp/.sgit/description +1 -0
  6. package/.temp/.sgit/hooks/applypatch-msg.sample +15 -0
  7. package/.temp/.sgit/hooks/commit-msg.sample +24 -0
  8. package/.temp/.sgit/hooks/fsmonitor-watchman.sample +174 -0
  9. package/.temp/.sgit/hooks/post-update.sample +8 -0
  10. package/.temp/.sgit/hooks/pre-applypatch.sample +14 -0
  11. package/.temp/.sgit/hooks/pre-commit +6 -0
  12. package/.temp/.sgit/hooks/pre-commit.sample +49 -0
  13. package/.temp/.sgit/hooks/pre-merge-commit.sample +13 -0
  14. package/.temp/.sgit/hooks/pre-push.sample +53 -0
  15. package/.temp/.sgit/hooks/pre-rebase.sample +169 -0
  16. package/.temp/.sgit/hooks/pre-receive.sample +24 -0
  17. package/.temp/.sgit/hooks/prepare-commit-msg.sample +42 -0
  18. package/.temp/.sgit/hooks/push-to-checkout.sample +78 -0
  19. package/.temp/.sgit/hooks/sendemail-validate.sample +77 -0
  20. package/.temp/.sgit/hooks/update.sample +128 -0
  21. package/.temp/.sgit/index +0 -0
  22. package/.temp/.sgit/info/exclude +11 -0
  23. package/.temp/.sgit/logs/HEAD +1 -0
  24. package/.temp/.sgit/logs/refs/heads/master +1 -0
  25. package/.temp/.sgit/objects/34/b3190a8a836593fb568a56c073a416a56fbf6d +0 -0
  26. package/.temp/.sgit/objects/70/ad6bbbfefd330d72a43dc2a810e6ef42269911 +0 -0
  27. package/.temp/.sgit/objects/90/4e09c7aadaa32d2426ab5464a975d5151ef195 +0 -0
  28. package/.temp/.sgit/objects/a4/6358feef49515d5f85d1d3a64e929182da7149 +3 -0
  29. package/.temp/.sgit/objects/e8/0f4253888cdadda1e3d810f6a3cb3c68c20b50 +0 -0
  30. package/.temp/.sgit/refs/heads/master +1 -0
  31. package/.temp/.sgit.json +6 -0
  32. package/.temp/README.md +3 -0
  33. package/.temp/docs/guide.md +10 -0
  34. package/.temp/lib/__init__.py +1 -0
  35. package/.temp/lib/utils.py +5 -0
  36. package/.temp/src/config.py +3 -0
  37. package/.temp/src/main.py +5 -0
  38. package/.temp/temp.md +3 -0
  39. package/.temp/tests/test_utils.py +7 -0
  40. package/CLAUDE.md +51 -0
  41. package/README.md +72 -0
  42. package/dist/cli.js +1052 -0
  43. package/package.json +35 -0
  44. package/src/cli.ts +55 -0
  45. package/src/commands/forward.ts +12 -0
  46. package/src/commands/track/add.ts +91 -0
  47. package/src/commands/track/completion.ts +243 -0
  48. package/src/commands/track/destroy.ts +81 -0
  49. package/src/commands/track/index.ts +91 -0
  50. package/src/commands/track/init.ts +94 -0
  51. package/src/commands/track/list.ts +15 -0
  52. package/src/commands/track/remove.ts +66 -0
  53. package/src/commands/track/sync.ts +50 -0
  54. package/src/commands/track/verify.ts +98 -0
  55. package/src/core/errors.ts +42 -0
  56. package/src/core/exclude.ts +106 -0
  57. package/src/core/git.ts +18 -0
  58. package/src/core/hooks.ts +152 -0
  59. package/src/core/manifest.ts +49 -0
  60. package/src/core/paths.ts +58 -0
  61. package/src/core/repository.ts +39 -0
  62. package/src/utils/logger.ts +21 -0
  63. package/src/utils/process.ts +13 -0
  64. package/tests/commands/forward.test.ts +55 -0
  65. package/tests/commands/track/add.test.ts +148 -0
  66. package/tests/commands/track/completion.test.ts +124 -0
  67. package/tests/commands/track/destroy.test.ts +112 -0
  68. package/tests/commands/track/init.test.ts +146 -0
  69. package/tests/commands/track/remove.test.ts +107 -0
  70. package/tests/commands/track/sync.test.ts +118 -0
  71. package/tests/commands/track/verify.test.ts +96 -0
  72. package/tests/core/exclude.test.ts +120 -0
  73. package/tests/core/hooks.test.ts +222 -0
  74. package/tests/core/manifest.test.ts +98 -0
  75. package/tests/core/paths.test.ts +113 -0
  76. package/tests/core/repository.test.ts +68 -0
  77. package/tests/helpers.ts +21 -0
  78. package/tsconfig.json +17 -0
  79. package/tsup.config.ts +11 -0
  80. package/vitest.config.ts +8 -0
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ readFile,
4
+ mkdir,
5
+ writeFile,
6
+ access,
7
+ symlink,
8
+ } from 'node:fs/promises';
9
+ import { constants } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { trackInit } from '../../../src/commands/track/init';
12
+ import { execGit } from '../../../src/core/git';
13
+ import { createTempGitRepo, removeTempDir } from '../../helpers';
14
+
15
+ describe('track init', () => {
16
+ let tempDir: string;
17
+ const originalCwd = process.cwd();
18
+
19
+ beforeEach(async () => {
20
+ tempDir = await createTempGitRepo();
21
+ process.chdir(tempDir);
22
+ });
23
+
24
+ afterEach(async () => {
25
+ process.chdir(originalCwd);
26
+ await removeTempDir(tempDir);
27
+ });
28
+
29
+ it('initializes shadow repository', async () => {
30
+ await trackInit();
31
+
32
+ // .sgit directory exists
33
+ await access(join(tempDir, '.sgit'), constants.F_OK);
34
+
35
+ // Config values
36
+ const bare = await execGit(['config', 'core.bare'], {
37
+ gitDir: join(tempDir, '.sgit'),
38
+ });
39
+ expect(bare).toBe('false');
40
+
41
+ const worktree = await execGit(['config', 'core.worktree'], {
42
+ gitDir: join(tempDir, '.sgit'),
43
+ });
44
+ expect(worktree).toBe(tempDir);
45
+
46
+ // .sgit.json exists with empty paths
47
+ const manifest = JSON.parse(
48
+ await readFile(join(tempDir, '.sgit.json'), 'utf-8'),
49
+ );
50
+ expect(manifest).toEqual({ paths: [] });
51
+
52
+ // .git/info/exclude has sgit entries
53
+ const mainExclude = await readFile(
54
+ join(tempDir, '.git', 'info', 'exclude'),
55
+ 'utf-8',
56
+ );
57
+ expect(mainExclude).toContain('# sgit\n.sgit\n');
58
+ expect(mainExclude).toContain('# sgit\n.sgit.json\n');
59
+
60
+ // .sgit/info/exclude has base pattern
61
+ const shadowExclude = await readFile(
62
+ join(tempDir, '.sgit', 'info', 'exclude'),
63
+ 'utf-8',
64
+ );
65
+ expect(shadowExclude).toContain('*');
66
+ expect(shadowExclude).toContain('!.sgit.json');
67
+
68
+ // Hooks installed
69
+ const mainHook = await readFile(
70
+ join(tempDir, '.git', 'hooks', 'pre-commit.d', 'sgit'),
71
+ 'utf-8',
72
+ );
73
+ expect(mainHook).toContain('sgit track verify --main');
74
+
75
+ const shadowHook = await readFile(
76
+ join(tempDir, '.sgit', 'hooks', 'pre-commit'),
77
+ 'utf-8',
78
+ );
79
+ expect(shadowHook).toContain('sgit track verify --shadow');
80
+
81
+ // Initial commit with .sgit.json
82
+ const log = await execGit(['log', '--oneline'], {
83
+ gitDir: join(tempDir, '.sgit'),
84
+ workTree: tempDir,
85
+ });
86
+ expect(log).toContain('sgit: initialize shadow repository');
87
+
88
+ const files = await execGit(['ls-files'], {
89
+ gitDir: join(tempDir, '.sgit'),
90
+ workTree: tempDir,
91
+ });
92
+ expect(files).toContain('.sgit.json');
93
+ });
94
+
95
+ it('errors if already initialized', async () => {
96
+ await trackInit();
97
+ await expect(trackInit()).rejects.toThrow('already initialized');
98
+ });
99
+
100
+ it('errors outside git repo', async () => {
101
+ const { mkdtemp, realpath, rm } = await import('node:fs/promises');
102
+ const { tmpdir } = await import('node:os');
103
+ const nonGitRaw = await mkdtemp(join(tmpdir(), 'sgit-test-nogit-'));
104
+ const nonGit = await realpath(nonGitRaw);
105
+ process.chdir(nonGit);
106
+
107
+ await expect(trackInit()).rejects.toThrow();
108
+
109
+ process.chdir(tempDir);
110
+ await rm(nonGit, { recursive: true });
111
+ });
112
+
113
+ it('preserves existing pre-commit hook', async () => {
114
+ const hookDir = join(tempDir, '.git', 'hooks');
115
+ await mkdir(hookDir, { recursive: true });
116
+ await writeFile(join(hookDir, 'pre-commit'), '#!/bin/sh\necho original', {
117
+ mode: 0o755,
118
+ });
119
+
120
+ await trackInit();
121
+
122
+ const original = await readFile(
123
+ join(hookDir, 'pre-commit.d', 'original'),
124
+ 'utf-8',
125
+ );
126
+ expect(original).toContain('echo original');
127
+ });
128
+
129
+ it('preserves existing .git/info/exclude entries', async () => {
130
+ await mkdir(join(tempDir, '.git', 'info'), { recursive: true });
131
+ await writeFile(
132
+ join(tempDir, '.git', 'info', 'exclude'),
133
+ '*.log\nbuild/\n',
134
+ );
135
+
136
+ await trackInit();
137
+
138
+ const exclude = await readFile(
139
+ join(tempDir, '.git', 'info', 'exclude'),
140
+ 'utf-8',
141
+ );
142
+ expect(exclude).toContain('*.log');
143
+ expect(exclude).toContain('build/');
144
+ expect(exclude).toContain('# sgit\n.sgit\n');
145
+ });
146
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { trackInit } from '../../../src/commands/track/init';
6
+ import { trackAdd } from '../../../src/commands/track/add';
7
+ import { trackRemove } from '../../../src/commands/track/remove';
8
+ import { trackList } from '../../../src/commands/track/list';
9
+ import { readManifest } from '../../../src/core/manifest';
10
+ import { execGit } from '../../../src/core/git';
11
+ import { createTempGitRepo, removeTempDir } from '../../helpers';
12
+
13
+ describe('track remove', () => {
14
+ let tempDir: string;
15
+ const originalCwd = process.cwd();
16
+
17
+ beforeEach(async () => {
18
+ tempDir = await createTempGitRepo();
19
+ process.chdir(tempDir);
20
+ await trackInit();
21
+ });
22
+
23
+ afterEach(async () => {
24
+ process.chdir(originalCwd);
25
+ await removeTempDir(tempDir);
26
+ });
27
+
28
+ it('removes a tracked file', async () => {
29
+ await writeFile(join(tempDir, 'notes.md'), 'hello');
30
+ await trackAdd(['notes.md']);
31
+ await execGit(['commit', '-m', 'add notes'], {
32
+ gitDir: join(tempDir, '.sgit'),
33
+ workTree: tempDir,
34
+ });
35
+
36
+ await trackRemove(['notes.md']);
37
+
38
+ // Removed from manifest
39
+ const manifest = await readManifest(tempDir);
40
+ expect(manifest.paths).not.toContain('notes.md');
41
+
42
+ // File still on disk
43
+ await access(join(tempDir, 'notes.md'), constants.F_OK);
44
+
45
+ // No longer in shadow exclude
46
+ const mainExclude = await readFile(
47
+ join(tempDir, '.git', 'info', 'exclude'),
48
+ 'utf-8',
49
+ );
50
+ expect(mainExclude).not.toContain('# sgit\nnotes.md\n');
51
+ });
52
+
53
+ it('removes a tracked directory', async () => {
54
+ await mkdir(join(tempDir, 'docs', '.internal'), { recursive: true });
55
+ await writeFile(
56
+ join(tempDir, 'docs', '.internal', 'design.md'),
57
+ 'notes',
58
+ );
59
+ await trackAdd(['docs/.internal']);
60
+ await execGit(['commit', '-m', 'add docs'], {
61
+ gitDir: join(tempDir, '.sgit'),
62
+ workTree: tempDir,
63
+ });
64
+
65
+ await trackRemove(['docs/.internal/']);
66
+
67
+ const manifest = await readManifest(tempDir);
68
+ expect(manifest.paths).not.toContain('docs/.internal/');
69
+ });
70
+
71
+ it('errors for untracked path', async () => {
72
+ await expect(trackRemove(['not-tracked.md'])).rejects.toThrow(
73
+ 'not tracked by sgit',
74
+ );
75
+ });
76
+ });
77
+
78
+ describe('track list', () => {
79
+ let tempDir: string;
80
+ const originalCwd = process.cwd();
81
+
82
+ beforeEach(async () => {
83
+ tempDir = await createTempGitRepo();
84
+ process.chdir(tempDir);
85
+ await trackInit();
86
+ });
87
+
88
+ afterEach(async () => {
89
+ process.chdir(originalCwd);
90
+ await removeTempDir(tempDir);
91
+ });
92
+
93
+ it('returns tracked paths', async () => {
94
+ await writeFile(join(tempDir, 'a.txt'), 'a');
95
+ await writeFile(join(tempDir, 'b.txt'), 'b');
96
+ await trackAdd(['a.txt', 'b.txt']);
97
+
98
+ const paths = await trackList();
99
+ expect(paths).toContain('a.txt');
100
+ expect(paths).toContain('b.txt');
101
+ });
102
+
103
+ it('returns empty array when no paths', async () => {
104
+ const paths = await trackList();
105
+ expect(paths).toEqual([]);
106
+ });
107
+ });
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { trackInit } from '../../../src/commands/track/init';
5
+ import { trackSync } from '../../../src/commands/track/sync';
6
+ import { readManifest, writeManifest } from '../../../src/core/manifest';
7
+ import { createTempGitRepo, removeTempDir } from '../../helpers';
8
+
9
+ describe('track sync', () => {
10
+ let tempDir: string;
11
+ const originalCwd = process.cwd();
12
+
13
+ beforeEach(async () => {
14
+ tempDir = await createTempGitRepo();
15
+ process.chdir(tempDir);
16
+ await trackInit();
17
+ });
18
+
19
+ afterEach(async () => {
20
+ process.chdir(originalCwd);
21
+ await removeTempDir(tempDir);
22
+ });
23
+
24
+ it('regenerates exclude files from manifest', async () => {
25
+ // Add a path to manifest manually
26
+ await mkdir(join(tempDir, 'notes'), { recursive: true });
27
+ await writeFile(join(tempDir, 'notes', 'todo.md'), 'hello');
28
+ const manifest = await readManifest(tempDir);
29
+ manifest.paths.push('notes/todo.md');
30
+ await writeManifest(tempDir, manifest);
31
+
32
+ // Clear exclude files
33
+ await writeFile(join(tempDir, '.git', 'info', 'exclude'), '');
34
+ await writeFile(join(tempDir, '.sgit', 'info', 'exclude'), '');
35
+
36
+ await trackSync();
37
+
38
+ // Verify .git/info/exclude
39
+ const mainExclude = await readFile(
40
+ join(tempDir, '.git', 'info', 'exclude'),
41
+ 'utf-8',
42
+ );
43
+ expect(mainExclude).toContain('# sgit\n.sgit\n');
44
+ expect(mainExclude).toContain('# sgit\nnotes/todo.md\n');
45
+
46
+ // Verify .sgit/info/exclude
47
+ const shadowExclude = await readFile(
48
+ join(tempDir, '.sgit', 'info', 'exclude'),
49
+ 'utf-8',
50
+ );
51
+ expect(shadowExclude).toContain('*');
52
+ expect(shadowExclude).toContain('!.sgit.json');
53
+ expect(shadowExclude).toContain('!notes/');
54
+ expect(shadowExclude).toContain('notes/*');
55
+ expect(shadowExclude).toContain('!notes/todo.md');
56
+ });
57
+
58
+ it('preserves non-sgit entries in .git/info/exclude', async () => {
59
+ // Add user entry then corrupt sgit entries
60
+ await writeFile(
61
+ join(tempDir, '.git', 'info', 'exclude'),
62
+ '*.log\nbuild/\n# sgit\ngarbage\n',
63
+ );
64
+
65
+ await trackSync();
66
+
67
+ const mainExclude = await readFile(
68
+ join(tempDir, '.git', 'info', 'exclude'),
69
+ 'utf-8',
70
+ );
71
+ expect(mainExclude).toContain('*.log');
72
+ expect(mainExclude).toContain('build/');
73
+ expect(mainExclude).not.toContain('garbage');
74
+ expect(mainExclude).toContain('# sgit\n.sgit\n');
75
+ });
76
+
77
+ it('warns for missing manifest paths', async () => {
78
+ // Add a path to manifest that does not exist on disk
79
+ const manifest = await readManifest(tempDir);
80
+ manifest.paths.push('nonexistent/file.md');
81
+ await writeManifest(tempDir, manifest);
82
+
83
+ // Capture stderr to check for warning
84
+ const stderrWrite = process.stderr.write;
85
+ let stderrOutput = '';
86
+ process.stderr.write = ((chunk: any) => {
87
+ stderrOutput += chunk.toString();
88
+ return true;
89
+ }) as any;
90
+
91
+ await trackSync();
92
+
93
+ process.stderr.write = stderrWrite;
94
+ expect(stderrOutput).toContain('path does not exist: nonexistent/file.md');
95
+ });
96
+
97
+ it('handles deeply nested paths', async () => {
98
+ await mkdir(join(tempDir, 'a', 'b', 'c'), { recursive: true });
99
+ await writeFile(join(tempDir, 'a', 'b', 'c', 'file.txt'), 'deep');
100
+ const manifest = await readManifest(tempDir);
101
+ manifest.paths.push('a/b/c/file.txt');
102
+ await writeManifest(tempDir, manifest);
103
+
104
+ await trackSync();
105
+
106
+ const shadowExclude = await readFile(
107
+ join(tempDir, '.sgit', 'info', 'exclude'),
108
+ 'utf-8',
109
+ );
110
+ expect(shadowExclude).toContain('!a/');
111
+ expect(shadowExclude).toContain('a/*');
112
+ expect(shadowExclude).toContain('!a/b/');
113
+ expect(shadowExclude).toContain('a/b/*');
114
+ expect(shadowExclude).toContain('!a/b/c/');
115
+ expect(shadowExclude).toContain('a/b/c/*');
116
+ expect(shadowExclude).toContain('!a/b/c/file.txt');
117
+ });
118
+ });
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { writeFile, mkdir } 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 { verifyMain, verifyShadow } from '../../../src/commands/track/verify';
7
+ import { execGit } from '../../../src/core/git';
8
+ import { createTempGitRepo, removeTempDir } from '../../helpers';
9
+
10
+ describe('track verify', () => {
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
+ describe('--main', () => {
26
+ it('blocks commit of shadow-tracked file in main repo', async () => {
27
+ await mkdir(join(tempDir, 'notes'), { recursive: true });
28
+ await writeFile(join(tempDir, 'notes', 'todo.md'), 'hello');
29
+ await trackAdd(['notes/todo.md']);
30
+
31
+ // Force-stage in main git (bypassing exclude)
32
+ await execGit(['add', '-f', 'notes/todo.md'], { cwd: tempDir });
33
+
34
+ const result = await verifyMain(tempDir);
35
+ expect(result.valid).toBe(false);
36
+ expect(result.violations).toContain('notes/todo.md');
37
+ });
38
+
39
+ it('allows commit of non-shadow file in main repo', async () => {
40
+ await writeFile(join(tempDir, 'readme.md'), 'hello');
41
+ await execGit(['add', 'readme.md'], { cwd: tempDir });
42
+
43
+ const result = await verifyMain(tempDir);
44
+ expect(result.valid).toBe(true);
45
+ });
46
+
47
+ it('blocks file inside shadow-tracked directory', async () => {
48
+ await mkdir(join(tempDir, 'docs', '.internal'), { recursive: true });
49
+ await writeFile(
50
+ join(tempDir, 'docs', '.internal', 'design.md'),
51
+ 'notes',
52
+ );
53
+ await trackAdd(['docs/.internal']);
54
+
55
+ await execGit(['add', '-f', 'docs/.internal/design.md'], {
56
+ cwd: tempDir,
57
+ });
58
+
59
+ const result = await verifyMain(tempDir);
60
+ expect(result.valid).toBe(false);
61
+ expect(result.violations).toContain('docs/.internal/design.md');
62
+ });
63
+ });
64
+
65
+ describe('--shadow', () => {
66
+ it('blocks commit of non-manifest file in shadow repo', async () => {
67
+ await mkdir(join(tempDir, 'src'), { recursive: true });
68
+ await writeFile(join(tempDir, 'src', 'main.ts'), 'code');
69
+
70
+ // Force-stage in shadow repo
71
+ await execGit(['add', '-f', 'src/main.ts'], {
72
+ gitDir: join(tempDir, '.sgit'),
73
+ workTree: tempDir,
74
+ });
75
+
76
+ const result = await verifyShadow(tempDir);
77
+ expect(result.valid).toBe(false);
78
+ expect(result.violations).toContain('src/main.ts');
79
+ });
80
+
81
+ it('allows .sgit.json in shadow repo', async () => {
82
+ // .sgit.json is always allowed
83
+ const result = await verifyShadow(tempDir);
84
+ // The init already committed .sgit.json, check with no new staged files
85
+ expect(result.valid).toBe(true);
86
+ });
87
+
88
+ it('allows manifest-tracked file in shadow repo', async () => {
89
+ await writeFile(join(tempDir, 'notes.md'), 'hello');
90
+ await trackAdd(['notes.md']);
91
+
92
+ const result = await verifyShadow(tempDir);
93
+ expect(result.valid).toBe(true);
94
+ });
95
+ });
96
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ generateMainExclude,
4
+ generateShadowExclude,
5
+ } from '../../src/core/exclude';
6
+
7
+ describe('generateMainExclude', () => {
8
+ it('adds sgit entries to empty file', () => {
9
+ 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');
13
+ });
14
+
15
+ it('preserves existing non-sgit lines', () => {
16
+ const existing = '*.log\nbuild/\n';
17
+ const result = generateMainExclude(existing, []);
18
+ expect(result).toContain('*.log');
19
+ expect(result).toContain('build/');
20
+ expect(result).toContain('# sgit\n.sgit\n');
21
+ });
22
+
23
+ it('replaces old sgit entries', () => {
24
+ const existing = 'user-entry\n# sgit\nold-path\n';
25
+ const result = generateMainExclude(existing, ['new-path.md']);
26
+ expect(result).toContain('user-entry');
27
+ expect(result).not.toContain('old-path');
28
+ expect(result).toContain('# sgit\nnew-path.md\n');
29
+ });
30
+
31
+ it('includes manifest paths with sgit marker', () => {
32
+ const result = generateMainExclude('', [
33
+ 'docs/.internal/',
34
+ 'notes/todo.md',
35
+ ]);
36
+ expect(result).toContain('# sgit\ndocs/.internal/\n');
37
+ expect(result).toContain('# sgit\nnotes/todo.md\n');
38
+ });
39
+
40
+ it('orders entries: .sgit, .sgit.json, then manifest paths', () => {
41
+ const result = generateMainExclude('', ['z.txt', 'a.txt']);
42
+ const lines = result.split('\n');
43
+ const sgitLine = lines.indexOf('.sgit');
44
+ const sgitJsonLine = lines.indexOf('.sgit.json');
45
+ const zLine = lines.indexOf('z.txt');
46
+ const aLine = lines.indexOf('a.txt');
47
+ expect(sgitLine).toBeLessThan(sgitJsonLine);
48
+ expect(sgitJsonLine).toBeLessThan(zLine);
49
+ expect(sgitJsonLine).toBeLessThan(aLine);
50
+ });
51
+ });
52
+
53
+ describe('generateShadowExclude', () => {
54
+ it('generates base pattern for empty manifest', () => {
55
+ const result = generateShadowExclude([]);
56
+ expect(result).toContain('# Exclude everything by default');
57
+ expect(result).toContain('*');
58
+ expect(result).toContain('# .sgit.json (always tracked)');
59
+ expect(result).toContain('!.sgit.json');
60
+ });
61
+
62
+ it('generates ancestor chain for single file', () => {
63
+ const result = generateShadowExclude(['notes/todo.md']);
64
+ const lines = result.split('\n');
65
+ expect(lines).toContain('!notes/');
66
+ expect(lines).toContain('notes/*');
67
+ expect(lines).toContain('!notes/todo.md');
68
+ });
69
+
70
+ it('generates ancestor chain for directory', () => {
71
+ const result = generateShadowExclude(['docs/.internal/']);
72
+ const lines = result.split('\n');
73
+ expect(lines).toContain('!docs/');
74
+ expect(lines).toContain('docs/*');
75
+ expect(lines).toContain('!docs/.internal/');
76
+ });
77
+
78
+ it('generates deep ancestor chain', () => {
79
+ const result = generateShadowExclude(['a/b/c/file.txt']);
80
+ const lines = result.split('\n');
81
+ expect(lines).toContain('!a/');
82
+ expect(lines).toContain('a/*');
83
+ expect(lines).toContain('!a/b/');
84
+ expect(lines).toContain('a/b/*');
85
+ expect(lines).toContain('!a/b/c/');
86
+ expect(lines).toContain('a/b/c/*');
87
+ expect(lines).toContain('!a/b/c/file.txt');
88
+ });
89
+
90
+ it('deduplicates shared ancestors', () => {
91
+ const result = generateShadowExclude([
92
+ 'docs/.internal/',
93
+ 'docs/.drafts/notes.md',
94
+ ]);
95
+ const lines = result.split('\n');
96
+ // !docs/ and docs/* should appear only once
97
+ const docsUnignore = lines.filter((l) => l === '!docs/');
98
+ const docsReignore = lines.filter((l) => l === 'docs/*');
99
+ expect(docsUnignore).toHaveLength(1);
100
+ expect(docsReignore).toHaveLength(1);
101
+ // Both targets should be present
102
+ expect(lines).toContain('!docs/.internal/');
103
+ expect(lines).toContain('!docs/.drafts/');
104
+ expect(lines).toContain('docs/.drafts/*');
105
+ expect(lines).toContain('!docs/.drafts/notes.md');
106
+ });
107
+
108
+ it('handles root-level file (no ancestors)', () => {
109
+ const result = generateShadowExclude(['readme.md']);
110
+ const lines = result.split('\n');
111
+ expect(lines).toContain('!readme.md');
112
+ // No ancestor chain needed for root-level files
113
+ expect(lines).not.toContain('!./');
114
+ });
115
+
116
+ it('includes comment before each tracked path section', () => {
117
+ const result = generateShadowExclude(['notes/todo.md']);
118
+ expect(result).toContain('# notes/todo.md');
119
+ });
120
+ });