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.
- package/.claude/settings.local.json +39 -0
- package/.temp/.sgit/COMMIT_EDITMSG +1 -0
- package/.temp/.sgit/HEAD +1 -0
- package/.temp/.sgit/config +8 -0
- package/.temp/.sgit/description +1 -0
- package/.temp/.sgit/hooks/applypatch-msg.sample +15 -0
- package/.temp/.sgit/hooks/commit-msg.sample +24 -0
- package/.temp/.sgit/hooks/fsmonitor-watchman.sample +174 -0
- package/.temp/.sgit/hooks/post-update.sample +8 -0
- package/.temp/.sgit/hooks/pre-applypatch.sample +14 -0
- package/.temp/.sgit/hooks/pre-commit +6 -0
- package/.temp/.sgit/hooks/pre-commit.sample +49 -0
- package/.temp/.sgit/hooks/pre-merge-commit.sample +13 -0
- package/.temp/.sgit/hooks/pre-push.sample +53 -0
- package/.temp/.sgit/hooks/pre-rebase.sample +169 -0
- package/.temp/.sgit/hooks/pre-receive.sample +24 -0
- package/.temp/.sgit/hooks/prepare-commit-msg.sample +42 -0
- package/.temp/.sgit/hooks/push-to-checkout.sample +78 -0
- package/.temp/.sgit/hooks/sendemail-validate.sample +77 -0
- package/.temp/.sgit/hooks/update.sample +128 -0
- package/.temp/.sgit/index +0 -0
- package/.temp/.sgit/info/exclude +11 -0
- package/.temp/.sgit/logs/HEAD +1 -0
- package/.temp/.sgit/logs/refs/heads/master +1 -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 +3 -0
- package/.temp/.sgit/objects/e8/0f4253888cdadda1e3d810f6a3cb3c68c20b50 +0 -0
- package/.temp/.sgit/refs/heads/master +1 -0
- package/.temp/.sgit.json +6 -0
- package/.temp/README.md +3 -0
- package/.temp/docs/guide.md +10 -0
- package/.temp/lib/__init__.py +1 -0
- package/.temp/lib/utils.py +5 -0
- package/.temp/src/config.py +3 -0
- package/.temp/src/main.py +5 -0
- package/.temp/temp.md +3 -0
- package/.temp/tests/test_utils.py +7 -0
- package/CLAUDE.md +51 -0
- package/README.md +72 -0
- package/dist/cli.js +1052 -0
- package/package.json +35 -0
- package/src/cli.ts +55 -0
- package/src/commands/forward.ts +12 -0
- package/src/commands/track/add.ts +91 -0
- package/src/commands/track/completion.ts +243 -0
- package/src/commands/track/destroy.ts +81 -0
- package/src/commands/track/index.ts +91 -0
- package/src/commands/track/init.ts +94 -0
- package/src/commands/track/list.ts +15 -0
- package/src/commands/track/remove.ts +66 -0
- package/src/commands/track/sync.ts +50 -0
- package/src/commands/track/verify.ts +98 -0
- package/src/core/errors.ts +42 -0
- package/src/core/exclude.ts +106 -0
- package/src/core/git.ts +18 -0
- package/src/core/hooks.ts +152 -0
- package/src/core/manifest.ts +49 -0
- package/src/core/paths.ts +58 -0
- package/src/core/repository.ts +39 -0
- package/src/utils/logger.ts +21 -0
- package/src/utils/process.ts +13 -0
- package/tests/commands/forward.test.ts +55 -0
- package/tests/commands/track/add.test.ts +148 -0
- package/tests/commands/track/completion.test.ts +124 -0
- package/tests/commands/track/destroy.test.ts +112 -0
- package/tests/commands/track/init.test.ts +146 -0
- package/tests/commands/track/remove.test.ts +107 -0
- package/tests/commands/track/sync.test.ts +118 -0
- package/tests/commands/track/verify.test.ts +96 -0
- package/tests/core/exclude.test.ts +120 -0
- package/tests/core/hooks.test.ts +222 -0
- package/tests/core/manifest.test.ts +98 -0
- package/tests/core/paths.test.ts +113 -0
- package/tests/core/repository.test.ts +68 -0
- package/tests/helpers.ts +21 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +11 -0
- 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
|
+
});
|