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
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
|
+
}
|