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.
Files changed (80) hide show
  1. package/.claude/settings.local.json +39 -0
  2. package/.temp/.sgit/COMMIT_EDITMSG +1 -0
  3. package/.temp/.sgit/HEAD +1 -0
  4. package/.temp/.sgit/config +8 -0
  5. package/.temp/.sgit/description +1 -0
  6. package/.temp/.sgit/hooks/applypatch-msg.sample +15 -0
  7. package/.temp/.sgit/hooks/commit-msg.sample +24 -0
  8. package/.temp/.sgit/hooks/fsmonitor-watchman.sample +174 -0
  9. package/.temp/.sgit/hooks/post-update.sample +8 -0
  10. package/.temp/.sgit/hooks/pre-applypatch.sample +14 -0
  11. package/.temp/.sgit/hooks/pre-commit +6 -0
  12. package/.temp/.sgit/hooks/pre-commit.sample +49 -0
  13. package/.temp/.sgit/hooks/pre-merge-commit.sample +13 -0
  14. package/.temp/.sgit/hooks/pre-push.sample +53 -0
  15. package/.temp/.sgit/hooks/pre-rebase.sample +169 -0
  16. package/.temp/.sgit/hooks/pre-receive.sample +24 -0
  17. package/.temp/.sgit/hooks/prepare-commit-msg.sample +42 -0
  18. package/.temp/.sgit/hooks/push-to-checkout.sample +78 -0
  19. package/.temp/.sgit/hooks/sendemail-validate.sample +77 -0
  20. package/.temp/.sgit/hooks/update.sample +128 -0
  21. package/.temp/.sgit/index +0 -0
  22. package/.temp/.sgit/info/exclude +11 -0
  23. package/.temp/.sgit/logs/HEAD +1 -0
  24. package/.temp/.sgit/logs/refs/heads/master +1 -0
  25. package/.temp/.sgit/objects/34/b3190a8a836593fb568a56c073a416a56fbf6d +0 -0
  26. package/.temp/.sgit/objects/70/ad6bbbfefd330d72a43dc2a810e6ef42269911 +0 -0
  27. package/.temp/.sgit/objects/90/4e09c7aadaa32d2426ab5464a975d5151ef195 +0 -0
  28. package/.temp/.sgit/objects/a4/6358feef49515d5f85d1d3a64e929182da7149 +3 -0
  29. package/.temp/.sgit/objects/e8/0f4253888cdadda1e3d810f6a3cb3c68c20b50 +0 -0
  30. package/.temp/.sgit/refs/heads/master +1 -0
  31. package/.temp/.sgit.json +6 -0
  32. package/.temp/README.md +3 -0
  33. package/.temp/docs/guide.md +10 -0
  34. package/.temp/lib/__init__.py +1 -0
  35. package/.temp/lib/utils.py +5 -0
  36. package/.temp/src/config.py +3 -0
  37. package/.temp/src/main.py +5 -0
  38. package/.temp/temp.md +3 -0
  39. package/.temp/tests/test_utils.py +7 -0
  40. package/CLAUDE.md +51 -0
  41. package/README.md +72 -0
  42. package/dist/cli.js +1052 -0
  43. package/package.json +35 -0
  44. package/src/cli.ts +55 -0
  45. package/src/commands/forward.ts +12 -0
  46. package/src/commands/track/add.ts +91 -0
  47. package/src/commands/track/completion.ts +243 -0
  48. package/src/commands/track/destroy.ts +81 -0
  49. package/src/commands/track/index.ts +91 -0
  50. package/src/commands/track/init.ts +94 -0
  51. package/src/commands/track/list.ts +15 -0
  52. package/src/commands/track/remove.ts +66 -0
  53. package/src/commands/track/sync.ts +50 -0
  54. package/src/commands/track/verify.ts +98 -0
  55. package/src/core/errors.ts +42 -0
  56. package/src/core/exclude.ts +106 -0
  57. package/src/core/git.ts +18 -0
  58. package/src/core/hooks.ts +152 -0
  59. package/src/core/manifest.ts +49 -0
  60. package/src/core/paths.ts +58 -0
  61. package/src/core/repository.ts +39 -0
  62. package/src/utils/logger.ts +21 -0
  63. package/src/utils/process.ts +13 -0
  64. package/tests/commands/forward.test.ts +55 -0
  65. package/tests/commands/track/add.test.ts +148 -0
  66. package/tests/commands/track/completion.test.ts +124 -0
  67. package/tests/commands/track/destroy.test.ts +112 -0
  68. package/tests/commands/track/init.test.ts +146 -0
  69. package/tests/commands/track/remove.test.ts +107 -0
  70. package/tests/commands/track/sync.test.ts +118 -0
  71. package/tests/commands/track/verify.test.ts +96 -0
  72. package/tests/core/exclude.test.ts +120 -0
  73. package/tests/core/hooks.test.ts +222 -0
  74. package/tests/core/manifest.test.ts +98 -0
  75. package/tests/core/paths.test.ts +113 -0
  76. package/tests/core/repository.test.ts +68 -0
  77. package/tests/helpers.ts +21 -0
  78. package/tsconfig.json +17 -0
  79. package/tsup.config.ts +11 -0
  80. 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
+ }
@@ -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
+ }