shadow-git 1.0.3 → 1.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/dist/cli.js CHANGED
@@ -70,6 +70,44 @@ var PathNotTrackedError = class extends SgitError {
70
70
  super(`${path} is not tracked by sgit`);
71
71
  }
72
72
  };
73
+ var StaleGitignoreError = class extends SgitError {
74
+ constructor(files) {
75
+ super(
76
+ [
77
+ "found stale .gitignore.sgit-parked files (a previous sgit command did not complete cleanly):",
78
+ ...files.map((f) => ` ${f}`),
79
+ "",
80
+ "rename them back to .gitignore manually, then retry."
81
+ ].join("\n")
82
+ );
83
+ }
84
+ };
85
+ var AlreadySwappedError = class extends SgitError {
86
+ constructor() {
87
+ super("shadow-git is already in swap mode; run 'sgit track unswap' to exit.");
88
+ }
89
+ };
90
+ var NotSwappedError = class extends SgitError {
91
+ constructor() {
92
+ super("shadow-git is not in swap mode.");
93
+ }
94
+ };
95
+ var SwapModeActiveError = class extends SgitError {
96
+ constructor(op) {
97
+ super(`cannot ${op} while in swap mode; run 'sgit track unswap' first.`);
98
+ }
99
+ };
100
+ var SwapModeCorruptError = class extends SgitError {
101
+ constructor(details) {
102
+ super(`shadow-git state is inconsistent:
103
+ ${details}`);
104
+ }
105
+ };
106
+ var InProgressGitOpError = class extends SgitError {
107
+ constructor(gitDir, marker) {
108
+ super(`cannot proceed: ${gitDir} has an in-progress operation (${marker}). Finish or abort it first.`);
109
+ }
110
+ };
73
111
 
74
112
  // src/core/repository.ts
