shadow-git 0.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.
Files changed (80) hide show
  1. package/.claude/settings.local.json +39 -0
  2. package/.temp/.sgit/COMMIT_EDITMSG +1 -0
  3. package/.temp/.sgit/HEAD +1 -0
  4. package/.temp/.sgit/config +8 -0
  5. package/.temp/.sgit/description +1 -0
  6. package/.temp/.sgit/hooks/applypatch-msg.sample +15 -0
  7. package/.temp/.sgit/hooks/commit-msg.sample +24 -0
  8. package/.temp/.sgit/hooks/fsmonitor-watchman.sample +174 -0
  9. package/.temp/.sgit/hooks/post-update.sample +8 -0
  10. package/.temp/.sgit/hooks/pre-applypatch.sample +14 -0
  11. package/.temp/.sgit/hooks/pre-commit +6 -0
  12. package/.temp/.sgit/hooks/pre-commit.sample +49 -0
  13. package/.temp/.sgit/hooks/pre-merge-commit.sample +13 -0
  14. package/.temp/.sgit/hooks/pre-push.sample +53 -0
  15. package/.temp/.sgit/hooks/pre-rebase.sample +169 -0
  16. package/.temp/.sgit/hooks/pre-receive.sample +24 -0
  17. package/.temp/.sgit/hooks/prepare-commit-msg.sample +42 -0
  18. package/.temp/.sgit/hooks/push-to-checkout.sample +78 -0
  19. package/.temp/.sgit/hooks/sendemail-validate.sample +77 -0
  20. package/.temp/.sgit/hooks/update.sample +128 -0
  21. package/.temp/.sgit/index +0 -0
  22. package/.temp/.sgit/info/exclude +11 -0
  23. package/.temp/.sgit/logs/HEAD +1 -0
  24. package/.temp/.sgit/logs/refs/heads/master +1 -0
  25. package/.temp/.sgit/objects/34/b3190a8a836593fb568a56c073a416a56fbf6d +0 -0
  26. package/.temp/.sgit/objects/70/ad6bbbfefd330d72a43dc2a810e6ef42269911 +0 -0
  27. package/.temp/.sgit/objects/90/4e09c7aadaa32d2426ab5464a975d5151ef195 +0 -0
  28. package/.temp/.sgit/objects/a4/6358feef49515d5f85d1d3a64e929182da7149 +3 -0
  29. package/.temp/.sgit/objects/e8/0f4253888cdadda1e3d810f6a3cb3c68c20b50 +0 -0
  30. package/.temp/.sgit/refs/heads/master +1 -0
  31. package/.temp/.sgit.json +6 -0
  32. package/.temp/README.md +3 -0
  33. package/.temp/docs/guide.md +10 -0
  34. package/.temp/lib/__init__.py +1 -0
  35. package/.temp/lib/utils.py +5 -0
  36. package/.temp/src/config.py +3 -0
  37. package/.temp/src/main.py +5 -0
  38. package/.temp/temp.md +3 -0
  39. package/.temp/tests/test_utils.py +7 -0
  40. package/CLAUDE.md +51 -0
  41. package/README.md +72 -0
  42. package/dist/cli.js +1052 -0
  43. package/package.json +35 -0
  44. package/src/cli.ts +55 -0
  45. package/src/commands/forward.ts +12 -0
  46. package/src/commands/track/add.ts +91 -0
  47. package/src/commands/track/completion.ts +243 -0
  48. package/src/commands/track/destroy.ts +81 -0
  49. package/src/commands/track/index.ts +91 -0
  50. package/src/commands/track/init.ts +94 -0
  51. package/src/commands/track/list.ts +15 -0
  52. package/src/commands/track/remove.ts +66 -0
  53. package/src/commands/track/sync.ts +50 -0
  54. package/src/commands/track/verify.ts +98 -0
  55. package/src/core/errors.ts +42 -0
  56. package/src/core/exclude.ts +106 -0
  57. package/src/core/git.ts +18 -0
  58. package/src/core/hooks.ts +152 -0
  59. package/src/core/manifest.ts +49 -0
  60. package/src/core/paths.ts +58 -0
  61. package/src/core/repository.ts +39 -0
  62. package/src/utils/logger.ts +21 -0
  63. package/src/utils/process.ts +13 -0
  64. package/tests/commands/forward.test.ts +55 -0
  65. package/tests/commands/track/add.test.ts +148 -0
  66. package/tests/commands/track/completion.test.ts +124 -0
  67. package/tests/commands/track/destroy.test.ts +112 -0
  68. package/tests/commands/track/init.test.ts +146 -0
  69. package/tests/commands/track/remove.test.ts +107 -0
  70. package/tests/commands/track/sync.test.ts +118 -0
  71. package/tests/commands/track/verify.test.ts +96 -0
  72. package/tests/core/exclude.test.ts +120 -0
  73. package/tests/core/hooks.test.ts +222 -0
  74. package/tests/core/manifest.test.ts +98 -0
  75. package/tests/core/paths.test.ts +113 -0
  76. package/tests/core/repository.test.ts +68 -0
  77. package/tests/helpers.ts +21 -0
  78. package/tsconfig.json +17 -0
  79. package/tsup.config.ts +11 -0
  80. package/vitest.config.ts +8 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1052 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/core/repository.ts
