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/.temp/.sgit/info/exclude +1 -1
- package/CLAUDE.md +2 -1
- package/dist/cli.js +322 -74
- package/package.json +1 -1
- package/src/cli.ts +36 -4
- package/src/commands/forward.ts +3 -3
- package/src/commands/track/add.ts +6 -4
- package/src/commands/track/clone.ts +16 -1
- package/src/commands/track/destroy.ts +5 -0
- package/src/commands/track/ignore.ts +2 -2
- package/src/commands/track/index.ts +12 -0
- package/src/commands/track/init.ts +5 -1
- package/src/commands/track/remove.ts +3 -2
- package/src/commands/track/swap.ts +61 -0
- package/src/commands/track/sync.ts +9 -6
- package/src/commands/track/unswap.ts +49 -0
- package/src/commands/track/verify.ts +11 -9
- package/src/core/errors.ts +31 -1
- package/src/core/exclude.ts +2 -2
- package/src/core/gitignore.ts +18 -3
- package/src/core/repository.ts +8 -0
- package/src/core/swap-mode.ts +83 -0
- package/src/utils/logger.ts +12 -0
- package/tests/commands/forward.test.ts +6 -6
- package/tests/commands/track/add.test.ts +6 -6
- package/tests/commands/track/swap.test.ts +266 -0
- package/tests/commands/track/verify.test.ts +16 -16
- package/tests/core/exclude.test.ts +10 -10
- package/tests/core/gitignore.test.ts +30 -30
- package/tests/helpers/guard-child.mjs +2 -2
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
|
-
|
|
124
|
-
|
|
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
|
|
159
|
-
import { constants as
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
505
|
-
import { constants as
|
|
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
|
|
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(
|
|
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(
|
|
520
|
-
await mkdir3(join6(
|
|
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
|
|
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
|
|
541
|
-
import { constants as
|
|
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-
|
|
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 =
|
|
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
|
|
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:
|
|
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:
|
|
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 {
|
|
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 =
|
|
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
|
|
717
|
-
var DISABLED_NAME = ".gitignore-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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-
|
|
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 =
|
|
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 :
|
|
808
|
-
const targetPath =
|
|
809
|
-
if (
|
|
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 =
|
|
815
|
-
if (
|
|
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 =
|
|
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-
|
|
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:
|
|
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 =
|
|
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-
|
|
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
|
|
991
|
-
import { access as
|
|
992
|
-
import { constants as
|
|
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 =
|
|
1151
|
+
const sgitDir = join9(root, ".sgit");
|
|
1001
1152
|
try {
|
|
1002
|
-
await
|
|
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
|
|
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(
|
|
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(
|
|
1076
|
-
await writeFile5(
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
1406
|
+
await rmdir(join10(root, dir));
|
|
1248
1407
|
} catch {
|
|
1249
1408
|
}
|
|
1250
1409
|
}
|
|
1251
1410
|
}
|
|
1252
|
-
await rm3(
|
|
1253
|
-
await rm3(
|
|
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 =
|
|
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
|
|
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
|
|
1727
|
+
return join11(home, ".zsh", "completions", "_sgit");
|
|
1570
1728
|
case "fish":
|
|
1571
|
-
return
|
|
1729
|
+
return join11(home, ".config", "fish", "completions", "sgit.fish");
|
|
1572
1730
|
default:
|
|
1573
|
-
return
|
|
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
|
-
|
|
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);
|