shadow-git 0.1.0 → 1.0.2

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 (54) hide show
  1. package/.claude/settings.local.json +5 -1
  2. package/.temp/.sgit/index +0 -0
  3. package/.temp/.sgit/info/exclude +16 -7
  4. package/.temp/.sgit/objects/03/7f4e37a02573e40ab78b35dd4b442cc6110bae +0 -0
  5. package/.temp/.sgit/objects/2f/f0fd85dec20039eef6ff63ead905778a88843f +0 -0
  6. package/.temp/.sgit/objects/c3/2432f724b666f647fe21a1ce16535cd7693f74 +0 -0
  7. package/.temp/.sgit/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +0 -0
  8. package/.temp/.sgit/objects/ee/9dd32cca3b134623d184eb4de5805704c493e3 +0 -0
  9. package/.temp/.sgit.json +6 -2
  10. package/.temp/README.md +0 -3
  11. package/.temp/rec1/rec2/test +0 -0
  12. package/.temp/temp/shouldnt-track +0 -0
  13. package/.temp/test/1 +0 -0
  14. package/CLAUDE.md +10 -2
  15. package/README.md +16 -29
  16. package/dist/cli.js +810 -119
  17. package/package.json +1 -1
  18. package/src/cli.ts +50 -19
  19. package/src/commands/debug/gitignores.ts +28 -0
  20. package/src/commands/debug/index.ts +32 -0
  21. package/src/commands/track/add.ts +11 -0
  22. package/src/commands/track/clone.ts +213 -0
  23. package/src/commands/track/completion.ts +50 -15
  24. package/src/commands/track/destroy.ts +97 -19
  25. package/src/commands/track/ignore.ts +55 -0
  26. package/src/commands/track/index.ts +23 -1
  27. package/src/commands/track/init.ts +5 -2
  28. package/src/commands/track/sync.ts +1 -1
  29. package/src/commands/track/unignore.ts +50 -0
  30. package/src/commands/track/verify.ts +93 -20
  31. package/src/core/errors.ts +23 -2
  32. package/src/core/exclude.ts +40 -43
  33. package/src/core/gitignore.ts +242 -0
  34. package/src/core/hooks.ts +19 -0
  35. package/src/core/manifest.ts +33 -1
  36. package/src/core/paths.ts +21 -3
  37. package/src/core/repository.ts +17 -2
  38. package/tests/commands/track/add.test.ts +31 -1
  39. package/tests/commands/track/clone.test.ts +315 -0
  40. package/tests/commands/track/completion.test.ts +1 -1
  41. package/tests/commands/track/destroy.test.ts +68 -2
  42. package/tests/commands/track/ignore.test.ts +109 -0
  43. package/tests/commands/track/init.test.ts +27 -4
  44. package/tests/commands/track/remove.test.ts +1 -1
  45. package/tests/commands/track/sync.test.ts +3 -3
  46. package/tests/commands/track/unignore.test.ts +72 -0
  47. package/tests/commands/track/verify.test.ts +132 -1
  48. package/tests/core/exclude.test.ts +107 -19
  49. package/tests/core/gitignore.test.ts +561 -0
  50. package/tests/core/hooks.test.ts +34 -0
  51. package/tests/core/paths.test.ts +24 -0
  52. package/tests/core/repository.test.ts +39 -5
  53. package/tests/helpers/guard-child.mjs +125 -0
  54. package/tests/helpers.ts +37 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadow-git",
3
- "version": "0.1.0",
3
+ "version": "1.0.2",
4
4
  "description": "A secondary git repository for tracking files excluded from your main repo",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -2,31 +2,57 @@ import { Command } from 'commander';
2
2
  import { findRoot, validateShadowRepo } from './core/repository.js';
3
3
  import { forwardToGit } from './commands/forward.js';
4
4
  import { registerTrackCommands } from './commands/track/index.js';
5
+ import { registerDebugCommands } from './commands/debug/index.js';
5
6
  import { SgitError } from './core/errors.js';
7
+ import { readManifest } from './core/manifest.js';
8
+ import { setupGitignoreGuard } from './core/gitignore.js';
6
9
  import * as logger from './utils/logger.js';
7
10
 
