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.
- package/.claude/settings.local.json +39 -0
- package/.temp/.sgit/COMMIT_EDITMSG +1 -0
- package/.temp/.sgit/HEAD +1 -0
- package/.temp/.sgit/config +8 -0
- package/.temp/.sgit/description +1 -0
- package/.temp/.sgit/hooks/applypatch-msg.sample +15 -0
- package/.temp/.sgit/hooks/commit-msg.sample +24 -0
- package/.temp/.sgit/hooks/fsmonitor-watchman.sample +174 -0
- package/.temp/.sgit/hooks/post-update.sample +8 -0
- package/.temp/.sgit/hooks/pre-applypatch.sample +14 -0
- package/.temp/.sgit/hooks/pre-commit +6 -0
- package/.temp/.sgit/hooks/pre-commit.sample +49 -0
- package/.temp/.sgit/hooks/pre-merge-commit.sample +13 -0
- package/.temp/.sgit/hooks/pre-push.sample +53 -0
- package/.temp/.sgit/hooks/pre-rebase.sample +169 -0
- package/.temp/.sgit/hooks/pre-receive.sample +24 -0
- package/.temp/.sgit/hooks/prepare-commit-msg.sample +42 -0
- package/.temp/.sgit/hooks/push-to-checkout.sample +78 -0
- package/.temp/.sgit/hooks/sendemail-validate.sample +77 -0
- package/.temp/.sgit/hooks/update.sample +128 -0
- package/.temp/.sgit/index +0 -0
- package/.temp/.sgit/info/exclude +11 -0
- package/.temp/.sgit/logs/HEAD +1 -0
- package/.temp/.sgit/logs/refs/heads/master +1 -0
- package/.temp/.sgit/objects/34/b3190a8a836593fb568a56c073a416a56fbf6d +0 -0
- package/.temp/.sgit/objects/70/ad6bbbfefd330d72a43dc2a810e6ef42269911 +0 -0
- package/.temp/.sgit/objects/90/4e09c7aadaa32d2426ab5464a975d5151ef195 +0 -0
- package/.temp/.sgit/objects/a4/6358feef49515d5f85d1d3a64e929182da7149 +3 -0
- package/.temp/.sgit/objects/e8/0f4253888cdadda1e3d810f6a3cb3c68c20b50 +0 -0
- package/.temp/.sgit/refs/heads/master +1 -0
- package/.temp/.sgit.json +6 -0
- package/.temp/README.md +3 -0
- package/.temp/docs/guide.md +10 -0
- package/.temp/lib/__init__.py +1 -0
- package/.temp/lib/utils.py +5 -0
- package/.temp/src/config.py +3 -0
- package/.temp/src/main.py +5 -0
- package/.temp/temp.md +3 -0
- package/.temp/tests/test_utils.py +7 -0
- package/CLAUDE.md +51 -0
- package/README.md +72 -0
- package/dist/cli.js +1052 -0
- package/package.json +35 -0
- package/src/cli.ts +55 -0
- package/src/commands/forward.ts +12 -0
- package/src/commands/track/add.ts +91 -0
- package/src/commands/track/completion.ts +243 -0
- package/src/commands/track/destroy.ts +81 -0
- package/src/commands/track/index.ts +91 -0
- package/src/commands/track/init.ts +94 -0
- package/src/commands/track/list.ts +15 -0
- package/src/commands/track/remove.ts +66 -0
- package/src/commands/track/sync.ts +50 -0
- package/src/commands/track/verify.ts +98 -0
- package/src/core/errors.ts +42 -0
- package/src/core/exclude.ts +106 -0
- package/src/core/git.ts +18 -0
- package/src/core/hooks.ts +152 -0
- package/src/core/manifest.ts +49 -0
- package/src/core/paths.ts +58 -0
- package/src/core/repository.ts +39 -0
- package/src/utils/logger.ts +21 -0
- package/src/utils/process.ts +13 -0
- package/tests/commands/forward.test.ts +55 -0
- package/tests/commands/track/add.test.ts +148 -0
- package/tests/commands/track/completion.test.ts +124 -0
- package/tests/commands/track/destroy.test.ts +112 -0
- package/tests/commands/track/init.test.ts +146 -0
- package/tests/commands/track/remove.test.ts +107 -0
- package/tests/commands/track/sync.test.ts +118 -0
- package/tests/commands/track/verify.test.ts +96 -0
- package/tests/core/exclude.test.ts +120 -0
- package/tests/core/hooks.test.ts +222 -0
- package/tests/core/manifest.test.ts +98 -0
- package/tests/core/paths.test.ts +113 -0
- package/tests/core/repository.test.ts +68 -0
- package/tests/helpers.ts +21 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +11 -0
- 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
|
+
});
|