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.
Files changed (54) hide show
  1. package/.claude/settings.local.json +5 -1
  2. package/.temp/.sgit/index +0 -0
  3. package/.temp/.sgit/info/exclude +16 -7
  4. package/.temp/.sgit/objects/03/7f4e37a02573e40ab78b35dd4b442cc6110bae +0 -0
  5. package/.temp/.sgit/objects/2f/f0fd85dec20039eef6ff63ead905778a88843f +0 -0
  6. package/.temp/.sgit/objects/c3/2432f724b666f647fe21a1ce16535cd7693f74 +0 -0
  7. package/.temp/.sgit/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +0 -0
  8. package/.temp/.sgit/objects/ee/9dd32cca3b134623d184eb4de5805704c493e3 +0 -0
  9. package/.temp/.sgit.json +6 -2
  10. package/.temp/README.md +0 -3
  11. package/.temp/rec1/rec2/test +0 -0
  12. package/.temp/temp/shouldnt-track +0 -0
  13. package/.temp/test/1 +0 -0
  14. package/CLAUDE.md +10 -2
  15. package/README.md +17 -30
  16. package/dist/cli.js +810 -119
  17. package/package.json +1 -1
  18. package/src/cli.ts +50 -19
  19. package/src/commands/debug/gitignores.ts +28 -0
  20. package/src/commands/debug/index.ts +32 -0
  21. package/src/commands/track/add.ts +11 -0
  22. package/src/commands/track/clone.ts +213 -0
  23. package/src/commands/track/completion.ts +50 -15
  24. package/src/commands/track/destroy.ts +97 -19
  25. package/src/commands/track/ignore.ts +55 -0
  26. package/src/commands/track/index.ts +23 -1
  27. package/src/commands/track/init.ts +5 -2
  28. package/src/commands/track/sync.ts +1 -1
  29. package/src/commands/track/unignore.ts +50 -0
  30. package/src/commands/track/verify.ts +93 -20
  31. package/src/core/errors.ts +23 -2
  32. package/src/core/exclude.ts +40 -43
  33. package/src/core/gitignore.ts +242 -0
  34. package/src/core/hooks.ts +19 -0
  35. package/src/core/manifest.ts +33 -1
  36. package/src/core/paths.ts +21 -3
  37. package/src/core/repository.ts +17 -2
  38. package/tests/commands/track/add.test.ts +31 -1
  39. package/tests/commands/track/clone.test.ts +315 -0
  40. package/tests/commands/track/completion.test.ts +1 -1
  41. package/tests/commands/track/destroy.test.ts +68 -2
  42. package/tests/commands/track/ignore.test.ts +109 -0
  43. package/tests/commands/track/init.test.ts +27 -4
  44. package/tests/commands/track/remove.test.ts +1 -1
  45. package/tests/commands/track/sync.test.ts +3 -3
  46. package/tests/commands/track/unignore.test.ts +72 -0
  47. package/tests/commands/track/verify.test.ts +132 -1
  48. package/tests/core/exclude.test.ts +107 -19
  49. package/tests/core/gitignore.test.ts +561 -0
  50. package/tests/core/hooks.test.ts +34 -0
  51. package/tests/core/paths.test.ts +24 -0
  52. package/tests/core/repository.test.ts +39 -5
  53. package/tests/helpers/guard-child.mjs +125 -0
  54. package/tests/helpers.ts +37 -0
@@ -1,9 +1,13 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import { mkdir } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { findRoot, validateShadowRepo } from '../../src/core/repository';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import { findRoot, validateShadowRepo, validateGitIdentity } from '../../src/core/repository';
5
7
  import { createTempGitRepo, removeTempDir } from '../helpers';
6
8
 