11
+ const COMMANDER_COMMANDS = new Set(['track', 'debug']);
12
+
8
13
  async function run(): Promise<void> {
9
14
  const args = process.argv.slice(2);
10
15
 
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');
16
+ // Skip guard for commands that manage gitignores themselves
17
+ const skipGuard =
18
+ (args[0] === 'track' && (args[1] === 'init' || args[1] === 'clone')) || args[0] === 'debug';
19
+
20
+ // Set up gitignore guard
21
+ let guardCleanup: (() => void) | undefined;
22
+ if (!skipGuard) {
23
+ try {
24
+ const root = await findRoot();
25
+ const manifest = await readManifest(root);
26
+ guardCleanup = setupGitignoreGuard(root, manifest.paths);
27
+ } catch {
28
+ // No git repo, no .sgit, or no manifest — proceed without guard
29
+ }
30
+ }
19
31
 
20
- registerTrackCommands(program);
32
+ try {
33
+ if (
34
+ args.length === 0 ||
35
+ COMMANDER_COMMANDS.has(args[0]) ||
36
+ args[0].startsWith('-')
37
+ ) {
38
+ const program = new Command('sgit')
39
+ .description('Shadow Git — a secondary git repository for your project')
40
+ .version('0.1.0');
21
41
 
22
- program.addHelpText(
23
- 'after',
24
- `
42
+ registerTrackCommands(program);
43
+ registerDebugCommands(program);
44
+
45
+ program.addHelpText(
46
+ 'after',
47
+ `
25
48
  Track commands:
26
49
  sgit track init Initialize shadow repository
50
+ sgit track clone Clone shadow repository from remote URL
27
51
  sgit track add Start shadow-tracking paths (--adopt to move from main git)
28
52
  sgit track remove Stop shadow-tracking paths
29
53
  sgit track list List all shadow-tracked paths
54
+ sgit track ignore Ignore paths within tracked directories
55
+ sgit track unignore Stop ignoring paths
30
56
  sgit track sync Regenerate exclude files
31
57
  sgit track verify Validate staged files
32
58
  sgit track destroy Remove shadow repository
@@ -34,14 +60,19 @@ Track commands:
34
60
 
35
61
  All other commands are forwarded to git in the shadow repository:
36
62
  sgit status, sgit commit, sgit log, sgit diff, ...`,
37
- );
63
+ );
38
64
 
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);
65
+ await program.parseAsync(process.argv);
66
+ } else {
67
+ const root = await findRoot();
68
+ await validateShadowRepo(root);
69
+ const exitCode = forwardToGit(root, args);
70
+ guardCleanup?.();
71
+ guardCleanup = undefined;
72
+ process.exit(exitCode);
73
+ }
74
+ } finally {
75
+ guardCleanup?.();
45
76
  }
46
77
  }
47
78
 
@@ -0,0 +1,28 @@
1
+ import { findRoot, validateShadowRepo } from '../../core/repository.js';
2
+ import { readManifest } from '../../core/manifest.js';
3
+ import { setupGitignoreGuard } from '../../core/gitignore.js';
4
+ import * as logger from '../../utils/logger.js';
5
+
6
+ export async function debugGitignores(): Promise<void> {
7
+ const root = await findRoot();
8
+ await validateShadowRepo(root);
9
+ const manifest = await readManifest(root);
10
+
11
+ const cleanup = setupGitignoreGuard(root, manifest.paths);
12
+
13
+ try {
14
+ logger.info('gitignores disabled — waiting 10 seconds…');
15
+ for (let i = 10; i > 0; i--) {
16
+ process.stdout.write(`\r ${i}s remaining `);
17
+ await sleep(1000);
18
+ }
19
+ process.stdout.write('\r \r');
20
+ logger.success('re-enabling gitignores');
21
+ } finally {
22
+ cleanup();
23
+ }
24
+ }
25
+
26
+ function sleep(ms: number): Promise<void> {
27
+ return new Promise((resolve) => setTimeout(resolve, ms));
28
+ }
@@ -0,0 +1,32 @@
1
+ import { Command } from 'commander';
2
+ import { SgitError } from '../../core/errors.js';
3
+ import * as logger from '../../utils/logger.js';
4
+ import { debugGitignores } from './gitignores.js';
5
+
6
+ export function registerDebugCommands(program: Command): void {
7
+ const debug = new Command('debug')
8
+ .description('Debug utilities');
9
+
10
+ debug
11
+ .command('gitignores')
12
+ .description('Disable .gitignore files for 10 seconds, then re-enable')
13
+ .action(wrapAction(debugGitignores));
14
+
15
+ program.addCommand(debug, { hidden: true });
16
+ }
17
+
18
+ function wrapAction(
19
+ fn: (...args: any[]) => Promise<void>,
20
+ ): (...args: any[]) => Promise<void> {
21
+ return async (...args: any[]) => {
22
+ try {
23
+ await fn(...args);
24
+ } catch (err) {
25
+ if (err instanceof SgitError) {
26
+ logger.error(err.message);
27
+ process.exit(1);
28
+ }
29
+ throw err;
30
+ }
31
+ };
32
+ }
@@ -9,9 +9,12 @@ import { syncExcludes } from './sync.js';
9
9
  import {
10
10
  PathNotFoundError,
11
11
  PathTrackedByMainError,
12
+ SgitError,
12
13
  } from '../../core/errors.js';
13
14
  import * as logger from '../../utils/logger.js';
14
15
 
16
+ const BLOCKED_BASENAMES = new Set(['.gitignore', '.gitignore-sgit.bat']);
17
+
15
18
  export async function trackAdd(paths: string[], opts: { adopt?: boolean } = {}): Promise<void> {
16
19
  const root = await findRoot();
17
20
  await validateShadowRepo(root);
@@ -22,6 +25,14 @@ export async function trackAdd(paths: string[], opts: { adopt?: boolean } = {}):
22
25
  for (const inputPath of paths) {
23
26
  const normalized = await normalizePath(inputPath, root);
24
27
 
28
+ // .gitignore files are managed by sgit and cannot be tracked
29
+ const basename = normalized.split('/').pop() ?? '';
30
+ if (BLOCKED_BASENAMES.has(basename)) {
31
+ throw new SgitError(
32
+ `cannot track ${normalized}: .gitignore files are managed by sgit and cannot be tracked directly`,
33
+ );
34
+ }
35
+
25
36
  // Must exist on disk
26
37
  const checkPath = normalized.endsWith('/')
27
38
  ? normalized.slice(0, -1)
@@ -0,0 +1,213 @@
1
+ import { join, extname } from 'node:path';
2
+ import { access, readFile, writeFile, rename, mkdir, rm } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { createInterface } from 'node:readline';
5
+ import { randomBytes } from 'node:crypto';
6
+ import {
7
+ findRoot,
8
+ validateNotWorktree,
9
+ validateNotSubmodule,
10
+ validateGitIdentity,
11
+ } from '../../core/repository.js';
12
+ import { execGit } from '../../core/git.js';
13
+ import { installMainHook, installShadowHook, installShadowSyncHooks } from '../../core/hooks.js';
14
+ import { syncExcludes } from './sync.js';
15
+ import { AlreadyInitializedError, SgitError } from '../../core/errors.js';
16
+ import type { Manifest } from '../../core/manifest.js';
17
+ import * as logger from '../../utils/logger.js';
18
+
19
+ export async function trackClone(
20
+ url: string,
21
+ opts: { onConflict?: 1 | 2 | 3 } = {},
22
+ ): Promise<void> {
23
+ const root = await findRoot();
24
+ await validateNotWorktree(root);
25
+ await validateNotSubmodule(root);
26
+ await validateGitIdentity(root);
27
+
28
+ const sgitDir = join(root, '.sgit');
29
+
30
+ // Check if already initialized
31
+ try {
32
+ await access(sgitDir, constants.F_OK);
33
+ throw new AlreadyInitializedError();
34
+ } catch (err) {
35
+ if (err instanceof AlreadyInitializedError) throw err;
36
+ // Not found — continue
37
+ }
38
+
39
+ // Clone bare
40
+ try {
41
+ await execGit(['clone', '--bare', url, sgitDir]);
42
+ } catch {
43
+ // Clean up partial clone
44
+ try { await rm(sgitDir, { recursive: true, force: true }); } catch { /* best effort */ }
45
+ throw new SgitError(`failed to clone from ${url}`);
46
+ }
47
+
48
+ // Configure as non-bare with worktree
49
+ await execGit(['config', 'core.bare', 'false'], { gitDir: sgitDir });
50
+ await execGit(['config', 'core.worktree', root], { gitDir: sgitDir });
51
+
52
+ // Copy user identity from main repo
53
+ try {
54
+ const name = await execGit(['config', 'user.name'], { cwd: root });
55
+ if (name) {
56
+ await execGit(['config', 'user.name', name], { gitDir: sgitDir });
57
+ }
58
+ } catch {
59
+ // Not configured in main repo
60
+ }
61
+ try {
62
+ const email = await execGit(['config', 'user.email'], { cwd: root });
63
+ if (email) {
64
+ await execGit(['config', 'user.email', email], { gitDir: sgitDir });
65
+ }
66
+ } catch {
67
+ // Not configured in main repo
68
+ }
69
+
70
+ // Read manifest from cloned repo
71
+ let manifest: Manifest;
72
+ try {
73
+ const manifestJson = await execGit(['show', 'HEAD:.sgit.json'], { gitDir: sgitDir });
74
+ manifest = JSON.parse(manifestJson) as Manifest;
75
+ } catch {
76
+ manifest = { paths: [], ignore: [] };
77
+ }
78
+
79
+ // Get list of tracked files
80
+ let trackedFiles: string[] = [];
81
+ try {
82
+ const output = await execGit(['ls-tree', '-r', '--name-only', 'HEAD'], { gitDir: sgitDir });
83
+ if (output.trim()) {
84
+ trackedFiles = output.trim().split('\n');
85
+ }
86
+ } catch {
87
+ // Empty repo — nothing to checkout
88
+ }
89
+
90
+ // Detect conflicts
91
+ const conflicts: string[] = [];
92
+ for (const file of trackedFiles) {
93
+ try {
94
+ await access(join(root, file), constants.F_OK);
95
+ conflicts.push(file);
96
+ } catch {
97
+ // No conflict
98
+ }
99
+ }
100
+
101
+ // Resolve conflicts
102
+ let choice: 1 | 2 | 3 = 1;
103
+ if (conflicts.length > 0) {
104
+ if (opts.onConflict) {
105
+ choice = opts.onConflict;
106
+ } else {
107
+ logger.warn(`${conflicts.length} file(s) already exist on disk:`);
108
+ for (const f of conflicts) {
109
+ logger.info(` ${f}`);
110
+ }
111
+ choice = await promptConflictResolution();
112
+ }
113
+
114
+ if (choice === 2) {
115
+ // Save conflicting files before checkout
116
+ const savedContent = new Map<string, Buffer>();
117
+ for (const file of conflicts) {
118
+ savedContent.set(file, await readFile(join(root, file)));
119
+ }
120
+
121
+ // Checkout all files
122
+ if (trackedFiles.length > 0) {
123
+ await execGit(['checkout', 'HEAD', '--', '.'], { gitDir: sgitDir, workTree: root });
124
+ }
125
+
126
+ // Restore originals
127
+ for (const [file, content] of savedContent) {
128
+ await mkdir(join(root, file, '..'), { recursive: true });
129
+ await writeFile(join(root, file), content);
130
+ }
131
+ } else if (choice === 3) {
132
+ // Rename conflicting files
133
+ for (const file of conflicts) {
134
+ const renamed = generateOriginalName(file);
135
+ await rename(join(root, file), join(root, renamed));
136
+ }
137
+
138
+ // Checkout all files
139
+ if (trackedFiles.length > 0) {
140
+ await execGit(['checkout', 'HEAD', '--', '.'], { gitDir: sgitDir, workTree: root });
141
+ }
142
+ } else {
143
+ // Option 1: just checkout (overwrites)
144
+ if (trackedFiles.length > 0) {
145
+ await execGit(['checkout', 'HEAD', '--', '.'], { gitDir: sgitDir, workTree: root });
146
+ }
147
+ }
148
+ } else {
149
+ // No conflicts — checkout directly
150
+ if (trackedFiles.length > 0) {
151
+ await execGit(['checkout', 'HEAD', '--', '.'], { gitDir: sgitDir, workTree: root });
152
+ }
153
+ }
154
+
155
+ // Sync exclude files
156
+ await syncExcludes(root);
157
+
158
+ // Install hooks
159
+ await installMainHook(root);
160
+ await installShadowHook(sgitDir);
161
+ await installShadowSyncHooks(sgitDir);
162
+
163
+ // Success message
164
+ const fileCount = trackedFiles.filter(f => f !== '.sgit.json').length;
165
+ logger.success(`Shadow repository cloned from ${url}`);
166
+ logger.info(` ${fileCount} file(s) checked out`);
167
+ if (conflicts.length > 0) {
168
+ switch (choice) {
169
+ case 1:
170
+ logger.info(` ${conflicts.length} conflicting file(s) overwritten`);
171
+ break;
172
+ case 2:
173
+ logger.info(` ${conflicts.length} local file(s) kept (shadow repo shows them as modified)`);
174
+ break;
175
+ case 3:
176
+ logger.info(` ${conflicts.length} local file(s) renamed with -original~ suffix`);
177
+ break;
178
+ }
179
+ }
180
+ }
181
+
182
+ async function promptConflictResolution(): Promise<1 | 2 | 3> {
183
+ const rl = createInterface({
184
+ input: process.stdin,
185
+ output: process.stdout,
186
+ });
187
+
188
+ return new Promise((resolve) => {
189
+ process.stdout.write('\nHow should conflicts be resolved?\n');
190
+ process.stdout.write(' 1 - Keep sgit (overwrite local files)\n');
191
+ process.stdout.write(' 2 - Keep originals (local files stay, sgit shows them as modified)\n');
192
+ process.stdout.write(' 3 - Rename originals (add -original~ suffix, checkout sgit versions)\n');
193
+ rl.question('\nChoice [1/2/3]: ', (answer) => {
194
+ rl.close();
195
+ const num = parseInt(answer.trim(), 10);
196
+ if (num === 1 || num === 2 || num === 3) {
197
+ resolve(num as 1 | 2 | 3);
198
+ } else {
199
+ resolve(2); // Default to safest option
200
+ }
201
+ });
202
+ });
203
+ }
204
+
205
+ function generateOriginalName(filePath: string): string {
206
+ const suffix = randomBytes(2).toString('hex');
207
+ const ext = extname(filePath);
208
+ if (ext) {
209
+ const base = filePath.slice(0, filePath.length - ext.length);
210
+ return `${base}-original~${suffix}${ext}`;
211
+ }
212
+ return `${filePath}-original~${suffix}`;
213
+ }
@@ -11,7 +11,7 @@ _sgit() {
11
11
 
12
12
  if [[ \${words[1]} == "track" ]]; then
13
13
  if [[ $cword -eq 2 ]]; then
14
- COMPREPLY=($(compgen -W "init add remove list sync verify destroy completion" -- "$cur"))
14
+ COMPREPLY=($(compgen -W "init clone add remove list sync verify ignore unignore destroy completion" -- "$cur"))
15
15
  return
16
16
  fi
17
17
 
@@ -24,24 +24,34 @@ _sgit() {
24
24
  _filedir
25
25
  fi
26
26
  ;;
27
+ clone)
28
+ ;;
27
29
  remove)
28
30
  local paths
29
31
  paths=$(sgit track list 2>/dev/null)
30
32
  COMPREPLY=($(compgen -W "$paths" -- "$cur"))
31
33
  ;;
34
+ ignore)
35
+ _filedir
36
+ ;;
37
+ unignore)
38
+ local ignored
39
+ ignored=$(python3 -c "import json; d=json.load(open('.sgit.json')); print(' '.join(d.get('ignore',[])))" 2>/dev/null)
40
+ COMPREPLY=($(compgen -W "$ignored" -- "$cur"))
41
+ ;;
32
42
  verify)
33
43
  COMPREPLY=($(compgen -W "--main --shadow" -- "$cur"))
34
44
  ;;
35
45
  destroy)
36
- COMPREPLY=($(compgen -W "--force" -- "$cur"))
46
+ COMPREPLY=($(compgen -W "--force --keep-files" -- "$cur"))
37
47
  ;;
38
48
  completion)
39
49
  if [[ $prev == "--shell" ]]; then
40
50
  COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur"))
41
- elif [[ $cur == -* ]]; then
42
- COMPREPLY=($(compgen -W "--shell" -- "$cur"))
51
+ elif [[ $cword -eq 3 ]]; then
52
+ COMPREPLY=($(compgen -W "install --shell" -- "$cur"))
43
53
  else
44
- COMPREPLY=($(compgen -W "install" -- "$cur"))
54
+ COMPREPLY=($(compgen -W "--shell" -- "$cur"))
45
55
  fi
46
56
  ;;
47
57
  esac
@@ -75,11 +85,14 @@ _sgit_track() {
75
85
  local -a subcmds
76
86
  subcmds=(
77
87
  'init:Initialize shadow repository'
88
+ 'clone:Clone shadow repository from remote URL'
78
89
  'add:Start shadow-tracking paths'
79
90
  'remove:Stop shadow-tracking paths'
80
91
  'list:List all shadow-tracked paths'
81
92
  'sync:Regenerate exclude files'
82
93
  'verify:Validate staged files'
94
+ 'ignore:Ignore paths within tracked directories'
95
+ 'unignore:Stop ignoring paths within tracked directories'
83
96
  'destroy:Remove shadow repository'
84
97
  'completion:Install shell completions'
85
98
  )
@@ -90,6 +103,9 @@ _sgit_track() {
90
103
  fi
91
104
 
92
105
  case "\${words[2]}" in
106
+ clone)
107
+ _arguments '1:url:'
108
+ ;;
93
109
  add)
94
110
  _arguments '--adopt[Move main-git-tracked files to shadow tracking]' '*:file:_files'
95
111
  ;;
@@ -98,11 +114,19 @@ _sgit_track() {
98
114
  paths=(\${(f)"$(sgit track list 2>/dev/null)"})
99
115
  _describe 'tracked path' paths
100
116
  ;;
117
+ ignore)
118
+ _files
119
+ ;;
120
+ unignore)
121
+ local -a ignored
122
+ ignored=(\${(f)"$(python3 -c "import json; d=json.load(open('.sgit.json')); print('\\\\n'.join(d.get('ignore',[])))" 2>/dev/null)"})
123
+ _describe 'ignored path' ignored
124
+ ;;
101
125
  verify)
102
126
  _arguments '--main[Check main repo]' '--shadow[Check shadow repo]'
103
127
  ;;
104
128
  destroy)
105
- _arguments '--force[Skip confirmation]'
129
+ _arguments '--force[Skip confirmation]' '--keep-files[Keep tracked files on disk]'
106
130
  ;;
107
131
  completion)
108
132
  _arguments '--shell[Shell type]:shell:(bash zsh fish)' '1:action:(install)'
@@ -148,14 +172,18 @@ complete -c sgit -w git
148
172
  complete -c sgit -n '__fish_use_subcommand' -a track -d 'Manage shadow-tracked paths'
149
173
 
150
174
  # 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'
175
+ set -l __sgit_track_cmds init clone add remove list sync verify ignore unignore destroy completion
176
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from \$__sgit_track_cmds" -a init -d 'Initialize shadow repository'
177
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from \$__sgit_track_cmds" -a clone -d 'Clone shadow repository from remote URL'
178
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from \$__sgit_track_cmds" -a add -d 'Start shadow-tracking paths'
179
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from \$__sgit_track_cmds" -a remove -d 'Stop shadow-tracking paths'
180
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from \$__sgit_track_cmds" -a list -d 'List all shadow-tracked paths'
181
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from \$__sgit_track_cmds" -a sync -d 'Regenerate exclude files'
182
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from \$__sgit_track_cmds" -a verify -d 'Validate staged files'
183
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from \$__sgit_track_cmds" -a ignore -d 'Ignore paths within tracked directories'
184
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from \$__sgit_track_cmds" -a unignore -d 'Stop ignoring paths within tracked directories'
185
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from \$__sgit_track_cmds" -a destroy -d 'Remove shadow repository'
186
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from \$__sgit_track_cmds" -a completion -d 'Install shell completions'
159
187
 
160
188
  # Track add options and file completions
161
189
  complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from add' -l adopt -d 'Move main-git-tracked files'
@@ -164,15 +192,22 @@ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcomma
164
192
  # Track remove — complete from manifest
165
193
  complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from remove' -a '(sgit track list 2>/dev/null)'
166
194
 
195
+ # Track ignore — file completions
196
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from ignore' -F
197
+
198
+ # Track unignore — complete from .sgit.json ignore list
199
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from unignore' -a "(python3 -c \\"import json; d=json.load(open('.sgit.json')); print(' '.join(d.get('ignore',[])))\\\" 2>/dev/null)"
200
+
167
201
  # Track verify options
168
202
  complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from verify' -l main -d 'Check main repo'
169
203
  complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from verify' -l shadow -d 'Check shadow repo'
170
204
 
171
205
  # Track destroy options
172
206
  complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from destroy' -l force -d 'Skip confirmation'
207
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from destroy' -l keep-files -d 'Keep tracked files on disk'
173
208
 
174
209
  # Track completion options
175
- complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from completion' -a install -d 'Install completions'
210
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from completion; and not __fish_seen_subcommand_from install' -a install -d 'Install completions'
176
211
  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
212
  `;
178
213
  }