shadow-git 0.1.0 → 1.0.3
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 +5 -1
- package/.temp/.sgit/index +0 -0
- package/.temp/.sgit/info/exclude +16 -7
- package/.temp/.sgit/objects/03/7f4e37a02573e40ab78b35dd4b442cc6110bae +0 -0
- package/.temp/.sgit/objects/2f/f0fd85dec20039eef6ff63ead905778a88843f +0 -0
- package/.temp/.sgit/objects/c3/2432f724b666f647fe21a1ce16535cd7693f74 +0 -0
- package/.temp/.sgit/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +0 -0
- package/.temp/.sgit/objects/ee/9dd32cca3b134623d184eb4de5805704c493e3 +0 -0
- package/.temp/.sgit.json +6 -2
- package/.temp/README.md +0 -3
- package/.temp/rec1/rec2/test +0 -0
- package/.temp/temp/shouldnt-track +0 -0
- package/.temp/test/1 +0 -0
- package/CLAUDE.md +10 -2
- package/README.md +17 -30
- package/dist/cli.js +810 -119
- package/package.json +1 -1
- package/src/cli.ts +50 -19
- package/src/commands/debug/gitignores.ts +28 -0
- package/src/commands/debug/index.ts +32 -0
- package/src/commands/track/add.ts +11 -0
- package/src/commands/track/clone.ts +213 -0
- package/src/commands/track/completion.ts +50 -15
- package/src/commands/track/destroy.ts +97 -19
- package/src/commands/track/ignore.ts +55 -0
- package/src/commands/track/index.ts +23 -1
- package/src/commands/track/init.ts +5 -2
- package/src/commands/track/sync.ts +1 -1
- package/src/commands/track/unignore.ts +50 -0
- package/src/commands/track/verify.ts +93 -20
- package/src/core/errors.ts +23 -2
- package/src/core/exclude.ts +40 -43
- package/src/core/gitignore.ts +242 -0
- package/src/core/hooks.ts +19 -0
- package/src/core/manifest.ts +33 -1
- package/src/core/paths.ts +21 -3
- package/src/core/repository.ts +17 -2
- package/tests/commands/track/add.test.ts +31 -1
- package/tests/commands/track/clone.test.ts +315 -0
- package/tests/commands/track/completion.test.ts +1 -1
- package/tests/commands/track/destroy.test.ts +68 -2
- package/tests/commands/track/ignore.test.ts +109 -0
- package/tests/commands/track/init.test.ts +27 -4
- package/tests/commands/track/remove.test.ts +1 -1
- package/tests/commands/track/sync.test.ts +3 -3
- package/tests/commands/track/unignore.test.ts +72 -0
- package/tests/commands/track/verify.test.ts +132 -1
- package/tests/core/exclude.test.ts +107 -19
- package/tests/core/gitignore.test.ts +561 -0
- package/tests/core/hooks.test.ts +34 -0
- package/tests/core/paths.test.ts +24 -0
- package/tests/core/repository.test.ts +39 -5
- package/tests/helpers/guard-child.mjs +125 -0
- 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
|
|
47
|
+
expect(after).not.toContain('# sgit-managed');
|
|
47
48
|
expect(after).not.toContain('.sgit');
|
|
48
49
|
});
|
|
49
50
|
|
|
50
|
-
it('
|
|
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('
|
|
58
|
-
expect(mainExclude).toContain('
|
|
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
|
|
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('
|
|
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
|
|
44
|
-
expect(mainExclude).toContain('
|
|
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
|
|
74
|
+
expect(mainExclude).toContain('# sgit-managed\n');
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
it('warns for missing manifest paths', async () => {
|