9
+ const execFileAsync = promisify(execFile);
10
+
7
11
  describe('findRoot', () => {
8
12
  let tempDir: string;
9
13
  const originalCwd = process.cwd();
@@ -18,15 +22,15 @@ describe('findRoot', () => {
18
22
  await removeTempDir(tempDir);
19
23
  });
20
24
 
21
- it('returns the git root directory', async () => {
25
+ it('returns cwd when .git exists', async () => {
22
26
  expect(await findRoot()).toBe(tempDir);
23
27
  });
24
28
 
25
- it('works from a subdirectory', async () => {
29
+ it('does not traverse up to parent git repo', async () => {
26
30
  const subdir = join(tempDir, 'subdir');
27
31
  await mkdir(subdir);
28
32
  process.chdir(subdir);
29
- expect(await findRoot()).toBe(tempDir);
33
+ await expect(findRoot()).rejects.toThrow('no .git in current directory');
30
34
  });
31
35
 
32
36
  it('throws outside a git repo', async () => {
@@ -35,12 +39,42 @@ describe('findRoot', () => {
35
39
  const raw = await mkdtemp(join(tmpdir(), 'sgit-test-nogit-'));
36
40
  const nonGitDir = await realpath(raw);
37
41
  process.chdir(nonGitDir);
38
- await expect(findRoot()).rejects.toThrow();
42
+ await expect(findRoot()).rejects.toThrow('no .git in current directory');
39
43
  process.chdir(tempDir);
40
44
  await rm(nonGitDir, { recursive: true });
41
45
  });
42
46
  });
43
47
 
48
+ describe('validateGitIdentity', () => {
49
+ let tempDir: string;
50
+ const originalCwd = process.cwd();
51
+
52
+ beforeEach(async () => {
53
+ tempDir = await createTempGitRepo();
54
+ process.chdir(tempDir);
55
+ });
56
+
57
+ afterEach(async () => {
58
+ process.chdir(originalCwd);
59
+ await removeTempDir(tempDir);
60
+ });
61
+
62
+ it('passes when user.name and user.email are configured', async () => {
63
+ await expect(validateGitIdentity(tempDir)).resolves.toBeUndefined();
64
+ });
65
+
66
+ it('throws when user.name is missing', async () => {
67
+ await execFileAsync('git', ['-C', tempDir, 'config', '--unset', 'user.name']);
68
+ // Also unset global to ensure test isolation
69
+ await expect(validateGitIdentity(tempDir)).rejects.toThrow('git user identity not configured');
70
+ });
71
+
72
+ it('throws when user.email is missing', async () => {
73
+ await execFileAsync('git', ['-C', tempDir, 'config', '--unset', 'user.email']);
74
+ await expect(validateGitIdentity(tempDir)).rejects.toThrow('git user identity not configured');
75
+ });
76
+ });
77
+
44
78
  describe('validateShadowRepo', () => {
45
79
  let tempDir: string;
46
80
  const originalCwd = process.cwd();
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Child process helper for testing gitignore guard cleanup.
3
+ *
4
+ * Usage: node tests/helpers/guard-child.mjs <root> <exitMode>
5
+ *
6
+ * This script directly implements the guard's cleanup mechanism using plain
7
+ * node:fs so it can run without TypeScript transpilation. It renames
8
+ * .gitignore → .gitignore-sgit.bat, registers signal/exit handlers, then
9
+ * exits in the specified way.
10
+ */
11
+ import {
12
+ readdirSync,
13
+ renameSync,
14
+ existsSync,
15
+ writeFileSync,
16
+ unlinkSync,
17
+ } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { spawnSync } from 'node:child_process';
20
+
21
+ const [,, root, exitMode] = process.argv;
22
+ const manifestPaths = ['secret/'];
23
+ const DISABLED_NAME = '.gitignore-sgit.bat';
24
+
25
+ // --- Minimal guard implementation (mirrors src/core/gitignore.ts) ---
26
+
27
+ function walkSync(dir, targetName, results) {
28
+ let entries;
29
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
30
+ for (const entry of entries) {
31
+ if (entry.isDirectory() && (entry.name === '.git' || entry.name === '.sgit')) continue;
32
+ const fullPath = join(dir, entry.name);
33
+ if (entry.isDirectory()) walkSync(fullPath, targetName, results);
34
+ else if (entry.name === targetName) results.push(fullPath);
35
+ }
36
+ }
37
+
38
+ function findInTrackedDirs(targetName) {
39
+ const results = [];
40
+ for (const mPath of manifestPaths) {
41
+ if (!mPath.endsWith('/')) continue;
42
+ const dirAbs = join(root, mPath);
43
+ if (!existsSync(dirAbs)) continue;
44
+ walkSync(dirAbs, targetName, results);
45
+ }
46
+ return results;
47
+ }
48
+
49
+ function enableGitignores() {
50
+ const disabled = findInTrackedDirs(DISABLED_NAME);
51
+ for (const absPath of disabled) {
52
+ const restoredPath = absPath.substring(0, absPath.length - DISABLED_NAME.length) + '.gitignore';
53
+ renameSync(absPath, restoredPath);
54
+ }
55
+ }
56
+
57
+ // Disable gitignores
58
+ const gitignores = findInTrackedDirs('.gitignore');
59
+ for (const absPath of gitignores) {
60
+ const dir = absPath.substring(0, absPath.length - '.gitignore'.length);
61
+ renameSync(absPath, dir + DISABLED_NAME);
62
+ }
63
+
64
+ // Write lock
65
+ const lockPath = join(root, '.sgit', '.gitignore-guard');
66
+ try { writeFileSync(lockPath, String(process.pid)); } catch {}
67
+
68
+ // Register cleanup handlers
69
+ let cleaned = false;
70
+ const cleanup = () => {
71
+ if (cleaned) return;
72
+ cleaned = true;
73
+ try { enableGitignores(); } catch {}
74
+ try { unlinkSync(lockPath); } catch {}
75
+ };
76
+
77
+ process.on('SIGINT', () => { cleanup(); process.exit(130); });
78
+ process.on('SIGTERM', () => { cleanup(); process.exit(143); });
79
+ process.on('exit', cleanup);
80
+
81
+ // Now exit in the specified way
82
+ switch (exitMode) {
83
+ case 'success':
84
+ // Normal cleanup then exit
85
+ cleanup();
86
+ process.exit(0);
87
+ break;
88
+
89
+ case 'throw':
90
+ // Uncaught exception — 'exit' handler should fire
91
+ throw new Error('simulated crash');
92
+
93
+ case 'exit1':
94
+ // process.exit(1) — 'exit' handler should fire
95
+ process.exit(1);
96
+ break;
97
+
98
+ case 'sigint':
99
+ // Send SIGINT to self
100
+ process.kill(process.pid, 'SIGINT');
101
+ break;
102
+
103
+ case 'sigterm':
104
+ // Send SIGTERM to self
105
+ process.kill(process.pid, 'SIGTERM');
106
+ break;
107
+
108
+ case 'timeout':
109
+ // Hang until killed externally
110
+ setTimeout(() => {}, 60000);
111
+ break;
112
+
113
+ case 'git-error':
114
+ // Simulate a git command that fails, then exit with error
115
+ spawnSync('git', ['--git-dir=/nonexistent', 'status'], { stdio: 'ignore' });
116
+ process.exit(1);
117
+ break;
118
+
119
+ case 'sgit-error':
120
+ // Simulate an error being thrown
121
+ throw new Error('simulated sgit error');
122
+
123
+ default:
124
+ process.exit(2);
125
+ }
package/tests/helpers.ts CHANGED
@@ -19,3 +19,40 @@ export async function createTempGitRepo(): Promise<string> {
19
19
  export async function removeTempDir(dir: string): Promise<void> {
20
20
  await rm(dir, { recursive: true, force: true });
21
21
  }
22
+
23
+ export async function createBareRemoteRepo(
24
+ files: Record<string, string>,
25
+ ): Promise<string> {
26
+ // Create a temporary normal repo with content
27
+ const workRaw = await mkdtemp(join(tmpdir(), 'sgit-remote-work-'));
28
+ const workDir = await realpath(workRaw);
29
+ await execFileAsync('git', ['init'], { cwd: workDir });
30
+ await execFileAsync('git', ['-C', workDir, 'config', 'user.email', 'test@test.com']);
31
+ await execFileAsync('git', ['-C', workDir, 'config', 'user.name', 'Test']);
32
+
33
+ // Write .sgit.json manifest (file keys as paths)
34
+ const paths = Object.keys(files);
35
+ const { writeFile, mkdir: mkdirFs } = await import('node:fs/promises');
36
+ await writeFile(join(workDir, '.sgit.json'), JSON.stringify({ paths }, null, 2) + '\n');
37
+
38
+ // Write all files
39
+ for (const [filePath, content] of Object.entries(files)) {
40
+ const dir = join(workDir, filePath, '..');
41
+ await mkdirFs(dir, { recursive: true });
42
+ await writeFile(join(workDir, filePath), content);
43
+ }
44
+
45
+ await execFileAsync('git', ['add', '-A'], { cwd: workDir });
46
+ await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: workDir });
47
+
48
+ // Create bare clone
49
+ const bareRaw = await mkdtemp(join(tmpdir(), 'sgit-remote-bare-'));
50
+ const bareDir = await realpath(bareRaw);
51
+ await rm(bareDir, { recursive: true }); // git clone --bare needs empty target
52
+ await execFileAsync('git', ['clone', '--bare', workDir, bareDir]);
53
+
54
+ // Clean up work dir
55
+ await rm(workDir, { recursive: true });
56
+
57
+ return bareDir;
58
+ }