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/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { Command } from "commander";
4
+ import { Command as Command2 } from "commander";
5
5
 
6
6
  // src/core/repository.ts
7
7
  import { access, lstat } from "fs/promises";
@@ -35,7 +35,14 @@ var SgitError = class extends Error {
35
35
  };
36
36
  var NotGitRepoError = class extends SgitError {
37
37
  constructor() {
38
- super("not a git repository");
38
+ super("not a git repository (no .git in current directory)");
39
+ }
40
+ };
41
+ var NoGitIdentityError = class extends SgitError {
42
+ constructor() {
43
+ super(
44
+ "git user identity not configured; set user.name and user.email with 'git config'"
45
+ );
39
46
  }
40
47
  };
41
48
  var AlreadyInitializedError = class extends SgitError {
@@ -45,7 +52,7 @@ var AlreadyInitializedError = class extends SgitError {
45
52
  };
46
53
  var NotInitializedError = class extends SgitError {
47
54
  constructor() {
48
- super("not a shadow git repository; run 'sgit track init' first");
55
+ super("not a shadow git repository; run 'sgit track init' or 'sgit track clone <url>'");
49
56
  }
50
57
  };
51
58
  var PathNotFoundError = class extends SgitError {
@@ -66,11 +73,25 @@ var PathNotTrackedError = class extends SgitError {
66
73
 
67
74
  // src/core/repository.ts
68
75
  async function findRoot() {
76
+ const cwd = process.cwd();
69
77
  try {
70
- return await execGit(["rev-parse", "--show-toplevel"]);
78
+ await access(join(cwd, ".git"), constants.F_OK);
71
79
  } catch {
72
80
  throw new NotGitRepoError();
73
81
  }
82
+ return cwd;
83
+ }
84
+ async function validateGitIdentity(root) {
85
+ try {
86
+ await execGit(["config", "user.name"], { cwd: root });
87
+ } catch {
88
+ throw new NoGitIdentityError();
89
+ }
90
+ try {
91
+ await execGit(["config", "user.email"], { cwd: root });
92
+ } catch {
93
+ throw new NoGitIdentityError();
94
+ }
74
95
  }
75
96
  async function validateShadowRepo(root) {
76
97
  try {
@@ -142,7 +163,11 @@ import { readFile, writeFile } from "fs/promises";
142
163
  import { join as join3 } from "path";
143
164
  async function readManifest(root) {
144
165
  const content = await readFile(join3(root, ".sgit.json"), "utf-8");
145
- return JSON.parse(content);
166
+ const data = JSON.parse(content);
167
+ if (!Array.isArray(data.ignore)) {
168
+ data.ignore = [];
169
+ }
170
+ return data;
146
171
  }
147
172
  async function writeManifest(root, data) {
148
173
  await writeFile(
@@ -169,81 +194,106 @@ async function removePath(root, normalizedPath) {
169
194
  await writeManifest(root, manifest);
170
195
  return true;
171
196
  }
197
+ async function addIgnore(root, normalizedPath) {
198
+ const manifest = await readManifest(root);
199
+ if (manifest.ignore.includes(normalizedPath)) {
200
+ return false;
201
+ }
202
+ manifest.ignore.push(normalizedPath);
203
+ await writeManifest(root, manifest);
204
+ return true;
205
+ }
206
+ async function removeIgnore(root, normalizedPath) {
207
+ const manifest = await readManifest(root);
208
+ const index = manifest.ignore.indexOf(normalizedPath);
209
+ if (index === -1) {
210
+ return false;
211
+ }
212
+ manifest.ignore.splice(index, 1);
213
+ await writeManifest(root, manifest);
214
+ return true;
215
+ }
172
216
 
173
217
  // src/core/exclude.ts
174
- var SGIT_MARKER = "# sgit";
175
- var SGIT_MANAGED_HEADER = "# sgit-managed (do not edit below this line)";
218
+ var SGIT_BLOCK_START = "# sgit-managed";
219
+ var SGIT_BLOCK_END = "# end: sgit-managed";
176
220
  function generateMainExclude(existingContent, manifestPaths) {
177
221
  const lines = existingContent.split("\n");
178
222
  const preserved = [];
223
+ let insideBlock = false;
179
224
  let skipNext = false;
180
225
  for (const line of lines) {
181
- if (line === SGIT_MANAGED_HEADER) {
182
- skipNext = false;
226
+ if (line === SGIT_BLOCK_START || line === "# sgit-managed (do not edit below this line)") {
227
+ insideBlock = true;
183
228
  continue;
184
229
  }
185
- if (line === SGIT_MARKER) {
186
- skipNext = true;
230
+ if (line === SGIT_BLOCK_END) {
231
+ insideBlock = false;
187
232
  continue;
188
233
  }
189
- if (line.endsWith(` ${SGIT_MARKER}`)) {
234
+ if (insideBlock) continue;
235
+ if (line === "# sgit") {
236
+ skipNext = true;
190
237
  continue;
191
238
  }
192
239
  if (skipNext) {
193
240
  skipNext = false;
194
241
  continue;
195
242
  }
243
+ if (line.endsWith(" # sgit")) continue;
196
244
  preserved.push(line);
197
245
  }
198
246
  while (preserved.length > 0 && preserved[preserved.length - 1].trim() === "") {
199
247
  preserved.pop();
200
248
  }
201
- const sgitEntries = [".sgit", ".sgit.json", ...manifestPaths];
202
- const sgitLines = sgitEntries.flatMap((entry) => [SGIT_MARKER, entry]);
203
- const sections = [
249
+ const sgitEntries = [".sgit", ".sgit.json", ".gitignore-sgit.bat", ...manifestPaths];
250
+ return [
204
251
  ...preserved,
205
252
  "",
206
- SGIT_MANAGED_HEADER,
207
- ...sgitLines
208
- ];
209
- return sections.join("\n") + "\n";
210
- }
211
- function generateShadowExclude(manifestPaths) {
212
- const lines = [
213
- "# Exclude everything by default",
214
- "*",
215
- "",
216
- "# .sgit.json (always tracked)",
217
- "!.sgit.json"
218
- ];
253
+ SGIT_BLOCK_START,
254
+ ...sgitEntries,
255
+ SGIT_BLOCK_END
256
+ ].join("\n") + "\n";
257
+ }
258
+ function generateShadowExclude(manifestPaths, ignorePaths = []) {
219
259
  const emitted = /* @__PURE__ */ new Set();
260
+ const entryLines = [];
220
261
  for (const trackedPath of manifestPaths) {
221
- const pathLines = [];
222
262
  const ancestors = getAncestors(trackedPath);
223
263
  for (const ancestor of ancestors) {
224
264
  const unignore = `!${ancestor}/`;
225
265
  const reignore = `${ancestor}/*`;
226
266
  if (!emitted.has(unignore)) {
227
- pathLines.push(unignore);
267
+ entryLines.push(unignore);
228
268
  emitted.add(unignore);
229
269
  }
230
270
  if (!emitted.has(reignore)) {
231
- pathLines.push(reignore);
271
+ entryLines.push(reignore);
232
272
  emitted.add(reignore);
233
273
  }
234
274
  }
235
275
  const target = `!${trackedPath}`;
236
276
  if (!emitted.has(target)) {
237
- pathLines.push(target);
277
+ entryLines.push(target);
238
278
  emitted.add(target);
239
279
  }
240
- if (pathLines.length > 0) {
241
- lines.push("");
242
- lines.push(`# ${trackedPath}`);
243
- lines.push(...pathLines);
280
+ if (trackedPath.endsWith("/")) {
281
+ const recursive = `!${trackedPath}**`;
282
+ if (!emitted.has(recursive)) {
283
+ entryLines.push(recursive);
284
+ emitted.add(recursive);
285
+ }
244
286
  }
245
287
  }
246
- return lines.join("\n") + "\n";
288
+ return [
289
+ SGIT_BLOCK_START,
290
+ "*",
291
+ "!.sgit.json",
292
+ ...entryLines,
293
+ ...ignorePaths,
294
+ ".gitignore-sgit.bat",
295
+ SGIT_BLOCK_END
296
+ ].join("\n") + "\n";
247
297
  }
248
298
  function getAncestors(path) {
249
299
  const cleanPath = path.endsWith("/") ? path.slice(0, -1) : path;
@@ -296,6 +346,16 @@ else
296
346
  echo "sgit: not found in PATH, skipping pre-commit check" >&2
297
347
  fi
298
348
  `;
349
+ var SHADOW_POST_MERGE_HOOK = `#!/bin/sh
350
+ if command -v sgit >/dev/null 2>&1; then
351
+ sgit track sync
352
+ fi
353
+ `;
354
+ var SHADOW_POST_CHECKOUT_HOOK = `#!/bin/sh
355
+ if command -v sgit >/dev/null 2>&1; then
356
+ sgit track sync
357
+ fi
358
+ `;
299
359
  async function getHookDir(root) {
300
360
  try {
301
361
  const hookPath = await execGit(["config", "core.hooksPath"], {
@@ -337,6 +397,12 @@ async function installShadowHook(sgitDir) {
337
397
  await mkdir(hooksDir, { recursive: true });
338
398
  await writeFile2(join4(hooksDir, "pre-commit"), SHADOW_HOOK, { mode: 493 });
339
399
  }
400
+ async function installShadowSyncHooks(sgitDir) {
401
+ const hooksDir = join4(sgitDir, "hooks");
402
+ await mkdir(hooksDir, { recursive: true });
403
+ await writeFile2(join4(hooksDir, "post-merge"), SHADOW_POST_MERGE_HOOK, { mode: 493 });
404
+ await writeFile2(join4(hooksDir, "post-checkout"), SHADOW_POST_CHECKOUT_HOOK, { mode: 493 });
405
+ }
340
406
  async function uninstallMainHook(root) {
341
407
  const hookDir = await getHookDir(root);
342
408
  const preCommitPath = join4(hookDir, "pre-commit");
@@ -380,6 +446,7 @@ async function trackInit() {
380
446
  const root = await findRoot();
381
447
  await validateNotWorktree(root);
382
448
  await validateNotSubmodule(root);
449
+ await validateGitIdentity(root);
383
450
  const sgitDir = join5(root, ".sgit");
384
451
  try {
385
452
  await access3(sgitDir, constants3.F_OK);
@@ -409,7 +476,7 @@ async function trackInit() {
409
476
  join5(sgitDir, "info", "exclude"),
410
477
  generateShadowExclude([])
411
478
  );
412
- await writeManifest(root, { paths: [] });
479
+ await writeManifest(root, { paths: [], ignore: [] });
413
480
  const mainExcludePath = join5(root, ".git", "info", "exclude");
414
481
  let existingExclude = "";
415
482
  try {
@@ -420,6 +487,7 @@ async function trackInit() {
420
487
  await writeFile3(mainExcludePath, generateMainExclude(existingExclude, []));
421
488
  await installMainHook(root);
422
489
  await installShadowHook(sgitDir);
490
+ await installShadowSyncHooks(sgitDir);
423
491
  await execGit(["add", "-f", ".sgit.json"], {
424
492
  gitDir: sgitDir,
425
493
  workTree: root
@@ -450,7 +518,7 @@ async function syncExcludes(root) {
450
518
  );
451
519
  const sgitExcludePath = join6(root, ".sgit", "info", "exclude");
452
520
  await mkdir3(join6(root, ".sgit", "info"), { recursive: true });
453
- await writeFile4(sgitExcludePath, generateShadowExclude(manifest.paths));
521
+ await writeFile4(sgitExcludePath, generateShadowExclude(manifest.paths, manifest.ignore));
454
522
  for (const p of manifest.paths) {
455
523
  const checkPath = p.endsWith("/") ? p.slice(0, -1) : p;
456
524
  try {
@@ -494,21 +562,35 @@ async function normalizePath(inputPath, root) {
494
562
  }
495
563
  return rel;
496
564
  }
497
- function isPathCoveredByManifest(filePath, manifestPaths) {
565
+ function isPathCoveredByManifest(filePath, manifestPaths, ignorePaths = []) {
498
566
  const caseInsensitive = process.platform === "darwin";
499
567
  const normalize = caseInsensitive ? (s) => s.toLowerCase() : (s) => s;
500
568
  const normalizedFile = normalize(filePath);
569
+ let covered = false;
501
570
  for (const mPath of manifestPaths) {
502
571
  const normalizedManifest = normalize(mPath);
503
- if (normalizedManifest === normalizedFile) return true;
572
+ if (normalizedManifest === normalizedFile) {
573
+ covered = true;
574
+ break;
575
+ }
504
576
  if (normalizedManifest.endsWith("/") && normalizedFile.startsWith(normalizedManifest)) {
505
- return true;
577
+ covered = true;
578
+ break;
506
579
  }
507
580
  }
508
- return false;
581
+ if (!covered) return false;
582
+ for (const iPath of ignorePaths) {
583
+ const normalizedIgnore = normalize(iPath);
584
+ if (normalizedIgnore === normalizedFile) return false;
585
+ if (normalizedIgnore.endsWith("/") && normalizedFile.startsWith(normalizedIgnore)) {
586
+ return false;
587
+ }
588
+ }
589
+ return true;
509
590
  }
510
591
 
511
592
  // src/commands/track/add.ts
593
+ var BLOCKED_BASENAMES = /* @__PURE__ */ new Set([".gitignore", ".gitignore-sgit.bat"]);
512
594
  async function trackAdd(paths, opts = {}) {
513
595
  const root = await findRoot();
514
596
  await validateShadowRepo(root);
@@ -516,6 +598,12 @@ async function trackAdd(paths, opts = {}) {
516
598
  const added = [];
517
599
  for (const inputPath of paths) {
518
600
  const normalized = await normalizePath(inputPath, root);
601
+ const basename = normalized.split("/").pop() ?? "";
602
+ if (BLOCKED_BASENAMES.has(basename)) {
603
+ throw new SgitError(
604
+ `cannot track ${normalized}: .gitignore files are managed by sgit and cannot be tracked directly`
605
+ );
606
+ }
519
607
  const checkPath = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
520
608
  try {
521
609
  await access5(join7(root, checkPath), constants5.F_OK);
@@ -614,41 +702,252 @@ async function trackList() {
614
702
  }
615
703
 
616
704
  // src/commands/track/verify.ts
617
- import { join as join9 } from "path";
705
+ import { join as join10 } from "path";
706
+
707
+ // src/core/gitignore.ts
708
+ import {
709
+ readdirSync,
710
+ renameSync,
711
+ existsSync,
712
+ readFileSync,
713
+ writeFileSync,
714
+ unlinkSync
715
+ } from "fs";
716
+ import { join as join9, relative as relative3, sep as sep3 } from "path";
717
+ var DISABLED_NAME = ".gitignore-sgit.bat";
718
+ var LOCK_FILE = ".gitignore-guard";
719
+ function enableGitignores(root, manifestPaths) {
720
+ const disabled = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
721
+ const enabled = [];
722
+ for (const relPath of disabled) {
723
+ const absPath = join9(root, relPath);
724
+ const restoredPath = absPath.substring(0, absPath.length - DISABLED_NAME.length) + ".gitignore";
725
+ renameSync(absPath, restoredPath);
726
+ enabled.push(relPath);
727
+ }
728
+ return enabled;
729
+ }
730
+ function findStaleGitignores(root, manifestPaths) {
731
+ return findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
732
+ }
733
+ function isGuardActive(root) {
734
+ const lockPath = join9(root, ".sgit", LOCK_FILE);
735
+ try {
736
+ const pid = parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
737
+ if (isNaN(pid)) return false;
738
+ process.kill(pid, 0);
739
+ return true;
740
+ } catch {
741
+ return false;
742
+ }
743
+ }
744
+ function setupGitignoreGuard(root, manifestPaths) {
745
+ const lockPath = join9(root, ".sgit", LOCK_FILE);
746
+ if (isGuardActive(root)) {
747
+ return () => {
748
+ };
749
+ }
750
+ const stale = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
751
+ if (stale.length > 0) {
752
+ warn(
753
+ "found stale .gitignore-sgit.bat files from a previous crash; restoring"
754
+ );
755
+ enableGitignores(root, manifestPaths);
756
+ }
757
+ cleanStaleLock(lockPath);
758
+ const gitignores = findInTrackedDirs(root, manifestPaths, ".gitignore");
759
+ if (gitignores.length === 0) {
760
+ return () => {
761
+ };
762
+ }
763
+ for (const relPath of gitignores) {
764
+ const absPath = join9(root, relPath);
765
+ const dir = absPath.substring(0, absPath.length - ".gitignore".length);
766
+ renameSync(absPath, dir + DISABLED_NAME);
767
+ }
768
+ try {
769
+ writeFileSync(lockPath, String(process.pid));
770
+ } catch {
771
+ }
772
+ let cleaned = false;
773
+ const cleanup = () => {
774
+ if (cleaned) return;
775
+ cleaned = true;
776
+ try {
777
+ enableGitignores(root, manifestPaths);
778
+ } catch {
779
+ }
780
+ try {
781
+ unlinkSync(lockPath);
782
+ } catch {
783
+ }
784
+ };
785
+ const onSigint = () => {
786
+ cleanup();
787
+ process.exit(130);
788
+ };
789
+ const onSigterm = () => {
790
+ cleanup();
791
+ process.exit(143);
792
+ };
793
+ process.on("SIGINT", onSigint);
794
+ process.on("SIGTERM", onSigterm);
795
+ process.on("exit", cleanup);
796
+ return () => {
797
+ process.removeListener("SIGINT", onSigint);
798
+ process.removeListener("SIGTERM", onSigterm);
799
+ process.removeListener("exit", cleanup);
800
+ cleanup();
801
+ };
802
+ }
803
+ function findInTrackedDirs(root, manifestPaths, targetName) {
804
+ const found = /* @__PURE__ */ new Set();
805
+ for (const mPath of manifestPaths) {
806
+ for (const ancestor of getAncestorDirs(mPath)) {
807
+ const dirAbs = ancestor === "" ? root : join9(root, ancestor);
808
+ const targetPath = join9(dirAbs, targetName);
809
+ if (existsSync(targetPath)) {
810
+ found.add(targetPath);
811
+ }
812
+ }
813
+ if (mPath.endsWith("/")) {
814
+ const dirAbs = join9(root, mPath);
815
+ if (existsSync(dirAbs)) {
816
+ walkSync(dirAbs, targetName, found);
817
+ }
818
+ }
819
+ }
820
+ return [...found].map((abs) => relative3(root, abs).split(sep3).join("/"));
821
+ }
822
+ function getAncestorDirs(path) {
823
+ const clean = path.endsWith("/") ? path.slice(0, -1) : path;
824
+ const parts = clean.split("/");
825
+ parts.pop();
826
+ const ancestors = [""];
827
+ for (let i = 1; i <= parts.length; i++) {
828
+ ancestors.push(parts.slice(0, i).join("/"));
829
+ }
830
+ return ancestors;
831
+ }
832
+ function walkSync(dir, targetName, results) {
833
+ let entries;
834
+ try {
835
+ entries = readdirSync(dir, { withFileTypes: true });
836
+ } catch {
837
+ return;
838
+ }
839
+ for (const entry of entries) {
840
+ if (entry.isDirectory() && (entry.name === ".git" || entry.name === ".sgit")) {
841
+ continue;
842
+ }
843
+ const fullPath = join9(dir, entry.name);
844
+ if (entry.isDirectory()) {
845
+ walkSync(fullPath, targetName, results);
846
+ } else if (entry.name === targetName) {
847
+ results.add(fullPath);
848
+ }
849
+ }
850
+ }
851
+ function cleanStaleLock(lockPath) {
852
+ try {
853
+ unlinkSync(lockPath);
854
+ } catch {
855
+ }
856
+ }
857
+
858
+ // src/commands/track/verify.ts
618
859
  async function verifyMain(root) {
619
860
  const manifest = await readManifest(root);
620
- let staged;
861
+ const violations = [];
862
+ if (!isGuardActive(root)) {
863
+ const stale = findStaleGitignores(root, manifest.paths);
864
+ violations.push(
865
+ ...stale.map(
866
+ (f) => `${f} (stale .gitignore-sgit.bat \u2014 rename back to .gitignore)`
867
+ )
868
+ );
869
+ }
621
870
  try {
622
- staged = await execGit(["diff", "--cached", "--name-only"], {
623
- gitDir: join9(root, ".git"),
871
+ const staged = await execGit(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], {
872
+ gitDir: join10(root, ".git"),
624
873
  workTree: root
625
874
  });
875
+ if (staged.trim()) {
876
+ const stagedFiles = staged.trim().split("\n");
877
+ violations.push(
878
+ ...stagedFiles.filter((f) => isPathCoveredByManifest(f, manifest.paths, manifest.ignore))
879
+ );
880
+ }
626
881
  } catch {
627
- return { valid: true, violations: [] };
628
882
  }
629
- if (!staged.trim()) return { valid: true, violations: [] };
630
- const stagedFiles = staged.trim().split("\n");
631
- const violations = stagedFiles.filter(
632
- (f) => isPathCoveredByManifest(f, manifest.paths)
633
- );
634
883
  return { valid: violations.length === 0, violations };
635
884
  }
636
885
  async function verifyShadow(root) {
637
- const manifest = await readManifest(root);
638
- let staged;
886
+ let manifest;
887
+ try {
888
+ manifest = await readManifest(root);
889
+ } catch {
890
+ return {
891
+ valid: false,
892
+ violations: [".sgit.json (missing or invalid on disk)"]
893
+ };
894
+ }
895
+ const sgitDir = join10(root, ".sgit");
896
+ const violations = [];
897
+ if (!isGuardActive(root)) {
898
+ const stale = findStaleGitignores(root, manifest.paths);
899
+ violations.push(
900
+ ...stale.map(
901
+ (f) => `${f} (stale .gitignore-sgit.bat \u2014 rename back to .gitignore)`
902
+ )
903
+ );
904
+ }
639
905
  try {
640
- staged = await execGit(["diff", "--cached", "--name-only"], {
641
- gitDir: join9(root, ".sgit"),
906
+ const deleted = await execGit(["diff", "--cached", "--name-only", "--diff-filter=D"], {
907
+ gitDir: sgitDir,
642
908
  workTree: root
643
909
  });
910
+ if (deleted.trim().split("\n").includes(".sgit.json")) {
911
+ violations.push(".sgit.json (cannot delete sgit manifest)");
912
+ }
913
+ } catch {
914
+ }
915
+ try {
916
+ const modified = await execGit(["diff", "--cached", "--name-only", "--diff-filter=M"], {
917
+ gitDir: sgitDir,
918
+ workTree: root
919
+ });
920
+ if (modified.trim().split("\n").includes(".sgit.json")) {
921
+ const content = await execGit(["show", ":0:.sgit.json"], {
922
+ gitDir: sgitDir,
923
+ workTree: root
924
+ });
925
+ try {
926
+ const parsed = JSON.parse(content);
927
+ if (!Array.isArray(parsed.paths)) {
928
+ violations.push('.sgit.json (invalid: missing "paths" array)');
929
+ }
930
+ } catch {
931
+ violations.push(".sgit.json (invalid JSON)");
932
+ }
933
+ }
934
+ } catch {
935
+ }
936
+ try {
937
+ const staged = await execGit(["diff", "--cached", "--name-only"], {
938
+ gitDir: sgitDir,
939
+ workTree: root
940
+ });
941
+ if (staged.trim()) {
942
+ const stagedFiles = staged.trim().split("\n");
943
+ violations.push(
944
+ ...stagedFiles.filter(
945
+ (f) => f !== ".sgit.json" && !isPathCoveredByManifest(f, manifest.paths, manifest.ignore)
946
+ )
947
+ );
948
+ }
644
949
  } catch {
645
- return { valid: true, violations: [] };
646
950
  }
647
- if (!staged.trim()) return { valid: true, violations: [] };
648
- const stagedFiles = staged.trim().split("\n");
649
- const violations = stagedFiles.filter(
650
- (f) => f !== ".sgit.json" && !isPathCoveredByManifest(f, manifest.paths)
651
- );
652
951
  return { valid: violations.length === 0, violations };
653
952
  }
654
953
  async function trackVerify(opts) {
@@ -687,71 +986,359 @@ async function trackVerify(opts) {
687
986
  }
688
987
  }
689
988
 
690
- // src/commands/track/destroy.ts
691
- import { join as join10 } from "path";
692
- import { readFile as readFile5, writeFile as writeFile5, rm as rm2 } from "fs/promises";
989
+ // src/commands/track/clone.ts
990
+ import { join as join11, extname } from "path";
991
+ import { access as access6, readFile as readFile5, writeFile as writeFile5, rename as rename2, mkdir as mkdir4, rm as rm2 } from "fs/promises";
992
+ import { constants as constants6 } from "fs";
693
993
  import { createInterface } from "readline";
994
+ import { randomBytes } from "crypto";
995
+ async function trackClone(url, opts = {}) {
996
+ const root = await findRoot();
997
+ await validateNotWorktree(root);
998
+ await validateNotSubmodule(root);
999
+ await validateGitIdentity(root);
1000
+ const sgitDir = join11(root, ".sgit");
1001
+ try {
1002
+ await access6(sgitDir, constants6.F_OK);
1003
+ throw new AlreadyInitializedError();
1004
+ } catch (err) {
1005
+ if (err instanceof AlreadyInitializedError) throw err;
1006
+ }
1007
+ try {
1008
+ await execGit(["clone", "--bare", url, sgitDir]);
1009
+ } catch {
1010
+ try {
1011
+ await rm2(sgitDir, { recursive: true, force: true });
1012
+ } catch {
1013
+ }
1014
+ throw new SgitError(`failed to clone from ${url}`);
1015
+ }
1016
+ await execGit(["config", "core.bare", "false"], { gitDir: sgitDir });
1017
+ await execGit(["config", "core.worktree", root], { gitDir: sgitDir });
1018
+ try {
1019
+ const name = await execGit(["config", "user.name"], { cwd: root });
1020
+ if (name) {
1021
+ await execGit(["config", "user.name", name], { gitDir: sgitDir });
1022
+ }
1023
+ } catch {
1024
+ }
1025
+ try {
1026
+ const email = await execGit(["config", "user.email"], { cwd: root });
1027
+ if (email) {
1028
+ await execGit(["config", "user.email", email], { gitDir: sgitDir });
1029
+ }
1030
+ } catch {
1031
+ }
1032
+ let manifest;
1033
+ try {
1034
+ const manifestJson = await execGit(["show", "HEAD:.sgit.json"], { gitDir: sgitDir });
1035
+ manifest = JSON.parse(manifestJson);
1036
+ } catch {
1037
+ manifest = { paths: [], ignore: [] };
1038
+ }
1039
+ let trackedFiles = [];
1040
+ try {
1041
+ const output = await execGit(["ls-tree", "-r", "--name-only", "HEAD"], { gitDir: sgitDir });
1042
+ if (output.trim()) {
1043
+ trackedFiles = output.trim().split("\n");
1044
+ }
1045
+ } catch {
1046
+ }
1047
+ const conflicts = [];
1048
+ for (const file of trackedFiles) {
1049
+ try {
1050
+ await access6(join11(root, file), constants6.F_OK);
1051
+ conflicts.push(file);
1052
+ } catch {
1053
+ }
1054
+ }
1055
+ let choice = 1;
1056
+ if (conflicts.length > 0) {
1057
+ if (opts.onConflict) {
1058
+ choice = opts.onConflict;
1059
+ } else {
1060
+ warn(`${conflicts.length} file(s) already exist on disk:`);
1061
+ for (const f of conflicts) {
1062
+ info(` ${f}`);
1063
+ }
1064
+ choice = await promptConflictResolution();
1065
+ }
1066
+ if (choice === 2) {
1067
+ const savedContent = /* @__PURE__ */ new Map();
1068
+ for (const file of conflicts) {
1069
+ savedContent.set(file, await readFile5(join11(root, file)));
1070
+ }
1071
+ if (trackedFiles.length > 0) {
1072
+ await execGit(["checkout", "HEAD", "--", "."], { gitDir: sgitDir, workTree: root });
1073
+ }
1074
+ for (const [file, content] of savedContent) {
1075
+ await mkdir4(join11(root, file, ".."), { recursive: true });
1076
+ await writeFile5(join11(root, file), content);
1077
+ }
1078
+ } else if (choice === 3) {
1079
+ for (const file of conflicts) {
1080
+ const renamed = generateOriginalName(file);
1081
+ await rename2(join11(root, file), join11(root, renamed));
1082
+ }
1083
+ if (trackedFiles.length > 0) {
1084
+ await execGit(["checkout", "HEAD", "--", "."], { gitDir: sgitDir, workTree: root });
1085
+ }
1086
+ } else {
1087
+ if (trackedFiles.length > 0) {
1088
+ await execGit(["checkout", "HEAD", "--", "."], { gitDir: sgitDir, workTree: root });
1089
+ }
1090
+ }
1091
+ } else {
1092
+ if (trackedFiles.length > 0) {
1093
+ await execGit(["checkout", "HEAD", "--", "."], { gitDir: sgitDir, workTree: root });
1094
+ }
1095
+ }
1096
+ await syncExcludes(root);
1097
+ await installMainHook(root);
1098
+ await installShadowHook(sgitDir);
1099
+ await installShadowSyncHooks(sgitDir);
1100
+ const fileCount = trackedFiles.filter((f) => f !== ".sgit.json").length;
1101
+ success(`Shadow repository cloned from ${url}`);
1102
+ info(` ${fileCount} file(s) checked out`);
1103
+ if (conflicts.length > 0) {
1104
+ switch (choice) {
1105
+ case 1:
1106
+ info(` ${conflicts.length} conflicting file(s) overwritten`);
1107
+ break;
1108
+ case 2:
1109
+ info(` ${conflicts.length} local file(s) kept (shadow repo shows them as modified)`);
1110
+ break;
1111
+ case 3:
1112
+ info(` ${conflicts.length} local file(s) renamed with -original~ suffix`);
1113
+ break;
1114
+ }
1115
+ }
1116
+ }
1117
+ async function promptConflictResolution() {
1118
+ const rl = createInterface({
1119
+ input: process.stdin,
1120
+ output: process.stdout
1121
+ });
1122
+ return new Promise((resolve5) => {
1123
+ process.stdout.write("\nHow should conflicts be resolved?\n");
1124
+ process.stdout.write(" 1 - Keep sgit (overwrite local files)\n");
1125
+ process.stdout.write(" 2 - Keep originals (local files stay, sgit shows them as modified)\n");
1126
+ process.stdout.write(" 3 - Rename originals (add -original~ suffix, checkout sgit versions)\n");
1127
+ rl.question("\nChoice [1/2/3]: ", (answer) => {
1128
+ rl.close();
1129
+ const num = parseInt(answer.trim(), 10);
1130
+ if (num === 1 || num === 2 || num === 3) {
1131
+ resolve5(num);
1132
+ } else {
1133
+ resolve5(2);
1134
+ }
1135
+ });
1136
+ });
1137
+ }
1138
+ function generateOriginalName(filePath) {
1139
+ const suffix = randomBytes(2).toString("hex");
1140
+ const ext = extname(filePath);
1141
+ if (ext) {
1142
+ const base = filePath.slice(0, filePath.length - ext.length);
1143
+ return `${base}-original~${suffix}${ext}`;
1144
+ }
1145
+ return `${filePath}-original~${suffix}`;
1146
+ }
1147
+
1148
+ // src/commands/track/destroy.ts
1149
+ import { join as join12, dirname as dirname2 } from "path";
1150
+ import { readFile as readFile6, writeFile as writeFile6, rm as rm3, rmdir, unlink as unlink2 } from "fs/promises";
1151
+ import { createInterface as createInterface2 } from "readline";
694
1152
  async function trackDestroy(opts) {
695
1153
  const root = await findRoot();
696
1154
  await validateShadowRepo(root);
1155
+ const sgitDir = join12(root, ".sgit");
1156
+ let trackedFiles = [];
1157
+ try {
1158
+ const output = await execGit(["ls-tree", "-r", "--name-only", "HEAD"], {
1159
+ gitDir: sgitDir,
1160
+ workTree: root
1161
+ });
1162
+ if (output.trim()) {
1163
+ trackedFiles = output.trim().split("\n").filter((f) => f !== ".sgit.json");
1164
+ }
1165
+ } catch {
1166
+ }
1167
+ let uncommittedStatus = "";
1168
+ try {
1169
+ uncommittedStatus = await execGit(["status", "--porcelain"], {
1170
+ gitDir: sgitDir,
1171
+ workTree: root
1172
+ });
1173
+ } catch {
1174
+ }
1175
+ const hasUncommitted = uncommittedStatus.trim().length > 0;
697
1176
  if (!opts.force) {
698
- const confirmed = await confirm(
699
- "This will permanently remove the shadow repository. Continue?"
700
- );
1177
+ if (hasUncommitted) {
1178
+ warn("there are uncommitted changes in the shadow repository:");
1179
+ for (const line of uncommittedStatus.trim().split("\n")) {
1180
+ info(` ${line}`);
1181
+ }
1182
+ process.stdout.write("\n");
1183
+ }
1184
+ let message;
1185
+ if (opts.keepFiles) {
1186
+ message = hasUncommitted ? "This will remove the shadow repository. Tracked files remain on disk but shadow history will be lost. Continue?" : "This will remove the shadow repository. Tracked files will remain on disk. Continue?";
1187
+ } else {
1188
+ message = hasUncommitted ? `This will permanently remove the shadow repository and delete ${trackedFiles.length} tracked file(s). Uncommitted changes will be lost. Continue?` : `This will permanently remove the shadow repository and delete ${trackedFiles.length} tracked file(s). Continue?`;
1189
+ }
1190
+ const confirmed = await confirm(message);
701
1191
  if (!confirmed) {
702
1192
  info("Aborted.");
703
1193
  return;
704
1194
  }
705
1195
  }
706
- const excludePath = join10(root, ".git", "info", "exclude");
1196
+ const excludePath = join12(root, ".git", "info", "exclude");
707
1197
  try {
708
- const content = await readFile5(excludePath, "utf-8");
1198
+ const content = await readFile6(excludePath, "utf-8");
709
1199
  const lines = content.split("\n");
710
1200
  const result = [];
1201
+ let insideBlock = false;
711
1202
  let skipNext = false;
712
1203
  for (const line of lines) {
713
- if (line === SGIT_MANAGED_HEADER) {
714
- skipNext = false;
1204
+ if (line === SGIT_BLOCK_START || line === "# sgit-managed (do not edit below this line)") {
1205
+ insideBlock = true;
715
1206
  continue;
716
1207
  }
717
- if (line === SGIT_MARKER) {
718
- skipNext = true;
1208
+ if (line === SGIT_BLOCK_END) {
1209
+ insideBlock = false;
719
1210
  continue;
720
1211
  }
721
- if (line.endsWith(` ${SGIT_MARKER}`)) {
1212
+ if (insideBlock) continue;
1213
+ if (line === "# sgit") {
1214
+ skipNext = true;
722
1215
  continue;
723
1216
  }
724
1217
  if (skipNext) {
725
1218
  skipNext = false;
726
1219
  continue;
727
1220
  }
1221
+ if (line.endsWith(" # sgit")) continue;
728
1222
  result.push(line);
729
1223
  }
730
1224
  const cleaned = result.join("\n");
731
- await writeFile5(excludePath, cleaned.replace(/\n+$/, "\n"));
1225
+ await writeFile6(excludePath, cleaned.replace(/\n+$/, "\n"));
732
1226
  } catch {
733
1227
  }
734
1228
  await uninstallMainHook(root);
735
- await rm2(join10(root, ".sgit"), { recursive: true, force: true });
736
- await rm2(join10(root, ".sgit.json"), { force: true });
737
- success("Shadow repository removed.");
1229
+ if (!opts.keepFiles && trackedFiles.length > 0) {
1230
+ for (const file of trackedFiles) {
1231
+ try {
1232
+ await unlink2(join12(root, file));
1233
+ } catch {
1234
+ }
1235
+ }
1236
+ const dirs = /* @__PURE__ */ new Set();
1237
+ for (const file of trackedFiles) {
1238
+ let dir = dirname2(file);
1239
+ while (dir !== ".") {
1240
+ dirs.add(dir);
1241
+ dir = dirname2(dir);
1242
+ }
1243
+ }
1244
+ const sortedDirs = [...dirs].sort((a, b) => b.length - a.length);
1245
+ for (const dir of sortedDirs) {
1246
+ try {
1247
+ await rmdir(join12(root, dir));
1248
+ } catch {
1249
+ }
1250
+ }
1251
+ }
1252
+ await rm3(join12(root, ".sgit"), { recursive: true, force: true });
1253
+ await rm3(join12(root, ".sgit.json"), { force: true });
1254
+ if (opts.keepFiles) {
1255
+ success("Shadow repository removed. Tracked files kept on disk.");
1256
+ } else {
1257
+ success("Shadow repository and tracked files removed.");
1258
+ }
738
1259
  }
739
1260
  async function confirm(message) {
740
- const rl = createInterface({
1261
+ const rl = createInterface2({
741
1262
  input: process.stdin,
742
1263
  output: process.stdout
743
1264
  });
744
- return new Promise((resolve4) => {
1265
+ return new Promise((resolve5) => {
745
1266
  rl.question(`${message} [y/N] `, (answer) => {
746
1267
  rl.close();
747
- resolve4(answer.toLowerCase() === "y");
1268
+ resolve5(answer.toLowerCase() === "y");
748
1269
  });
749
1270
  });
750
1271
  }
751
1272
 
1273
+ // src/commands/track/ignore.ts
1274
+ import { join as join13 } from "path";
1275
+ async function trackIgnore(paths) {
1276
+ const root = await findRoot();
1277
+ await validateShadowRepo(root);
1278
+ const sgitDir = join13(root, ".sgit");
1279
+ const added = [];
1280
+ for (const inputPath of paths) {
1281
+ const normalized = await normalizePath(inputPath, root);
1282
+ const manifest = await readManifest(root);
1283
+ if (!isPathCoveredByManifest(normalized, manifest.paths)) {
1284
+ throw new PathNotTrackedError(normalized);
1285
+ }
1286
+ const wasAdded = await addIgnore(root, normalized);
1287
+ if (wasAdded) {
1288
+ added.push(normalized);
1289
+ const rmPath = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
1290
+ try {
1291
+ const rmArgs = normalized.endsWith("/") ? ["rm", "--cached", "-r", rmPath] : ["rm", "--cached", rmPath];
1292
+ await execGit(rmArgs, { gitDir: sgitDir, workTree: root });
1293
+ } catch {
1294
+ }
1295
+ }
1296
+ }
1297
+ await syncExcludes(root);
1298
+ if (added.length > 0) {
1299
+ success(`Ignoring ${added.length} path(s):`);
1300
+ for (const p of added) {
1301
+ info(` ${p}`);
1302
+ }
1303
+ } else {
1304
+ info("All paths already ignored.");
1305
+ }
1306
+ }
1307
+
1308
+ // src/commands/track/unignore.ts
1309
+ import { resolve as resolve4, relative as relative4, sep as sep4 } from "path";
1310
+ async function trackUnignore(paths) {
1311
+ const root = await findRoot();
1312
+ await validateShadowRepo(root);
1313
+ const removed = [];
1314
+ for (const inputPath of paths) {
1315
+ const absolute = resolve4(process.cwd(), inputPath);
1316
+ let normalized = relative4(root, absolute).split(sep4).join("/");
1317
+ if (inputPath.endsWith("/") && !normalized.endsWith("/")) {
1318
+ normalized += "/";
1319
+ }
1320
+ const manifest = await readManifest(root);
1321
+ const exactPath = manifest.ignore.find((p) => p === normalized) || manifest.ignore.find((p) => p === normalized + "/") || (normalized.endsWith("/") ? manifest.ignore.find((p) => p === normalized.slice(0, -1)) : void 0);
1322
+ if (!exactPath) {
1323
+ throw new SgitError(`${normalized} is not in the ignore list`);
1324
+ }
1325
+ const wasRemoved = await removeIgnore(root, exactPath);
1326
+ if (wasRemoved) {
1327
+ removed.push(exactPath);
1328
+ }
1329
+ }
1330
+ await syncExcludes(root);
1331
+ if (removed.length > 0) {
1332
+ success(`Unignored ${removed.length} path(s):`);
1333
+ for (const p of removed) {
1334
+ info(` ${p}`);
1335
+ }
1336
+ }
1337
+ }
1338
+
752
1339
  // src/commands/track/completion.ts
753
- import { join as join11, dirname as dirname2 } from "path";
754
- import { mkdir as mkdir4, writeFile as writeFile6 } from "fs/promises";
1340
+ import { join as join14, dirname as dirname3 } from "path";
1341
+ import { mkdir as mkdir5, writeFile as writeFile7 } from "fs/promises";
755
1342
  import { homedir } from "os";
756
1343
  function bashCompletion() {
757
1344
  return `# sgit bash completion
@@ -761,7 +1348,7 @@ _sgit() {
761
1348
 
762
1349
  if [[ \${words[1]} == "track" ]]; then
763
1350
  if [[ $cword -eq 2 ]]; then
764
- COMPREPLY=($(compgen -W "init add remove list sync verify destroy completion" -- "$cur"))
1351
+ COMPREPLY=($(compgen -W "init clone add remove list sync verify ignore unignore destroy completion" -- "$cur"))
765
1352
  return
766
1353
  fi
767
1354
 
@@ -774,24 +1361,34 @@ _sgit() {
774
1361
  _filedir
775
1362
  fi
776
1363
  ;;
1364
+ clone)
1365
+ ;;
777
1366
  remove)
778
1367
  local paths
779
1368
  paths=$(sgit track list 2>/dev/null)
780
1369
  COMPREPLY=($(compgen -W "$paths" -- "$cur"))
781
1370
  ;;
1371
+ ignore)
1372
+ _filedir
1373
+ ;;
1374
+ unignore)
1375
+ local ignored
1376
+ ignored=$(python3 -c "import json; d=json.load(open('.sgit.json')); print(' '.join(d.get('ignore',[])))" 2>/dev/null)
1377
+ COMPREPLY=($(compgen -W "$ignored" -- "$cur"))
1378
+ ;;
782
1379
  verify)
783
1380
  COMPREPLY=($(compgen -W "--main --shadow" -- "$cur"))
784
1381
  ;;
785
1382
  destroy)
786
- COMPREPLY=($(compgen -W "--force" -- "$cur"))
1383
+ COMPREPLY=($(compgen -W "--force --keep-files" -- "$cur"))
787
1384
  ;;
788
1385
  completion)
789
1386
  if [[ $prev == "--shell" ]]; then
790
1387
  COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur"))
791
- elif [[ $cur == -* ]]; then
792
- COMPREPLY=($(compgen -W "--shell" -- "$cur"))
1388
+ elif [[ $cword -eq 3 ]]; then
1389
+ COMPREPLY=($(compgen -W "install --shell" -- "$cur"))
793
1390
  else
794
- COMPREPLY=($(compgen -W "install" -- "$cur"))
1391
+ COMPREPLY=($(compgen -W "--shell" -- "$cur"))
795
1392
  fi
796
1393
  ;;
797
1394
  esac
@@ -824,11 +1421,14 @@ _sgit_track() {
824
1421
  local -a subcmds
825
1422
  subcmds=(
826
1423
  'init:Initialize shadow repository'
1424
+ 'clone:Clone shadow repository from remote URL'
827
1425
  'add:Start shadow-tracking paths'
828
1426
  'remove:Stop shadow-tracking paths'
829
1427
  'list:List all shadow-tracked paths'
830
1428
  'sync:Regenerate exclude files'
831
1429
  'verify:Validate staged files'
1430
+ 'ignore:Ignore paths within tracked directories'
1431
+ 'unignore:Stop ignoring paths within tracked directories'
832
1432
  'destroy:Remove shadow repository'
833
1433
  'completion:Install shell completions'
834
1434
  )
@@ -839,6 +1439,9 @@ _sgit_track() {
839
1439
  fi
840
1440
 
841
1441
  case "\${words[2]}" in
1442
+ clone)
1443
+ _arguments '1:url:'
1444
+ ;;
842
1445
  add)
843
1446
  _arguments '--adopt[Move main-git-tracked files to shadow tracking]' '*:file:_files'
844
1447
  ;;
@@ -847,11 +1450,19 @@ _sgit_track() {
847
1450
  paths=(\${(f)"$(sgit track list 2>/dev/null)"})
848
1451
  _describe 'tracked path' paths
849
1452
  ;;
1453
+ ignore)
1454
+ _files
1455
+ ;;
1456
+ unignore)
1457
+ local -a ignored
1458
+ ignored=(\${(f)"$(python3 -c "import json; d=json.load(open('.sgit.json')); print('\\\\n'.join(d.get('ignore',[])))" 2>/dev/null)"})
1459
+ _describe 'ignored path' ignored
1460
+ ;;
850
1461
  verify)
851
1462
  _arguments '--main[Check main repo]' '--shadow[Check shadow repo]'
852
1463
  ;;
853
1464
  destroy)
854
- _arguments '--force[Skip confirmation]'
1465
+ _arguments '--force[Skip confirmation]' '--keep-files[Keep tracked files on disk]'
855
1466
  ;;
856
1467
  completion)
857
1468
  _arguments '--shell[Shell type]:shell:(bash zsh fish)' '1:action:(install)'
@@ -896,14 +1507,18 @@ complete -c sgit -w git
896
1507
  complete -c sgit -n '__fish_use_subcommand' -a track -d 'Manage shadow-tracked paths'
897
1508
 
898
1509
  # Track subcommands
899
- 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'
900
- 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'
901
- 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'
902
- 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'
903
- 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'
904
- 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'
905
- 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'
906
- 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'
1510
+ set -l __sgit_track_cmds init clone add remove list sync verify ignore unignore destroy completion
1511
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from $__sgit_track_cmds" -a init -d 'Initialize shadow repository'
1512
+ 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'
1513
+ 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'
1514
+ 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'
1515
+ 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'
1516
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from $__sgit_track_cmds" -a sync -d 'Regenerate exclude files'
1517
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from $__sgit_track_cmds" -a verify -d 'Validate staged files'
1518
+ 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'
1519
+ 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'
1520
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from $__sgit_track_cmds" -a destroy -d 'Remove shadow repository'
1521
+ complete -c sgit -n "__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from $__sgit_track_cmds" -a completion -d 'Install shell completions'
907
1522
 
908
1523
  # Track add options and file completions
909
1524
  complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from add' -l adopt -d 'Move main-git-tracked files'
@@ -912,15 +1527,22 @@ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcomma
912
1527
  # Track remove \u2014 complete from manifest
913
1528
  complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from remove' -a '(sgit track list 2>/dev/null)'
914
1529
 
1530
+ # Track ignore \u2014 file completions
1531
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from ignore' -F
1532
+
1533
+ # Track unignore \u2014 complete from .sgit.json ignore list
1534
+ 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)"
1535
+
915
1536
  # Track verify options
916
1537
  complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from verify' -l main -d 'Check main repo'
917
1538
  complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from verify' -l shadow -d 'Check shadow repo'
918
1539
 
919
1540
  # Track destroy options
920
1541
  complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from destroy' -l force -d 'Skip confirmation'
1542
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from destroy' -l keep-files -d 'Keep tracked files on disk'
921
1543
 
922
1544
  # Track completion options
923
- complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from completion' -a install -d 'Install completions'
1545
+ 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'
924
1546
  complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from completion' -l shell -d 'Shell type' -ra 'bash zsh fish'
925
1547
  `;
926
1548
  }
@@ -944,11 +1566,11 @@ function getInstallPath(shell) {
944
1566
  const home = homedir();
945
1567
  switch (shell) {
946
1568
  case "zsh":
947
- return join11(home, ".zsh", "completions", "_sgit");
1569
+ return join14(home, ".zsh", "completions", "_sgit");
948
1570
  case "fish":
949
- return join11(home, ".config", "fish", "completions", "sgit.fish");
1571
+ return join14(home, ".config", "fish", "completions", "sgit.fish");
950
1572
  default:
951
- return join11(home, ".local", "share", "bash-completion", "completions", "sgit");
1573
+ return join14(home, ".local", "share", "bash-completion", "completions", "sgit");
952
1574
  }
953
1575
  }
954
1576
  async function trackCompletion(action, opts) {
@@ -960,8 +1582,8 @@ async function trackCompletion(action, opts) {
960
1582
  }
961
1583
  if (action === "install") {
962
1584
  const installPath = getInstallPath(shell);
963
- await mkdir4(dirname2(installPath), { recursive: true });
964
- await writeFile6(installPath, script, { mode: 420 });
1585
+ await mkdir5(dirname3(installPath), { recursive: true });
1586
+ await writeFile7(installPath, script, { mode: 420 });
965
1587
  success(`Installed ${shell} completions to ${installPath}`);
966
1588
  if (shell === "zsh") {
967
1589
  info(
@@ -978,6 +1600,7 @@ async function trackCompletion(action, opts) {
978
1600
  function registerTrackCommands(program) {
979
1601
  const track = program.command("track").description("Manage shadow-tracked paths");
980
1602
  track.command("init").description("Initialize shadow repository").action(wrapAction(trackInit));
1603
+ track.command("clone").description("Clone a shadow repository from a remote URL").argument("<url>").action(wrapAction(async (url) => trackClone(url)));
981
1604
  track.command("add").description("Start shadow-tracking paths").argument("<paths...>").option("--adopt", "Move main-git-tracked files to shadow tracking").action(wrapAction(async (paths, opts) => trackAdd(paths, opts)));
982
1605
  track.command("remove").description("Stop shadow-tracking paths").argument("<paths...>").action(wrapAction(async (paths) => trackRemove(paths)));
983
1606
  track.command("list").description("List all shadow-tracked paths").action(wrapAction(async () => {
@@ -989,7 +1612,9 @@ function registerTrackCommands(program) {
989
1612
  async (opts) => trackVerify(opts)
990
1613
  )
991
1614
  );
992
- track.command("destroy").description("Remove shadow repository entirely").option("--force", "Skip confirmation prompt").action(
1615
+ track.command("ignore").description("Ignore paths within tracked directories").argument("<paths...>").action(wrapAction(async (paths) => trackIgnore(paths)));
1616
+ track.command("unignore").description("Stop ignoring paths within tracked directories").argument("<paths...>").action(wrapAction(async (paths) => trackUnignore(paths)));
1617
+ track.command("destroy").description("Remove shadow repository entirely").option("--force", "Skip confirmation prompt").option("--keep-files", "Keep tracked files on disk (default: delete them)").action(
993
1618
  wrapAction(async (opts) => trackDestroy(opts))
994
1619
  );
995
1620
  track.command("completion").description("Install shell completion scripts").argument("[action]", "Action (install)").option("--shell <name>", "Shell (bash, zsh, fish)").action(
@@ -1012,20 +1637,81 @@ function wrapAction(fn) {
1012
1637
  };
1013
1638
  }
1014
1639
 
1640
+ // src/commands/debug/index.ts
1641
+ import { Command } from "commander";
1642
+
1643
+ // src/commands/debug/gitignores.ts
1644
+ async function debugGitignores() {
1645
+ const root = await findRoot();
1646
+ await validateShadowRepo(root);
1647
+ const manifest = await readManifest(root);
1648
+ const cleanup = setupGitignoreGuard(root, manifest.paths);
1649
+ try {
1650
+ info("gitignores disabled \u2014 waiting 10 seconds\u2026");
1651
+ for (let i = 10; i > 0; i--) {
1652
+ process.stdout.write(`\r ${i}s remaining `);
1653
+ await sleep(1e3);
1654
+ }
1655
+ process.stdout.write("\r \r");
1656
+ success("re-enabling gitignores");
1657
+ } finally {
1658
+ cleanup();
1659
+ }
1660
+ }
1661
+ function sleep(ms) {
1662
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
1663
+ }
1664
+
1665
+ // src/commands/debug/index.ts
1666
+ function registerDebugCommands(program) {
1667
+ const debug = new Command("debug").description("Debug utilities");
1668
+ debug.command("gitignores").description("Disable .gitignore files for 10 seconds, then re-enable").action(wrapAction2(debugGitignores));
1669
+ program.addCommand(debug, { hidden: true });
1670
+ }
1671
+ function wrapAction2(fn) {
1672
+ return async (...args) => {
1673
+ try {
1674
+ await fn(...args);
1675
+ } catch (err) {
1676
+ if (err instanceof SgitError) {
1677
+ error(err.message);
1678
+ process.exit(1);
1679
+ }
1680
+ throw err;
1681
+ }
1682
+ };
1683
+ }
1684
+
1015
1685
  // src/cli.ts
1686
+ var COMMANDER_COMMANDS = /* @__PURE__ */ new Set(["track", "debug"]);
1016
1687
  async function run() {
1017
1688
  const args = process.argv.slice(2);
1018
- if (args.length === 0 || args[0] === "track" || args[0].startsWith("-")) {
1019
- const program = new Command("sgit").description("Shadow Git \u2014 a secondary git repository for your project").version("0.1.0");
1020
- registerTrackCommands(program);
1021
- program.addHelpText(
1022
- "after",
1023
- `
1689
+ const skipGuard = args[0] === "track" && (args[1] === "init" || args[1] === "clone") || args[0] === "debug";
1690
+ let guardCleanup;
1691
+ if (!skipGuard) {
1692
+ try {
1693
+ const root = await findRoot();
1694
+ const manifest = await readManifest(root);
1695
+ guardCleanup = setupGitignoreGuard(root, manifest.paths);
1696
+ } catch {
1697
+ }
1698
+ }
1699
+ try {
1700
+ if (args.length === 0 || COMMANDER_COMMANDS.has(args[0]) || args[0].startsWith("-")) {
1701
+ const program = new Command2("sgit").description("Shadow Git \u2014 a secondary git repository for your project").version("0.1.0");
1702
+ registerTrackCommands(program);
1703
+ registerDebugCommands(program);
1704
+ program.addHelpText(
1705
+ "after",
1706
+ `
1024
1707
  Track commands:
1025
1708
  sgit track init Initialize shadow repository
1709
+ sgit track clone Clone shadow repository from remote URL
1026
1710
  sgit track add Start shadow-tracking paths (--adopt to move from main git)
1027
1711
  sgit track remove Stop shadow-tracking paths
1028
1712
  sgit track list List all shadow-tracked paths
1713
+ sgit track ignore Ignore paths within tracked directories
1714
+ sgit track unignore Stop ignoring paths
1029
1715
  sgit track sync Regenerate exclude files
1030
1716
  sgit track verify Validate staged files
1031
1717
  sgit track destroy Remove shadow repository
@@ -1033,13 +1719,18 @@ Track commands:
1033
1719
 
1034
1720
  All other commands are forwarded to git in the shadow repository:
1035
1721
  sgit status, sgit commit, sgit log, sgit diff, ...`
1036
- );
1037
- await program.parseAsync(process.argv);
1038
- } else {
1039
- const root = await findRoot();
1040
- await validateShadowRepo(root);
1041
- const exitCode = forwardToGit(root, args);
1042
- process.exit(exitCode);
1722
+ );
1723
+ await program.parseAsync(process.argv);
1724
+ } else {
1725
+ const root = await findRoot();
1726
+ await validateShadowRepo(root);
1727
+ const exitCode = forwardToGit(root, args);
1728
+ guardCleanup?.();
1729
+ guardCleanup = void 0;
1730
+ process.exit(exitCode);
1731
+ }
1732
+ } finally {
1733
+ guardCleanup?.();
1043
1734
  }
1044
1735
  }
1045
1736
  run().catch((err) => {