shadow-git 0.1.0 → 1.0.2
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 +16 -29
- 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
|
@@ -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 {
|
|
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
|
|
25
|
+
it('returns cwd when .git exists', async () => {
|
|
22
26
|
expect(await findRoot()).toBe(tempDir);
|
|
23
27
|
});
|
|
24
28
|
|
|
25
|
-
it('
|
|
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(
|
|
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
|
+
}
|