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,58 @@
|
|
|
1
|
+
import { resolve, relative, sep } from 'node:path';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { SgitError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export async function normalizePath(
|
|
6
|
+
inputPath: string,
|
|
7
|
+
root: string,
|
|
8
|
+
): Promise<string> {
|
|
9
|
+
const absolute = resolve(process.cwd(), inputPath);
|
|
10
|
+
let rel = relative(root, absolute);
|
|
11
|
+
|
|
12
|
+
// Reject paths outside root
|
|
13
|
+
if (rel.startsWith('..')) {
|
|
14
|
+
throw new SgitError(`path is outside the project root: ${inputPath}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Normalize separators to forward slashes
|
|
18
|
+
rel = rel.split(sep).join('/');
|
|
19
|
+
|
|
20
|
+
// Check if directory and add trailing slash
|
|
21
|
+
try {
|
|
22
|
+
const st = await stat(absolute);
|
|
23
|
+
if (st.isDirectory() && !rel.endsWith('/')) {
|
|
24
|
+
rel += '/';
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// If stat fails and input had trailing slash, keep it
|
|
28
|
+
if (inputPath.endsWith('/') && !rel.endsWith('/')) {
|
|
29
|
+
rel += '/';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return rel;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isPathCoveredByManifest(
|
|
37
|
+
filePath: string,
|
|
38
|
+
manifestPaths: string[],
|
|
39
|
+
): boolean {
|
|
40
|
+
const caseInsensitive = process.platform === 'darwin';
|
|
41
|
+
const normalize = caseInsensitive
|
|
42
|
+
? (s: string) => s.toLowerCase()
|
|
43
|
+
: (s: string) => s;
|
|
44
|
+
|
|
45
|
+
const normalizedFile = normalize(filePath);
|
|
46
|
+
|
|
47
|
+
for (const mPath of manifestPaths) {
|
|
48
|
+
const normalizedManifest = normalize(mPath);
|
|
49
|
+
if (normalizedManifest === normalizedFile) return true;
|
|
50
|
+
if (
|
|
51
|
+
normalizedManifest.endsWith('/') &&
|
|
52
|
+
normalizedFile.startsWith(normalizedManifest)
|
|
53
|
+
) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { access, lstat } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { execGit } from './git.js';
|
|
5
|
+
import { NotGitRepoError, NotInitializedError, SgitError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
export async function findRoot(): Promise<string> {
|
|
8
|
+
try {
|
|
9
|
+
return await execGit(['rev-parse', '--show-toplevel']);
|
|
10
|
+
} catch {
|
|
11
|
+
throw new NotGitRepoError();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function validateShadowRepo(root: string): Promise<void> {
|
|
16
|
+
try {
|
|
17
|
+
await access(join(root, '.sgit'), constants.F_OK);
|
|
18
|
+
} catch {
|
|
19
|
+
throw new NotInitializedError();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function validateNotWorktree(root: string): Promise<void> {
|
|
24
|
+
const gitDir = await execGit(['rev-parse', '--git-dir'], { cwd: root });
|
|
25
|
+
const commonDir = await execGit(['rev-parse', '--git-common-dir'], { cwd: root });
|
|
26
|
+
const absGitDir = resolve(root, gitDir);
|
|
27
|
+
const absCommonDir = resolve(root, commonDir);
|
|
28
|
+
if (absGitDir !== absCommonDir) {
|
|
29
|
+
throw new SgitError('shadow git is not supported in linked worktrees');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function validateNotSubmodule(root: string): Promise<void> {
|
|
34
|
+
const gitPath = join(root, '.git');
|
|
35
|
+
const stats = await lstat(gitPath);
|
|
36
|
+
if (!stats.isDirectory()) {
|
|
37
|
+
throw new SgitError('shadow git is not supported inside git submodules');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const RESET = '\x1b[0m';
|
|
2
|
+
const GREEN = '\x1b[32m';
|
|
3
|
+
const YELLOW = '\x1b[33m';
|
|
4
|
+
const RED = '\x1b[31m';
|
|
5
|
+
const CYAN = '\x1b[36m';
|
|
6
|
+
|
|
7
|
+
export function info(msg: string): void {
|
|
8
|
+
process.stdout.write(`${CYAN}${msg}${RESET}\n`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function success(msg: string): void {
|
|
12
|
+
process.stdout.write(`${GREEN}${msg}${RESET}\n`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function warn(msg: string): void {
|
|
16
|
+
process.stderr.write(`${YELLOW}warning: ${msg}${RESET}\n`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function error(msg: string): void {
|
|
20
|
+
process.stderr.write(`${RED}${msg}${RESET}\n`);
|
|
21
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export async function exec(
|
|
7
|
+
cmd: string,
|
|
8
|
+
args: string[],
|
|
9
|
+
opts?: { cwd?: string },
|
|
10
|
+
): Promise<string> {
|
|
11
|
+
const { stdout } = await execFileAsync(cmd, args, { ...opts, encoding: 'utf8' });
|
|
12
|
+
return stdout.trimEnd();
|
|
13
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { writeFile } from 'node:fs/promises';
|
|
4
|
+
import { forwardToGit } from '../../src/commands/forward';
|
|
5
|
+
import { execGit } from '../../src/core/git';
|
|
6
|
+
import { createTempGitRepo, removeTempDir } from '../helpers';
|
|
7
|
+
|
|
8
|
+
describe('forwardToGit', () => {
|
|
9
|
+
let tempDir: string;
|
|
10
|
+
const originalCwd = process.cwd();
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tempDir = await createTempGitRepo();
|
|
14
|
+
process.chdir(tempDir);
|
|
15
|
+
|
|
16
|
+
// Set up a minimal shadow repo
|
|
17
|
+
const sgitDir = join(tempDir, '.sgit');
|
|
18
|
+
await execGit(['init', '--bare', sgitDir]);
|
|
19
|
+
await execGit(['config', 'core.bare', 'false'], { gitDir: sgitDir });
|
|
20
|
+
await execGit(['config', 'core.worktree', tempDir], { gitDir: sgitDir });
|
|
21
|
+
await execGit(['config', 'user.email', 'test@test.com'], { gitDir: sgitDir });
|
|
22
|
+
await execGit(['config', 'user.name', 'Test'], { gitDir: sgitDir });
|
|
23
|
+
|
|
24
|
+
await writeFile(join(tempDir, '.sgit.json'), '{"paths":[]}');
|
|
25
|
+
await execGit(['add', '-f', '.sgit.json'], {
|
|
26
|
+
gitDir: sgitDir,
|
|
27
|
+
workTree: tempDir,
|
|
28
|
+
});
|
|
29
|
+
await execGit(['commit', '-m', 'init'], {
|
|
30
|
+
gitDir: sgitDir,
|
|
31
|
+
workTree: tempDir,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
process.chdir(originalCwd);
|
|
37
|
+
await removeTempDir(tempDir);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('forwards git commands to shadow repo and returns exit code', () => {
|
|
41
|
+
const exitCode = forwardToGit(tempDir, ['log', '--oneline']);
|
|
42
|
+
expect(exitCode).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns non-zero exit code for failing commands', () => {
|
|
46
|
+
const exitCode = forwardToGit(tempDir, ['log', '--invalid-flag-xyz']);
|
|
47
|
+
expect(exitCode).not.toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('uses .sgit as git-dir', () => {
|
|
51
|
+
// rev-parse --git-dir should show the .sgit path
|
|
52
|
+
const exitCode = forwardToGit(tempDir, ['rev-parse', '--git-dir']);
|
|
53
|
+
expect(exitCode).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
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 { readManifest } from '../../../src/core/manifest';
|
|
8
|
+
import { execGit } from '../../../src/core/git';
|
|
9
|
+
import { createTempGitRepo, removeTempDir } from '../../helpers';
|
|
10
|
+
|
|
11
|
+
describe('track add', () => {
|
|
12
|
+
let tempDir: string;
|
|
13
|
+
const originalCwd = process.cwd();
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
tempDir = await createTempGitRepo();
|
|
17
|
+
process.chdir(tempDir);
|
|
18
|
+
await trackInit();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
process.chdir(originalCwd);
|
|
23
|
+
await removeTempDir(tempDir);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('adds a file to shadow tracking', async () => {
|
|
27
|
+
await mkdir(join(tempDir, 'notes'), { recursive: true });
|
|
28
|
+
await writeFile(join(tempDir, 'notes', 'todo.md'), 'hello');
|
|
29
|
+
|
|
30
|
+
await trackAdd(['notes/todo.md']);
|
|
31
|
+
|
|
32
|
+
// In manifest
|
|
33
|
+
const manifest = await readManifest(tempDir);
|
|
34
|
+
expect(manifest.paths).toContain('notes/todo.md');
|
|
35
|
+
|
|
36
|
+
// Staged in shadow repo
|
|
37
|
+
const staged = await execGit(['diff', '--cached', '--name-only'], {
|
|
38
|
+
gitDir: join(tempDir, '.sgit'),
|
|
39
|
+
workTree: tempDir,
|
|
40
|
+
});
|
|
41
|
+
expect(staged).toContain('notes/todo.md');
|
|
42
|
+
|
|
43
|
+
// Excluded from main git
|
|
44
|
+
const mainExclude = await readFile(
|
|
45
|
+
join(tempDir, '.git', 'info', 'exclude'),
|
|
46
|
+
'utf-8',
|
|
47
|
+
);
|
|
48
|
+
expect(mainExclude).toContain('# sgit\nnotes/todo.md\n');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('adds a directory with trailing slash', async () => {
|
|
52
|
+
await mkdir(join(tempDir, 'docs', '.internal'), { recursive: true });
|
|
53
|
+
await writeFile(
|
|
54
|
+
join(tempDir, 'docs', '.internal', 'design.md'),
|
|
55
|
+
'notes',
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await trackAdd(['docs/.internal']);
|
|
59
|
+
|
|
60
|
+
const manifest = await readManifest(tempDir);
|
|
61
|
+
expect(manifest.paths).toContain('docs/.internal/');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('skips already tracked path', async () => {
|
|
65
|
+
await mkdir(join(tempDir, 'notes'), { recursive: true });
|
|
66
|
+
await writeFile(join(tempDir, 'notes', 'todo.md'), 'hello');
|
|
67
|
+
|
|
68
|
+
await trackAdd(['notes/todo.md']);
|
|
69
|
+
await trackAdd(['notes/todo.md']); // no-op
|
|
70
|
+
|
|
71
|
+
const manifest = await readManifest(tempDir);
|
|
72
|
+
const count = manifest.paths.filter((p) => p === 'notes/todo.md').length;
|
|
73
|
+
expect(count).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('errors for nonexistent path', async () => {
|
|
77
|
+
await expect(trackAdd(['nonexistent.md'])).rejects.toThrow(
|
|
78
|
+
'path does not exist',
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('errors for main-git-tracked file', async () => {
|
|
83
|
+
await writeFile(join(tempDir, 'tracked.md'), 'hello');
|
|
84
|
+
await execGit(['add', 'tracked.md'], { cwd: tempDir });
|
|
85
|
+
await execGit(['commit', '-m', 'add tracked'], { cwd: tempDir });
|
|
86
|
+
|
|
87
|
+
await expect(trackAdd(['tracked.md'])).rejects.toThrow(
|
|
88
|
+
'tracked by the main repository',
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('adopts a main-git-tracked file with --adopt', async () => {
|
|
93
|
+
await writeFile(join(tempDir, 'config.json'), '{"key": "value"}');
|
|
94
|
+
await execGit(['add', 'config.json'], { cwd: tempDir });
|
|
95
|
+
await execGit(['commit', '-m', 'add config'], { cwd: tempDir });
|
|
96
|
+
|
|
97
|
+
await trackAdd(['config.json'], { adopt: true });
|
|
98
|
+
|
|
99
|
+
// Removed from main git index
|
|
100
|
+
const mainFiles = await execGit(['ls-files'], { cwd: tempDir });
|
|
101
|
+
expect(mainFiles).not.toContain('config.json');
|
|
102
|
+
|
|
103
|
+
// In manifest
|
|
104
|
+
const manifest = await readManifest(tempDir);
|
|
105
|
+
expect(manifest.paths).toContain('config.json');
|
|
106
|
+
|
|
107
|
+
// Staged in shadow repo
|
|
108
|
+
const staged = await execGit(['diff', '--cached', '--name-only'], {
|
|
109
|
+
gitDir: join(tempDir, '.sgit'),
|
|
110
|
+
workTree: tempDir,
|
|
111
|
+
});
|
|
112
|
+
expect(staged).toContain('config.json');
|
|
113
|
+
|
|
114
|
+
// File still on disk
|
|
115
|
+
await access(join(tempDir, 'config.json'), constants.F_OK);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('adopts a main-git-tracked directory with --adopt', async () => {
|
|
119
|
+
await mkdir(join(tempDir, 'secrets'), { recursive: true });
|
|
120
|
+
await writeFile(join(tempDir, 'secrets', 'key.pem'), 'secret');
|
|
121
|
+
await execGit(['add', 'secrets/'], { cwd: tempDir });
|
|
122
|
+
await execGit(['commit', '-m', 'add secrets'], { cwd: tempDir });
|
|
123
|
+
|
|
124
|
+
await trackAdd(['secrets'], { adopt: true });
|
|
125
|
+
|
|
126
|
+
// Removed from main git index
|
|
127
|
+
const mainFiles = await execGit(['ls-files'], { cwd: tempDir });
|
|
128
|
+
expect(mainFiles).not.toContain('secrets/key.pem');
|
|
129
|
+
|
|
130
|
+
// In manifest
|
|
131
|
+
const manifest = await readManifest(tempDir);
|
|
132
|
+
expect(manifest.paths).toContain('secrets/');
|
|
133
|
+
|
|
134
|
+
// File still on disk
|
|
135
|
+
await access(join(tempDir, 'secrets', 'key.pem'), constants.F_OK);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('adds multiple paths at once', async () => {
|
|
139
|
+
await writeFile(join(tempDir, 'a.txt'), 'a');
|
|
140
|
+
await writeFile(join(tempDir, 'b.txt'), 'b');
|
|
141
|
+
|
|
142
|
+
await trackAdd(['a.txt', 'b.txt']);
|
|
143
|
+
|
|
144
|
+
const manifest = await readManifest(tempDir);
|
|
145
|
+
expect(manifest.paths).toContain('a.txt');
|
|
146
|
+
expect(manifest.paths).toContain('b.txt');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import {
|
|
5
|
+
bashCompletion,
|
|
6
|
+
zshCompletion,
|
|
7
|
+
fishCompletion,
|
|
8
|
+
detectShell,
|
|
9
|
+
getInstallPath,
|
|
10
|
+
trackCompletion,
|
|
11
|
+
} from '../../../src/commands/track/completion';
|
|
12
|
+
|
|
13
|
+
describe('completion scripts', () => {
|
|
14
|
+
it('bash script dynamically sources git commands', () => {
|
|
15
|
+
const script = bashCompletion();
|
|
16
|
+
expect(script).toContain('complete -F _sgit sgit');
|
|
17
|
+
expect(script).toContain('_init_completion');
|
|
18
|
+
expect(script).toContain('git --list-cmds=main,others');
|
|
19
|
+
expect(script).toContain('init add remove list sync verify destroy completion');
|
|
20
|
+
expect(script).toContain('--adopt');
|
|
21
|
+
expect(script).toContain('sgit track list');
|
|
22
|
+
expect(script).toContain('__git_wrap__git_main');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('zsh script delegates to _git for git commands', () => {
|
|
26
|
+
const script = zshCompletion();
|
|
27
|
+
expect(script).toContain('#compdef sgit');
|
|
28
|
+
expect(script).toContain('_sgit_track');
|
|
29
|
+
expect(script).toContain('--adopt');
|
|
30
|
+
expect(script).toContain('sgit track list');
|
|
31
|
+
expect(script).toContain('words[1]=git');
|
|
32
|
+
expect(script).toContain('_git');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('fish script wraps git completions', () => {
|
|
36
|
+
const script = fishCompletion();
|
|
37
|
+
expect(script).toContain('complete -c sgit -w git');
|
|
38
|
+
expect(script).toContain('-l adopt');
|
|
39
|
+
expect(script).toContain('sgit track list');
|
|
40
|
+
expect(script).toContain('-l force');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('detectShell', () => {
|
|
45
|
+
const origShell = process.env.SHELL;
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
if (origShell !== undefined) {
|
|
49
|
+
process.env.SHELL = origShell;
|
|
50
|
+
} else {
|
|
51
|
+
delete process.env.SHELL;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('detects bash', () => {
|
|
56
|
+
process.env.SHELL = '/bin/bash';
|
|
57
|
+
expect(detectShell()).toBe('bash');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('detects zsh', () => {
|
|
61
|
+
process.env.SHELL = '/usr/bin/zsh';
|
|
62
|
+
expect(detectShell()).toBe('zsh');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('detects fish', () => {
|
|
66
|
+
process.env.SHELL = '/usr/bin/fish';
|
|
67
|
+
expect(detectShell()).toBe('fish');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('defaults to bash', () => {
|
|
71
|
+
delete process.env.SHELL;
|
|
72
|
+
expect(detectShell()).toBe('bash');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('getInstallPath', () => {
|
|
77
|
+
const home = homedir();
|
|
78
|
+
|
|
79
|
+
it('returns bash path', () => {
|
|
80
|
+
expect(getInstallPath('bash')).toBe(
|
|
81
|
+
join(home, '.local', 'share', 'bash-completion', 'completions', 'sgit'),
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns zsh path', () => {
|
|
86
|
+
expect(getInstallPath('zsh')).toBe(
|
|
87
|
+
join(home, '.zsh', 'completions', '_sgit'),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns fish path', () => {
|
|
92
|
+
expect(getInstallPath('fish')).toBe(
|
|
93
|
+
join(home, '.config', 'fish', 'completions', 'sgit.fish'),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('trackCompletion', () => {
|
|
99
|
+
it('prints script to stdout when no action', async () => {
|
|
100
|
+
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
101
|
+
await trackCompletion(undefined, { shell: 'bash' });
|
|
102
|
+
expect(writeSpy).toHaveBeenCalledOnce();
|
|
103
|
+
expect(writeSpy.mock.calls[0][0]).toContain('complete -F _sgit sgit');
|
|
104
|
+
writeSpy.mockRestore();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('installs to correct path', async () => {
|
|
108
|
+
const { mkdtemp, readFile, rm } = await import('node:fs/promises');
|
|
109
|
+
const { tmpdir } = await import('node:os');
|
|
110
|
+
const tempHome = await mkdtemp(join(tmpdir(), 'sgit-comp-'));
|
|
111
|
+
const origHome = process.env.HOME;
|
|
112
|
+
process.env.HOME = tempHome;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await trackCompletion('install', { shell: 'bash' });
|
|
116
|
+
const expectedPath = join(tempHome, '.local', 'share', 'bash-completion', 'completions', 'sgit');
|
|
117
|
+
const content = await readFile(expectedPath, 'utf-8');
|
|
118
|
+
expect(content).toContain('complete -F _sgit sgit');
|
|
119
|
+
} finally {
|
|
120
|
+
process.env.HOME = origHome;
|
|
121
|
+
await rm(tempHome, { recursive: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
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 { trackDestroy } from '../../../src/commands/track/destroy';
|
|
8
|
+
import { createTempGitRepo, removeTempDir } from '../../helpers';
|
|
9
|
+
|
|
10
|
+
describe('track destroy', () => {
|
|
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('removes .sgit and .sgit.json', async () => {
|
|
26
|
+
await trackDestroy({ force: true });
|
|
27
|
+
|
|
28
|
+
await expect(
|
|
29
|
+
access(join(tempDir, '.sgit'), constants.F_OK),
|
|
30
|
+
).rejects.toThrow();
|
|
31
|
+
await expect(
|
|
32
|
+
access(join(tempDir, '.sgit.json'), constants.F_OK),
|
|
33
|
+
).rejects.toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('cleans sgit entries from .git/info/exclude', async () => {
|
|
37
|
+
// Add a user entry first
|
|
38
|
+
const excludePath = join(tempDir, '.git', 'info', 'exclude');
|
|
39
|
+
const before = await readFile(excludePath, 'utf-8');
|
|
40
|
+
// Write with a user entry preserved
|
|
41
|
+
await writeFile(excludePath, '*.log\n' + before.split('\n').slice(1).join('\n'));
|
|
42
|
+
|
|
43
|
+
await trackDestroy({ force: true });
|
|
44
|
+
|
|
45
|
+
const after = await readFile(excludePath, 'utf-8');
|
|
46
|
+
expect(after).not.toContain('# sgit\n');
|
|
47
|
+
expect(after).not.toContain('.sgit');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('keeps previously tracked files on disk', async () => {
|
|
51
|
+
await writeFile(join(tempDir, 'notes.md'), 'keep me');
|
|
52
|
+
await trackAdd(['notes.md']);
|
|
53
|
+
|
|
54
|
+
await trackDestroy({ force: true });
|
|
55
|
+
|
|
56
|
+
// File remains
|
|
57
|
+
const content = await readFile(join(tempDir, 'notes.md'), 'utf-8');
|
|
58
|
+
expect(content).toBe('keep me');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('restores original pre-commit hook', async () => {
|
|
62
|
+
// Reinit with a pre-existing hook
|
|
63
|
+
process.chdir(originalCwd);
|
|
64
|
+
await removeTempDir(tempDir);
|
|
65
|
+
tempDir = await createTempGitRepo();
|
|
66
|
+
process.chdir(tempDir);
|
|
67
|
+
|
|
68
|
+
const hookDir = join(tempDir, '.git', 'hooks');
|
|
69
|
+
await mkdir(hookDir, { recursive: true });
|
|
70
|
+
await writeFile(join(hookDir, 'pre-commit'), '#!/bin/sh\necho original', {
|
|
71
|
+
mode: 0o755,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await trackInit();
|
|
75
|
+
await trackDestroy({ force: true });
|
|
76
|
+
|
|
77
|
+
const hook = await readFile(join(hookDir, 'pre-commit'), 'utf-8');
|
|
78
|
+
expect(hook).toContain('echo original');
|
|
79
|
+
await expect(
|
|
80
|
+
access(join(hookDir, 'pre-commit.d'), constants.F_OK),
|
|
81
|
+
).rejects.toThrow();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('leaves other hooks in pre-commit.d', async () => {
|
|
85
|
+
const hookDir = join(tempDir, '.git', 'hooks');
|
|
86
|
+
await writeFile(
|
|
87
|
+
join(hookDir, 'pre-commit.d', 'lint'),
|
|
88
|
+
'#!/bin/sh\necho lint',
|
|
89
|
+
{ mode: 0o755 },
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
await trackDestroy({ force: true });
|
|
93
|
+
|
|
94
|
+
// Runner and lint should remain
|
|
95
|
+
const runner = await readFile(join(hookDir, 'pre-commit'), 'utf-8');
|
|
96
|
+
expect(runner).toContain('pre-commit.d');
|
|
97
|
+
const lint = await readFile(join(hookDir, 'pre-commit.d', 'lint'), 'utf-8');
|
|
98
|
+
expect(lint).toContain('echo lint');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('removes runner when pre-commit.d is empty after uninstall', async () => {
|
|
102
|
+
await trackDestroy({ force: true });
|
|
103
|
+
|
|
104
|
+
const hookDir = join(tempDir, '.git', 'hooks');
|
|
105
|
+
await expect(
|
|
106
|
+
access(join(hookDir, 'pre-commit'), constants.F_OK),
|
|
107
|
+
).rejects.toThrow();
|
|
108
|
+
await expect(
|
|
109
|
+
access(join(hookDir, 'pre-commit.d'), constants.F_OK),
|
|
110
|
+
).rejects.toThrow();
|
|
111
|
+
});
|
|
112
|
+
});
|