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,222 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
readFile,
|
|
4
|
+
writeFile,
|
|
5
|
+
mkdir,
|
|
6
|
+
access,
|
|
7
|
+
symlink,
|
|
8
|
+
readdir,
|
|
9
|
+
} from 'node:fs/promises';
|
|
10
|
+
import { constants } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
installMainHook,
|
|
14
|
+
installShadowHook,
|
|
15
|
+
uninstallMainHook,
|
|
16
|
+
getHookDir,
|
|
17
|
+
} from '../../src/core/hooks';
|
|
18
|
+
import { createTempGitRepo, removeTempDir } from '../helpers';
|
|
19
|
+
|
|
20
|
+
describe('getHookDir', () => {
|
|
21
|
+
let root: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
root = await createTempGitRepo();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await removeTempDir(root);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns .git/hooks by default', async () => {
|
|
32
|
+
expect(await getHookDir(root)).toBe(join(root, '.git', 'hooks'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns custom hooksPath when configured', async () => {
|
|
36
|
+
const { execFile } = await import('node:child_process');
|
|
37
|
+
const { promisify } = await import('node:util');
|
|
38
|
+
const exec = promisify(execFile);
|
|
39
|
+
await exec('git', ['-C', root, 'config', 'core.hooksPath', '.githooks']);
|
|
40
|
+
expect(await getHookDir(root)).toBe(join(root, '.githooks'));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('installMainHook', () => {
|
|
45
|
+
let root: string;
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
root = await createTempGitRepo();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await removeTempDir(root);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('creates runner and sgit hook when no hook exists', async () => {
|
|
56
|
+
await installMainHook(root);
|
|
57
|
+
|
|
58
|
+
const hookDir = join(root, '.git', 'hooks');
|
|
59
|
+
const runner = await readFile(join(hookDir, 'pre-commit'), 'utf-8');
|
|
60
|
+
expect(runner).toContain('pre-commit.d');
|
|
61
|
+
expect(runner).toContain('#!/bin/sh');
|
|
62
|
+
|
|
63
|
+
const sgitHook = await readFile(
|
|
64
|
+
join(hookDir, 'pre-commit.d', 'sgit'),
|
|
65
|
+
'utf-8',
|
|
66
|
+
);
|
|
67
|
+
expect(sgitHook).toContain('sgit track verify --main');
|
|
68
|
+
|
|
69
|
+
// Verify executable
|
|
70
|
+
await access(join(hookDir, 'pre-commit'), constants.X_OK);
|
|
71
|
+
await access(join(hookDir, 'pre-commit.d', 'sgit'), constants.X_OK);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('preserves existing regular file hook', async () => {
|
|
75
|
+
const hookDir = join(root, '.git', 'hooks');
|
|
76
|
+
await mkdir(hookDir, { recursive: true });
|
|
77
|
+
await writeFile(join(hookDir, 'pre-commit'), '#!/bin/sh\necho original', {
|
|
78
|
+
mode: 0o755,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await installMainHook(root);
|
|
82
|
+
|
|
83
|
+
const original = await readFile(
|
|
84
|
+
join(hookDir, 'pre-commit.d', 'original'),
|
|
85
|
+
'utf-8',
|
|
86
|
+
);
|
|
87
|
+
expect(original).toContain('echo original');
|
|
88
|
+
|
|
89
|
+
const runner = await readFile(join(hookDir, 'pre-commit'), 'utf-8');
|
|
90
|
+
expect(runner).toContain('pre-commit.d');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('handles existing symlink hook', async () => {
|
|
94
|
+
const hookDir = join(root, '.git', 'hooks');
|
|
95
|
+
await mkdir(hookDir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
// Create target file elsewhere
|
|
98
|
+
const target = join(root, 'my-hook.sh');
|
|
99
|
+
await writeFile(target, '#!/bin/sh\necho symlink-target', { mode: 0o755 });
|
|
100
|
+
await symlink(target, join(hookDir, 'pre-commit'));
|
|
101
|
+
|
|
102
|
+
await installMainHook(root);
|
|
103
|
+
|
|
104
|
+
const original = await readFile(
|
|
105
|
+
join(hookDir, 'pre-commit.d', 'original'),
|
|
106
|
+
'utf-8',
|
|
107
|
+
);
|
|
108
|
+
expect(original).toContain('echo symlink-target');
|
|
109
|
+
|
|
110
|
+
const runner = await readFile(join(hookDir, 'pre-commit'), 'utf-8');
|
|
111
|
+
expect(runner).toContain('pre-commit.d');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('only adds sgit hook if pre-commit.d already exists', async () => {
|
|
115
|
+
const hookDir = join(root, '.git', 'hooks');
|
|
116
|
+
const dDir = join(hookDir, 'pre-commit.d');
|
|
117
|
+
await mkdir(dDir, { recursive: true });
|
|
118
|
+
await writeFile(join(dDir, 'lint'), '#!/bin/sh\necho lint', {
|
|
119
|
+
mode: 0o755,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await installMainHook(root);
|
|
123
|
+
|
|
124
|
+
// lint hook still there
|
|
125
|
+
const lint = await readFile(join(dDir, 'lint'), 'utf-8');
|
|
126
|
+
expect(lint).toContain('echo lint');
|
|
127
|
+
|
|
128
|
+
// sgit hook added
|
|
129
|
+
const sgit = await readFile(join(dDir, 'sgit'), 'utf-8');
|
|
130
|
+
expect(sgit).toContain('sgit track verify --main');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('installShadowHook', () => {
|
|
135
|
+
let root: string;
|
|
136
|
+
|
|
137
|
+
beforeEach(async () => {
|
|
138
|
+
root = await createTempGitRepo();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
afterEach(async () => {
|
|
142
|
+
await removeTempDir(root);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('creates shadow pre-commit hook', async () => {
|
|
146
|
+
const sgitDir = join(root, '.sgit');
|
|
147
|
+
await mkdir(sgitDir, { recursive: true });
|
|
148
|
+
|
|
149
|
+
await installShadowHook(sgitDir);
|
|
150
|
+
|
|
151
|
+
const hook = await readFile(join(sgitDir, 'hooks', 'pre-commit'), 'utf-8');
|
|
152
|
+
expect(hook).toContain('sgit track verify --shadow');
|
|
153
|
+
await access(join(sgitDir, 'hooks', 'pre-commit'), constants.X_OK);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('uninstallMainHook', () => {
|
|
158
|
+
let root: string;
|
|
159
|
+
|
|
160
|
+
beforeEach(async () => {
|
|
161
|
+
root = await createTempGitRepo();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterEach(async () => {
|
|
165
|
+
await removeTempDir(root);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('restores original hook when present', async () => {
|
|
169
|
+
const hookDir = join(root, '.git', 'hooks');
|
|
170
|
+
await mkdir(hookDir, { recursive: true });
|
|
171
|
+
await writeFile(join(hookDir, 'pre-commit'), '#!/bin/sh\necho original', {
|
|
172
|
+
mode: 0o755,
|
|
173
|
+
});
|
|
174
|
+
await installMainHook(root);
|
|
175
|
+
|
|
176
|
+
await uninstallMainHook(root);
|
|
177
|
+
|
|
178
|
+
const restored = await readFile(join(hookDir, 'pre-commit'), 'utf-8');
|
|
179
|
+
expect(restored).toContain('echo original');
|
|
180
|
+
// pre-commit.d should be gone
|
|
181
|
+
await expect(
|
|
182
|
+
access(join(hookDir, 'pre-commit.d'), constants.F_OK),
|
|
183
|
+
).rejects.toThrow();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('removes runner when pre-commit.d is empty', async () => {
|
|
187
|
+
await installMainHook(root);
|
|
188
|
+
await uninstallMainHook(root);
|
|
189
|
+
|
|
190
|
+
const hookDir = join(root, '.git', 'hooks');
|
|
191
|
+
await expect(
|
|
192
|
+
access(join(hookDir, 'pre-commit'), constants.F_OK),
|
|
193
|
+
).rejects.toThrow();
|
|
194
|
+
await expect(
|
|
195
|
+
access(join(hookDir, 'pre-commit.d'), constants.F_OK),
|
|
196
|
+
).rejects.toThrow();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('leaves runner when other hooks exist in pre-commit.d', async () => {
|
|
200
|
+
const hookDir = join(root, '.git', 'hooks');
|
|
201
|
+
await installMainHook(root);
|
|
202
|
+
|
|
203
|
+
// Add another hook to pre-commit.d
|
|
204
|
+
await writeFile(
|
|
205
|
+
join(hookDir, 'pre-commit.d', 'lint'),
|
|
206
|
+
'#!/bin/sh\necho lint',
|
|
207
|
+
{ mode: 0o755 },
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
await uninstallMainHook(root);
|
|
211
|
+
|
|
212
|
+
// Runner and lint should remain
|
|
213
|
+
const runner = await readFile(join(hookDir, 'pre-commit'), 'utf-8');
|
|
214
|
+
expect(runner).toContain('pre-commit.d');
|
|
215
|
+
const lint = await readFile(join(hookDir, 'pre-commit.d', 'lint'), 'utf-8');
|
|
216
|
+
expect(lint).toContain('echo lint');
|
|
217
|
+
// sgit hook should be gone
|
|
218
|
+
await expect(
|
|
219
|
+
access(join(hookDir, 'pre-commit.d', 'sgit'), constants.F_OK),
|
|
220
|
+
).rejects.toThrow();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
readManifest,
|
|
6
|
+
writeManifest,
|
|
7
|
+
addPath,
|
|
8
|
+
removePath,
|
|
9
|
+
} from '../../src/core/manifest';
|
|
10
|
+
import { createTempGitRepo, removeTempDir } from '../helpers';
|
|
11
|
+
|
|
12
|
+
describe('manifest', () => {
|
|
13
|
+
let root: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
root = await createTempGitRepo();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await removeTempDir(root);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('readManifest', () => {
|
|
24
|
+
it('reads a valid manifest', async () => {
|
|
25
|
+
await writeFile(
|
|
26
|
+
join(root, '.sgit.json'),
|
|
27
|
+
JSON.stringify({ paths: ['docs/'] }, null, 2),
|
|
28
|
+
);
|
|
29
|
+
const manifest = await readManifest(root);
|
|
30
|
+
expect(manifest.paths).toEqual(['docs/']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('throws if manifest does not exist', async () => {
|
|
34
|
+
await expect(readManifest(root)).rejects.toThrow();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('writeManifest', () => {
|
|
39
|
+
it('writes formatted JSON with trailing newline', async () => {
|
|
40
|
+
await writeManifest(root, { paths: ['a.txt', 'b/'] });
|
|
41
|
+
const raw = await readFile(join(root, '.sgit.json'), 'utf-8');
|
|
42
|
+
expect(raw).toBe(
|
|
43
|
+
JSON.stringify({ paths: ['a.txt', 'b/'] }, null, 2) + '\n',
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('preserves unknown keys', async () => {
|
|
48
|
+
await writeFile(
|
|
49
|
+
join(root, '.sgit.json'),
|
|
50
|
+
JSON.stringify(
|
|
51
|
+
{ paths: ['a.txt'], remotes: { origin: 'url' } },
|
|
52
|
+
null,
|
|
53
|
+
2,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
const manifest = await readManifest(root);
|
|
57
|
+
manifest.paths.push('b.txt');
|
|
58
|
+
await writeManifest(root, manifest);
|
|
59
|
+
const result = JSON.parse(
|
|
60
|
+
await readFile(join(root, '.sgit.json'), 'utf-8'),
|
|
61
|
+
);
|
|
62
|
+
expect(result.remotes).toEqual({ origin: 'url' });
|
|
63
|
+
expect(result.paths).toEqual(['a.txt', 'b.txt']);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('addPath', () => {
|
|
68
|
+
it('adds a new path', async () => {
|
|
69
|
+
await writeManifest(root, { paths: [] });
|
|
70
|
+
const added = await addPath(root, 'notes/todo.md');
|
|
71
|
+
expect(added).toBe(true);
|
|
72
|
+
const manifest = await readManifest(root);
|
|
73
|
+
expect(manifest.paths).toEqual(['notes/todo.md']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns false for duplicate', async () => {
|
|
77
|
+
await writeManifest(root, { paths: ['notes/todo.md'] });
|
|
78
|
+
const added = await addPath(root, 'notes/todo.md');
|
|
79
|
+
expect(added).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('removePath', () => {
|
|
84
|
+
it('removes an existing path', async () => {
|
|
85
|
+
await writeManifest(root, { paths: ['a.txt', 'b.txt'] });
|
|
86
|
+
const removed = await removePath(root, 'a.txt');
|
|
87
|
+
expect(removed).toBe(true);
|
|
88
|
+
const manifest = await readManifest(root);
|
|
89
|
+
expect(manifest.paths).toEqual(['b.txt']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('returns false for non-existent path', async () => {
|
|
93
|
+
await writeManifest(root, { paths: ['a.txt'] });
|
|
94
|
+
const removed = await removePath(root, 'missing.txt');
|
|
95
|
+
expect(removed).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
normalizePath,
|
|
6
|
+
isPathCoveredByManifest,
|
|
7
|
+
} from '../../src/core/paths';
|
|
8
|
+
import { createTempGitRepo, removeTempDir } from '../helpers';
|
|
9
|
+
|
|
10
|
+
describe('normalizePath', () => {
|
|
11
|
+
let root: string;
|
|
12
|
+
let originalCwd: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
originalCwd = process.cwd();
|
|
16
|
+
root = await createTempGitRepo();
|
|
17
|
+
process.chdir(root);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
process.chdir(originalCwd);
|
|
22
|
+
await removeTempDir(root);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('normalizes a simple file path', async () => {
|
|
26
|
+
await writeFile(join(root, 'notes.md'), 'hello');
|
|
27
|
+
expect(await normalizePath('notes.md', root)).toBe('notes.md');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('strips ./ prefix', async () => {
|
|
31
|
+
await writeFile(join(root, 'notes.md'), 'hello');
|
|
32
|
+
expect(await normalizePath('./notes.md', root)).toBe('notes.md');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('resolves .. components', async () => {
|
|
36
|
+
await mkdir(join(root, 'a'));
|
|
37
|
+
await writeFile(join(root, 'notes.md'), 'hello');
|
|
38
|
+
expect(await normalizePath('a/../notes.md', root)).toBe('notes.md');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('collapses double slashes', async () => {
|
|
42
|
+
await mkdir(join(root, 'docs'), { recursive: true });
|
|
43
|
+
await writeFile(join(root, 'docs', 'file.md'), 'hello');
|
|
44
|
+
expect(await normalizePath('docs//file.md', root)).toBe('docs/file.md');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('adds trailing slash for directories', async () => {
|
|
48
|
+
await mkdir(join(root, 'docs', '.internal'), { recursive: true });
|
|
49
|
+
expect(await normalizePath('docs/.internal', root)).toBe(
|
|
50
|
+
'docs/.internal/',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('keeps trailing slash for directory input', async () => {
|
|
55
|
+
await mkdir(join(root, 'docs'), { recursive: true });
|
|
56
|
+
expect(await normalizePath('docs/', root)).toBe('docs/');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects paths outside root', async () => {
|
|
60
|
+
await expect(normalizePath('../../etc/passwd', root)).rejects.toThrow(
|
|
61
|
+
'outside the project root',
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('resolves relative to root from subdirectory path', async () => {
|
|
66
|
+
await mkdir(join(root, 'sub'));
|
|
67
|
+
await writeFile(join(root, 'sub', 'file.txt'), 'hello');
|
|
68
|
+
// Calling with a path relative to root
|
|
69
|
+
expect(await normalizePath('sub/file.txt', root)).toBe('sub/file.txt');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('isPathCoveredByManifest', () => {
|
|
74
|
+
it('matches exact file path', () => {
|
|
75
|
+
expect(
|
|
76
|
+
isPathCoveredByManifest('notes/todo.md', ['notes/todo.md']),
|
|
77
|
+
).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('matches file inside tracked directory', () => {
|
|
81
|
+
expect(
|
|
82
|
+
isPathCoveredByManifest('docs/.internal/design.md', [
|
|
83
|
+
'docs/.internal/',
|
|
84
|
+
]),
|
|
85
|
+
).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('matches nested file inside tracked directory', () => {
|
|
89
|
+
expect(
|
|
90
|
+
isPathCoveredByManifest('docs/.internal/sub/deep.md', [
|
|
91
|
+
'docs/.internal/',
|
|
92
|
+
]),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('does not match unrelated path', () => {
|
|
97
|
+
expect(
|
|
98
|
+
isPathCoveredByManifest('src/main.ts', ['docs/.internal/']),
|
|
99
|
+
).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('does not match partial directory name', () => {
|
|
103
|
+
expect(
|
|
104
|
+
isPathCoveredByManifest('docs/.internal-extra/file.md', [
|
|
105
|
+
'docs/.internal/',
|
|
106
|
+
]),
|
|
107
|
+
).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns false for empty manifest', () => {
|
|
111
|
+
expect(isPathCoveredByManifest('anything.md', [])).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { findRoot, validateShadowRepo } from '../../src/core/repository';
|
|
5
|
+
import { createTempGitRepo, removeTempDir } from '../helpers';
|
|
6
|
+
|
|
7
|
+
describe('findRoot', () => {
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
const originalCwd = process.cwd();
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tempDir = await createTempGitRepo();
|
|
13
|
+
process.chdir(tempDir);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
process.chdir(originalCwd);
|
|
18
|
+
await removeTempDir(tempDir);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns the git root directory', async () => {
|
|
22
|
+
expect(await findRoot()).toBe(tempDir);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('works from a subdirectory', async () => {
|
|
26
|
+
const subdir = join(tempDir, 'subdir');
|
|
27
|
+
await mkdir(subdir);
|
|
28
|
+
process.chdir(subdir);
|
|
29
|
+
expect(await findRoot()).toBe(tempDir);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('throws outside a git repo', async () => {
|
|
33
|
+
const { mkdtemp, realpath, rm } = await import('node:fs/promises');
|
|
34
|
+
const { tmpdir } = await import('node:os');
|
|
35
|
+
const raw = await mkdtemp(join(tmpdir(), 'sgit-test-nogit-'));
|
|
36
|
+
const nonGitDir = await realpath(raw);
|
|
37
|
+
process.chdir(nonGitDir);
|
|
38
|
+
await expect(findRoot()).rejects.toThrow();
|
|
39
|
+
process.chdir(tempDir);
|
|
40
|
+
await rm(nonGitDir, { recursive: true });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('validateShadowRepo', () => {
|
|
45
|
+
let tempDir: string;
|
|
46
|
+
const originalCwd = process.cwd();
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
tempDir = await createTempGitRepo();
|
|
50
|
+
process.chdir(tempDir);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
process.chdir(originalCwd);
|
|
55
|
+
await removeTempDir(tempDir);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('throws when .sgit does not exist', async () => {
|
|
59
|
+
await expect(validateShadowRepo(tempDir)).rejects.toThrow(
|
|
60
|
+
'not a shadow git repository',
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('passes when .sgit exists', async () => {
|
|
65
|
+
await mkdir(join(tempDir, '.sgit'));
|
|
66
|
+
await expect(validateShadowRepo(tempDir)).resolves.toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
});
|
package/tests/helpers.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { mkdtemp, rm, realpath } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export async function createTempGitRepo(): Promise<string> {
|
|
10
|
+
const raw = await mkdtemp(join(tmpdir(), 'sgit-test-'));
|
|
11
|
+
const dir = await realpath(raw);
|
|
12
|
+
await execFileAsync('git', ['init'], { cwd: dir });
|
|
13
|
+
await execFileAsync('git', ['-C', dir, 'config', 'user.email', 'test@test.com']);
|
|
14
|
+
await execFileAsync('git', ['-C', dir, 'config', 'user.name', 'Test']);
|
|
15
|
+
await execFileAsync('git', ['commit', '--allow-empty', '-m', 'initial'], { cwd: dir });
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function removeTempDir(dir: string): Promise<void> {
|
|
20
|
+
await rm(dir, { recursive: true, force: true });
|
|
21
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"sourceMap": true,
|
|
13
|
+
"types": ["node"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|
package/tsup.config.ts
ADDED