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
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "shadow-git",
3
+ "version": "0.1.0",
4
+ "description": "A secondary git repository for tracking files excluded from your main repo",
5
+ "type": "module",
6
+ "bin": {
7
+ "sgit": "./dist/cli.js"
8
+ },
9
+ "engines": {
10
+ "node": "^18.19.0 || >=20.6.0"
11
+ },
12
+ "scripts": {
13
+ "build": "tsup",
14
+ "dev": "tsup --watch",
15
+ "test": "vitest",
16
+ "lint": "tsc --noEmit"
17
+ },
18
+ "license": "MIT",
19
+ "keywords": [
20
+ "git",
21
+ "shadow",
22
+ "sgit",
23
+ "version-control",
24
+ "cli"
25
+ ],
26
+ "dependencies": {
27
+ "commander": "^14.0.3"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^25.5.0",
31
+ "tsup": "^8.5.1",
32
+ "typescript": "^6.0.2",
33
+ "vitest": "^4.1.2"
34
+ }
35
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { Command } from 'commander';
2
+ import { findRoot, validateShadowRepo } from './core/repository.js';
3
+ import { forwardToGit } from './commands/forward.js';
4
+ import { registerTrackCommands } from './commands/track/index.js';
5
+ import { SgitError } from './core/errors.js';
6
+ import * as logger from './utils/logger.js';
7
+
8
+ async function run(): Promise<void> {
9
+ const args = process.argv.slice(2);
10
+
11
+ if (
12
+ args.length === 0 ||
13
+ args[0] === 'track' ||
14
+ args[0].startsWith('-')
15
+ ) {
16
+ const program = new Command('sgit')
17
+ .description('Shadow Git — a secondary git repository for your project')
18
+ .version('0.1.0');
19
+
20
+ registerTrackCommands(program);
21
+
22
+ program.addHelpText(
23
+ 'after',
24
+ `
25
+ Track commands:
26
+ sgit track init Initialize shadow repository
27
+ sgit track add Start shadow-tracking paths (--adopt to move from main git)
28
+ sgit track remove Stop shadow-tracking paths
29
+ sgit track list List all shadow-tracked paths
30
+ sgit track sync Regenerate exclude files
31
+ sgit track verify Validate staged files
32
+ sgit track destroy Remove shadow repository
33
+ sgit track completion Install shell completions
34
+
35
+ All other commands are forwarded to git in the shadow repository:
36
+ sgit status, sgit commit, sgit log, sgit diff, ...`,
37
+ );
38
+
39
+ await program.parseAsync(process.argv);
40
+ } else {
41
+ const root = await findRoot();
42
+ await validateShadowRepo(root);
43
+ const exitCode = forwardToGit(root, args);
44
+ process.exit(exitCode);
45
+ }
46
+ }
47
+
48
+ run().catch((err) => {
49
+ if (err instanceof SgitError) {
50
+ logger.error(err.message);
51
+ } else {
52
+ console.error(err);
53
+ }
54
+ process.exit(1);
55
+ });
@@ -0,0 +1,12 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+
4
+ export function forwardToGit(root: string, args: string[]): number {
5
+ const sgitDir = join(root, '.sgit');
6
+ const result = spawnSync(
7
+ 'git',
8
+ [`--git-dir=${sgitDir}`, `--work-tree=${root}`, ...args],
9
+ { stdio: 'inherit' },
10
+ );
11
+ return result.status ?? 1;
12
+ }
@@ -0,0 +1,91 @@
1
+ import { join } from 'node:path';
2
+ import { access } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { findRoot, validateShadowRepo } from '../../core/repository.js';
5
+ import { execGit } from '../../core/git.js';
6
+ import { normalizePath } from '../../core/paths.js';
7
+ import { addPath, readManifest } from '../../core/manifest.js';
8
+ import { syncExcludes } from './sync.js';
9
+ import {
10
+ PathNotFoundError,
11
+ PathTrackedByMainError,
12
+ } from '../../core/errors.js';
13
+ import * as logger from '../../utils/logger.js';
14
+
15
+ export async function trackAdd(paths: string[], opts: { adopt?: boolean } = {}): Promise<void> {
16
+ const root = await findRoot();
17
+ await validateShadowRepo(root);
18
+ const sgitDir = join(root, '.sgit');
19
+
20
+ const added: string[] = [];
21
+
22
+ for (const inputPath of paths) {
23
+ const normalized = await normalizePath(inputPath, root);
24
+
25
+ // Must exist on disk
26
+ const checkPath = normalized.endsWith('/')
27
+ ? normalized.slice(0, -1)
28
+ : normalized;
29
+ try {
30
+ await access(join(root, checkPath), constants.F_OK);
31
+ } catch {
32
+ throw new PathNotFoundError(normalized);
33
+ }
34
+
35
+ // Check if already tracked by sgit (skip adopt if so)
36
+ const manifest = await readManifest(root);
37
+ const alreadyTracked = manifest.paths.includes(normalized);
38
+
39
+ // Must not be tracked by main git (unless --adopt)
40
+ let trackedByMain = false;
41
+ try {
42
+ await execGit(['ls-files', '--error-unmatch', checkPath], {
43
+ gitDir: join(root, '.git'),
44
+ workTree: root,
45
+ });
46
+ trackedByMain = true;
47
+ } catch {
48
+ // git error = not tracked by main
49
+ }
50
+
51
+ if (trackedByMain) {
52
+ if (opts.adopt && !alreadyTracked) {
53
+ const rmArgs = normalized.endsWith('/')
54
+ ? ['rm', '--cached', '-r', checkPath]
55
+ : ['rm', '--cached', checkPath];
56
+ await execGit(rmArgs, { gitDir: join(root, '.git'), workTree: root });
57
+ } else if (!opts.adopt) {
58
+ throw new PathTrackedByMainError(normalized);
59
+ }
60
+ // If adopt + alreadyTracked, skip git rm — already in sgit
61
+ }
62
+
63
+ // Add to manifest
64
+ const wasAdded = await addPath(root, normalized);
65
+ if (wasAdded) {
66
+ added.push(normalized);
67
+ }
68
+ }
69
+
70
+ // Sync exclude files
71
+ await syncExcludes(root);
72
+
73
+ // Force-add to shadow repo
74
+ for (const p of added) {
75
+ const gitPath = p.endsWith('/') ? p.slice(0, -1) : p;
76
+ await execGit(['add', '-f', gitPath], {
77
+ gitDir: sgitDir,
78
+ workTree: root,
79
+ });
80
+ }
81
+
82
+ // Print summary
83
+ if (added.length > 0) {
84
+ logger.success(`Tracking ${added.length} new path(s):`);
85
+ for (const p of added) {
86
+ logger.info(` ${p}`);
87
+ }
88
+ } else {
89
+ logger.info('All paths already tracked.');
90
+ }
91
+ }
@@ -0,0 +1,243 @@
1
+ import { join, dirname } from 'node:path';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import * as logger from '../../utils/logger.js';
5
+
6
+ function bashCompletion(): string {
7
+ return `# sgit bash completion
8
+ _sgit() {
9
+ local cur prev words cword
10
+ _init_completion || return
11
+
12
+ if [[ \${words[1]} == "track" ]]; then
13
+ if [[ $cword -eq 2 ]]; then
14
+ COMPREPLY=($(compgen -W "init add remove list sync verify destroy completion" -- "$cur"))
15
+ return
16
+ fi
17
+
18
+ local subcmd="\${words[2]}"
19
+ case "$subcmd" in
20
+ add)
21
+ if [[ $cur == -* ]]; then
22
+ COMPREPLY=($(compgen -W "--adopt" -- "$cur"))
23
+ else
24
+ _filedir
25
+ fi
26
+ ;;
27
+ remove)
28
+ local paths
29
+ paths=$(sgit track list 2>/dev/null)
30
+ COMPREPLY=($(compgen -W "$paths" -- "$cur"))
31
+ ;;
32
+ verify)
33
+ COMPREPLY=($(compgen -W "--main --shadow" -- "$cur"))
34
+ ;;
35
+ destroy)
36
+ COMPREPLY=($(compgen -W "--force" -- "$cur"))
37
+ ;;
38
+ completion)
39
+ if [[ $prev == "--shell" ]]; then
40
+ COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur"))
41
+ elif [[ $cur == -* ]]; then
42
+ COMPREPLY=($(compgen -W "--shell" -- "$cur"))
43
+ else
44
+ COMPREPLY=($(compgen -W "install" -- "$cur"))
45
+ fi
46
+ ;;
47
+ esac
48
+ return
49
+ fi
50
+
51
+ if [[ $cword -eq 1 ]]; then
52
+ # "track" plus all git subcommands
53
+ local git_cmds
54
+ git_cmds=$(git --list-cmds=main,others 2>/dev/null)
55
+ COMPREPLY=($(compgen -W "track $git_cmds" -- "$cur"))
56
+ return
57
+ fi
58
+
59
+ # Non-track commands: delegate to git completion
60
+ if declare -f __git_wrap__git_main &>/dev/null; then
61
+ __git_wrap__git_main
62
+ elif declare -f _git &>/dev/null; then
63
+ _git
64
+ fi
65
+ }
66
+ complete -F _sgit sgit
67
+ `;
68
+ }
69
+
70
+ function zshCompletion(): string {
71
+ return `#compdef sgit
72
+ # sgit zsh completion
73
+
74
+ _sgit_track() {
75
+ local -a subcmds
76
+ subcmds=(
77
+ 'init:Initialize shadow repository'
78
+ 'add:Start shadow-tracking paths'
79
+ 'remove:Stop shadow-tracking paths'
80
+ 'list:List all shadow-tracked paths'
81
+ 'sync:Regenerate exclude files'
82
+ 'verify:Validate staged files'
83
+ 'destroy:Remove shadow repository'
84
+ 'completion:Install shell completions'
85
+ )
86
+
87
+ if (( CURRENT == 2 )); then
88
+ _describe 'track subcommand' subcmds
89
+ return
90
+ fi
91
+
92
+ case "\${words[2]}" in
93
+ add)
94
+ _arguments '--adopt[Move main-git-tracked files to shadow tracking]' '*:file:_files'
95
+ ;;
96
+ remove)
97
+ local -a paths
98
+ paths=(\${(f)"$(sgit track list 2>/dev/null)"})
99
+ _describe 'tracked path' paths
100
+ ;;
101
+ verify)
102
+ _arguments '--main[Check main repo]' '--shadow[Check shadow repo]'
103
+ ;;
104
+ destroy)
105
+ _arguments '--force[Skip confirmation]'
106
+ ;;
107
+ completion)
108
+ _arguments '--shell[Shell type]:shell:(bash zsh fish)' '1:action:(install)'
109
+ ;;
110
+ esac
111
+ }
112
+
113
+ _sgit() {
114
+ if (( CURRENT == 2 )); then
115
+ local -a track_cmd
116
+ track_cmd=('track:Manage shadow-tracked paths')
117
+ _describe 'sgit command' track_cmd
118
+ # Also offer all git subcommands
119
+ words[1]=git
120
+ _git
121
+ return
122
+ fi
123
+
124
+ case "\${words[2]}" in
125
+ track)
126
+ (( CURRENT-- ))
127
+ shift words
128
+ _sgit_track
129
+ ;;
130
+ *)
131
+ words[1]=git
132
+ _git
133
+ ;;
134
+ esac
135
+ }
136
+
137
+ _sgit "$@"
138
+ `;
139
+ }
140
+
141
+ function fishCompletion(): string {
142
+ return `# sgit fish completion
143
+
144
+ # Inherit all git completions (sgit forwards non-track commands to git)
145
+ complete -c sgit -w git
146
+
147
+ # Add "track" as a top-level subcommand
148
+ complete -c sgit -n '__fish_use_subcommand' -a track -d 'Manage shadow-tracked paths'
149
+
150
+ # Track subcommands
151
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a init -d 'Initialize shadow repository'
152
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a add -d 'Start shadow-tracking paths'
153
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a remove -d 'Stop shadow-tracking paths'
154
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a list -d 'List all shadow-tracked paths'
155
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a sync -d 'Regenerate exclude files'
156
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a verify -d 'Validate staged files'
157
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a destroy -d 'Remove shadow repository'
158
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a completion -d 'Install shell completions'
159
+
160
+ # Track add options and file completions
161
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from add' -l adopt -d 'Move main-git-tracked files'
162
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from add' -F
163
+
164
+ # Track remove — complete from manifest
165
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from remove' -a '(sgit track list 2>/dev/null)'
166
+
167
+ # Track verify options
168
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from verify' -l main -d 'Check main repo'
169
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from verify' -l shadow -d 'Check shadow repo'
170
+
171
+ # Track destroy options
172
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from destroy' -l force -d 'Skip confirmation'
173
+
174
+ # Track completion options
175
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from completion' -a install -d 'Install completions'
176
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from completion' -l shell -d 'Shell type' -ra 'bash zsh fish'
177
+ `;
178
+ }
179
+
180
+ function detectShell(): string {
181
+ const shell = process.env.SHELL || '';
182
+ if (shell.includes('zsh')) return 'zsh';
183
+ if (shell.includes('fish')) return 'fish';
184
+ return 'bash';
185
+ }
186
+
187
+ function getScript(shell: string): string {
188
+ switch (shell) {
189
+ case 'zsh':
190
+ return zshCompletion();
191
+ case 'fish':
192
+ return fishCompletion();
193
+ default:
194
+ return bashCompletion();
195
+ }
196
+ }
197
+
198
+ function getInstallPath(shell: string): string {
199
+ const home = homedir();
200
+ switch (shell) {
201
+ case 'zsh':
202
+ return join(home, '.zsh', 'completions', '_sgit');
203
+ case 'fish':
204
+ return join(home, '.config', 'fish', 'completions', 'sgit.fish');
205
+ default:
206
+ return join(home, '.local', 'share', 'bash-completion', 'completions', 'sgit');
207
+ }
208
+ }
209
+
210
+ export async function trackCompletion(
211
+ action?: string,
212
+ opts?: { shell?: string },
213
+ ): Promise<void> {
214
+ const shell = opts?.shell || detectShell();
215
+ const script = getScript(shell);
216
+
217
+ if (!action) {
218
+ process.stdout.write(script);
219
+ return;
220
+ }
221
+
222
+ if (action === 'install') {
223
+ const installPath = getInstallPath(shell);
224
+ await mkdir(dirname(installPath), { recursive: true });
225
+ await writeFile(installPath, script, { mode: 0o644 });
226
+ logger.success(`Installed ${shell} completions to ${installPath}`);
227
+
228
+ if (shell === 'zsh') {
229
+ logger.info(
230
+ 'Ensure ~/.zsh/completions is in your $fpath. Add to ~/.zshrc:\n' +
231
+ ' fpath=(~/.zsh/completions $fpath)\n' +
232
+ ' autoload -Uz compinit && compinit',
233
+ );
234
+ }
235
+ return;
236
+ }
237
+
238
+ logger.error(`Unknown action: ${action}. Use 'install' or omit for stdout.`);
239
+ process.exit(1);
240
+ }
241
+
242
+ // Exported for testing
243
+ export { bashCompletion, zshCompletion, fishCompletion, detectShell, getInstallPath };
@@ -0,0 +1,81 @@
1
+ import { join } from 'node:path';
2
+ import { readFile, writeFile, rm } from 'node:fs/promises';
3
+ import { createInterface } from 'node:readline';
4
+ import { findRoot, validateShadowRepo } from '../../core/repository.js';
5
+ import { uninstallMainHook } from '../../core/hooks.js';
6
+ import { SGIT_MARKER, SGIT_MANAGED_HEADER } from '../../core/exclude.js';
7
+ import * as logger from '../../utils/logger.js';
8
+
9
+ export async function trackDestroy(opts: {
10
+ force?: boolean;
11
+ }): Promise<void> {
12
+ const root = await findRoot();
13
+ await validateShadowRepo(root);
14
+
15
+ if (!opts.force) {
16
+ const confirmed = await confirm(
17
+ 'This will permanently remove the shadow repository. Continue?',
18
+ );
19
+ if (!confirmed) {
20
+ logger.info('Aborted.');
21
+ return;
22
+ }
23
+ }
24
+
25
+ // Remove sgit entries from .git/info/exclude
26
+ const excludePath = join(root, '.git', 'info', 'exclude');
27
+ try {
28
+ const content = await readFile(excludePath, 'utf-8');
29
+ const lines = content.split('\n');
30
+ const result: string[] = [];
31
+ let skipNext = false;
32
+ for (const line of lines) {
33
+ if (line === SGIT_MANAGED_HEADER) {
34
+ skipNext = false;
35
+ continue;
36
+ }
37
+ if (line === SGIT_MARKER) {
38
+ skipNext = true;
39
+ continue;
40
+ }
41
+ // Strip legacy inline-comment format: "entry # sgit"
42
+ if (line.endsWith(` ${SGIT_MARKER}`)) {
43
+ continue;
44
+ }
45
+ if (skipNext) {
46
+ skipNext = false;
47
+ continue;
48
+ }
49
+ result.push(line);
50
+ }
51
+ const cleaned = result.join('\n');
52
+ // Trim trailing blank lines
53
+ await writeFile(excludePath, cleaned.replace(/\n+$/, '\n'));
54
+ } catch {
55
+ // Exclude file might not exist
56
+ }
57
+
58
+ // Uninstall main repo hook
59
+ await uninstallMainHook(root);
60
+
61
+ // Delete .sgit/
62
+ await rm(join(root, '.sgit'), { recursive: true, force: true });
63
+
64
+ // Delete .sgit.json
65
+ await rm(join(root, '.sgit.json'), { force: true });
66
+
67
+ logger.success('Shadow repository removed.');
68
+ }
69
+
70
+ async function confirm(message: string): Promise<boolean> {
71
+ const rl = createInterface({
72
+ input: process.stdin,
73
+ output: process.stdout,
74
+ });
75
+ return new Promise((resolve) => {
76
+ rl.question(`${message} [y/N] `, (answer) => {
77
+ rl.close();
78
+ resolve(answer.toLowerCase() === 'y');
79
+ });
80
+ });
81
+ }
@@ -0,0 +1,91 @@
1
+ import { Command } from 'commander';
2
+ import { SgitError } from '../../core/errors.js';
3
+ import * as logger from '../../utils/logger.js';
4
+ import { trackInit } from './init.js';
5
+ import { trackSync } from './sync.js';
6
+ import { trackAdd } from './add.js';
7
+ import { trackRemove } from './remove.js';
8
+ import { trackList } from './list.js';
9
+ import { trackVerify } from './verify.js';
10
+ import { trackDestroy } from './destroy.js';
11
+ import { trackCompletion } from './completion.js';
12
+
13
+ export function registerTrackCommands(program: Command): void {
14
+ const track = program
15
+ .command('track')
16
+ .description('Manage shadow-tracked paths');
17
+
18
+ track
19
+ .command('init')
20
+ .description('Initialize shadow repository')
21
+ .action(wrapAction(trackInit));
22
+
23
+ track
24
+ .command('add')
25
+ .description('Start shadow-tracking paths')
26
+ .argument('<paths...>')
27
+ .option('--adopt', 'Move main-git-tracked files to shadow tracking')
28
+ .action(wrapAction(async (paths: string[], opts: { adopt?: boolean }) => trackAdd(paths, opts)));
29
+
30
+ track
31
+ .command('remove')
32
+ .description('Stop shadow-tracking paths')
33
+ .argument('<paths...>')
34
+ .action(wrapAction(async (paths: string[]) => trackRemove(paths)));
35
+
36
+ track
37
+ .command('list')
38
+ .description('List all shadow-tracked paths')
39
+ .action(wrapAction(async () => { await trackList(); }));
40
+
41
+ track
42
+ .command('sync')
43
+ .description('Regenerate exclude files from .sgit.json')
44
+ .action(wrapAction(trackSync));
45
+
46
+ track
47
+ .command('verify')
48
+ .description('Validate staged files against manifest')
49
+ .option('--main', 'Check main repo staged files')
50
+ .option('--shadow', 'Check shadow repo staged files')
51
+ .action(
52
+ wrapAction(async (opts: { main?: boolean; shadow?: boolean }) =>
53
+ trackVerify(opts),
54
+ ),
55
+ );
56
+
57
+ track
58
+ .command('destroy')
59
+ .description('Remove shadow repository entirely')
60
+ .option('--force', 'Skip confirmation prompt')
61
+ .action(
62
+ wrapAction(async (opts: { force?: boolean }) => trackDestroy(opts)),
63
+ );
64
+
65
+ track
66
+ .command('completion')
67
+ .description('Install shell completion scripts')
68
+ .argument('[action]', 'Action (install)')
69
+ .option('--shell <name>', 'Shell (bash, zsh, fish)')
70
+ .action(
71
+ wrapAction(async (action?: string, opts?: { shell?: string }) =>
72
+ trackCompletion(action, opts),
73
+ ),
74
+ );
75
+ }
76
+
77
+ export function wrapAction(
78
+ fn: (...args: any[]) => Promise<void>,
79
+ ): (...args: any[]) => Promise<void> {
80
+ return async (...args: any[]) => {
81
+ try {
82
+ await fn(...args);
83
+ } catch (err) {
84
+ if (err instanceof SgitError) {
85
+ logger.error(err.message);
86
+ process.exit(1);
87
+ }
88
+ throw err;
89
+ }
90
+ };
91
+ }