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,94 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { mkdir, writeFile, readFile, access } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
findRoot,
|
|
6
|
+
validateNotWorktree,
|
|
7
|
+
validateNotSubmodule,
|
|
8
|
+
} from '../../core/repository.js';
|
|
9
|
+
import { execGit } from '../../core/git.js';
|
|
10
|
+
import { writeManifest } from '../../core/manifest.js';
|
|
11
|
+
import {
|
|
12
|
+
generateMainExclude,
|
|
13
|
+
generateShadowExclude,
|
|
14
|
+
} from '../../core/exclude.js';
|
|
15
|
+
import { installMainHook, installShadowHook } from '../../core/hooks.js';
|
|
16
|
+
import { AlreadyInitializedError } from '../../core/errors.js';
|
|
17
|
+
import * as logger from '../../utils/logger.js';
|
|
18
|
+
|
|
19
|
+
export async function trackInit(): Promise<void> {
|
|
20
|
+
const root = await findRoot();
|
|
21
|
+
await validateNotWorktree(root);
|
|
22
|
+
await validateNotSubmodule(root);
|
|
23
|
+
|
|
24
|
+
const sgitDir = join(root, '.sgit');
|
|
25
|
+
|
|
26
|
+
// Check if already initialized
|
|
27
|
+
try {
|
|
28
|
+
await access(sgitDir, constants.F_OK);
|
|
29
|
+
throw new AlreadyInitializedError();
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (err instanceof AlreadyInitializedError) throw err;
|
|
32
|
+
// Not found — continue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Create bare repo and configure
|
|
36
|
+
await execGit(['init', '--bare', sgitDir]);
|
|
37
|
+
await execGit(['config', 'core.bare', 'false'], { gitDir: sgitDir });
|
|
38
|
+
await execGit(['config', 'core.worktree', root], { gitDir: sgitDir });
|
|
39
|
+
|
|
40
|
+
// Copy user identity from main repo to shadow repo
|
|
41
|
+
try {
|
|
42
|
+
const name = await execGit(['config', 'user.name'], { cwd: root });
|
|
43
|
+
if (name) {
|
|
44
|
+
await execGit(['config', 'user.name', name], { gitDir: sgitDir });
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Not configured in main repo
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const email = await execGit(['config', 'user.email'], { cwd: root });
|
|
51
|
+
if (email) {
|
|
52
|
+
await execGit(['config', 'user.email', email], { gitDir: sgitDir });
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Not configured in main repo
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Generate .sgit/info/exclude
|
|
59
|
+
await mkdir(join(sgitDir, 'info'), { recursive: true });
|
|
60
|
+
await writeFile(
|
|
61
|
+
join(sgitDir, 'info', 'exclude'),
|
|
62
|
+
generateShadowExclude([]),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Create .sgit.json
|
|
66
|
+
await writeManifest(root, { paths: [] });
|
|
67
|
+
|
|
68
|
+
// Update .git/info/exclude
|
|
69
|
+
const mainExcludePath = join(root, '.git', 'info', 'exclude');
|
|
70
|
+
let existingExclude = '';
|
|
71
|
+
try {
|
|
72
|
+
existingExclude = await readFile(mainExcludePath, 'utf-8');
|
|
73
|
+
} catch {
|
|
74
|
+
// File might not exist
|
|
75
|
+
}
|
|
76
|
+
await mkdir(join(root, '.git', 'info'), { recursive: true });
|
|
77
|
+
await writeFile(mainExcludePath, generateMainExclude(existingExclude, []));
|
|
78
|
+
|
|
79
|
+
// Install hooks
|
|
80
|
+
await installMainHook(root);
|
|
81
|
+
await installShadowHook(sgitDir);
|
|
82
|
+
|
|
83
|
+
// Stage and commit .sgit.json
|
|
84
|
+
await execGit(['add', '-f', '.sgit.json'], {
|
|
85
|
+
gitDir: sgitDir,
|
|
86
|
+
workTree: root,
|
|
87
|
+
});
|
|
88
|
+
await execGit(['commit', '-m', 'sgit: initialize shadow repository'], {
|
|
89
|
+
gitDir: sgitDir,
|
|
90
|
+
workTree: root,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
logger.success('Shadow repository initialized at .sgit/');
|
|
94
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
2
|
+
import { readManifest } from '../../core/manifest.js';
|
|
3
|
+
import * as logger from '../../utils/logger.js';
|
|
4
|
+
|
|
5
|
+
export async function trackList(): Promise<string[]> {
|
|
6
|
+
const root = await findRoot();
|
|
7
|
+
await validateShadowRepo(root);
|
|
8
|
+
const manifest = await readManifest(root);
|
|
9
|
+
|
|
10
|
+
for (const p of manifest.paths) {
|
|
11
|
+
logger.info(p);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return manifest.paths;
|
|
15
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { join, resolve, relative, sep } from 'node:path';
|
|
2
|
+
import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
3
|
+
import { execGit } from '../../core/git.js';
|
|
4
|
+
import { removePath, readManifest } from '../../core/manifest.js';
|
|
5
|
+
import { syncExcludes } from './sync.js';
|
|
6
|
+
import { PathNotTrackedError } from '../../core/errors.js';
|
|
7
|
+
import * as logger from '../../utils/logger.js';
|
|
8
|
+
|
|
9
|
+
export async function trackRemove(paths: string[]): Promise<void> {
|
|
10
|
+
const root = await findRoot();
|
|
11
|
+
await validateShadowRepo(root);
|
|
12
|
+
const sgitDir = join(root, '.sgit');
|
|
13
|
+
|
|
14
|
+
const removed: string[] = [];
|
|
15
|
+
|
|
16
|
+
for (const inputPath of paths) {
|
|
17
|
+
// Normalize without stat (file may be deleted from disk)
|
|
18
|
+
const absolute = resolve(process.cwd(), inputPath);
|
|
19
|
+
let normalized = relative(root, absolute).split(sep).join('/');
|
|
20
|
+
if (inputPath.endsWith('/') && !normalized.endsWith('/')) {
|
|
21
|
+
normalized += '/';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Find exact matching path in manifest (try with/without trailing slash)
|
|
25
|
+
const manifest = await readManifest(root);
|
|
26
|
+
const exactPath =
|
|
27
|
+
manifest.paths.find((p) => p === normalized) ||
|
|
28
|
+
manifest.paths.find((p) => p === normalized + '/') ||
|
|
29
|
+
(normalized.endsWith('/')
|
|
30
|
+
? manifest.paths.find((p) => p === normalized.slice(0, -1))
|
|
31
|
+
: undefined);
|
|
32
|
+
|
|
33
|
+
if (!exactPath) {
|
|
34
|
+
throw new PathNotTrackedError(normalized);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const wasRemoved = await removePath(root, exactPath);
|
|
38
|
+
if (wasRemoved) {
|
|
39
|
+
removed.push(exactPath);
|
|
40
|
+
|
|
41
|
+
// Remove from shadow repo index
|
|
42
|
+
const rmPath = exactPath.endsWith('/')
|
|
43
|
+
? exactPath.slice(0, -1)
|
|
44
|
+
: exactPath;
|
|
45
|
+
try {
|
|
46
|
+
const rmArgs = exactPath.endsWith('/')
|
|
47
|
+
? ['rm', '--cached', '-r', rmPath]
|
|
48
|
+
: ['rm', '--cached', rmPath];
|
|
49
|
+
await execGit(rmArgs, { gitDir: sgitDir, workTree: root });
|
|
50
|
+
} catch {
|
|
51
|
+
// Might not be in index (never committed)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Sync exclude files
|
|
57
|
+
await syncExcludes(root);
|
|
58
|
+
|
|
59
|
+
// Print summary
|
|
60
|
+
if (removed.length > 0) {
|
|
61
|
+
logger.success(`Removed ${removed.length} path(s) from shadow tracking:`);
|
|
62
|
+
for (const p of removed) {
|
|
63
|
+
logger.info(` ${p}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { writeFile, readFile, mkdir, access } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
5
|
+
import { readManifest } from '../../core/manifest.js';
|
|
6
|
+
import {
|
|
7
|
+
generateMainExclude,
|
|
8
|
+
generateShadowExclude,
|
|
9
|
+
} from '../../core/exclude.js';
|
|
10
|
+
import * as logger from '../../utils/logger.js';
|
|
11
|
+
|
|
12
|
+
export async function syncExcludes(root: string): Promise<void> {
|
|
13
|
+
const manifest = await readManifest(root);
|
|
14
|
+
|
|
15
|
+
// Regenerate .git/info/exclude
|
|
16
|
+
const mainExcludePath = join(root, '.git', 'info', 'exclude');
|
|
17
|
+
let existingExclude = '';
|
|
18
|
+
try {
|
|
19
|
+
existingExclude = await readFile(mainExcludePath, 'utf-8');
|
|
20
|
+
} catch {
|
|
21
|
+
// File might not exist
|
|
22
|
+
}
|
|
23
|
+
await mkdir(join(root, '.git', 'info'), { recursive: true });
|
|
24
|
+
await writeFile(
|
|
25
|
+
mainExcludePath,
|
|
26
|
+
generateMainExclude(existingExclude, manifest.paths),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Regenerate .sgit/info/exclude
|
|
30
|
+
const sgitExcludePath = join(root, '.sgit', 'info', 'exclude');
|
|
31
|
+
await mkdir(join(root, '.sgit', 'info'), { recursive: true });
|
|
32
|
+
await writeFile(sgitExcludePath, generateShadowExclude(manifest.paths));
|
|
33
|
+
|
|
34
|
+
// Warn for missing paths
|
|
35
|
+
for (const p of manifest.paths) {
|
|
36
|
+
const checkPath = p.endsWith('/') ? p.slice(0, -1) : p;
|
|
37
|
+
try {
|
|
38
|
+
await access(join(root, checkPath), constants.F_OK);
|
|
39
|
+
} catch {
|
|
40
|
+
logger.warn(`path does not exist: ${p}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function trackSync(): Promise<void> {
|
|
46
|
+
const root = await findRoot();
|
|
47
|
+
await validateShadowRepo(root);
|
|
48
|
+
await syncExcludes(root);
|
|
49
|
+
logger.success('Exclude files regenerated from .sgit.json');
|
|
50
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { findRoot, validateShadowRepo } from '../../core/repository.js';
|
|
3
|
+
import { execGit } from '../../core/git.js';
|
|
4
|
+
import { readManifest } from '../../core/manifest.js';
|
|
5
|
+
import { isPathCoveredByManifest } from '../../core/paths.js';
|
|
6
|
+
import { SgitError } from '../../core/errors.js';
|
|
7
|
+
import * as logger from '../../utils/logger.js';
|
|
8
|
+
|
|
9
|
+
export interface VerifyResult {
|
|
10
|
+
valid: boolean;
|
|
11
|
+
violations: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function verifyMain(root: string): Promise<VerifyResult> {
|
|
15
|
+
const manifest = await readManifest(root);
|
|
16
|
+
let staged: string;
|
|
17
|
+
try {
|
|
18
|
+
staged = await execGit(['diff', '--cached', '--name-only'], {
|
|
19
|
+
gitDir: join(root, '.git'),
|
|
20
|
+
workTree: root,
|
|
21
|
+
});
|
|
22
|
+
} catch {
|
|
23
|
+
return { valid: true, violations: [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!staged.trim()) return { valid: true, violations: [] };
|
|
27
|
+
|
|
28
|
+
const stagedFiles = staged.trim().split('\n');
|
|
29
|
+
const violations = stagedFiles.filter((f) =>
|
|
30
|
+
isPathCoveredByManifest(f, manifest.paths),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return { valid: violations.length === 0, violations };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function verifyShadow(root: string): Promise<VerifyResult> {
|
|
37
|
+
const manifest = await readManifest(root);
|
|
38
|
+
let staged: string;
|
|
39
|
+
try {
|
|
40
|
+
staged = await execGit(['diff', '--cached', '--name-only'], {
|
|
41
|
+
gitDir: join(root, '.sgit'),
|
|
42
|
+
workTree: root,
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
return { valid: true, violations: [] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!staged.trim()) return { valid: true, violations: [] };
|
|
49
|
+
|
|
50
|
+
const stagedFiles = staged.trim().split('\n');
|
|
51
|
+
const violations = stagedFiles.filter(
|
|
52
|
+
(f) => f !== '.sgit.json' && !isPathCoveredByManifest(f, manifest.paths),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return { valid: violations.length === 0, violations };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function trackVerify(opts: {
|
|
59
|
+
main?: boolean;
|
|
60
|
+
shadow?: boolean;
|
|
61
|
+
}): Promise<void> {
|
|
62
|
+
const root = await findRoot();
|
|
63
|
+
await validateShadowRepo(root);
|
|
64
|
+
|
|
65
|
+
if (!opts.main && !opts.shadow) {
|
|
66
|
+
throw new SgitError('specify --main or --shadow');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (opts.main) {
|
|
70
|
+
const result = await verifyMain(root);
|
|
71
|
+
if (!result.valid) {
|
|
72
|
+
throw new SgitError(
|
|
73
|
+
[
|
|
74
|
+
'ERROR: The following files are tracked by shadow git and cannot be committed to the main repository:',
|
|
75
|
+
'',
|
|
76
|
+
...result.violations.map((f) => ` ${f}`),
|
|
77
|
+
'',
|
|
78
|
+
"Remove them with 'git reset HEAD <file>', or untrack from sgit with 'sgit track remove <path>'.",
|
|
79
|
+
].join('\n'),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (opts.shadow) {
|
|
85
|
+
const result = await verifyShadow(root);
|
|
86
|
+
if (!result.valid) {
|
|
87
|
+
throw new SgitError(
|
|
88
|
+
[
|
|
89
|
+
'ERROR: The following files are not listed in .sgit.json and cannot be committed to the shadow repository:',
|
|
90
|
+
'',
|
|
91
|
+
...result.violations.map((f) => ` ${f}`),
|
|
92
|
+
'',
|
|
93
|
+
"Add them with 'sgit track add <path>' first.",
|
|
94
|
+
].join('\n'),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export class SgitError extends Error {
|
|
2
|
+
constructor(message: string) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'SgitError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class NotGitRepoError extends SgitError {
|
|
9
|
+
constructor() {
|
|
10
|
+
super('not a git repository');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class AlreadyInitializedError extends SgitError {
|
|
15
|
+
constructor() {
|
|
16
|
+
super('shadow repository already initialized');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class NotInitializedError extends SgitError {
|
|
21
|
+
constructor() {
|
|
22
|
+
super('not a shadow git repository; run \'sgit track init\' first');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class PathNotFoundError extends SgitError {
|
|
27
|
+
constructor(path: string) {
|
|
28
|
+
super(`path does not exist: ${path}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class PathTrackedByMainError extends SgitError {
|
|
33
|
+
constructor(path: string) {
|
|
34
|
+
super(`${path} is tracked by the main repository. Use 'sgit track add --adopt ${path}' to move it to shadow tracking.`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class PathNotTrackedError extends SgitError {
|
|
39
|
+
constructor(path: string) {
|
|
40
|
+
super(`${path} is not tracked by sgit`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export const SGIT_MARKER = '# sgit';
|
|
2
|
+
export const SGIT_MANAGED_HEADER = '# sgit-managed (do not edit below this line)';
|
|
3
|
+
|
|
4
|
+
export function generateMainExclude(
|
|
5
|
+
existingContent: string,
|
|
6
|
+
manifestPaths: string[],
|
|
7
|
+
): string {
|
|
8
|
+
// Preserve non-sgit lines, stripping marker lines and their associated entries
|
|
9
|
+
const lines = existingContent.split('\n');
|
|
10
|
+
const preserved: string[] = [];
|
|
11
|
+
let skipNext = false;
|
|
12
|
+
for (const line of lines) {
|
|
13
|
+
if (line === SGIT_MANAGED_HEADER) {
|
|
14
|
+
skipNext = false;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (line === SGIT_MARKER) {
|
|
18
|
+
skipNext = true;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
// Strip legacy inline-comment format: "entry # sgit"
|
|
22
|
+
if (line.endsWith(` ${SGIT_MARKER}`)) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (skipNext) {
|
|
26
|
+
skipNext = false;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
preserved.push(line);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Trim trailing empty lines from preserved content
|
|
33
|
+
while (preserved.length > 0 && preserved[preserved.length - 1].trim() === '') {
|
|
34
|
+
preserved.pop();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sgitEntries = ['.sgit', '.sgit.json', ...manifestPaths];
|
|
38
|
+
const sgitLines = sgitEntries.flatMap((entry) => [SGIT_MARKER, entry]);
|
|
39
|
+
|
|
40
|
+
const sections = [
|
|
41
|
+
...preserved,
|
|
42
|
+
'',
|
|
43
|
+
SGIT_MANAGED_HEADER,
|
|
44
|
+
...sgitLines,
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
return sections.join('\n') + '\n';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function generateShadowExclude(manifestPaths: string[]): string {
|
|
51
|
+
const lines: string[] = [
|
|
52
|
+
'# Exclude everything by default',
|
|
53
|
+
'*',
|
|
54
|
+
'',
|
|
55
|
+
'# .sgit.json (always tracked)',
|
|
56
|
+
'!.sgit.json',
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const emitted = new Set<string>();
|
|
60
|
+
|
|
61
|
+
for (const trackedPath of manifestPaths) {
|
|
62
|
+
const pathLines: string[] = [];
|
|
63
|
+
const ancestors = getAncestors(trackedPath);
|
|
64
|
+
|
|
65
|
+
for (const ancestor of ancestors) {
|
|
66
|
+
const unignore = `!${ancestor}/`;
|
|
67
|
+
const reignore = `${ancestor}/*`;
|
|
68
|
+
if (!emitted.has(unignore)) {
|
|
69
|
+
pathLines.push(unignore);
|
|
70
|
+
emitted.add(unignore);
|
|
71
|
+
}
|
|
72
|
+
if (!emitted.has(reignore)) {
|
|
73
|
+
pathLines.push(reignore);
|
|
74
|
+
emitted.add(reignore);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const target = `!${trackedPath}`;
|
|
79
|
+
if (!emitted.has(target)) {
|
|
80
|
+
pathLines.push(target);
|
|
81
|
+
emitted.add(target);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (pathLines.length > 0) {
|
|
85
|
+
lines.push('');
|
|
86
|
+
lines.push(`# ${trackedPath}`);
|
|
87
|
+
lines.push(...pathLines);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return lines.join('\n') + '\n';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getAncestors(path: string): string[] {
|
|
95
|
+
// Remove trailing slash for directories
|
|
96
|
+
const cleanPath = path.endsWith('/') ? path.slice(0, -1) : path;
|
|
97
|
+
const parts = cleanPath.split('/');
|
|
98
|
+
// Remove the last part (the file/dir itself)
|
|
99
|
+
parts.pop();
|
|
100
|
+
|
|
101
|
+
const ancestors: string[] = [];
|
|
102
|
+
for (let i = 1; i <= parts.length; i++) {
|
|
103
|
+
ancestors.push(parts.slice(0, i).join('/'));
|
|
104
|
+
}
|
|
105
|
+
return ancestors;
|
|
106
|
+
}
|
package/src/core/git.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { exec } from '../utils/process.js';
|
|
2
|
+
|
|
3
|
+
export interface GitOptions {
|
|
4
|
+
gitDir?: string;
|
|
5
|
+
workTree?: string;
|
|
6
|
+
cwd?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function execGit(
|
|
10
|
+
args: string[],
|
|
11
|
+
opts?: GitOptions,
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const gitArgs: string[] = [];
|
|
14
|
+
if (opts?.gitDir) gitArgs.push(`--git-dir=${opts.gitDir}`);
|
|
15
|
+
if (opts?.workTree) gitArgs.push(`--work-tree=${opts.workTree}`);
|
|
16
|
+
gitArgs.push(...args);
|
|
17
|
+
return exec('git', gitArgs, { cwd: opts?.cwd });
|
|
18
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { join, resolve as resolvePath, dirname } from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
mkdir,
|
|
4
|
+
writeFile,
|
|
5
|
+
readFile,
|
|
6
|
+
rename,
|
|
7
|
+
access,
|
|
8
|
+
lstat,
|
|
9
|
+
unlink,
|
|
10
|
+
copyFile,
|
|
11
|
+
chmod,
|
|
12
|
+
rm,
|
|
13
|
+
readdir,
|
|
14
|
+
} from 'node:fs/promises';
|
|
15
|
+
import { constants } from 'node:fs';
|
|
16
|
+
import { execGit } from './git.js';
|
|
17
|
+
|
|
18
|
+
const RUNNER_SCRIPT = `#!/bin/sh
|
|
19
|
+
# sgit hook runner — executes all hooks in pre-commit.d/
|
|
20
|
+
dir="$(dirname "$0")/pre-commit.d"
|
|
21
|
+
for hook in "$dir"/*; do
|
|
22
|
+
[ -f "$hook" ] && [ -x "$hook" ] || continue
|
|
23
|
+
"$hook"
|
|
24
|
+
status=$?
|
|
25
|
+
[ $status -ne 0 ] && exit $status
|
|
26
|
+
done
|
|
27
|
+
exit 0
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const MAIN_HOOK = `#!/bin/sh
|
|
31
|
+
if command -v sgit >/dev/null 2>&1; then
|
|
32
|
+
sgit track verify --main
|
|
33
|
+
else
|
|
34
|
+
echo "sgit: not found in PATH, skipping pre-commit check" >&2
|
|
35
|
+
fi
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const SHADOW_HOOK = `#!/bin/sh
|
|
39
|
+
if command -v sgit >/dev/null 2>&1; then
|
|
40
|
+
sgit track verify --shadow
|
|
41
|
+
else
|
|
42
|
+
echo "sgit: not found in PATH, skipping pre-commit check" >&2
|
|
43
|
+
fi
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
export async function getHookDir(root: string): Promise<string> {
|
|
47
|
+
try {
|
|
48
|
+
const hookPath = await execGit(['config', 'core.hooksPath'], {
|
|
49
|
+
cwd: root,
|
|
50
|
+
});
|
|
51
|
+
if (hookPath) {
|
|
52
|
+
return resolvePath(root, hookPath);
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Not configured
|
|
56
|
+
}
|
|
57
|
+
return join(root, '.git', 'hooks');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function installMainHook(root: string): Promise<void> {
|
|
61
|
+
const hookDir = await getHookDir(root);
|
|
62
|
+
const preCommitPath = join(hookDir, 'pre-commit');
|
|
63
|
+
const preCommitDDir = join(hookDir, 'pre-commit.d');
|
|
64
|
+
const sgitHookPath = join(preCommitDDir, 'sgit');
|
|
65
|
+
|
|
66
|
+
// Case: pre-commit.d already exists
|
|
67
|
+
if (await exists(preCommitDDir)) {
|
|
68
|
+
await writeFile(sgitHookPath, MAIN_HOOK, { mode: 0o755 });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check if pre-commit exists
|
|
73
|
+
const hookExists = await exists(preCommitPath);
|
|
74
|
+
|
|
75
|
+
// Create pre-commit.d
|
|
76
|
+
await mkdir(preCommitDDir, { recursive: true });
|
|
77
|
+
|
|
78
|
+
if (hookExists) {
|
|
79
|
+
const stats = await lstat(preCommitPath);
|
|
80
|
+
if (stats.isSymbolicLink()) {
|
|
81
|
+
// Resolve symlink and copy content
|
|
82
|
+
await copyFile(preCommitPath, join(preCommitDDir, 'original'));
|
|
83
|
+
await chmod(join(preCommitDDir, 'original'), 0o755);
|
|
84
|
+
await unlink(preCommitPath);
|
|
85
|
+
} else {
|
|
86
|
+
// Move existing file
|
|
87
|
+
await rename(preCommitPath, join(preCommitDDir, 'original'));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Write runner script
|
|
92
|
+
await writeFile(preCommitPath, RUNNER_SCRIPT, { mode: 0o755 });
|
|
93
|
+
|
|
94
|
+
// Write sgit hook
|
|
95
|
+
await writeFile(sgitHookPath, MAIN_HOOK, { mode: 0o755 });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function installShadowHook(sgitDir: string): Promise<void> {
|
|
99
|
+
const hooksDir = join(sgitDir, 'hooks');
|
|
100
|
+
await mkdir(hooksDir, { recursive: true });
|
|
101
|
+
await writeFile(join(hooksDir, 'pre-commit'), SHADOW_HOOK, { mode: 0o755 });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function uninstallMainHook(root: string): Promise<void> {
|
|
105
|
+
const hookDir = await getHookDir(root);
|
|
106
|
+
const preCommitPath = join(hookDir, 'pre-commit');
|
|
107
|
+
const preCommitDDir = join(hookDir, 'pre-commit.d');
|
|
108
|
+
const sgitHookPath = join(preCommitDDir, 'sgit');
|
|
109
|
+
const originalPath = join(preCommitDDir, 'original');
|
|
110
|
+
|
|
111
|
+
// Remove sgit hook
|
|
112
|
+
try {
|
|
113
|
+
await unlink(sgitHookPath);
|
|
114
|
+
} catch {
|
|
115
|
+
return; // Nothing to uninstall
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check what remains in pre-commit.d/
|
|
119
|
+
let remaining: string[] = [];
|
|
120
|
+
try {
|
|
121
|
+
remaining = await readdir(preCommitDDir);
|
|
122
|
+
} catch {
|
|
123
|
+
// Directory might not exist
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const hasOriginal = remaining.includes('original');
|
|
127
|
+
const hasOtherHooks = remaining.some((e) => e !== 'original');
|
|
128
|
+
|
|
129
|
+
if (hasOriginal && !hasOtherHooks) {
|
|
130
|
+
// Only original left — restore it as pre-commit, clean up directory
|
|
131
|
+
await rename(originalPath, preCommitPath);
|
|
132
|
+
await rm(preCommitDDir, { recursive: true });
|
|
133
|
+
} else if (remaining.length === 0) {
|
|
134
|
+
// Empty directory — remove it and the runner
|
|
135
|
+
await rm(preCommitDDir, { recursive: true });
|
|
136
|
+
try {
|
|
137
|
+
await unlink(preCommitPath);
|
|
138
|
+
} catch {
|
|
139
|
+
// Runner might not exist
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// If other hooks remain (with or without original), leave runner and directory
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function exists(path: string): Promise<boolean> {
|
|
146
|
+
try {
|
|
147
|
+
await access(path, constants.F_OK);
|
|
148
|
+
return true;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface Manifest {
|
|
5
|
+
paths: string[];
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function readManifest(root: string): Promise<Manifest> {
|
|
10
|
+
const content = await readFile(join(root, '.sgit.json'), 'utf-8');
|
|
11
|
+
return JSON.parse(content) as Manifest;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function writeManifest(
|
|
15
|
+
root: string,
|
|
16
|
+
data: Manifest,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
await writeFile(
|
|
19
|
+
join(root, '.sgit.json'),
|
|
20
|
+
JSON.stringify(data, null, 2) + '\n',
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function addPath(
|
|
25
|
+
root: string,
|
|
26
|
+
normalizedPath: string,
|
|
27
|
+
): Promise<boolean> {
|
|
28
|
+
const manifest = await readManifest(root);
|
|
29
|
+
if (manifest.paths.includes(normalizedPath)) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
manifest.paths.push(normalizedPath);
|
|
33
|
+
await writeManifest(root, manifest);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function removePath(
|
|
38
|
+
root: string,
|
|
39
|
+
normalizedPath: string,
|
|
40
|
+
): Promise<boolean> {
|
|
41
|
+
const manifest = await readManifest(root);
|
|
42
|
+
const index = manifest.paths.indexOf(normalizedPath);
|
|
43
|
+
if (index === -1) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
manifest.paths.splice(index, 1);
|
|
47
|
+
await writeManifest(root, manifest);
|
|
48
|
+
return true;
|
|
49
|
+
}
|