7
+ import { access, lstat } from "fs/promises";
8
+ import { constants } from "fs";
9
+ import { join, resolve } from "path";
10
+
11
+ // src/utils/process.ts
12
+ import { execFile } from "child_process";
13
+ import { promisify } from "util";
14
+ var execFileAsync = promisify(execFile);
15
+ async function exec(cmd, args, opts) {
16
+ const { stdout } = await execFileAsync(cmd, args, { ...opts, encoding: "utf8" });
17
+ return stdout.trimEnd();
18
+ }
19
+
20
+ // src/core/git.ts
21
+ async function execGit(args, opts) {
22
+ const gitArgs = [];
23
+ if (opts?.gitDir) gitArgs.push(`--git-dir=${opts.gitDir}`);
24
+ if (opts?.workTree) gitArgs.push(`--work-tree=${opts.workTree}`);
25
+ gitArgs.push(...args);
26
+ return exec("git", gitArgs, { cwd: opts?.cwd });
27
+ }
28
+
29
+ // src/core/errors.ts
30
+ var SgitError = class extends Error {
31
+ constructor(message) {
32
+ super(message);
33
+ this.name = "SgitError";
34
+ }
35
+ };
36
+ var NotGitRepoError = class extends SgitError {
37
+ constructor() {
38
+ super("not a git repository");
39
+ }
40
+ };
41
+ var AlreadyInitializedError = class extends SgitError {
42
+ constructor() {
43
+ super("shadow repository already initialized");
44
+ }
45
+ };
46
+ var NotInitializedError = class extends SgitError {
47
+ constructor() {
48
+ super("not a shadow git repository; run 'sgit track init' first");
49
+ }
50
+ };
51
+ var PathNotFoundError = class extends SgitError {
52
+ constructor(path) {
53
+ super(`path does not exist: ${path}`);
54
+ }
55
+ };
56
+ var PathTrackedByMainError = class extends SgitError {
57
+ constructor(path) {
58
+ super(`${path} is tracked by the main repository. Use 'sgit track add --adopt ${path}' to move it to shadow tracking.`);
59
+ }
60
+ };
61
+ var PathNotTrackedError = class extends SgitError {
62
+ constructor(path) {
63
+ super(`${path} is not tracked by sgit`);
64
+ }
65
+ };
66
+
67
+ // src/core/repository.ts
68
+ async function findRoot() {
69
+ try {
70
+ return await execGit(["rev-parse", "--show-toplevel"]);
71
+ } catch {
72
+ throw new NotGitRepoError();
73
+ }
74
+ }
75
+ async function validateShadowRepo(root) {
76
+ try {
77
+ await access(join(root, ".sgit"), constants.F_OK);
78
+ } catch {
79
+ throw new NotInitializedError();
80
+ }
81
+ }
82
+ async function validateNotWorktree(root) {
83
+ const gitDir = await execGit(["rev-parse", "--git-dir"], { cwd: root });
84
+ const commonDir = await execGit(["rev-parse", "--git-common-dir"], { cwd: root });
85
+ const absGitDir = resolve(root, gitDir);
86
+ const absCommonDir = resolve(root, commonDir);
87
+ if (absGitDir !== absCommonDir) {
88
+ throw new SgitError("shadow git is not supported in linked worktrees");
89
+ }
90
+ }
91
+ async function validateNotSubmodule(root) {
92
+ const gitPath = join(root, ".git");
93
+ const stats = await lstat(gitPath);
94
+ if (!stats.isDirectory()) {
95
+ throw new SgitError("shadow git is not supported inside git submodules");
96
+ }
97
+ }
98
+
99
+ // src/commands/forward.ts
100
+ import { spawnSync } from "child_process";
101
+ import { join as join2 } from "path";
102
+ function forwardToGit(root, args) {
103
+ const sgitDir = join2(root, ".sgit");
104
+ const result = spawnSync(
105
+ "git",
106
+ [`--git-dir=${sgitDir}`, `--work-tree=${root}`, ...args],
107
+ { stdio: "inherit" }
108
+ );
109
+ return result.status ?? 1;
110
+ }
111
+
112
+ // src/utils/logger.ts
113
+ var RESET = "\x1B[0m";
114
+ var GREEN = "\x1B[32m";
115
+ var YELLOW = "\x1B[33m";
116
+ var RED = "\x1B[31m";
117
+ var CYAN = "\x1B[36m";
118
+ function info(msg) {
119
+ process.stdout.write(`${CYAN}${msg}${RESET}
120
+ `);
121
+ }
122
+ function success(msg) {
123
+ process.stdout.write(`${GREEN}${msg}${RESET}
124
+ `);
125
+ }
126
+ function warn(msg) {
127
+ process.stderr.write(`${YELLOW}warning: ${msg}${RESET}
128
+ `);
129
+ }
130
+ function error(msg) {
131
+ process.stderr.write(`${RED}${msg}${RESET}
132
+ `);
133
+ }
134
+
135
+ // src/commands/track/init.ts
136
+ import { join as join5 } from "path";
137
+ import { mkdir as mkdir2, writeFile as writeFile3, readFile as readFile3, access as access3 } from "fs/promises";
138
+ import { constants as constants3 } from "fs";
139
+
140
+ // src/core/manifest.ts
141
+ import { readFile, writeFile } from "fs/promises";
142
+ import { join as join3 } from "path";
143
+ async function readManifest(root) {
144
+ const content = await readFile(join3(root, ".sgit.json"), "utf-8");
145
+ return JSON.parse(content);
146
+ }
147
+ async function writeManifest(root, data) {
148
+ await writeFile(
149
+ join3(root, ".sgit.json"),
150
+ JSON.stringify(data, null, 2) + "\n"
151
+ );
152
+ }
153
+ async function addPath(root, normalizedPath) {
154
+ const manifest = await readManifest(root);
155
+ if (manifest.paths.includes(normalizedPath)) {
156
+ return false;
157
+ }
158
+ manifest.paths.push(normalizedPath);
159
+ await writeManifest(root, manifest);
160
+ return true;
161
+ }
162
+ async function removePath(root, normalizedPath) {
163
+ const manifest = await readManifest(root);
164
+ const index = manifest.paths.indexOf(normalizedPath);
165
+ if (index === -1) {
166
+ return false;
167
+ }
168
+ manifest.paths.splice(index, 1);
169
+ await writeManifest(root, manifest);
170
+ return true;
171
+ }
172
+
173
+ // src/core/exclude.ts
174
+ var SGIT_MARKER = "# sgit";
175
+ var SGIT_MANAGED_HEADER = "# sgit-managed (do not edit below this line)";
176
+ function generateMainExclude(existingContent, manifestPaths) {
177
+ const lines = existingContent.split("\n");
178
+ const preserved = [];
179
+ let skipNext = false;
180
+ for (const line of lines) {
181
+ if (line === SGIT_MANAGED_HEADER) {
182
+ skipNext = false;
183
+ continue;
184
+ }
185
+ if (line === SGIT_MARKER) {
186
+ skipNext = true;
187
+ continue;
188
+ }
189
+ if (line.endsWith(` ${SGIT_MARKER}`)) {
190
+ continue;
191
+ }
192
+ if (skipNext) {
193
+ skipNext = false;
194
+ continue;
195
+ }
196
+ preserved.push(line);
197
+ }
198
+ while (preserved.length > 0 && preserved[preserved.length - 1].trim() === "") {
199
+ preserved.pop();
200
+ }
201
+ const sgitEntries = [".sgit", ".sgit.json", ...manifestPaths];
202
+ const sgitLines = sgitEntries.flatMap((entry) => [SGIT_MARKER, entry]);
203
+ const sections = [
204
+ ...preserved,
205
+ "",
206
+ SGIT_MANAGED_HEADER,
207
+ ...sgitLines
208
+ ];
209
+ return sections.join("\n") + "\n";
210
+ }
211
+ function generateShadowExclude(manifestPaths) {
212
+ const lines = [
213
+ "# Exclude everything by default",
214
+ "*",
215
+ "",
216
+ "# .sgit.json (always tracked)",
217
+ "!.sgit.json"
218
+ ];
219
+ const emitted = /* @__PURE__ */ new Set();
220
+ for (const trackedPath of manifestPaths) {
221
+ const pathLines = [];
222
+ const ancestors = getAncestors(trackedPath);
223
+ for (const ancestor of ancestors) {
224
+ const unignore = `!${ancestor}/`;
225
+ const reignore = `${ancestor}/*`;
226
+ if (!emitted.has(unignore)) {
227
+ pathLines.push(unignore);
228
+ emitted.add(unignore);
229
+ }
230
+ if (!emitted.has(reignore)) {
231
+ pathLines.push(reignore);
232
+ emitted.add(reignore);
233
+ }
234
+ }
235
+ const target = `!${trackedPath}`;
236
+ if (!emitted.has(target)) {
237
+ pathLines.push(target);
238
+ emitted.add(target);
239
+ }
240
+ if (pathLines.length > 0) {
241
+ lines.push("");
242
+ lines.push(`# ${trackedPath}`);
243
+ lines.push(...pathLines);
244
+ }
245
+ }
246
+ return lines.join("\n") + "\n";
247
+ }
248
+ function getAncestors(path) {
249
+ const cleanPath = path.endsWith("/") ? path.slice(0, -1) : path;
250
+ const parts = cleanPath.split("/");
251
+ parts.pop();
252
+ const ancestors = [];
253
+ for (let i = 1; i <= parts.length; i++) {
254
+ ancestors.push(parts.slice(0, i).join("/"));
255
+ }
256
+ return ancestors;
257
+ }
258
+
259
+ // src/core/hooks.ts
260
+ import { join as join4, resolve as resolvePath } from "path";
261
+ import {
262
+ mkdir,
263
+ writeFile as writeFile2,
264
+ rename,
265
+ access as access2,
266
+ lstat as lstat2,
267
+ unlink,
268
+ copyFile,
269
+ chmod,
270
+ rm,
271
+ readdir
272
+ } from "fs/promises";
273
+ import { constants as constants2 } from "fs";
274
+ var RUNNER_SCRIPT = `#!/bin/sh
275
+ # sgit hook runner \u2014 executes all hooks in pre-commit.d/
276
+ dir="$(dirname "$0")/pre-commit.d"
277
+ for hook in "$dir"/*; do
278
+ [ -f "$hook" ] && [ -x "$hook" ] || continue
279
+ "$hook"
280
+ status=$?
281
+ [ $status -ne 0 ] && exit $status
282
+ done
283
+ exit 0
284
+ `;
285
+ var MAIN_HOOK = `#!/bin/sh
286
+ if command -v sgit >/dev/null 2>&1; then
287
+ sgit track verify --main
288
+ else
289
+ echo "sgit: not found in PATH, skipping pre-commit check" >&2
290
+ fi
291
+ `;
292
+ var SHADOW_HOOK = `#!/bin/sh
293
+ if command -v sgit >/dev/null 2>&1; then
294
+ sgit track verify --shadow
295
+ else
296
+ echo "sgit: not found in PATH, skipping pre-commit check" >&2
297
+ fi
298
+ `;
299
+ async function getHookDir(root) {
300
+ try {
301
+ const hookPath = await execGit(["config", "core.hooksPath"], {
302
+ cwd: root
303
+ });
304
+ if (hookPath) {
305
+ return resolvePath(root, hookPath);
306
+ }
307
+ } catch {
308
+ }
309
+ return join4(root, ".git", "hooks");
310
+ }
311
+ async function installMainHook(root) {
312
+ const hookDir = await getHookDir(root);
313
+ const preCommitPath = join4(hookDir, "pre-commit");
314
+ const preCommitDDir = join4(hookDir, "pre-commit.d");
315
+ const sgitHookPath = join4(preCommitDDir, "sgit");
316
+ if (await exists(preCommitDDir)) {
317
+ await writeFile2(sgitHookPath, MAIN_HOOK, { mode: 493 });
318
+ return;
319
+ }
320
+ const hookExists = await exists(preCommitPath);
321
+ await mkdir(preCommitDDir, { recursive: true });
322
+ if (hookExists) {
323
+ const stats = await lstat2(preCommitPath);
324
+ if (stats.isSymbolicLink()) {
325
+ await copyFile(preCommitPath, join4(preCommitDDir, "original"));
326
+ await chmod(join4(preCommitDDir, "original"), 493);
327
+ await unlink(preCommitPath);
328
+ } else {
329
+ await rename(preCommitPath, join4(preCommitDDir, "original"));
330
+ }
331
+ }
332
+ await writeFile2(preCommitPath, RUNNER_SCRIPT, { mode: 493 });
333
+ await writeFile2(sgitHookPath, MAIN_HOOK, { mode: 493 });
334
+ }
335
+ async function installShadowHook(sgitDir) {
336
+ const hooksDir = join4(sgitDir, "hooks");
337
+ await mkdir(hooksDir, { recursive: true });
338
+ await writeFile2(join4(hooksDir, "pre-commit"), SHADOW_HOOK, { mode: 493 });
339
+ }
340
+ async function uninstallMainHook(root) {
341
+ const hookDir = await getHookDir(root);
342
+ const preCommitPath = join4(hookDir, "pre-commit");
343
+ const preCommitDDir = join4(hookDir, "pre-commit.d");
344
+ const sgitHookPath = join4(preCommitDDir, "sgit");
345
+ const originalPath = join4(preCommitDDir, "original");
346
+ try {
347
+ await unlink(sgitHookPath);
348
+ } catch {
349
+ return;
350
+ }
351
+ let remaining = [];
352
+ try {
353
+ remaining = await readdir(preCommitDDir);
354
+ } catch {
355
+ }
356
+ const hasOriginal = remaining.includes("original");
357
+ const hasOtherHooks = remaining.some((e) => e !== "original");
358
+ if (hasOriginal && !hasOtherHooks) {
359
+ await rename(originalPath, preCommitPath);
360
+ await rm(preCommitDDir, { recursive: true });
361
+ } else if (remaining.length === 0) {
362
+ await rm(preCommitDDir, { recursive: true });
363
+ try {
364
+ await unlink(preCommitPath);
365
+ } catch {
366
+ }
367
+ }
368
+ }
369
+ async function exists(path) {
370
+ try {
371
+ await access2(path, constants2.F_OK);
372
+ return true;
373
+ } catch {
374
+ return false;
375
+ }
376
+ }
377
+
378
+ // src/commands/track/init.ts
379
+ async function trackInit() {
380
+ const root = await findRoot();
381
+ await validateNotWorktree(root);
382
+ await validateNotSubmodule(root);
383
+ const sgitDir = join5(root, ".sgit");
384
+ try {
385
+ await access3(sgitDir, constants3.F_OK);
386
+ throw new AlreadyInitializedError();
387
+ } catch (err) {
388
+ if (err instanceof AlreadyInitializedError) throw err;
389
+ }
390
+ await execGit(["init", "--bare", sgitDir]);
391
+ await execGit(["config", "core.bare", "false"], { gitDir: sgitDir });
392
+ await execGit(["config", "core.worktree", root], { gitDir: sgitDir });
393
+ try {
394
+ const name = await execGit(["config", "user.name"], { cwd: root });
395
+ if (name) {
396
+ await execGit(["config", "user.name", name], { gitDir: sgitDir });
397
+ }
398
+ } catch {
399
+ }
400
+ try {
401
+ const email = await execGit(["config", "user.email"], { cwd: root });
402
+ if (email) {
403
+ await execGit(["config", "user.email", email], { gitDir: sgitDir });
404
+ }
405
+ } catch {
406
+ }
407
+ await mkdir2(join5(sgitDir, "info"), { recursive: true });
408
+ await writeFile3(
409
+ join5(sgitDir, "info", "exclude"),
410
+ generateShadowExclude([])
411
+ );
412
+ await writeManifest(root, { paths: [] });
413
+ const mainExcludePath = join5(root, ".git", "info", "exclude");
414
+ let existingExclude = "";
415
+ try {
416
+ existingExclude = await readFile3(mainExcludePath, "utf-8");
417
+ } catch {
418
+ }
419
+ await mkdir2(join5(root, ".git", "info"), { recursive: true });
420
+ await writeFile3(mainExcludePath, generateMainExclude(existingExclude, []));
421
+ await installMainHook(root);
422
+ await installShadowHook(sgitDir);
423
+ await execGit(["add", "-f", ".sgit.json"], {
424
+ gitDir: sgitDir,
425
+ workTree: root
426
+ });
427
+ await execGit(["commit", "-m", "sgit: initialize shadow repository"], {
428
+ gitDir: sgitDir,
429
+ workTree: root
430
+ });
431
+ success("Shadow repository initialized at .sgit/");
432
+ }
433
+
434
+ // src/commands/track/sync.ts
435
+ import { join as join6 } from "path";
436
+ import { writeFile as writeFile4, readFile as readFile4, mkdir as mkdir3, access as access4 } from "fs/promises";
437
+ import { constants as constants4 } from "fs";
438
+ async function syncExcludes(root) {
439
+ const manifest = await readManifest(root);
440
+ const mainExcludePath = join6(root, ".git", "info", "exclude");
441
+ let existingExclude = "";
442
+ try {
443
+ existingExclude = await readFile4(mainExcludePath, "utf-8");
444
+ } catch {
445
+ }
446
+ await mkdir3(join6(root, ".git", "info"), { recursive: true });
447
+ await writeFile4(
448
+ mainExcludePath,
449
+ generateMainExclude(existingExclude, manifest.paths)
450
+ );
451
+ const sgitExcludePath = join6(root, ".sgit", "info", "exclude");
452
+ await mkdir3(join6(root, ".sgit", "info"), { recursive: true });
453
+ await writeFile4(sgitExcludePath, generateShadowExclude(manifest.paths));
454
+ for (const p of manifest.paths) {
455
+ const checkPath = p.endsWith("/") ? p.slice(0, -1) : p;
456
+ try {
457
+ await access4(join6(root, checkPath), constants4.F_OK);
458
+ } catch {
459
+ warn(`path does not exist: ${p}`);
460
+ }
461
+ }
462
+ }
463
+ async function trackSync() {
464
+ const root = await findRoot();
465
+ await validateShadowRepo(root);
466
+ await syncExcludes(root);
467
+ success("Exclude files regenerated from .sgit.json");
468
+ }
469
+
470
+ // src/commands/track/add.ts
471
+ import { join as join7 } from "path";
472
+ import { access as access5 } from "fs/promises";
473
+ import { constants as constants5 } from "fs";
474
+
475
+ // src/core/paths.ts
476
+ import { resolve as resolve2, relative, sep } from "path";
477
+ import { stat } from "fs/promises";
478
+ async function normalizePath(inputPath, root) {
479
+ const absolute = resolve2(process.cwd(), inputPath);
480
+ let rel = relative(root, absolute);
481
+ if (rel.startsWith("..")) {
482
+ throw new SgitError(`path is outside the project root: ${inputPath}`);
483
+ }
484
+ rel = rel.split(sep).join("/");
485
+ try {
486
+ const st = await stat(absolute);
487
+ if (st.isDirectory() && !rel.endsWith("/")) {
488
+ rel += "/";
489
+ }
490
+ } catch {
491
+ if (inputPath.endsWith("/") && !rel.endsWith("/")) {
492
+ rel += "/";
493
+ }
494
+ }
495
+ return rel;
496
+ }
497
+ function isPathCoveredByManifest(filePath, manifestPaths) {
498
+ const caseInsensitive = process.platform === "darwin";
499
+ const normalize = caseInsensitive ? (s) => s.toLowerCase() : (s) => s;
500
+ const normalizedFile = normalize(filePath);
501
+ for (const mPath of manifestPaths) {
502
+ const normalizedManifest = normalize(mPath);
503
+ if (normalizedManifest === normalizedFile) return true;
504
+ if (normalizedManifest.endsWith("/") && normalizedFile.startsWith(normalizedManifest)) {
505
+ return true;
506
+ }
507
+ }
508
+ return false;
509
+ }
510
+
511
+ // src/commands/track/add.ts
512
+ async function trackAdd(paths, opts = {}) {
513
+ const root = await findRoot();
514
+ await validateShadowRepo(root);
515
+ const sgitDir = join7(root, ".sgit");
516
+ const added = [];
517
+ for (const inputPath of paths) {
518
+ const normalized = await normalizePath(inputPath, root);
519
+ const checkPath = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
520
+ try {
521
+ await access5(join7(root, checkPath), constants5.F_OK);
522
+ } catch {
523
+ throw new PathNotFoundError(normalized);
524
+ }
525
+ const manifest = await readManifest(root);
526
+ const alreadyTracked = manifest.paths.includes(normalized);
527
+ let trackedByMain = false;
528
+ try {
529
+ await execGit(["ls-files", "--error-unmatch", checkPath], {
530
+ gitDir: join7(root, ".git"),
531
+ workTree: root
532
+ });
533
+ trackedByMain = true;
534
+ } catch {
535
+ }
536
+ if (trackedByMain) {
537
+ if (opts.adopt && !alreadyTracked) {
538
+ const rmArgs = normalized.endsWith("/") ? ["rm", "--cached", "-r", checkPath] : ["rm", "--cached", checkPath];
539
+ await execGit(rmArgs, { gitDir: join7(root, ".git"), workTree: root });
540
+ } else if (!opts.adopt) {
541
+ throw new PathTrackedByMainError(normalized);
542
+ }
543
+ }
544
+ const wasAdded = await addPath(root, normalized);
545
+ if (wasAdded) {
546
+ added.push(normalized);
547
+ }
548
+ }
549
+ await syncExcludes(root);
550
+ for (const p of added) {
551
+ const gitPath = p.endsWith("/") ? p.slice(0, -1) : p;
552
+ await execGit(["add", "-f", gitPath], {
553
+ gitDir: sgitDir,
554
+ workTree: root
555
+ });
556
+ }
557
+ if (added.length > 0) {
558
+ success(`Tracking ${added.length} new path(s):`);
559
+ for (const p of added) {
560
+ info(` ${p}`);
561
+ }
562
+ } else {
563
+ info("All paths already tracked.");
564
+ }
565
+ }
566
+
567
+ // src/commands/track/remove.ts
568
+ import { join as join8, resolve as resolve3, relative as relative2, sep as sep2 } from "path";
569
+ async function trackRemove(paths) {
570
+ const root = await findRoot();
571
+ await validateShadowRepo(root);
572
+ const sgitDir = join8(root, ".sgit");
573
+ const removed = [];
574
+ for (const inputPath of paths) {
575
+ const absolute = resolve3(process.cwd(), inputPath);
576
+ let normalized = relative2(root, absolute).split(sep2).join("/");
577
+ if (inputPath.endsWith("/") && !normalized.endsWith("/")) {
578
+ normalized += "/";
579
+ }
580
+ const manifest = await readManifest(root);
581
+ const exactPath = manifest.paths.find((p) => p === normalized) || manifest.paths.find((p) => p === normalized + "/") || (normalized.endsWith("/") ? manifest.paths.find((p) => p === normalized.slice(0, -1)) : void 0);
582
+ if (!exactPath) {
583
+ throw new PathNotTrackedError(normalized);
584
+ }
585
+ const wasRemoved = await removePath(root, exactPath);
586
+ if (wasRemoved) {
587
+ removed.push(exactPath);
588
+ const rmPath = exactPath.endsWith("/") ? exactPath.slice(0, -1) : exactPath;
589
+ try {
590
+ const rmArgs = exactPath.endsWith("/") ? ["rm", "--cached", "-r", rmPath] : ["rm", "--cached", rmPath];
591
+ await execGit(rmArgs, { gitDir: sgitDir, workTree: root });
592
+ } catch {
593
+ }
594
+ }
595
+ }
596
+ await syncExcludes(root);
597
+ if (removed.length > 0) {
598
+ success(`Removed ${removed.length} path(s) from shadow tracking:`);
599
+ for (const p of removed) {
600
+ info(` ${p}`);
601
+ }
602
+ }
603
+ }
604
+
605
+ // src/commands/track/list.ts
606
+ async function trackList() {
607
+ const root = await findRoot();
608
+ await validateShadowRepo(root);
609
+ const manifest = await readManifest(root);
610
+ for (const p of manifest.paths) {
611
+ info(p);
612
+ }
613
+ return manifest.paths;
614
+ }
615
+
616
+ // src/commands/track/verify.ts
617
+ import { join as join9 } from "path";
618
+ async function verifyMain(root) {
619
+ const manifest = await readManifest(root);
620
+ let staged;
621
+ try {
622
+ staged = await execGit(["diff", "--cached", "--name-only"], {
623
+ gitDir: join9(root, ".git"),
624
+ workTree: root
625
+ });
626
+ } catch {
627
+ return { valid: true, violations: [] };
628
+ }
629
+ if (!staged.trim()) return { valid: true, violations: [] };
630
+ const stagedFiles = staged.trim().split("\n");
631
+ const violations = stagedFiles.filter(
632
+ (f) => isPathCoveredByManifest(f, manifest.paths)
633
+ );
634
+ return { valid: violations.length === 0, violations };
635
+ }
636
+ async function verifyShadow(root) {
637
+ const manifest = await readManifest(root);
638
+ let staged;
639
+ try {
640
+ staged = await execGit(["diff", "--cached", "--name-only"], {
641
+ gitDir: join9(root, ".sgit"),
642
+ workTree: root
643
+ });
644
+ } catch {
645
+ return { valid: true, violations: [] };
646
+ }
647
+ if (!staged.trim()) return { valid: true, violations: [] };
648
+ const stagedFiles = staged.trim().split("\n");
649
+ const violations = stagedFiles.filter(
650
+ (f) => f !== ".sgit.json" && !isPathCoveredByManifest(f, manifest.paths)
651
+ );
652
+ return { valid: violations.length === 0, violations };
653
+ }
654
+ async function trackVerify(opts) {
655
+ const root = await findRoot();
656
+ await validateShadowRepo(root);
657
+ if (!opts.main && !opts.shadow) {
658
+ throw new SgitError("specify --main or --shadow");
659
+ }
660
+ if (opts.main) {
661
+ const result = await verifyMain(root);
662
+ if (!result.valid) {
663
+ throw new SgitError(
664
+ [
665
+ "ERROR: The following files are tracked by shadow git and cannot be committed to the main repository:",
666
+ "",
667
+ ...result.violations.map((f) => ` ${f}`),
668
+ "",
669
+ "Remove them with 'git reset HEAD <file>', or untrack from sgit with 'sgit track remove <path>'."
670
+ ].join("\n")
671
+ );
672
+ }
673
+ }
674
+ if (opts.shadow) {
675
+ const result = await verifyShadow(root);
676
+ if (!result.valid) {
677
+ throw new SgitError(
678
+ [
679
+ "ERROR: The following files are not listed in .sgit.json and cannot be committed to the shadow repository:",
680
+ "",
681
+ ...result.violations.map((f) => ` ${f}`),
682
+ "",
683
+ "Add them with 'sgit track add <path>' first."
684
+ ].join("\n")
685
+ );
686
+ }
687
+ }
688
+ }
689
+
690
+ // src/commands/track/destroy.ts
691
+ import { join as join10 } from "path";
692
+ import { readFile as readFile5, writeFile as writeFile5, rm as rm2 } from "fs/promises";
693
+ import { createInterface } from "readline";
694
+ async function trackDestroy(opts) {
695
+ const root = await findRoot();
696
+ await validateShadowRepo(root);
697
+ if (!opts.force) {
698
+ const confirmed = await confirm(
699
+ "This will permanently remove the shadow repository. Continue?"
700
+ );
701
+ if (!confirmed) {
702
+ info("Aborted.");
703
+ return;
704
+ }
705
+ }
706
+ const excludePath = join10(root, ".git", "info", "exclude");
707
+ try {
708
+ const content = await readFile5(excludePath, "utf-8");
709
+ const lines = content.split("\n");
710
+ const result = [];
711
+ let skipNext = false;
712
+ for (const line of lines) {
713
+ if (line === SGIT_MANAGED_HEADER) {
714
+ skipNext = false;
715
+ continue;
716
+ }
717
+ if (line === SGIT_MARKER) {
718
+ skipNext = true;
719
+ continue;
720
+ }
721
+ if (line.endsWith(` ${SGIT_MARKER}`)) {
722
+ continue;
723
+ }
724
+ if (skipNext) {
725
+ skipNext = false;
726
+ continue;
727
+ }
728
+ result.push(line);
729
+ }
730
+ const cleaned = result.join("\n");
731
+ await writeFile5(excludePath, cleaned.replace(/\n+$/, "\n"));
732
+ } catch {
733
+ }
734
+ await uninstallMainHook(root);
735
+ await rm2(join10(root, ".sgit"), { recursive: true, force: true });
736
+ await rm2(join10(root, ".sgit.json"), { force: true });
737
+ success("Shadow repository removed.");
738
+ }
739
+ async function confirm(message) {
740
+ const rl = createInterface({
741
+ input: process.stdin,
742
+ output: process.stdout
743
+ });
744
+ return new Promise((resolve4) => {
745
+ rl.question(`${message} [y/N] `, (answer) => {
746
+ rl.close();
747
+ resolve4(answer.toLowerCase() === "y");
748
+ });
749
+ });
750
+ }
751
+
752
+ // src/commands/track/completion.ts
753
+ import { join as join11, dirname as dirname2 } from "path";
754
+ import { mkdir as mkdir4, writeFile as writeFile6 } from "fs/promises";
755
+ import { homedir } from "os";
756
+ function bashCompletion() {
757
+ return `# sgit bash completion
758
+ _sgit() {
759
+ local cur prev words cword
760
+ _init_completion || return
761
+
762
+ if [[ \${words[1]} == "track" ]]; then
763
+ if [[ $cword -eq 2 ]]; then
764
+ COMPREPLY=($(compgen -W "init add remove list sync verify destroy completion" -- "$cur"))
765
+ return
766
+ fi
767
+
768
+ local subcmd="\${words[2]}"
769
+ case "$subcmd" in
770
+ add)
771
+ if [[ $cur == -* ]]; then
772
+ COMPREPLY=($(compgen -W "--adopt" -- "$cur"))
773
+ else
774
+ _filedir
775
+ fi
776
+ ;;
777
+ remove)
778
+ local paths
779
+ paths=$(sgit track list 2>/dev/null)
780
+ COMPREPLY=($(compgen -W "$paths" -- "$cur"))
781
+ ;;
782
+ verify)
783
+ COMPREPLY=($(compgen -W "--main --shadow" -- "$cur"))
784
+ ;;
785
+ destroy)
786
+ COMPREPLY=($(compgen -W "--force" -- "$cur"))
787
+ ;;
788
+ completion)
789
+ if [[ $prev == "--shell" ]]; then
790
+ COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur"))
791
+ elif [[ $cur == -* ]]; then
792
+ COMPREPLY=($(compgen -W "--shell" -- "$cur"))
793
+ else
794
+ COMPREPLY=($(compgen -W "install" -- "$cur"))
795
+ fi
796
+ ;;
797
+ esac
798
+ return
799
+ fi
800
+
801
+ if [[ $cword -eq 1 ]]; then
802
+ # "track" plus all git subcommands
803
+ local git_cmds
804
+ git_cmds=$(git --list-cmds=main,others 2>/dev/null)
805
+ COMPREPLY=($(compgen -W "track $git_cmds" -- "$cur"))
806
+ return
807
+ fi
808
+
809
+ # Non-track commands: delegate to git completion
810
+ if declare -f __git_wrap__git_main &>/dev/null; then
811
+ __git_wrap__git_main
812
+ elif declare -f _git &>/dev/null; then
813
+ _git
814
+ fi
815
+ }
816
+ complete -F _sgit sgit
817
+ `;
818
+ }
819
+ function zshCompletion() {
820
+ return `#compdef sgit
821
+ # sgit zsh completion
822
+
823
+ _sgit_track() {
824
+ local -a subcmds
825
+ subcmds=(
826
+ 'init:Initialize shadow repository'
827
+ 'add:Start shadow-tracking paths'
828
+ 'remove:Stop shadow-tracking paths'
829
+ 'list:List all shadow-tracked paths'
830
+ 'sync:Regenerate exclude files'
831
+ 'verify:Validate staged files'
832
+ 'destroy:Remove shadow repository'
833
+ 'completion:Install shell completions'
834
+ )
835
+
836
+ if (( CURRENT == 2 )); then
837
+ _describe 'track subcommand' subcmds
838
+ return
839
+ fi
840
+
841
+ case "\${words[2]}" in
842
+ add)
843
+ _arguments '--adopt[Move main-git-tracked files to shadow tracking]' '*:file:_files'
844
+ ;;
845
+ remove)
846
+ local -a paths
847
+ paths=(\${(f)"$(sgit track list 2>/dev/null)"})
848
+ _describe 'tracked path' paths
849
+ ;;
850
+ verify)
851
+ _arguments '--main[Check main repo]' '--shadow[Check shadow repo]'
852
+ ;;
853
+ destroy)
854
+ _arguments '--force[Skip confirmation]'
855
+ ;;
856
+ completion)
857
+ _arguments '--shell[Shell type]:shell:(bash zsh fish)' '1:action:(install)'
858
+ ;;
859
+ esac
860
+ }
861
+
862
+ _sgit() {
863
+ if (( CURRENT == 2 )); then
864
+ local -a track_cmd
865
+ track_cmd=('track:Manage shadow-tracked paths')
866
+ _describe 'sgit command' track_cmd
867
+ # Also offer all git subcommands
868
+ words[1]=git
869
+ _git
870
+ return
871
+ fi
872
+
873
+ case "\${words[2]}" in
874
+ track)
875
+ (( CURRENT-- ))
876
+ shift words
877
+ _sgit_track
878
+ ;;
879
+ *)
880
+ words[1]=git
881
+ _git
882
+ ;;
883
+ esac
884
+ }
885
+
886
+ _sgit "$@"
887
+ `;
888
+ }
889
+ function fishCompletion() {
890
+ return `# sgit fish completion
891
+
892
+ # Inherit all git completions (sgit forwards non-track commands to git)
893
+ complete -c sgit -w git
894
+
895
+ # Add "track" as a top-level subcommand
896
+ complete -c sgit -n '__fish_use_subcommand' -a track -d 'Manage shadow-tracked paths'
897
+
898
+ # Track subcommands
899
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a init -d 'Initialize shadow repository'
900
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a add -d 'Start shadow-tracking paths'
901
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a remove -d 'Stop shadow-tracking paths'
902
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a list -d 'List all shadow-tracked paths'
903
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a sync -d 'Regenerate exclude files'
904
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a verify -d 'Validate staged files'
905
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a destroy -d 'Remove shadow repository'
906
+ complete -c sgit -n '__fish_seen_subcommand_from track; and not __fish_seen_subcommand_from init add remove list sync verify destroy completion' -a completion -d 'Install shell completions'
907
+
908
+ # Track add options and file completions
909
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from add' -l adopt -d 'Move main-git-tracked files'
910
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from add' -F
911
+
912
+ # Track remove \u2014 complete from manifest
913
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from remove' -a '(sgit track list 2>/dev/null)'
914
+
915
+ # Track verify options
916
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from verify' -l main -d 'Check main repo'
917
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from verify' -l shadow -d 'Check shadow repo'
918
+
919
+ # Track destroy options
920
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from destroy' -l force -d 'Skip confirmation'
921
+
922
+ # Track completion options
923
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from completion' -a install -d 'Install completions'
924
+ complete -c sgit -n '__fish_seen_subcommand_from track; and __fish_seen_subcommand_from completion' -l shell -d 'Shell type' -ra 'bash zsh fish'
925
+ `;
926
+ }
927
+ function detectShell() {
928
+ const shell = process.env.SHELL || "";
929
+ if (shell.includes("zsh")) return "zsh";
930
+ if (shell.includes("fish")) return "fish";
931
+ return "bash";
932
+ }
933
+ function getScript(shell) {
934
+ switch (shell) {
935
+ case "zsh":
936
+ return zshCompletion();
937
+ case "fish":
938
+ return fishCompletion();
939
+ default:
940
+ return bashCompletion();
941
+ }
942
+ }
943
+ function getInstallPath(shell) {
944
+ const home = homedir();
945
+ switch (shell) {
946
+ case "zsh":
947
+ return join11(home, ".zsh", "completions", "_sgit");
948
+ case "fish":
949
+ return join11(home, ".config", "fish", "completions", "sgit.fish");
950
+ default:
951
+ return join11(home, ".local", "share", "bash-completion", "completions", "sgit");
952
+ }
953
+ }
954
+ async function trackCompletion(action, opts) {
955
+ const shell = opts?.shell || detectShell();
956
+ const script = getScript(shell);
957
+ if (!action) {
958
+ process.stdout.write(script);
959
+ return;
960
+ }
961
+ if (action === "install") {
962
+ const installPath = getInstallPath(shell);
963
+ await mkdir4(dirname2(installPath), { recursive: true });
964
+ await writeFile6(installPath, script, { mode: 420 });
965
+ success(`Installed ${shell} completions to ${installPath}`);
966
+ if (shell === "zsh") {
967
+ info(
968
+ "Ensure ~/.zsh/completions is in your $fpath. Add to ~/.zshrc:\n fpath=(~/.zsh/completions $fpath)\n autoload -Uz compinit && compinit"
969
+ );
970
+ }
971
+ return;
972
+ }
973
+ error(`Unknown action: ${action}. Use 'install' or omit for stdout.`);
974
+ process.exit(1);
975
+ }
976
+
977
+ // src/commands/track/index.ts
978
+ function registerTrackCommands(program) {
979
+ const track = program.command("track").description("Manage shadow-tracked paths");
980
+ track.command("init").description("Initialize shadow repository").action(wrapAction(trackInit));
981
+ track.command("add").description("Start shadow-tracking paths").argument("<paths...>").option("--adopt", "Move main-git-tracked files to shadow tracking").action(wrapAction(async (paths, opts) => trackAdd(paths, opts)));
982
+ track.command("remove").description("Stop shadow-tracking paths").argument("<paths...>").action(wrapAction(async (paths) => trackRemove(paths)));
983
+ track.command("list").description("List all shadow-tracked paths").action(wrapAction(async () => {
984
+ await trackList();
985
+ }));
986
+ track.command("sync").description("Regenerate exclude files from .sgit.json").action(wrapAction(trackSync));
987
+ track.command("verify").description("Validate staged files against manifest").option("--main", "Check main repo staged files").option("--shadow", "Check shadow repo staged files").action(
988
+ wrapAction(
989
+ async (opts) => trackVerify(opts)
990
+ )
991
+ );
992
+ track.command("destroy").description("Remove shadow repository entirely").option("--force", "Skip confirmation prompt").action(
993
+ wrapAction(async (opts) => trackDestroy(opts))
994
+ );
995
+ track.command("completion").description("Install shell completion scripts").argument("[action]", "Action (install)").option("--shell <name>", "Shell (bash, zsh, fish)").action(
996
+ wrapAction(
997
+ async (action, opts) => trackCompletion(action, opts)
998
+ )
999
+ );
1000
+ }
1001
+ function wrapAction(fn) {
1002
+ return async (...args) => {
1003
+ try {
1004
+ await fn(...args);
1005
+ } catch (err) {
1006
+ if (err instanceof SgitError) {
1007
+ error(err.message);
1008
+ process.exit(1);
1009
+ }
1010
+ throw err;
1011
+ }
1012
+ };
1013
+ }
1014
+
1015
+ // src/cli.ts
1016
+ async function run() {
1017
+ const args = process.argv.slice(2);
1018
+ if (args.length === 0 || args[0] === "track" || args[0].startsWith("-")) {
1019
+ const program = new Command("sgit").description("Shadow Git \u2014 a secondary git repository for your project").version("0.1.0");
1020
+ registerTrackCommands(program);
1021
+ program.addHelpText(
1022
+ "after",
1023
+ `
1024
+ Track commands:
1025
+ sgit track init Initialize shadow repository
1026
+ sgit track add Start shadow-tracking paths (--adopt to move from main git)
1027
+ sgit track remove Stop shadow-tracking paths
1028
+ sgit track list List all shadow-tracked paths
1029
+ sgit track sync Regenerate exclude files
1030
+ sgit track verify Validate staged files
1031
+ sgit track destroy Remove shadow repository
1032
+ sgit track completion Install shell completions
1033
+
1034
+ All other commands are forwarded to git in the shadow repository:
1035
+ sgit status, sgit commit, sgit log, sgit diff, ...`
1036
+ );
1037
+ await program.parseAsync(process.argv);
1038
+ } else {
1039
+ const root = await findRoot();
1040
+ await validateShadowRepo(root);
1041
+ const exitCode = forwardToGit(root, args);
1042
+ process.exit(exitCode);
1043
+ }
1044
+ }
1045
+ run().catch((err) => {
1046
+ if (err instanceof SgitError) {
1047
+ error(err.message);
1048
+ } else {
1049
+ console.error(err);
1050
+ }
1051
+ process.exit(1);
1052
+ });