shadow-git 1.0.3 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +5 -1
- package/README.md +13 -0
- package/dist/cli.js +498 -75
- package/package.json +1 -1
- package/src/cli.ts +69 -5
- 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/core/update-check.ts +214 -0
- package/src/utils/logger.ts +20 -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/core/update-check.test.ts +494 -0
- package/tests/helpers/guard-child.mjs +2 -2
- package/.claude/settings.local.json +0 -43
- package/.temp/.sgit/COMMIT_EDITMSG +0 -1
- package/.temp/.sgit/HEAD +0 -1
- package/.temp/.sgit/config +0 -8
- package/.temp/.sgit/description +0 -1
- package/.temp/.sgit/hooks/applypatch-msg.sample +0 -15
- package/.temp/.sgit/hooks/commit-msg.sample +0 -24
- package/.temp/.sgit/hooks/fsmonitor-watchman.sample +0 -174
- package/.temp/.sgit/hooks/post-update.sample +0 -8
- package/.temp/.sgit/hooks/pre-applypatch.sample +0 -14
- package/.temp/.sgit/hooks/pre-commit +0 -6
- package/.temp/.sgit/hooks/pre-commit.sample +0 -49
- package/.temp/.sgit/hooks/pre-merge-commit.sample +0 -13
- package/.temp/.sgit/hooks/pre-push.sample +0 -53
- package/.temp/.sgit/hooks/pre-rebase.sample +0 -169
- package/.temp/.sgit/hooks/pre-receive.sample +0 -24
- package/.temp/.sgit/hooks/prepare-commit-msg.sample +0 -42
- package/.temp/.sgit/hooks/push-to-checkout.sample +0 -78
- package/.temp/.sgit/hooks/sendemail-validate.sample +0 -77
- package/.temp/.sgit/hooks/update.sample +0 -128
- package/.temp/.sgit/index +0 -0
- package/.temp/.sgit/info/exclude +0 -20
- package/.temp/.sgit/logs/HEAD +0 -1
- package/.temp/.sgit/logs/refs/heads/master +0 -1
- package/.temp/.sgit/objects/03/7f4e37a02573e40ab78b35dd4b442cc6110bae +0 -0
- package/.temp/.sgit/objects/2f/f0fd85dec20039eef6ff63ead905778a88843f +0 -0
- package/.temp/.sgit/objects/34/b3190a8a836593fb568a56c073a416a56fbf6d +0 -0
- package/.temp/.sgit/objects/70/ad6bbbfefd330d72a43dc2a810e6ef42269911 +0 -0
- package/.temp/.sgit/objects/90/4e09c7aadaa32d2426ab5464a975d5151ef195 +0 -0
- package/.temp/.sgit/objects/a4/6358feef49515d5f85d1d3a64e929182da7149 +0 -3
- package/.temp/.sgit/objects/c3/2432f724b666f647fe21a1ce16535cd7693f74 +0 -0
- package/.temp/.sgit/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 +0 -0
- package/.temp/.sgit/objects/e8/0f4253888cdadda1e3d810f6a3cb3c68c20b50 +0 -0
- package/.temp/.sgit/objects/ee/9dd32cca3b134623d184eb4de5805704c493e3 +0 -0
- package/.temp/.sgit/refs/heads/master +0 -1
- package/.temp/.sgit.json +0 -10
- package/.temp/README.md +0 -0
- package/.temp/docs/guide.md +0 -10
- package/.temp/lib/__init__.py +0 -1
- package/.temp/lib/utils.py +0 -5
- package/.temp/rec1/rec2/test +0 -0
- package/.temp/src/config.py +0 -3
- package/.temp/src/main.py +0 -5
- package/.temp/temp/shouldnt-track +0 -0
- package/.temp/temp.md +0 -3
- package/.temp/test/1 +0 -0
- package/.temp/tests/test_utils.py +0 -7
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
4
6
|
import { Command as Command2 } from "commander";
|
|
5
7
|
|
|
6
8
|
// src/core/repository.ts
|
|
@@ -70,6 +72,44 @@ var PathNotTrackedError = class extends SgitError {
|
|
|
70
72
|
super(`${path} is not tracked by sgit`);
|
|
71
73
|
}
|
|
72
74
|
};
|
|
75
|
+
var StaleGitignoreError = class extends SgitError {
|
|
76
|
+
constructor(files) {
|
|
77
|
+
super(
|
|
78
|
+
[
|
|
79
|
+
"found stale .gitignore.sgit-parked files (a previous sgit command did not complete cleanly):",
|
|
80
|
+
...files.map((f) => ` ${f}`),
|
|
81
|
+
"",
|
|
82
|
+
"rename them back to .gitignore manually, then retry."
|
|
83
|
+
].join("\n")
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
var AlreadySwappedError = class extends SgitError {
|
|
88
|
+
constructor() {
|
|
89
|
+
super("shadow-git is already in swap mode; run 'sgit track unswap' to exit.");
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
var NotSwappedError = class extends SgitError {
|
|
93
|
+
constructor() {
|
|
94
|
+
super("shadow-git is not in swap mode.");
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
var SwapModeActiveError = class extends SgitError {
|
|
98
|
+
constructor(op) {
|
|
99
|
+
super(`cannot ${op} while in swap mode; run 'sgit track unswap' first.`);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
var SwapModeCorruptError = class extends SgitError {
|
|
103
|
+
constructor(details) {
|
|
104
|
+
super(`shadow-git state is inconsistent:
|
|
105
|
+
${details}`);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
var InProgressGitOpError = class extends SgitError {
|
|
109
|
+
constructor(gitDir, marker) {
|
|
110
|
+
super(`cannot proceed: ${gitDir} has an in-progress operation (${marker}). Finish or abort it first.`);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
73
113
|
|
|
74
114
|
// src/core/repository.ts
|
|
75
115
|
async function findRoot() {
|
|
@@ -96,6 +136,12 @@ async function validateGitIdentity(root) {
|
|
|
96
136
|
async function validateShadowRepo(root) {
|
|
97
137
|
try {
|
|
98
138
|
await access(join(root, ".sgit"), constants.F_OK);
|
|
139
|
+
return;
|
|
140
|
+
} catch {
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
await access(join(root, ".git.sgit-parked"), constants.F_OK);
|
|
144
|
+
return;
|
|
99
145
|
} catch {
|
|
100
146
|
throw new NotInitializedError();
|
|
101
147
|
}
|
|
@@ -119,9 +165,74 @@ async function validateNotSubmodule(root) {
|
|
|
119
165
|
|
|
120
166
|
// src/commands/forward.ts
|
|
121
167
|
import { spawnSync } from "child_process";
|
|
168
|
+
|
|
169
|
+
// src/core/swap-mode.ts
|
|
170
|
+
import { access as access2 } from "fs/promises";
|
|
171
|
+
import { constants as constants2, existsSync } from "fs";
|
|
122
172
|
import { join as join2 } from "path";
|
|
123
|
-
|
|
124
|
-
|
|
173
|
+
var SWAP_MARKER = ".git.sgit-parked";
|
|
174
|
+
async function pathExists(path) {
|
|
175
|
+
try {
|
|
176
|
+
await access2(path, constants2.F_OK);
|
|
177
|
+
return true;
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function isSwapMode(root) {
|
|
183
|
+
return pathExists(join2(root, SWAP_MARKER));
|
|
184
|
+
}
|
|
185
|
+
function isSwapModeSync(root) {
|
|
186
|
+
return existsSync(join2(root, SWAP_MARKER));
|
|
187
|
+
}
|
|
188
|
+
async function resolveShadowDir(root) {
|
|
189
|
+
return await isSwapMode(root) ? join2(root, ".git") : join2(root, ".sgit");
|
|
190
|
+
}
|
|
191
|
+
async function resolveMainDir(root) {
|
|
192
|
+
return await isSwapMode(root) ? join2(root, SWAP_MARKER) : join2(root, ".git");
|
|
193
|
+
}
|
|
194
|
+
var IN_PROGRESS_MARKERS = [
|
|
195
|
+
"index.lock",
|
|
196
|
+
"MERGE_HEAD",
|
|
197
|
+
"CHERRY_PICK_HEAD",
|
|
198
|
+
"REVERT_HEAD",
|
|
199
|
+
"rebase-apply",
|
|
200
|
+
"rebase-merge",
|
|
201
|
+
"BISECT_LOG"
|
|
202
|
+
];
|
|
203
|
+
async function assertNoInProgressOps(gitDir) {
|
|
204
|
+
for (const marker of IN_PROGRESS_MARKERS) {
|
|
205
|
+
if (await pathExists(join2(gitDir, marker))) {
|
|
206
|
+
throw new InProgressGitOpError(gitDir, marker);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function assertConsistentState(root) {
|
|
211
|
+
const hasDisabled = await pathExists(join2(root, SWAP_MARKER));
|
|
212
|
+
const hasGit = await pathExists(join2(root, ".git"));
|
|
213
|
+
const hasSgit = await pathExists(join2(root, ".sgit"));
|
|
214
|
+
if (hasDisabled && !hasGit) {
|
|
215
|
+
throw new SwapModeCorruptError(
|
|
216
|
+
[
|
|
217
|
+
`found ${SWAP_MARKER}/ but no .git/ \u2014 a swap-mode transition was interrupted.`,
|
|
218
|
+
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.`
|
|
219
|
+
].join("\n")
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (hasDisabled && hasSgit) {
|
|
223
|
+
throw new SwapModeCorruptError(
|
|
224
|
+
[
|
|
225
|
+
`found both ${SWAP_MARKER}/ and .sgit/ \u2014 unexpected state.`,
|
|
226
|
+
"normally only one exists at a time.",
|
|
227
|
+
`investigate and remove the stale one before proceeding.`
|
|
228
|
+
].join("\n")
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/commands/forward.ts
|
|
234
|
+
async function forwardToGit(root, args) {
|
|
235
|
+
const sgitDir = await resolveShadowDir(root);
|
|
125
236
|
const result = spawnSync(
|
|
126
237
|
"git",
|
|
127
238
|
[`--git-dir=${sgitDir}`, `--work-tree=${root}`, ...args],
|
|
@@ -152,11 +263,32 @@ function error(msg) {
|
|
|
152
263
|
process.stderr.write(`${RED}${msg}${RESET}
|
|
153
264
|
`);
|
|
154
265
|
}
|
|
266
|
+
function swapModeBanner() {
|
|
267
|
+
const msg = [
|
|
268
|
+
"\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",
|
|
269
|
+
"shadow-git is in SWAP MODE",
|
|
270
|
+
"main git is parked at .git.sgit-parked; .sgit is active at .git",
|
|
271
|
+
"this mode is for IDE/tool compatibility \u2014 read-only recommended",
|
|
272
|
+
"run 'sgit track unswap' before making main-repo changes",
|
|
273
|
+
"\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"
|
|
274
|
+
].join("\n");
|
|
275
|
+
process.stderr.write(`${YELLOW}${msg}${RESET}
|
|
276
|
+
`);
|
|
277
|
+
}
|
|
278
|
+
function updateNotice(current, latest) {
|
|
279
|
+
const msg = [
|
|
280
|
+
`Update available: shadow-git ${current} \u2192 ${latest}`,
|
|
281
|
+
"Please run 'npm install -g shadow-git' to update."
|
|
282
|
+
].join("\n");
|
|
283
|
+
process.stderr.write(`
|
|
284
|
+
${CYAN}${msg}${RESET}
|
|
285
|
+
`);
|
|
286
|
+
}
|
|
155
287
|
|
|
156
288
|
// src/commands/track/init.ts
|
|
157
289
|
import { join as join5 } from "path";
|
|
158
|
-
import { mkdir as mkdir2, writeFile as writeFile3, readFile as readFile3, access as
|
|
159
|
-
import { constants as
|
|
290
|
+
import { mkdir as mkdir2, writeFile as writeFile3, readFile as readFile3, access as access4 } from "fs/promises";
|
|
291
|
+
import { constants as constants4 } from "fs";
|
|
160
292
|
|
|
161
293
|
// src/core/manifest.ts
|
|
162
294
|
import { readFile, writeFile } from "fs/promises";
|
|
@@ -246,7 +378,7 @@ function generateMainExclude(existingContent, manifestPaths) {
|
|
|
246
378
|
while (preserved.length > 0 && preserved[preserved.length - 1].trim() === "") {
|
|
247
379
|
preserved.pop();
|
|
248
380
|
}
|
|
249
|
-
const sgitEntries = [".sgit", ".sgit.json", ".gitignore-
|
|
381
|
+
const sgitEntries = [".sgit", ".sgit.json", ".git.sgit-parked", ".gitignore.sgit-parked", ...manifestPaths];
|
|
250
382
|
return [
|
|
251
383
|
...preserved,
|
|
252
384
|
"",
|
|
@@ -291,7 +423,7 @@ function generateShadowExclude(manifestPaths, ignorePaths = []) {
|
|
|
291
423
|
"!.sgit.json",
|
|
292
424
|
...entryLines,
|
|
293
425
|
...ignorePaths,
|
|
294
|
-
".gitignore-
|
|
426
|
+
".gitignore.sgit-parked",
|
|
295
427
|
SGIT_BLOCK_END
|
|
296
428
|
].join("\n") + "\n";
|
|
297
429
|
}
|
|
@@ -312,7 +444,7 @@ import {
|
|
|
312
444
|
mkdir,
|
|
313
445
|
writeFile as writeFile2,
|
|
314
446
|
rename,
|
|
315
|
-
access as
|
|
447
|
+
access as access3,
|
|
316
448
|
lstat as lstat2,
|
|
317
449
|
unlink,
|
|
318
450
|
copyFile,
|
|
@@ -320,7 +452,7 @@ import {
|
|
|
320
452
|
rm,
|
|
321
453
|
readdir
|
|
322
454
|
} from "fs/promises";
|
|
323
|
-
import { constants as
|
|
455
|
+
import { constants as constants3 } from "fs";
|
|
324
456
|
var RUNNER_SCRIPT = `#!/bin/sh
|
|
325
457
|
# sgit hook runner \u2014 executes all hooks in pre-commit.d/
|
|
326
458
|
dir="$(dirname "$0")/pre-commit.d"
|
|
@@ -434,7 +566,7 @@ async function uninstallMainHook(root) {
|
|
|
434
566
|
}
|
|
435
567
|
async function exists(path) {
|
|
436
568
|
try {
|
|
437
|
-
await
|
|
569
|
+
await access3(path, constants3.F_OK);
|
|
438
570
|
return true;
|
|
439
571
|
} catch {
|
|
440
572
|
return false;
|
|
@@ -444,12 +576,15 @@ async function exists(path) {
|
|
|
444
576
|
// src/commands/track/init.ts
|
|
445
577
|
async function trackInit() {
|
|
446
578
|
const root = await findRoot();
|
|
579
|
+
if (await isSwapMode(root)) {
|
|
580
|
+
throw new SwapModeActiveError("init");
|
|
581
|
+
}
|
|
447
582
|
await validateNotWorktree(root);
|
|
448
583
|
await validateNotSubmodule(root);
|
|
449
584
|
await validateGitIdentity(root);
|
|
450
585
|
const sgitDir = join5(root, ".sgit");
|
|
451
586
|
try {
|
|
452
|
-
await
|
|
587
|
+
await access4(sgitDir, constants4.F_OK);
|
|
453
588
|
throw new AlreadyInitializedError();
|
|
454
589
|
} catch (err) {
|
|
455
590
|
if (err instanceof AlreadyInitializedError) throw err;
|
|
@@ -501,28 +636,30 @@ async function trackInit() {
|
|
|
501
636
|
|
|
502
637
|
// src/commands/track/sync.ts
|
|
503
638
|
import { join as join6 } from "path";
|
|
504
|
-
import { writeFile as writeFile4, readFile as readFile4, mkdir as mkdir3, access as
|
|
505
|
-
import { constants as
|
|
639
|
+
import { writeFile as writeFile4, readFile as readFile4, mkdir as mkdir3, access as access5 } from "fs/promises";
|
|
640
|
+
import { constants as constants5 } from "fs";
|
|
506
641
|
async function syncExcludes(root) {
|
|
507
642
|
const manifest = await readManifest(root);
|
|
508
|
-
const
|
|
643
|
+
const mainDir = await resolveMainDir(root);
|
|
644
|
+
const shadowDir = await resolveShadowDir(root);
|
|
645
|
+
const mainExcludePath = join6(mainDir, "info", "exclude");
|
|
509
646
|
let existingExclude = "";
|
|
510
647
|
try {
|
|
511
648
|
existingExclude = await readFile4(mainExcludePath, "utf-8");
|
|
512
649
|
} catch {
|
|
513
650
|
}
|
|
514
|
-
await mkdir3(join6(
|
|
651
|
+
await mkdir3(join6(mainDir, "info"), { recursive: true });
|
|
515
652
|
await writeFile4(
|
|
516
653
|
mainExcludePath,
|
|
517
654
|
generateMainExclude(existingExclude, manifest.paths)
|
|
518
655
|
);
|
|
519
|
-
const sgitExcludePath = join6(
|
|
520
|
-
await mkdir3(join6(
|
|
656
|
+
const sgitExcludePath = join6(shadowDir, "info", "exclude");
|
|
657
|
+
await mkdir3(join6(shadowDir, "info"), { recursive: true });
|
|
521
658
|
await writeFile4(sgitExcludePath, generateShadowExclude(manifest.paths, manifest.ignore));
|
|
522
659
|
for (const p of manifest.paths) {
|
|
523
660
|
const checkPath = p.endsWith("/") ? p.slice(0, -1) : p;
|
|
524
661
|
try {
|
|
525
|
-
await
|
|
662
|
+
await access5(join6(root, checkPath), constants5.F_OK);
|
|
526
663
|
} catch {
|
|
527
664
|
warn(`path does not exist: ${p}`);
|
|
528
665
|
}
|
|
@@ -537,8 +674,8 @@ async function trackSync() {
|
|
|
537
674
|
|
|
538
675
|
// src/commands/track/add.ts
|
|
539
676
|
import { join as join7 } from "path";
|
|
540
|
-
import { access as
|
|
541
|
-
import { constants as
|
|
677
|
+
import { access as access6 } from "fs/promises";
|
|
678
|
+
import { constants as constants6 } from "fs";
|
|
542
679
|
|
|
543
680
|
// src/core/paths.ts
|
|
544
681
|
import { resolve as resolve2, relative, sep } from "path";
|
|
@@ -590,11 +727,12 @@ function isPathCoveredByManifest(filePath, manifestPaths, ignorePaths = []) {
|
|
|
590
727
|
}
|
|
591
728
|
|
|
592
729
|
// src/commands/track/add.ts
|
|
593
|
-
var BLOCKED_BASENAMES = /* @__PURE__ */ new Set([".gitignore", ".gitignore-
|
|
730
|
+
var BLOCKED_BASENAMES = /* @__PURE__ */ new Set([".gitignore", ".gitignore.sgit-parked"]);
|
|
594
731
|
async function trackAdd(paths, opts = {}) {
|
|
595
732
|
const root = await findRoot();
|
|
596
733
|
await validateShadowRepo(root);
|
|
597
|
-
const sgitDir =
|
|
734
|
+
const sgitDir = await resolveShadowDir(root);
|
|
735
|
+
const mainDir = await resolveMainDir(root);
|
|
598
736
|
const added = [];
|
|
599
737
|
for (const inputPath of paths) {
|
|
600
738
|
const normalized = await normalizePath(inputPath, root);
|
|
@@ -606,7 +744,7 @@ async function trackAdd(paths, opts = {}) {
|
|
|
606
744
|
}
|
|
607
745
|
const checkPath = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
|
608
746
|
try {
|
|
609
|
-
await
|
|
747
|
+
await access6(join7(root, checkPath), constants6.F_OK);
|
|
610
748
|
} catch {
|
|
611
749
|
throw new PathNotFoundError(normalized);
|
|
612
750
|
}
|
|
@@ -615,7 +753,7 @@ async function trackAdd(paths, opts = {}) {
|
|
|
615
753
|
let trackedByMain = false;
|
|
616
754
|
try {
|
|
617
755
|
await execGit(["ls-files", "--error-unmatch", checkPath], {
|
|
618
|
-
gitDir:
|
|
756
|
+
gitDir: mainDir,
|
|
619
757
|
workTree: root
|
|
620
758
|
});
|
|
621
759
|
trackedByMain = true;
|
|
@@ -624,7 +762,7 @@ async function trackAdd(paths, opts = {}) {
|
|
|
624
762
|
if (trackedByMain) {
|
|
625
763
|
if (opts.adopt && !alreadyTracked) {
|
|
626
764
|
const rmArgs = normalized.endsWith("/") ? ["rm", "--cached", "-r", checkPath] : ["rm", "--cached", checkPath];
|
|
627
|
-
await execGit(rmArgs, { gitDir:
|
|
765
|
+
await execGit(rmArgs, { gitDir: mainDir, workTree: root });
|
|
628
766
|
} else if (!opts.adopt) {
|
|
629
767
|
throw new PathTrackedByMainError(normalized);
|
|
630
768
|
}
|
|
@@ -653,11 +791,11 @@ async function trackAdd(paths, opts = {}) {
|
|
|
653
791
|
}
|
|
654
792
|
|
|
655
793
|
// src/commands/track/remove.ts
|
|
656
|
-
import {
|
|
794
|
+
import { resolve as resolve3, relative as relative2, sep as sep2 } from "path";
|
|
657
795
|
async function trackRemove(paths) {
|
|
658
796
|
const root = await findRoot();
|
|
659
797
|
await validateShadowRepo(root);
|
|
660
|
-
const sgitDir =
|
|
798
|
+
const sgitDir = await resolveShadowDir(root);
|
|
661
799
|
const removed = [];
|
|
662
800
|
for (const inputPath of paths) {
|
|
663
801
|
const absolute = resolve3(process.cwd(), inputPath);
|
|
@@ -701,26 +839,46 @@ async function trackList() {
|
|
|
701
839
|
return manifest.paths;
|
|
702
840
|
}
|
|
703
841
|
|
|
704
|
-
// src/commands/track/verify.ts
|
|
705
|
-
import { join as join10 } from "path";
|
|
706
|
-
|
|
707
842
|
// src/core/gitignore.ts
|
|
708
843
|
import {
|
|
709
844
|
readdirSync,
|
|
710
845
|
renameSync,
|
|
711
|
-
existsSync,
|
|
846
|
+
existsSync as existsSync2,
|
|
712
847
|
readFileSync,
|
|
713
848
|
writeFileSync,
|
|
714
849
|
unlinkSync
|
|
715
850
|
} from "fs";
|
|
716
|
-
import { join as
|
|
717
|
-
var DISABLED_NAME = ".gitignore-
|
|
851
|
+
import { join as join8, relative as relative3, sep as sep3 } from "path";
|
|
852
|
+
var DISABLED_NAME = ".gitignore.sgit-parked";
|
|
718
853
|
var LOCK_FILE = ".gitignore-guard";
|
|
854
|
+
function refuseIfSwapped(root, op) {
|
|
855
|
+
if (isSwapModeSync(root)) {
|
|
856
|
+
throw new SwapModeActiveError(op);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
function disableGitignores(root, manifestPaths) {
|
|
860
|
+
refuseIfSwapped(root, "disable gitignores");
|
|
861
|
+
const gitignores = findInTrackedDirs(root, manifestPaths, ".gitignore");
|
|
862
|
+
if (gitignores.length === 0) return [];
|
|
863
|
+
const stale = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
|
|
864
|
+
if (stale.length > 0) {
|
|
865
|
+
throw new StaleGitignoreError(stale);
|
|
866
|
+
}
|
|
867
|
+
const disabled = [];
|
|
868
|
+
for (const relPath of gitignores) {
|
|
869
|
+
const absPath = join8(root, relPath);
|
|
870
|
+
const dir = absPath.substring(0, absPath.length - ".gitignore".length);
|
|
871
|
+
renameSync(absPath, dir + DISABLED_NAME);
|
|
872
|
+
disabled.push(relPath);
|
|
873
|
+
}
|
|
874
|
+
return disabled;
|
|
875
|
+
}
|
|
719
876
|
function enableGitignores(root, manifestPaths) {
|
|
877
|
+
refuseIfSwapped(root, "enable gitignores");
|
|
720
878
|
const disabled = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
|
|
721
879
|
const enabled = [];
|
|
722
880
|
for (const relPath of disabled) {
|
|
723
|
-
const absPath =
|
|
881
|
+
const absPath = join8(root, relPath);
|
|
724
882
|
const restoredPath = absPath.substring(0, absPath.length - DISABLED_NAME.length) + ".gitignore";
|
|
725
883
|
renameSync(absPath, restoredPath);
|
|
726
884
|
enabled.push(relPath);
|
|
@@ -731,7 +889,7 @@ function findStaleGitignores(root, manifestPaths) {
|
|
|
731
889
|
return findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
|
|
732
890
|
}
|
|
733
891
|
function isGuardActive(root) {
|
|
734
|
-
const lockPath =
|
|
892
|
+
const lockPath = join8(root, ".sgit", LOCK_FILE);
|
|
735
893
|
try {
|
|
736
894
|
const pid = parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
|
|
737
895
|
if (isNaN(pid)) return false;
|
|
@@ -742,7 +900,8 @@ function isGuardActive(root) {
|
|
|
742
900
|
}
|
|
743
901
|
}
|
|
744
902
|
function setupGitignoreGuard(root, manifestPaths) {
|
|
745
|
-
|
|
903
|
+
refuseIfSwapped(root, "set up gitignore guard");
|
|
904
|
+
const lockPath = join8(root, ".sgit", LOCK_FILE);
|
|
746
905
|
if (isGuardActive(root)) {
|
|
747
906
|
return () => {
|
|
748
907
|
};
|
|
@@ -750,7 +909,7 @@ function setupGitignoreGuard(root, manifestPaths) {
|
|
|
750
909
|
const stale = findInTrackedDirs(root, manifestPaths, DISABLED_NAME);
|
|
751
910
|
if (stale.length > 0) {
|
|
752
911
|
warn(
|
|
753
|
-
"found stale .gitignore-
|
|
912
|
+
"found stale .gitignore.sgit-parked files from a previous crash; restoring"
|
|
754
913
|
);
|
|
755
914
|
enableGitignores(root, manifestPaths);
|
|
756
915
|
}
|
|
@@ -761,7 +920,7 @@ function setupGitignoreGuard(root, manifestPaths) {
|
|
|
761
920
|
};
|
|
762
921
|
}
|
|
763
922
|
for (const relPath of gitignores) {
|
|
764
|
-
const absPath =
|
|
923
|
+
const absPath = join8(root, relPath);
|
|
765
924
|
const dir = absPath.substring(0, absPath.length - ".gitignore".length);
|
|
766
925
|
renameSync(absPath, dir + DISABLED_NAME);
|
|
767
926
|
}
|
|
@@ -804,15 +963,15 @@ function findInTrackedDirs(root, manifestPaths, targetName) {
|
|
|
804
963
|
const found = /* @__PURE__ */ new Set();
|
|
805
964
|
for (const mPath of manifestPaths) {
|
|
806
965
|
for (const ancestor of getAncestorDirs(mPath)) {
|
|
807
|
-
const dirAbs = ancestor === "" ? root :
|
|
808
|
-
const targetPath =
|
|
809
|
-
if (
|
|
966
|
+
const dirAbs = ancestor === "" ? root : join8(root, ancestor);
|
|
967
|
+
const targetPath = join8(dirAbs, targetName);
|
|
968
|
+
if (existsSync2(targetPath)) {
|
|
810
969
|
found.add(targetPath);
|
|
811
970
|
}
|
|
812
971
|
}
|
|
813
972
|
if (mPath.endsWith("/")) {
|
|
814
|
-
const dirAbs =
|
|
815
|
-
if (
|
|
973
|
+
const dirAbs = join8(root, mPath);
|
|
974
|
+
if (existsSync2(dirAbs)) {
|
|
816
975
|
walkSync(dirAbs, targetName, found);
|
|
817
976
|
}
|
|
818
977
|
}
|
|
@@ -840,7 +999,7 @@ function walkSync(dir, targetName, results) {
|
|
|
840
999
|
if (entry.isDirectory() && (entry.name === ".git" || entry.name === ".sgit")) {
|
|
841
1000
|
continue;
|
|
842
1001
|
}
|
|
843
|
-
const fullPath =
|
|
1002
|
+
const fullPath = join8(dir, entry.name);
|
|
844
1003
|
if (entry.isDirectory()) {
|
|
845
1004
|
walkSync(fullPath, targetName, results);
|
|
846
1005
|
} else if (entry.name === targetName) {
|
|
@@ -859,17 +1018,17 @@ function cleanStaleLock(lockPath) {
|
|
|
859
1018
|
async function verifyMain(root) {
|
|
860
1019
|
const manifest = await readManifest(root);
|
|
861
1020
|
const violations = [];
|
|
862
|
-
if (!isGuardActive(root)) {
|
|
1021
|
+
if (!isGuardActive(root) && !await isSwapMode(root)) {
|
|
863
1022
|
const stale = findStaleGitignores(root, manifest.paths);
|
|
864
1023
|
violations.push(
|
|
865
1024
|
...stale.map(
|
|
866
|
-
(f) => `${f} (stale .gitignore-
|
|
1025
|
+
(f) => `${f} (stale .gitignore.sgit-parked \u2014 rename back to .gitignore)`
|
|
867
1026
|
)
|
|
868
1027
|
);
|
|
869
1028
|
}
|
|
870
1029
|
try {
|
|
871
1030
|
const staged = await execGit(["diff", "--cached", "--name-only", "--diff-filter=ACMR"], {
|
|
872
|
-
gitDir:
|
|
1031
|
+
gitDir: await resolveMainDir(root),
|
|
873
1032
|
workTree: root
|
|
874
1033
|
});
|
|
875
1034
|
if (staged.trim()) {
|
|
@@ -892,13 +1051,13 @@ async function verifyShadow(root) {
|
|
|
892
1051
|
violations: [".sgit.json (missing or invalid on disk)"]
|
|
893
1052
|
};
|
|
894
1053
|
}
|
|
895
|
-
const sgitDir =
|
|
1054
|
+
const sgitDir = await resolveShadowDir(root);
|
|
896
1055
|
const violations = [];
|
|
897
|
-
if (!isGuardActive(root)) {
|
|
1056
|
+
if (!isGuardActive(root) && !await isSwapMode(root)) {
|
|
898
1057
|
const stale = findStaleGitignores(root, manifest.paths);
|
|
899
1058
|
violations.push(
|
|
900
1059
|
...stale.map(
|
|
901
|
-
(f) => `${f} (stale .gitignore-
|
|
1060
|
+
(f) => `${f} (stale .gitignore.sgit-parked \u2014 rename back to .gitignore)`
|
|
902
1061
|
)
|
|
903
1062
|
);
|
|
904
1063
|
}
|
|
@@ -987,19 +1146,22 @@ async function trackVerify(opts) {
|
|
|
987
1146
|
}
|
|
988
1147
|
|
|
989
1148
|
// src/commands/track/clone.ts
|
|
990
|
-
import { join as
|
|
991
|
-
import { access as
|
|
992
|
-
import { constants as
|
|
1149
|
+
import { join as join9, extname } from "path";
|
|
1150
|
+
import { access as access7, readFile as readFile5, writeFile as writeFile5, rename as rename2, mkdir as mkdir4, rm as rm2 } from "fs/promises";
|
|
1151
|
+
import { constants as constants7 } from "fs";
|
|
993
1152
|
import { createInterface } from "readline";
|
|
994
1153
|
import { randomBytes } from "crypto";
|
|
995
1154
|
async function trackClone(url, opts = {}) {
|
|
996
1155
|
const root = await findRoot();
|
|
1156
|
+
if (await isSwapMode(root)) {
|
|
1157
|
+
throw new SwapModeActiveError("clone");
|
|
1158
|
+
}
|
|
997
1159
|
await validateNotWorktree(root);
|
|
998
1160
|
await validateNotSubmodule(root);
|
|
999
1161
|
await validateGitIdentity(root);
|
|
1000
|
-
const sgitDir =
|
|
1162
|
+
const sgitDir = join9(root, ".sgit");
|
|
1001
1163
|
try {
|
|
1002
|
-
await
|
|
1164
|
+
await access7(sgitDir, constants7.F_OK);
|
|
1003
1165
|
throw new AlreadyInitializedError();
|
|
1004
1166
|
} catch (err) {
|
|
1005
1167
|
if (err instanceof AlreadyInitializedError) throw err;
|
|
@@ -1015,6 +1177,11 @@ async function trackClone(url, opts = {}) {
|
|
|
1015
1177
|
}
|
|
1016
1178
|
await execGit(["config", "core.bare", "false"], { gitDir: sgitDir });
|
|
1017
1179
|
await execGit(["config", "core.worktree", root], { gitDir: sgitDir });
|
|
1180
|
+
await execGit(
|
|
1181
|
+
["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"],
|
|
1182
|
+
{ gitDir: sgitDir }
|
|
1183
|
+
);
|
|
1184
|
+
await execGit(["fetch", "origin"], { gitDir: sgitDir });
|
|
1018
1185
|
try {
|
|
1019
1186
|
const name = await execGit(["config", "user.name"], { cwd: root });
|
|
1020
1187
|
if (name) {
|
|
@@ -1047,7 +1214,7 @@ async function trackClone(url, opts = {}) {
|
|
|
1047
1214
|
const conflicts = [];
|
|
1048
1215
|
for (const file of trackedFiles) {
|
|
1049
1216
|
try {
|
|
1050
|
-
await
|
|
1217
|
+
await access7(join9(root, file), constants7.F_OK);
|
|
1051
1218
|
conflicts.push(file);
|
|
1052
1219
|
} catch {
|
|
1053
1220
|
}
|
|
@@ -1066,19 +1233,19 @@ async function trackClone(url, opts = {}) {
|
|
|
1066
1233
|
if (choice === 2) {
|
|
1067
1234
|
const savedContent = /* @__PURE__ */ new Map();
|
|
1068
1235
|
for (const file of conflicts) {
|
|
1069
|
-
savedContent.set(file, await readFile5(
|
|
1236
|
+
savedContent.set(file, await readFile5(join9(root, file)));
|
|
1070
1237
|
}
|
|
1071
1238
|
if (trackedFiles.length > 0) {
|
|
1072
1239
|
await execGit(["checkout", "HEAD", "--", "."], { gitDir: sgitDir, workTree: root });
|
|
1073
1240
|
}
|
|
1074
1241
|
for (const [file, content] of savedContent) {
|
|
1075
|
-
await mkdir4(
|
|
1076
|
-
await writeFile5(
|
|
1242
|
+
await mkdir4(join9(root, file, ".."), { recursive: true });
|
|
1243
|
+
await writeFile5(join9(root, file), content);
|
|
1077
1244
|
}
|
|
1078
1245
|
} else if (choice === 3) {
|
|
1079
1246
|
for (const file of conflicts) {
|
|
1080
1247
|
const renamed = generateOriginalName(file);
|
|
1081
|
-
await rename2(
|
|
1248
|
+
await rename2(join9(root, file), join9(root, renamed));
|
|
1082
1249
|
}
|
|
1083
1250
|
if (trackedFiles.length > 0) {
|
|
1084
1251
|
await execGit(["checkout", "HEAD", "--", "."], { gitDir: sgitDir, workTree: root });
|
|
@@ -1146,13 +1313,16 @@ function generateOriginalName(filePath) {
|
|
|
1146
1313
|
}
|
|
1147
1314
|
|
|
1148
1315
|
// src/commands/track/destroy.ts
|
|
1149
|
-
import { join as
|
|
1316
|
+
import { join as join10, dirname as dirname2 } from "path";
|
|
1150
1317
|
import { readFile as readFile6, writeFile as writeFile6, rm as rm3, rmdir, unlink as unlink2 } from "fs/promises";
|
|
1151
1318
|
import { createInterface as createInterface2 } from "readline";
|
|
1152
1319
|
async function trackDestroy(opts) {
|
|
1153
1320
|
const root = await findRoot();
|
|
1321
|
+
if (await isSwapMode(root)) {
|
|
1322
|
+
throw new SwapModeActiveError("destroy");
|
|
1323
|
+
}
|
|
1154
1324
|
await validateShadowRepo(root);
|
|
1155
|
-
const sgitDir =
|
|
1325
|
+
const sgitDir = join10(root, ".sgit");
|
|
1156
1326
|
let trackedFiles = [];
|
|
1157
1327
|
try {
|
|
1158
1328
|
const output = await execGit(["ls-tree", "-r", "--name-only", "HEAD"], {
|
|
@@ -1193,7 +1363,7 @@ async function trackDestroy(opts) {
|
|
|
1193
1363
|
return;
|
|
1194
1364
|
}
|
|
1195
1365
|
}
|
|
1196
|
-
const excludePath =
|
|
1366
|
+
const excludePath = join10(root, ".git", "info", "exclude");
|
|
1197
1367
|
try {
|
|
1198
1368
|
const content = await readFile6(excludePath, "utf-8");
|
|
1199
1369
|
const lines = content.split("\n");
|
|
@@ -1229,7 +1399,7 @@ async function trackDestroy(opts) {
|
|
|
1229
1399
|
if (!opts.keepFiles && trackedFiles.length > 0) {
|
|
1230
1400
|
for (const file of trackedFiles) {
|
|
1231
1401
|
try {
|
|
1232
|
-
await unlink2(
|
|
1402
|
+
await unlink2(join10(root, file));
|
|
1233
1403
|
} catch {
|
|
1234
1404
|
}
|
|
1235
1405
|
}
|
|
@@ -1244,13 +1414,13 @@ async function trackDestroy(opts) {
|
|
|
1244
1414
|
const sortedDirs = [...dirs].sort((a, b) => b.length - a.length);
|
|
1245
1415
|
for (const dir of sortedDirs) {
|
|
1246
1416
|
try {
|
|
1247
|
-
await rmdir(
|
|
1417
|
+
await rmdir(join10(root, dir));
|
|
1248
1418
|
} catch {
|
|
1249
1419
|
}
|
|
1250
1420
|
}
|
|
1251
1421
|
}
|
|
1252
|
-
await rm3(
|
|
1253
|
-
await rm3(
|
|
1422
|
+
await rm3(join10(root, ".sgit"), { recursive: true, force: true });
|
|
1423
|
+
await rm3(join10(root, ".sgit.json"), { force: true });
|
|
1254
1424
|
if (opts.keepFiles) {
|
|
1255
1425
|
success("Shadow repository removed. Tracked files kept on disk.");
|
|
1256
1426
|
} else {
|
|
@@ -1271,11 +1441,10 @@ async function confirm(message) {
|
|
|
1271
1441
|
}
|
|
1272
1442
|
|
|
1273
1443
|
// src/commands/track/ignore.ts
|
|
1274
|
-
import { join as join13 } from "path";
|
|
1275
1444
|
async function trackIgnore(paths) {
|
|
1276
1445
|
const root = await findRoot();
|
|
1277
1446
|
await validateShadowRepo(root);
|
|
1278
|
-
const sgitDir =
|
|
1447
|
+
const sgitDir = await resolveShadowDir(root);
|
|
1279
1448
|
const added = [];
|
|
1280
1449
|
for (const inputPath of paths) {
|
|
1281
1450
|
const normalized = await normalizePath(inputPath, root);
|
|
@@ -1337,7 +1506,7 @@ async function trackUnignore(paths) {
|
|
|
1337
1506
|
}
|
|
1338
1507
|
|
|
1339
1508
|
// src/commands/track/completion.ts
|
|
1340
|
-
import { join as
|
|
1509
|
+
import { join as join11, dirname as dirname3 } from "path";
|
|
1341
1510
|
import { mkdir as mkdir5, writeFile as writeFile7 } from "fs/promises";
|
|
1342
1511
|
import { homedir } from "os";
|
|
1343
1512
|
function bashCompletion() {
|
|
@@ -1566,11 +1735,11 @@ function getInstallPath(shell) {
|
|
|
1566
1735
|
const home = homedir();
|
|
1567
1736
|
switch (shell) {
|
|
1568
1737
|
case "zsh":
|
|
1569
|
-
return
|
|
1738
|
+
return join11(home, ".zsh", "completions", "_sgit");
|
|
1570
1739
|
case "fish":
|
|
1571
|
-
return
|
|
1740
|
+
return join11(home, ".config", "fish", "completions", "sgit.fish");
|
|
1572
1741
|
default:
|
|
1573
|
-
return
|
|
1742
|
+
return join11(home, ".local", "share", "bash-completion", "completions", "sgit");
|
|
1574
1743
|
}
|
|
1575
1744
|
}
|
|
1576
1745
|
async function trackCompletion(action, opts) {
|
|
@@ -1596,6 +1765,76 @@ async function trackCompletion(action, opts) {
|
|
|
1596
1765
|
process.exit(1);
|
|
1597
1766
|
}
|
|
1598
1767
|
|
|
1768
|
+
// src/commands/track/swap.ts
|
|
1769
|
+
import { join as join12 } from "path";
|
|
1770
|
+
import { rename as rename3 } from "fs/promises";
|
|
1771
|
+
async function trackSwap() {
|
|
1772
|
+
const root = await findRoot();
|
|
1773
|
+
await validateShadowRepo(root);
|
|
1774
|
+
if (await isSwapMode(root)) {
|
|
1775
|
+
throw new AlreadySwappedError();
|
|
1776
|
+
}
|
|
1777
|
+
await assertNoInProgressOps(join12(root, ".git"));
|
|
1778
|
+
const manifest = await readManifest(root);
|
|
1779
|
+
const gitPath = join12(root, ".git");
|
|
1780
|
+
const sgitPath = join12(root, ".sgit");
|
|
1781
|
+
const disabledPath = join12(root, SWAP_MARKER);
|
|
1782
|
+
disableGitignores(root, manifest.paths);
|
|
1783
|
+
let gitRenamed = false;
|
|
1784
|
+
try {
|
|
1785
|
+
await rename3(gitPath, disabledPath);
|
|
1786
|
+
gitRenamed = true;
|
|
1787
|
+
await rename3(sgitPath, gitPath);
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
if (gitRenamed) {
|
|
1790
|
+
try {
|
|
1791
|
+
await rename3(disabledPath, gitPath);
|
|
1792
|
+
} catch {
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
try {
|
|
1796
|
+
enableGitignores(root, manifest.paths);
|
|
1797
|
+
} catch {
|
|
1798
|
+
}
|
|
1799
|
+
throw err;
|
|
1800
|
+
}
|
|
1801
|
+
success(
|
|
1802
|
+
"Swap mode enabled. .sgit is now .git; main git is parked at .git.sgit-parked."
|
|
1803
|
+
);
|
|
1804
|
+
info("Run 'sgit track unswap' to exit.");
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// src/commands/track/unswap.ts
|
|
1808
|
+
import { join as join13 } from "path";
|
|
1809
|
+
import { rename as rename4 } from "fs/promises";
|
|
1810
|
+
async function trackUnswap() {
|
|
1811
|
+
const root = await findRoot();
|
|
1812
|
+
if (!await isSwapMode(root)) {
|
|
1813
|
+
throw new NotSwappedError();
|
|
1814
|
+
}
|
|
1815
|
+
const gitPath = join13(root, ".git");
|
|
1816
|
+
const sgitPath = join13(root, ".sgit");
|
|
1817
|
+
const disabledPath = join13(root, SWAP_MARKER);
|
|
1818
|
+
await assertNoInProgressOps(gitPath);
|
|
1819
|
+
let shadowRenamed = false;
|
|
1820
|
+
try {
|
|
1821
|
+
await rename4(gitPath, sgitPath);
|
|
1822
|
+
shadowRenamed = true;
|
|
1823
|
+
await rename4(disabledPath, gitPath);
|
|
1824
|
+
} catch (err) {
|
|
1825
|
+
if (shadowRenamed) {
|
|
1826
|
+
try {
|
|
1827
|
+
await rename4(sgitPath, gitPath);
|
|
1828
|
+
} catch {
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
throw err;
|
|
1832
|
+
}
|
|
1833
|
+
const manifest = await readManifest(root);
|
|
1834
|
+
enableGitignores(root, manifest.paths);
|
|
1835
|
+
success("Swap mode disabled. Main git restored.");
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1599
1838
|
// src/commands/track/index.ts
|
|
1600
1839
|
function registerTrackCommands(program) {
|
|
1601
1840
|
const track = program.command("track").description("Manage shadow-tracked paths");
|
|
@@ -1622,6 +1861,8 @@ function registerTrackCommands(program) {
|
|
|
1622
1861
|
async (action, opts) => trackCompletion(action, opts)
|
|
1623
1862
|
)
|
|
1624
1863
|
);
|
|
1864
|
+
track.command("swap").description("Swap .git with .sgit so IDE tools see the shadow repo as the active git").action(wrapAction(trackSwap));
|
|
1865
|
+
track.command("unswap").description("Exit swap mode and restore the main repository").action(wrapAction(trackUnswap));
|
|
1625
1866
|
}
|
|
1626
1867
|
function wrapAction(fn) {
|
|
1627
1868
|
return async (...args) => {
|
|
@@ -1682,11 +1923,191 @@ function wrapAction2(fn) {
|
|
|
1682
1923
|
};
|
|
1683
1924
|
}
|
|
1684
1925
|
|
|
1926
|
+
// src/core/update-check.ts
|
|
1927
|
+
import { spawn } from "child_process";
|
|
1928
|
+
import {
|
|
1929
|
+
mkdirSync,
|
|
1930
|
+
readFileSync as readFileSync2,
|
|
1931
|
+
statSync,
|
|
1932
|
+
unlinkSync as unlinkSync2,
|
|
1933
|
+
writeFileSync as writeFileSync2
|
|
1934
|
+
} from "fs";
|
|
1935
|
+
import { homedir as homedir2 } from "os";
|
|
1936
|
+
import { isAbsolute, join as join14 } from "path";
|
|
1937
|
+
var PACKAGE_NAME = "shadow-git";
|
|
1938
|
+
var DIST_TAGS_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`;
|
|
1939
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
1940
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
1941
|
+
var LOCK_STALE_MS = 6e4;
|
|
1942
|
+
var STATE_FILE = "update-check.json";
|
|
1943
|
+
var LOCK_FILE2 = "update-check.lock";
|
|
1944
|
+
var VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
1945
|
+
var UPDATE_CHECK_ARG = "__update-check";
|
|
1946
|
+
var HANDLED_ENV = "SGIT_UPDATE_NOTICE_HANDLED";
|
|
1947
|
+
function setupUpdateNotifier(currentVersion, entry, args) {
|
|
1948
|
+
const nested = Boolean(process.env[HANDLED_ENV]);
|
|
1949
|
+
process.env[HANDLED_ENV] = "1";
|
|
1950
|
+
if (nested || !isEnabled(args)) return;
|
|
1951
|
+
try {
|
|
1952
|
+
const dir = getCacheDir();
|
|
1953
|
+
if (isCheckDue(readState(dir)) && acquireLock(dir)) {
|
|
1954
|
+
spawnWorker(dir, entry);
|
|
1955
|
+
}
|
|
1956
|
+
process.on("exit", () => showNotice(dir, currentVersion));
|
|
1957
|
+
} catch {
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
async function runUpdateCheckWorker() {
|
|
1961
|
+
try {
|
|
1962
|
+
const dir = getCacheDir();
|
|
1963
|
+
try {
|
|
1964
|
+
const latest = await fetchLatestVersion();
|
|
1965
|
+
if (latest !== void 0) {
|
|
1966
|
+
writeState(dir, { checkedAt: Date.now(), latest });
|
|
1967
|
+
}
|
|
1968
|
+
} finally {
|
|
1969
|
+
releaseLock(dir);
|
|
1970
|
+
}
|
|
1971
|
+
} catch {
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
function isNewerVersion(latest, current) {
|
|
1975
|
+
const a = VERSION_RE.exec(latest);
|
|
1976
|
+
const b = VERSION_RE.exec(current);
|
|
1977
|
+
if (!a || !b) return false;
|
|
1978
|
+
for (let i = 1; i <= 3; i++) {
|
|
1979
|
+
const diff = Number(a[i]) - Number(b[i]);
|
|
1980
|
+
if (diff !== 0) return diff > 0;
|
|
1981
|
+
}
|
|
1982
|
+
return a[4] === void 0 && b[4] !== void 0;
|
|
1983
|
+
}
|
|
1984
|
+
function getCacheDir() {
|
|
1985
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
1986
|
+
const base = xdg && isAbsolute(xdg) ? xdg : join14(homedir2(), ".cache");
|
|
1987
|
+
return join14(base, PACKAGE_NAME);
|
|
1988
|
+
}
|
|
1989
|
+
function isEnabled(args) {
|
|
1990
|
+
if (!process.stdout.isTTY || !process.stderr.isTTY) return false;
|
|
1991
|
+
if (process.env.NO_UPDATE_NOTIFIER) return false;
|
|
1992
|
+
if (args[0] === "track" && args[1] === "verify") return false;
|
|
1993
|
+
return true;
|
|
1994
|
+
}
|
|
1995
|
+
function isCheckDue(state) {
|
|
1996
|
+
if (!state) return true;
|
|
1997
|
+
const age = Date.now() - state.checkedAt;
|
|
1998
|
+
return age < 0 || age >= DAY_MS;
|
|
1999
|
+
}
|
|
2000
|
+
function showNotice(dir, currentVersion) {
|
|
2001
|
+
try {
|
|
2002
|
+
const latest = readState(dir)?.latest;
|
|
2003
|
+
if (latest !== void 0 && isNewerVersion(latest, currentVersion)) {
|
|
2004
|
+
updateNotice(currentVersion, latest);
|
|
2005
|
+
}
|
|
2006
|
+
} catch {
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
function spawnWorker(dir, entry) {
|
|
2010
|
+
try {
|
|
2011
|
+
const child = spawn(process.execPath, [entry, UPDATE_CHECK_ARG], {
|
|
2012
|
+
cwd: dir,
|
|
2013
|
+
// not the user's repo — a lingering cwd can block deleting it
|
|
2014
|
+
detached: true,
|
|
2015
|
+
stdio: "ignore",
|
|
2016
|
+
windowsHide: true
|
|
2017
|
+
});
|
|
2018
|
+
child.on("error", () => releaseLock(dir));
|
|
2019
|
+
child.unref();
|
|
2020
|
+
} catch {
|
|
2021
|
+
releaseLock(dir);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
async function fetchLatestVersion() {
|
|
2025
|
+
const res = await fetch(DIST_TAGS_URL, {
|
|
2026
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
2027
|
+
});
|
|
2028
|
+
if (!res.ok) return void 0;
|
|
2029
|
+
const tags = await res.json();
|
|
2030
|
+
return typeof tags?.latest === "string" ? tags.latest : void 0;
|
|
2031
|
+
}
|
|
2032
|
+
function readState(dir) {
|
|
2033
|
+
try {
|
|
2034
|
+
const data = JSON.parse(
|
|
2035
|
+
readFileSync2(join14(dir, STATE_FILE), "utf-8")
|
|
2036
|
+
);
|
|
2037
|
+
if (typeof data?.checkedAt !== "number" || typeof data.latest !== "string") {
|
|
2038
|
+
return void 0;
|
|
2039
|
+
}
|
|
2040
|
+
return { checkedAt: data.checkedAt, latest: data.latest };
|
|
2041
|
+
} catch {
|
|
2042
|
+
return void 0;
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
function writeState(dir, state) {
|
|
2046
|
+
writeFileSync2(join14(dir, STATE_FILE), JSON.stringify(state, null, 2) + "\n");
|
|
2047
|
+
}
|
|
2048
|
+
function acquireLock(dir) {
|
|
2049
|
+
const lockPath = join14(dir, LOCK_FILE2);
|
|
2050
|
+
try {
|
|
2051
|
+
mkdirSync(dir, { recursive: true });
|
|
2052
|
+
writeFileSync2(lockPath, String(process.pid), { flag: "wx" });
|
|
2053
|
+
return true;
|
|
2054
|
+
} catch {
|
|
2055
|
+
}
|
|
2056
|
+
try {
|
|
2057
|
+
const age = Math.abs(Date.now() - statSync(lockPath).mtimeMs);
|
|
2058
|
+
if (age < LOCK_STALE_MS) return false;
|
|
2059
|
+
writeFileSync2(lockPath, String(process.pid));
|
|
2060
|
+
return true;
|
|
2061
|
+
} catch {
|
|
2062
|
+
return false;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
function releaseLock(dir) {
|
|
2066
|
+
try {
|
|
2067
|
+
unlinkSync2(join14(dir, LOCK_FILE2));
|
|
2068
|
+
} catch {
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
|
|
1685
2072
|
// src/cli.ts
|
|
1686
2073
|
var COMMANDER_COMMANDS = /* @__PURE__ */ new Set(["track", "debug"]);
|
|
2074
|
+
function readVersion() {
|
|
2075
|
+
try {
|
|
2076
|
+
const pkg = JSON.parse(
|
|
2077
|
+
readFileSync3(new URL("../package.json", import.meta.url), "utf-8")
|
|
2078
|
+
);
|
|
2079
|
+
return typeof pkg.version === "string" ? pkg.version : void 0;
|
|
2080
|
+
} catch {
|
|
2081
|
+
return void 0;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
1687
2084
|
async function run() {
|
|
1688
2085
|
const args = process.argv.slice(2);
|
|
1689
|
-
|
|
2086
|
+
if (args[0] === UPDATE_CHECK_ARG) {
|
|
2087
|
+
await runUpdateCheckWorker();
|
|
2088
|
+
process.exit(0);
|
|
2089
|
+
}
|
|
2090
|
+
const version = readVersion();
|
|
2091
|
+
if (version) {
|
|
2092
|
+
setupUpdateNotifier(version, fileURLToPath(import.meta.url), args);
|
|
2093
|
+
}
|
|
2094
|
+
let swapActive = false;
|
|
2095
|
+
try {
|
|
2096
|
+
const root = await findRoot();
|
|
2097
|
+
await assertConsistentState(root);
|
|
2098
|
+
swapActive = await isSwapMode(root);
|
|
2099
|
+
} catch (err) {
|
|
2100
|
+
if (err instanceof SwapModeCorruptError) {
|
|
2101
|
+
error(err.message);
|
|
2102
|
+
process.exit(1);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
const suppressBanner = args[0] === "track" && (args[1] === "swap" || args[1] === "unswap");
|
|
2106
|
+
if (swapActive && !suppressBanner) {
|
|
2107
|
+
swapModeBanner();
|
|
2108
|
+
}
|
|
2109
|
+
const selfManagedGuard = args[0] === "track" && (args[1] === "init" || args[1] === "clone" || args[1] === "swap" || args[1] === "unswap");
|
|
2110
|
+
const skipGuard = selfManagedGuard || args[0] === "debug" || swapActive;
|
|
1690
2111
|
let guardCleanup;
|
|
1691
2112
|
if (!skipGuard) {
|
|
1692
2113
|
try {
|
|
@@ -1698,7 +2119,7 @@ async function run() {
|
|
|
1698
2119
|
}
|
|
1699
2120
|
try {
|
|
1700
2121
|
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("
|
|
2122
|
+
const program = new Command2("sgit").description("Shadow Git \u2014 a secondary git repository for your project").version(version ?? "unknown");
|
|
1702
2123
|
registerTrackCommands(program);
|
|
1703
2124
|
registerDebugCommands(program);
|
|
1704
2125
|
program.addHelpText(
|
|
@@ -1714,6 +2135,8 @@ Track commands:
|
|
|
1714
2135
|
sgit track unignore Stop ignoring paths
|
|
1715
2136
|
sgit track sync Regenerate exclude files
|
|
1716
2137
|
sgit track verify Validate staged files
|
|
2138
|
+
sgit track swap Swap .git with .sgit for IDE compatibility
|
|
2139
|
+
sgit track unswap Exit swap mode
|
|
1717
2140
|
sgit track destroy Remove shadow repository
|
|
1718
2141
|
sgit track completion Install shell completions
|
|
1719
2142
|
|
|
@@ -1724,7 +2147,7 @@ All other commands are forwarded to git in the shadow repository:
|
|
|
1724
2147
|
} else {
|
|
1725
2148
|
const root = await findRoot();
|
|
1726
2149
|
await validateShadowRepo(root);
|
|
1727
|
-
const exitCode = forwardToGit(root, args);
|
|
2150
|
+
const exitCode = await forwardToGit(root, args);
|
|
1728
2151
|
guardCleanup?.();
|
|
1729
2152
|
guardCleanup = void 0;
|
|
1730
2153
|
process.exit(exitCode);
|