75
113
  async function findRoot() {
@@ -96,6 +134,12 @@ async function validateGitIdentity(root) {
96
134
  async function validateShadowRepo(root) {
97
135
  try {
98
136
  await access(join(root, ".sgit"), constants.F_OK);
137
+ return;
138
+ } catch {
139
+ }
140
+ try {
141
+ await access(join(root, ".git.sgit-parked"), constants.F_OK);
142
+ return;
99
143
  } catch {
100
144
  throw new NotInitializedError();
101
145
  }
@@ -119,9 +163,74 @@ async function validateNotSubmodule(root) {
119
163
 
120
164
  // src/commands/forward.ts
121
165
  import { spawnSync } from "child_process";
166
+
167
+ // src/core/swap-mode.ts
168
+ import { access as access2 } from "fs/promises";
169
+ import { constants as constants2, existsSync } from "fs";
122
170
  import { join as join2 } from "path";
123
- function forwardToGit(root, args) {
124
- const sgitDir = join2(root, ".sgit");
171
+ var SWAP_MARKER = ".git.sgit-parked";
172
+ async function pathExists(path) {
173
+ try {
174
+ await access2(path, constants2.F_OK);
175
+ return true;
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+ async function isSwapMode(root) {
181
+ return pathExists(join2(root, SWAP_MARKER));
182
+ }
183
+ function isSwapModeSync(root) {
184
+ return existsSync(join2(root, SWAP_MARKER));
185
+ }
186
+ async function resolveShadowDir(root) {
187
+ return await isSwapMode(root) ? join2(root, ".git") : join2(root, ".sgit");
188
+ }
189
+ async function resolveMainDir(root) {
190
+ return await isSwapMode(root) ? join2(root, SWAP_MARKER) : join2(root, ".git");
191
+ }
192
+ var IN_PROGRESS_MARKERS = [
193
+ "index.lock",
194
+ "MERGE_HEAD",
195
+ "CHERRY_PICK_HEAD",
196
+ "REVERT_HEAD",
197
+ "rebase-apply",
198
+ "rebase-merge",
199
+ "BISECT_LOG"
200
+ ];
201
+ async function assertNoInProgressOps(gitDir) {
202
+ for (const marker of IN_PROGRESS_MARKERS) {
203
+ if (await pathExists(join2(gitDir, marker))) {
204
+ throw new InProgressGitOpError(gitDir, marker);
205
+ }
206
+ }
207
+ }
208
+ async function assertConsistentState(root) {
209
+ const hasDisabled = await pathExists(join2(root, SWAP_MARKER));
210
+ const hasGit = await pathExists(join2(root, ".git"));
211
+ const hasSgit = await pathExists(join2(root, ".sgit"));
212
+ if (hasDisabled && !hasGit) {
213
+ throw new SwapModeCorruptError(
214
+ [
215
+ `found ${SWAP_MARKER}/ but no .git/ \u2014 a swap-mode transition was interrupted.`,
216
+ hasSgit ? `to recover: rename ${SWAP_MARKER} back to .git (.sgit is still intact).` : `to recover: rename ${SWAP_MARKER} back to .git, or rename whichever of .sgit/${SWAP_MARKER} you want active to .git.`
217
+ ].join("\n")
218
+ );
219
+ }
220
+ if (hasDisabled && hasSgit) {
221
+ throw new SwapModeCorruptError(
222
+ [
223
+ `found both ${SWAP_MARKER}/ and .sgit/ \u2014 unexpected state.`,
224
+ "normally only one exists at a time.",
225
+ `investigate and remove the stale one before proceeding.`
226
+ ].join("\n")
227
+ );
228
+ }
229
+ }
230
+
231
+ // src/commands/forward.ts
232
+ async function forwardToGit(root, args) {
233
+ const sgitDir = await resolveShadowDir(root);
125
234
  const result = spawnSync(
126
235
  "git",
127
236
  [`--git-dir=${sgitDir}`, `--work-tree=${root}`, ...args],
@@ -152,11 +261,23 @@ function error(msg) {
152
261
  process.stderr.write(`${RED}${msg}${RESET}
153
262
  `);
154
263
  }
264
+ function swapModeBanner() {
265
+ const msg = [
266
+ "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
267
+ "shadow-git is in SWAP MODE",
268
+ "main git is parked at .git.sgit-parked; .sgit is active at .git",
269
+ "this mode is for IDE/tool compatibility \u2014 read-only recommended",
270
+ "run 'sgit track unswap' before making main-repo changes",
271
+ "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
272
+ ].join("\n");
273
+ process.stderr.write(`${YELLOW}${msg}${RESET}
274
+ `);
275
+ }
155
276
 
156
277
  // src/commands/track/init.ts
157
278
  import { join as join5 } from "path";
158
- import { mkdir as mkdir2, writeFile as writeFile3, readFile as readFile3, access as access3 } from "fs/promises";
159
- import { constants as constants3 } from "fs";
279
+ import { mkdir as mkdir2, writeFile as writeFile3, readFile as readFile3, access as access4 } from "fs/promises";
280
+ import { constants as constants4 } from "fs";
160
281
 
161
282
  // src/core/manifest.ts
162
283
  import { readFile, writeFile } from "fs/promises";
@@ -246,7 +367,7 @@ function generateMainExclude(existingContent, manifestPaths) {
246
367
  while (preserved.length > 0 && preserved[preserved.length - 1].trim() === "") {
247
368
  preserved.pop();
248
369
  }
249
- const sgitEntries = [".sgit", ".sgit.json", ".gitignore-sgit.bat", ...manifestPaths];
370
+ const sgitEntries = [".sgit", ".sgit.json", ".git.sgit-parked", ".gitignore.sgit-parked", ...manifestPaths];
250
371
  return [
251
372
  ...preserved,
252
373
  "",
@@ -291,7 +412,7 @@ function generateShadowExclude(manifestPaths, ignorePaths = []) {
291
412
  "!.sgit.json",
292
413
  ...entryLines,
293
414
  ...ignorePaths,
294
- ".gitignore-sgit.bat",
415
+ ".gitignore.sgit-parked",
295
416
  SGIT_BLOCK_END
296
417
  ].join("\n") + "\n";
297
418
  }
@@ -312,7 +433,7 @@ import {
312
433
  mkdir,
313
434
  writeFile as writeFile2,
314
435
  rename,
315
- access as access2,
436
+ access as access3,
316
437
  lstat as lstat2,
317
438
  unlink,
318
439
  copyFile,
@@ -320,7 +441,7 @@ import {
320
441
  rm,
321
442
  readdir
322
443
  } from "fs/promises";
323
- import { constants as constants2 } from "fs";
444
+ import { constants as constants3 } from "fs";
324
445
  var RUNNER_SCRIPT = `#!/bin/sh
325
446
  # sgit hook runner \u2014 executes all hooks in pre-commit.d/
326
447
  dir="$(dirname "$0")/pre-commit.d"
@@ -434,7 +555,7 @@ async function uninstallMainHook(root) {
434
555
  }
435
556
  async function exists(path) {
436
557
  try {
437
- await access2(path, constants2.F_OK);
558
+ await access3(path, constants3.F_OK);
438
559
  return true;
439
560
  } catch {
440
561
  return false;
@@ -444,12 +565,15 @@ async function exists(path) {
444
565
  // src/commands/track/init.ts
445
566
  async function trackInit() {
446
567
  const root = await findRoot();
568
+ if (await isSwapMode(root)) {
569
+ throw new SwapModeActiveError("init");
570
+ }
447
571
  await validateNotWorktree(root);
448
572
  await validateNotSubmodule(root);
449
573
  await validateGitIdentity(root);
450
574
  const sgitDir = join5(root, ".sgit");
451
575
  try {
452
- await access3(sgitDir, constants3.F_OK);
576
+ await access4(sgitDir, constants4.F_OK);
453
577
  throw new AlreadyInitializedError();
454
578
  } catch (err) {
455
579
  if (err instanceof AlreadyInitializedError) throw err;
@@ -501,28 +625,30 @@ async function trackInit() {
501
625
 
502
626
  // src/commands/track/sync.ts
503
627
  import { join as join6 } from "path";
504
- import { writeFile as writeFile4, readFile as readFile4, mkdir as mkdir3, access as access4 } from "fs/promises";
505
- import { constants as constants4 } from "fs";
628
+ import { writeFile as writeFile4, readFile as readFile4, mkdir as mkdir3, access as access5 } from "fs/promises";
629
+ import { constants as constants5 } from "fs";
506
630
  async function syncExcludes(root) {
507
631
  const manifest = await readManifest(root);
508
- const mainExcludePath = join6(root, ".git", "info", "exclude");
632
+ const mainDir = await resolveMainDir(root);
633
+ const shadowDir = await resolveShadowDir(root);
634
+ const mainExcludePath = join6(mainDir, "info", "exclude");
509
635
  let existingExclude = "";
510
636
  try {
511
637
  existingExclude = await readFile4(mainExcludePath, "utf-8");
512
638
  } catch {
513
639
  }
514
- await mkdir3(join6(root, ".git", "info"), { recursive: true });
640
+ await mkdir3(join6(mainDir, "info"), { recursive: true });
515
641
  await writeFile4(
516
642
  mainExcludePath,
517
643
  generateMainExclude(existingExclude, manifest.paths)
518
644
  );
519
- const sgitExcludePath = join6(root, ".sgit", "info", "exclude");
520
- await mkdir3(join6(root, ".sgit", "info"), { recursive: true });
645
+ const sgitExcludePath = join6(shadowDir, "info", "exclude");
646
+ await mkdir3(join6(shadowDir, "info"), { recursive: true });
521
647
  await writeFile4(sgitExcludePath, generateShadowExclude(manifest.paths, manifest.ignore));
522
648
  for (const p of manifest.paths) {
523
649
  const checkPath = p.endsWith("/") ? p.slice(0, -1) : p;
524
650
  try {
525
- await access4(join6(root, checkPath), constants4.F_OK);
651
+ await access5(join6(root, checkPath), constants5.F_OK);
526
652
  } catch {
527
653
  warn(`path does not exist: ${p}`);
528
654
  }
@@ -537,8 +663,8 @@ async function trackSync() {
537
663
 
538
664
  // src/commands/track/add.ts
539
665
  import { join as join7 } from "path";
540
- import { access as access5 } from "fs/promises";
541
- import { constants as constants5 } from "fs";
666
+ import { access as access6 } from "fs/promises";
667
+ import { constants as constants6 } from "fs";
542
668
 
543
669
  // src/core/paths.ts
544
670
  import { resolve as resolve2, relative, sep } from "path";
@@ -590,11 +716,12 @@ function isPathCoveredByManifest(filePath, manifestPaths, ignorePaths = []) {
590
716
  }
591
717
 
592
718
  // src/commands/track/add.ts
593
- var BLOCKED_BASENAMES = /* @__PURE__ */ new Set([".gitignore", ".gitignore-sgit.bat"]);
719
+ var BLOCKED_BASENAMES = /* @__PURE__ */ new Set([".gitignore", ".gitignore.sgit-parked"]);
594
720
  async function trackAdd(paths, opts = {}) {
595
721
  const root = await findRoot();
596
722
  await validateShadowRepo(root);
597
- const sgitDir = join7(root, ".sgit");
723
+ const sgitDir = await resolveShadowDir(root);
724
+ const mainDir = await resolveMainDir(root);
598
725
  const added = [];
599
726
  for (const inputPath of paths) {
600
727
  const normalized = await normalizePath(inputPath, root);
@@ -606,7 +733,7 @@ async function trackAdd(paths, opts = {}) {
606
733
  }
607
734
  const checkPath = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
608
735
  try {
609
- await access5(join7(root, checkPath), constants5.F_OK);
736
+ await access6(join7(root, checkPath), constants6.F_OK);
610
737
  } catch {
611
738
  throw new PathNotFoundError(normalized);
612
739
  }
@@ -615,7 +742,7 @@ async function trackAdd(paths, opts = {}) {
615
742
  let trackedByMain = false;
616
743
  try {
617
744
  await execGit(["ls-files", "--error-unmatch", checkPath], {
618
- gitDir: join7(root, ".git"),
745
+ gitDir: mainDir,
619
746
  workTree: root
620
747
  });
621
748
  trackedByMain = true;
@@ -624,7 +751,7 @@ async function trackAdd(paths, opts = {}) {
624
751
  if (trackedByMain) {
625
752
  if (opts.adopt && !alreadyTracked) {
626
753
  const rmArgs = normalized.endsWith("/") ? ["rm", "--cached", "-r", checkPath] : ["rm", "--cached", checkPath];
627
- await execGit(rmArgs, { gitDir: join7(root, ".git"), workTree: root });
754
+ await execGit(rmArgs, { gitDir: mainDir, workTree: root });
628
755
  } else if (!opts.adopt) {
629
756
  throw new PathTrackedByMainError(normalized);
630
757
  }
@@ -653,11 +780,11 @@ async function trackAdd(paths, opts = {}) {
653
780
  }
654
781
 
655
782
  // src/commands/track/remove.ts
656
- import { join as join8, resolve as resolve3, relative as relative2, sep as sep2 } from "path";
783
+ import { resolve as resolve3, relative as relative2, sep as sep2 } from "path";
657
784
  async function trackRemove(paths) {
658
785
  const root = await findRoot();
659
786
  await validateShadowRepo(root);
660
- const sgitDir = join8(root, ".sgit");
787
+ const sgitDir = await resolveShadowDir(root);
661
788
  const removed = [];
662
789
  for (const inputPath of paths) {
663
790
  const absolute = resolve3(process.cwd(), inputPath);
@@ -701,26 +828,46 @@ async function trackList() {
701
828
  return manifest.paths;
702
829
  }
703
830
 
704
- // src/commands/track/verify.ts
705
- import { join as join10 } from "path";
706
-
707
831
  // src/core/gitignore.ts
708
832
  import {
709
833
  readdirSync,
710
834
  renameSync,
711
- existsSync,
835
+ existsSync as existsSync2,
712
836
  readFileSync,
713
837
  writeFileSync,
714
838
  unlinkSync
715
839
  } from "fs";
716
- import { join as join9, relative as relative3, sep as sep3 } from "path";
717
- var DISABLED_NAME = ".gitignore-sgit.bat";
840
+ import { join as join8, relative as relative3, sep as sep3 } from "path";
841
+ var DISABLED_NAME = ".gitignore.sgit-parked";
718
842
  var LOCK_FILE = ".gitignore-guard";
843
+ function refuseIfSwapped(root, op) {
844
+ if (isSwapModeSync(root)) {
845
+ throw new SwapModeActiveError(op);
846
+ }
847
+ }
848
+ function disableGitignores(root, manifestPaths) {
849
+ refuseIfSwapped(root, "disable gitignores");
850
+ const gitignores = findInTrackedDirs(root, manifestPaths, ".gitignore");
851
+ if (gitignores.length === 0) return [];
852
+ const stale = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
853
+ if (stale.length > 0) {
854
+ throw new StaleGitignoreError(stale);
855
+ }
856
+ const disabled = [];
857
+ for (const relPath of gitignores) {
858
+ const absPath = join8(root, relPath);
859
+ const dir = absPath.substring(0, absPath.length - ".gitignore".length);
860
+ renameSync(absPath, dir + DISABLED_NAME);
861
+ disabled.push(relPath);
862
+ }
863
+ return disabled;
864
+ }
719
865
  function enableGitignores(root, manifestPaths) {
866
+ refuseIfSwapped(root, "enable gitignores");
720
867
  const disabled = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
721
868
  const enabled = [];
722
869
  for (const relPath of disabled) {
723
- const absPath = join9(root, relPath);
870
+ const absPath = join8(root, relPath);
724
871
  const restoredPath = absPath.substring(0, absPath.length - DISABLED_NAME.length) + ".gitignore";
725
872
  renameSync(absPath, restoredPath);
726
873
  enabled.push(relPath);
@@ -731,7 +878,7 @@ function findStaleGitignores(root, manifestPaths) {
731
878
  return findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
732
879
  }
733
880
  function isGuardActive(root) {
734
- const lockPath = join9(root, ".sgit", LOCK_FILE);
881
+ const lockPath = join8(root, ".sgit", LOCK_FILE);
735
882
  try {
736
883
  const pid = parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
737
884
  if (isNaN(pid)) return false;
@@ -742,7 +889,8 @@ function isGuardActive(root) {
742
889
  }
743
890
  }
744
891
  function setupGitignoreGuard(root, manifestPaths) {
745
- const lockPath = join9(root, ".sgit", LOCK_FILE);
892
+ refuseIfSwapped(root, "set up gitignore guard");
893
+ const lockPath = join8(root, ".sgit", LOCK_FILE);
746
894
  if (isGuardActive(root)) {
747
895
  return () => {
748
896
  };
@@ -750,7 +898,7 @@ function setupGitignoreGuard(root, manifestPaths) {
750
898
  const stale = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
751
899
  if (stale.length > 0) {
752
900
  warn(
753
- "found stale .gitignore-sgit.bat files from a previous crash; restoring"
901
+ "found stale .gitignore.sgit-parked files from a previous crash; restoring"
754
902
  );
755
903
  enableGitignores(root, manifestPaths);
756
904
  }
@@ -761,7 +909,7 @@ function setupGitignoreGuard(root, manifestPaths) {
761
909
  };
762
910
  }
763
911
  for (const relPath of gitignores) {
764
- const absPath = join9(root, relPath);
912
+ const absPath = join8(root, relPath);
765
913
  const dir = absPath.substring(0, absPath.length - ".gitignore".length);
766
914
  renameSync(absPath, dir + DISABLED_NAME);
767
915
  }
@@ -804,15 +952,15 @@ function findInTrackedDirs(root, manifestPaths, targetName) {
804
952
  const found = /* @__PURE__ */ new Set();
805
953
  for (const mPath of manifestPaths) {
806
954
  for (const ancestor of getAncestorDirs(mPath)) {
807
- const dirAbs = ancestor === "" ? root : join9(root, ancestor);
808
- const targetPath = join9(dirAbs, targetName);
809
- if (existsSync(targetPath)) {
955
+ const dirAbs = ancestor === "" ? root : join8(root, ancestor);
956
+ const targetPath = join8(dirAbs, targetName);
957
+ if (existsSync2(targetPath)) {
810
958
  found.add(targetPath);
811
959
  }
812
960
  }
813
961
  if (mPath.endsWith("/")) {
814
- const dirAbs = join9(root, mPath);
815
- if (existsSync(dirAbs)) {
962
+ const dirAbs = join8(root, mPath);
963
+ if (existsSync2(dirAbs)) {
816
964
  walkSync(dirAbs, targetName, found);
817
965
  }
818
966
  }
@@ -840,7 +988,7 @@ function walkSync(dir, targetName, results) {
840
988
  if (entry.isDirectory() && (entry.name === ".git" || entry.name === ".sgit")) {
841
989
  continue;
842
990
  }
843
- const fullPath = join9(dir, entry.name);
991
+ const fullPath = join8(dir, entry.name);
844
992
  if (entry.isDirectory()) {
845
993
  walkSync(fullPath, targetName, results);
846
994
  } else if (entry.name === targetName) {
@@ -859,17 +1007,17 @@ function cleanStaleLock(lockPath) {
859
1007
  async function verifyMain(root) {
860
1008
  const manifest = await readManifest(root);
861
1009
  const violations = [];
862
- if (!isGuardActive(root)) {
1010
+ if (!isGuardActive(root) && !await isSwapMode(root)) {
863
1011
  const stale = findStaleGitignores(root, manifest.paths);
864
1012
  violations.push(
865
1013
  ...stale.map(
866
- (f) => `${f} (stale .gitignore-sgit.bat \u2014 rename back to .gitignore)`
1014
+ (f) => `${f} (stale .gitignore.sgit-parked \u2014 rename back to .gitignore)`
867
1015
  )
868
1016
  );
869
1017
  }
870
1018
  try {
871
1019
  const staged = await execGit(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], {
872
- gitDir: join10(root, ".git"),
1020
+ gitDir: await resolveMainDir(root),
873
1021
  workTree: root
874
1022
  });
875
1023
  if (staged.trim()) {
@@ -892,13 +1040,13 @@ async function verifyShadow(root) {
892
1040
  violations: [".sgit.json (missing or invalid on disk)"]
893
1041
  };
894
1042
  }
895
- const sgitDir = join10(root, ".sgit");
1043
+ const sgitDir = await resolveShadowDir(root);
896
1044
  const violations = [];
897
- if (!isGuardActive(root)) {
1045
+ if (!isGuardActive(root) && !await isSwapMode(root)) {
898
1046
  const stale = findStaleGitignores(root, manifest.paths);
899
1047
  violations.push(
900
1048
  ...stale.map(
901
- (f) => `${f} (stale .gitignore-sgit.bat \u2014 rename back to .gitignore)`
1049
+ (f) => `${f} (stale .gitignore.sgit-parked \u2014 rename back to .gitignore)`
902
1050
  )
903
1051
  );
904
1052
  }
@@ -987,19 +1135,22 @@ async function trackVerify(opts) {
987
1135
  }
988
1136
 
989
1137
  // 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";
1138
+ import { join as join9, extname } from "path";
1139
+ import { access as access7, readFile as readFile5, writeFile as writeFile5, rename as rename2, mkdir as mkdir4, rm as rm2 } from "fs/promises";
1140
+ import { constants as constants7 } from "fs";
993
1141
  import { createInterface } from "readline";
994
1142
  import { randomBytes } from "crypto";
995
1143
  async function trackClone(url, opts = {}) {
996
1144
  const root = await findRoot();
1145
+ if (await isSwapMode(root)) {
1146
+ throw new SwapModeActiveError("clone");
1147
+ }
997
1148
  await validateNotWorktree(root);
998
1149
  await validateNotSubmodule(root);
999
1150
  await validateGitIdentity(root);
1000
- const sgitDir = join11(root, ".sgit");
1151
+ const sgitDir = join9(root, ".sgit");
1001
1152
  try {
1002
- await access6(sgitDir, constants6.F_OK);
1153
+ await access7(sgitDir, constants7.F_OK);
1003
1154
  throw new AlreadyInitializedError();
1004
1155
  } catch (err) {
1005
1156
  if (err instanceof AlreadyInitializedError) throw err;
@@ -1015,6 +1166,11 @@ async function trackClone(url, opts = {}) {
1015
1166
  }
1016
1167
  await execGit(["config", "core.bare", "false"], { gitDir: sgitDir });
1017
1168
  await execGit(["config", "core.worktree", root], { gitDir: sgitDir });
1169
+ await execGit(
1170
+ ["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"],
1171
+ { gitDir: sgitDir }
1172
+ );
1173
+ await execGit(["fetch", "origin"], { gitDir: sgitDir });
1018
1174
  try {
1019
1175
  const name = await execGit(["config", "user.name"], { cwd: root });
1020
1176
  if (name) {
@@ -1047,7 +1203,7 @@ async function trackClone(url, opts = {}) {
1047
1203
  const conflicts = [];
1048
1204
  for (const file of trackedFiles) {
1049
1205
  try {
1050
- await access6(join11(root, file), constants6.F_OK);
1206
+ await access7(join9(root, file), constants7.F_OK);
1051
1207
  conflicts.push(file);
1052
1208
  } catch {
1053
1209
  }
@@ -1066,19 +1222,19 @@ async function trackClone(url, opts = {}) {
1066
1222
  if (choice === 2) {
1067
1223
  const savedContent = /* @__PURE__ */ new Map();
1068
1224
  for (const file of conflicts) {
1069
- savedContent.set(file, await readFile5(join11(root, file)));
1225
+ savedContent.set(file, await readFile5(join9(root, file)));
1070
1226
  }
1071
1227
  if (trackedFiles.length > 0) {
1072
1228
  await execGit(["checkout", "HEAD", "--", "."], { gitDir: sgitDir, workTree: root });
1073
1229
  }
1074
1230
  for (const [file, content] of savedContent) {
1075
- await mkdir4(join11(root, file, ".."), { recursive: true });
1076
- await writeFile5(join11(root, file), content);
1231
+ await mkdir4(join9(root, file, ".."), { recursive: true });
1232
+ await writeFile5(join9(root, file), content);
1077
1233
  }
1078
1234
  } else if (choice === 3) {
1079
1235
  for (const file of conflicts) {
1080
1236
  const renamed = generateOriginalName(file);
1081
- await rename2(join11(root, file), join11(root, renamed));
1237
+ await rename2(join9(root, file), join9(root, renamed));
1082
1238
  }
1083
1239
  if (trackedFiles.length > 0) {
1084
1240
  await execGit(["checkout", "HEAD", "--", "."], { gitDir: sgitDir, workTree: root });
@@ -1146,13 +1302,16 @@ function generateOriginalName(filePath) {
1146
1302
  }
1147
1303
 
1148
1304
  // src/commands/track/destroy.ts
1149
- import { join as join12, dirname as dirname2 } from "path";
1305
+ import { join as join10, dirname as dirname2 } from "path";
1150
1306
  import { readFile as readFile6, writeFile as writeFile6, rm as rm3, rmdir, unlink as unlink2 } from "fs/promises";
1151
1307
  import { createInterface as createInterface2 } from "readline";
1152
1308
  async function trackDestroy(opts) {
1153
1309
  const root = await findRoot();
1310
+ if (await isSwapMode(root)) {
1311
+ throw new SwapModeActiveError("destroy");
1312
+ }
1154
1313
  await validateShadowRepo(root);
1155
- const sgitDir = join12(root, ".sgit");
1314
+ const sgitDir = join10(root, ".sgit");
1156
1315
  let trackedFiles = [];
1157
1316
  try {
1158
1317
  const output = await execGit(["ls-tree", "-r", "--name-only", "HEAD"], {
@@ -1193,7 +1352,7 @@ async function trackDestroy(opts) {
1193
1352
  return;
1194
1353
  }
1195
1354
  }
1196
- const excludePath = join12(root, ".git", "info", "exclude");
1355
+ const excludePath = join10(root, ".git", "info", "exclude");
1197
1356
  try {
1198
1357
  const content = await readFile6(excludePath, "utf-8");
1199
1358
  const lines = content.split("\n");
@@ -1229,7 +1388,7 @@ async function trackDestroy(opts) {
1229
1388
  if (!opts.keepFiles && trackedFiles.length > 0) {
1230
1389
  for (const file of trackedFiles) {
1231
1390
  try {
1232
- await unlink2(join12(root, file));
1391
+ await unlink2(join10(root, file));
1233
1392
  } catch {
1234
1393
  }
1235
1394
  }
@@ -1244,13 +1403,13 @@ async function trackDestroy(opts) {
1244
1403
  const sortedDirs = [...dirs].sort((a, b) => b.length - a.length);
1245
1404
  for (const dir of sortedDirs) {
1246
1405
  try {
1247
- await rmdir(join12(root, dir));
1406
+ await rmdir(join10(root, dir));
1248
1407
  } catch {
1249
1408
  }
1250
1409
  }
1251
1410
  }
1252
- await rm3(join12(root, ".sgit"), { recursive: true, force: true });
1253
- await rm3(join12(root, ".sgit.json"), { force: true });
1411
+ await rm3(join10(root, ".sgit"), { recursive: true, force: true });
1412
+ await rm3(join10(root, ".sgit.json"), { force: true });
1254
1413
  if (opts.keepFiles) {
1255
1414
  success("Shadow repository removed. Tracked files kept on disk.");
1256
1415
  } else {
@@ -1271,11 +1430,10 @@ async function confirm(message) {
1271
1430
  }
1272
1431
 
1273
1432
  // src/commands/track/ignore.ts
1274
- import { join as join13 } from "path";
1275
1433
  async function trackIgnore(paths) {
1276
1434
  const root = await findRoot();
1277
1435
  await validateShadowRepo(root);
1278
- const sgitDir = join13(root, ".sgit");
1436
+ const sgitDir = await resolveShadowDir(root);
1279
1437
  const added = [];
1280
1438
  for (const inputPath of paths) {
1281
1439
  const normalized = await normalizePath(inputPath, root);
@@ -1337,7 +1495,7 @@ async function trackUnignore(paths) {
1337
1495
  }
1338
1496
 
1339
1497
  // src/commands/track/completion.ts
1340
- import { join as join14, dirname as dirname3 } from "path";
1498
+ import { join as join11, dirname as dirname3 } from "path";
1341
1499
  import { mkdir as mkdir5, writeFile as writeFile7 } from "fs/promises";
1342
1500
  import { homedir } from "os";
1343
1501
  function bashCompletion() {
@@ -1566,11 +1724,11 @@ function getInstallPath(shell) {
1566
1724
  const home = homedir();
1567
1725
  switch (shell) {
1568
1726
  case "zsh":
1569
- return join14(home, ".zsh", "completions", "_sgit");
1727
+ return join11(home, ".zsh", "completions", "_sgit");
1570
1728
  case "fish":
1571
- return join14(home, ".config", "fish", "completions", "sgit.fish");
1729
+ return join11(home, ".config", "fish", "completions", "sgit.fish");
1572
1730
  default:
1573
- return join14(home, ".local", "share", "bash-completion", "completions", "sgit");
1731
+ return join11(home, ".local", "share", "bash-completion", "completions", "sgit");
1574
1732
  }
1575
1733
  }
1576
1734
  async function trackCompletion(action, opts) {
@@ -1596,6 +1754,76 @@ async function trackCompletion(action, opts) {
1596
1754
  process.exit(1);
1597
1755
  }
1598
1756
 
1757
+ // src/commands/track/swap.ts
1758
+ import { join as join12 } from "path";
1759
+ import { rename as rename3 } from "fs/promises";
1760
+ async function trackSwap() {
1761
+ const root = await findRoot();
1762
+ await validateShadowRepo(root);
1763
+ if (await isSwapMode(root)) {
1764
+ throw new AlreadySwappedError();
1765
+ }
1766
+ await assertNoInProgressOps(join12(root, ".git"));
1767
+ const manifest = await readManifest(root);
1768
+ const gitPath = join12(root, ".git");
1769
+ const sgitPath = join12(root, ".sgit");
1770
+ const disabledPath = join12(root, SWAP_MARKER);
1771
+ disableGitignores(root, manifest.paths);
1772
+ let gitRenamed = false;
1773
+ try {
1774
+ await rename3(gitPath, disabledPath);
1775
+ gitRenamed = true;
1776
+ await rename3(sgitPath, gitPath);
1777
+ } catch (err) {
1778
+ if (gitRenamed) {
1779
+ try {
1780
+ await rename3(disabledPath, gitPath);
1781
+ } catch {
1782
+ }
1783
+ }
1784
+ try {
1785
+ enableGitignores(root, manifest.paths);
1786
+ } catch {
1787
+ }
1788
+ throw err;
1789
+ }
1790
+ success(
1791
+ "Swap mode enabled. .sgit is now .git; main git is parked at .git.sgit-parked."
1792
+ );
1793
+ info("Run 'sgit track unswap' to exit.");
1794
+ }
1795
+
1796
+ // src/commands/track/unswap.ts
1797
+ import { join as join13 } from "path";
1798
+ import { rename as rename4 } from "fs/promises";
1799
+ async function trackUnswap() {
1800
+ const root = await findRoot();
1801
+ if (!await isSwapMode(root)) {
1802
+ throw new NotSwappedError();
1803
+ }
1804
+ const gitPath = join13(root, ".git");
1805
+ const sgitPath = join13(root, ".sgit");
1806
+ const disabledPath = join13(root, SWAP_MARKER);
1807
+ await assertNoInProgressOps(gitPath);
1808
+ let shadowRenamed = false;
1809
+ try {
1810
+ await rename4(gitPath, sgitPath);
1811
+ shadowRenamed = true;
1812
+ await rename4(disabledPath, gitPath);
1813
+ } catch (err) {
1814
+ if (shadowRenamed) {
1815
+ try {
1816
+ await rename4(sgitPath, gitPath);
1817
+ } catch {
1818
+ }
1819
+ }
1820
+ throw err;
1821
+ }
1822
+ const manifest = await readManifest(root);
1823
+ enableGitignores(root, manifest.paths);
1824
+ success("Swap mode disabled. Main git restored.");
1825
+ }
1826
+
1599
1827
  // src/commands/track/index.ts
1600
1828
  function registerTrackCommands(program) {
1601
1829
  const track = program.command("track").description("Manage shadow-tracked paths");
@@ -1622,6 +1850,8 @@ function registerTrackCommands(program) {
1622
1850
  async (action, opts) => trackCompletion(action, opts)
1623
1851
  )
1624
1852
  );
1853
+ track.command("swap").description("Swap .git with .sgit so IDE tools see the shadow repo as the active git").action(wrapAction(trackSwap));
1854
+ track.command("unswap").description("Exit swap mode and restore the main repository").action(wrapAction(trackUnswap));
1625
1855
  }
1626
1856
  function wrapAction(fn) {
1627
1857
  return async (...args) => {
@@ -1686,7 +1916,23 @@ function wrapAction2(fn) {
1686
1916
  var COMMANDER_COMMANDS = /* @__PURE__ */ new Set(["track", "debug"]);
1687
1917
  async function run() {
1688
1918
  const args = process.argv.slice(2);
1689
- const skipGuard = args[0] === "track" && (args[1] === "init" || args[1] === "clone") || args[0] === "debug";
1919
+ let swapActive = false;
1920
+ try {
1921
+ const root = await findRoot();
1922
+ await assertConsistentState(root);
1923
+ swapActive = await isSwapMode(root);
1924
+ } catch (err) {
1925
+ if (err instanceof SwapModeCorruptError) {
1926
+ error(err.message);
1927
+ process.exit(1);
1928
+ }
1929
+ }
1930
+ const suppressBanner = args[0] === "track" && (args[1] === "swap" || args[1] === "unswap");
1931
+ if (swapActive && !suppressBanner) {
1932
+ swapModeBanner();
1933
+ }
1934
+ const selfManagedGuard = args[0] === "track" && (args[1] === "init" || args[1] === "clone" || args[1] === "swap" || args[1] === "unswap");
1935
+ const skipGuard = selfManagedGuard || args[0] === "debug" || swapActive;
1690
1936
  let guardCleanup;
1691
1937
  if (!skipGuard) {
1692
1938
  try {
@@ -1714,6 +1960,8 @@ Track commands:
1714
1960
  sgit track unignore Stop ignoring paths
1715
1961
  sgit track sync Regenerate exclude files
1716
1962
  sgit track verify Validate staged files
1963
+ sgit track swap Swap .git with .sgit for IDE compatibility
1964
+ sgit track unswap Exit swap mode
1717
1965
  sgit track destroy Remove shadow repository
1718
1966
  sgit track completion Install shell completions
1719
1967
 
@@ -1724,7 +1972,7 @@ All other commands are forwarded to git in the shadow repository:
1724
1972
  } else {
1725
1973
  const root = await findRoot();
1726
1974
  await validateShadowRepo(root);
1727
- const exitCode = forwardToGit(root, args);
1975
+ const exitCode = await forwardToGit(root, args);
1728
1976
  guardCleanup?.();
1729
1977
  guardCleanup = void 0;
1730
1978
  process.exit(exitCode);