git-sich 0.0.0-stage → 0.2.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -2
  3. package/dist/cli.js +1768 -0
  4. package/package.json +42 -3
package/dist/cli.js ADDED
@@ -0,0 +1,1768 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { realpathSync as realpathSync3, statSync } from "node:fs";
5
+ import { resolve as resolve6 } from "node:path";
6
+ // package.json
7
+ var version = "0.2.0";
8
+
9
+ // src/ui.ts
10
+ class SichError extends Error {
11
+ }
12
+ function fail(message) {
13
+ throw new SichError(message);
14
+ }
15
+ var colorOut = !!process.stdout.isTTY && !process.env.NO_COLOR;
16
+ var colorErr = colorOut && !!process.stderr.isTTY;
17
+ function painter(enabled) {
18
+ const wrap = (code) => (s) => enabled ? `\x1B[${code}m${s}\x1B[0m` : s;
19
+ return {
20
+ bold: wrap("1"),
21
+ dim: wrap("2"),
22
+ red: wrap("31"),
23
+ green: wrap("32"),
24
+ yellow: wrap("33"),
25
+ cyan: wrap("36")
26
+ };
27
+ }
28
+ var c = painter(colorOut);
29
+ var ce = painter(colorErr);
30
+ function out(line = "") {
31
+ process.stdout.write(line + `
32
+ `);
33
+ }
34
+ function note(line) {
35
+ out(c.dim(line));
36
+ }
37
+ function warn(message) {
38
+ process.stderr.write(`${ce.yellow("warning:")} ${message}
39
+ `);
40
+ }
41
+ function printError(message) {
42
+ process.stderr.write(`${ce.red("sich:")} ${message}
43
+ `);
44
+ }
45
+ function plural(n, word, pluralWord = word + "s") {
46
+ return `${n} ${n === 1 ? word : pluralWord}`;
47
+ }
48
+ function shellQuote(s) {
49
+ return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
50
+ }
51
+ function listSome(items, max = 5) {
52
+ if (items.length <= max)
53
+ return items.join(", ");
54
+ return `${items.slice(0, max).join(", ")} and ${items.length - max} more`;
55
+ }
56
+
57
+ // src/args.ts
58
+ function parseArgs(argv, spec, aliases = {}) {
59
+ const flags = {};
60
+ const positionals = [];
61
+ for (let i = 0;i < argv.length; i++) {
62
+ const arg = argv[i];
63
+ if (arg === "--") {
64
+ positionals.push(...argv.slice(i + 1));
65
+ break;
66
+ }
67
+ if (!arg.startsWith("-") || arg === "-") {
68
+ positionals.push(arg);
69
+ continue;
70
+ }
71
+ let name = arg;
72
+ let value;
73
+ const eq = arg.indexOf("=");
74
+ if (arg.startsWith("--") && eq > 0) {
75
+ name = arg.slice(0, eq);
76
+ value = arg.slice(eq + 1);
77
+ }
78
+ name = aliases[name] ?? name;
79
+ const kind = spec[name];
80
+ if (!kind)
81
+ fail(`unknown option '${arg}'`);
82
+ if (kind === "bool") {
83
+ if (value !== undefined)
84
+ fail(`option '${name}' takes no value`);
85
+ flags[name] = true;
86
+ } else if (kind === "value") {
87
+ if (value === undefined) {
88
+ value = argv[++i];
89
+ if (value === undefined)
90
+ fail(`option '${name}' needs a value`);
91
+ }
92
+ flags[name] = value;
93
+ } else {
94
+ const next = argv[i + 1];
95
+ if (value === undefined && next !== undefined && !next.startsWith("-")) {
96
+ value = next;
97
+ i++;
98
+ }
99
+ flags[name] = value ?? true;
100
+ }
101
+ }
102
+ return { flags, positionals };
103
+ }
104
+ function str(p, name) {
105
+ const v = p.flags[name];
106
+ return typeof v === "string" ? v : undefined;
107
+ }
108
+ function wantsHelp(argv) {
109
+ for (const a of argv) {
110
+ if (a === "--")
111
+ return false;
112
+ if (a === "--help" || a === "-h")
113
+ return true;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ // src/commands/check.ts
119
+ import { existsSync as existsSync5, realpathSync as realpathSync2 } from "node:fs";
120
+ import { basename as basename2, dirname as dirname4, join as join3, resolve as resolve4 } from "node:path";
121
+
122
+ // src/claims.ts
123
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
124
+ var isDirClaim = (c2) => c2.endsWith("/");
125
+ var bare = (c2) => isDirClaim(c2) ? c2.slice(0, -1) : c2;
126
+ function samePath(a, b) {
127
+ return bare(a) === bare(b);
128
+ }
129
+ function isInside(p, dir) {
130
+ return bare(p).startsWith(bare(dir) + "/");
131
+ }
132
+ function covers(claim, p) {
133
+ return samePath(claim, p) || isDirClaim(claim) && isInside(p, claim);
134
+ }
135
+ function cleanClaim(raw) {
136
+ let p = raw.trim();
137
+ while (p.startsWith("./"))
138
+ p = p.slice(2);
139
+ if (p.startsWith("/"))
140
+ p = p.replace(/^\/+/, "");
141
+ const dir = p.endsWith("/");
142
+ const segs = bare(p).split("/");
143
+ if (!p || segs.some((s) => s === "" || s === "." || s === "..") || p.includes("\x00"))
144
+ return null;
145
+ if (segs[0] === ".git" || segs[0] === ".sich")
146
+ return null;
147
+ return segs.join("/") + (dir ? "/" : "");
148
+ }
149
+ function storable(claim) {
150
+ return !/[\r\n]/.test(claim) && !claim.startsWith("#") && cleanClaim(claim) === claim;
151
+ }
152
+ function normalizeClaims(claims) {
153
+ const unique = [...new Set(claims)];
154
+ const kept = unique.filter((c2) => !(!isDirClaim(c2) && unique.includes(c2 + "/")));
155
+ return kept.filter((c2) => !kept.some((d) => d !== c2 && isDirClaim(d) && isInside(c2, d))).sort();
156
+ }
157
+ var warned = new Set;
158
+ function warnOnce(message) {
159
+ if (warned.has(message))
160
+ return;
161
+ warned.add(message);
162
+ warn(message);
163
+ }
164
+ function readManifest(file, layer) {
165
+ if (!existsSync(file))
166
+ return { claims: [], comments: [] };
167
+ return parseManifest(readFileSync(file, "utf8"), layer);
168
+ }
169
+ function parseManifest(text, layer) {
170
+ const claims = [];
171
+ const comments = [];
172
+ for (const line of text.split(`
173
+ `)) {
174
+ const t = line.trim();
175
+ if (!t)
176
+ continue;
177
+ if (t.startsWith("#")) {
178
+ comments.push(line.trimEnd());
179
+ continue;
180
+ }
181
+ const claim = cleanClaim(t);
182
+ if (claim)
183
+ claims.push(claim);
184
+ else
185
+ warnOnce(`${layer}: ignoring invalid claim ${JSON.stringify(t)} in .sich/${layer}.paths`);
186
+ }
187
+ return { claims: normalizeClaims(claims), comments };
188
+ }
189
+ function writeManifest(file, m) {
190
+ const body = [...m.comments, ...normalizeClaims(m.claims)];
191
+ writeFileSync(file, body.length ? body.join(`
192
+ `) + `
193
+ ` : "");
194
+ }
195
+ function ownerOf(all, p, icase = false) {
196
+ const fold = (s) => icase ? s.toLowerCase() : s;
197
+ const path = fold(p);
198
+ let best = null;
199
+ for (const [layer, claims] of all) {
200
+ for (const claim of claims) {
201
+ if (covers(fold(claim), path) && (!best || bare(claim).length > bare(best.claim).length)) {
202
+ best = { layer, claim };
203
+ }
204
+ }
205
+ }
206
+ return best;
207
+ }
208
+
209
+ // src/context.ts
210
+ import { existsSync as existsSync3, lstatSync, readdirSync, realpathSync } from "node:fs";
211
+ import { basename, dirname as dirname2, isAbsolute, join, relative as relative2, resolve as resolve2, sep } from "node:path";
212
+
213
+ // src/excludes.ts
214
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
215
+ import { dirname, relative, resolve } from "node:path";
216
+
217
+ // src/git.ts
218
+ import { spawnSync } from "node:child_process";
219
+ var STRIPPED_ENV = [
220
+ "GIT_DIR",
221
+ "GIT_WORK_TREE",
222
+ "GIT_INDEX_FILE",
223
+ "GIT_OBJECT_DIRECTORY",
224
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
225
+ "GIT_COMMON_DIR",
226
+ "GIT_PREFIX",
227
+ "GIT_NAMESPACE",
228
+ "GIT_QUARANTINE_PATH",
229
+ "GIT_IMPLICIT_WORK_TREE",
230
+ "GIT_INTERNAL_SUPER_PREFIX",
231
+ "GIT_LITERAL_PATHSPECS",
232
+ "GIT_GLOB_PATHSPECS",
233
+ "GIT_NOGLOB_PATHSPECS",
234
+ "GIT_ICASE_PATHSPECS"
235
+ ];
236
+ function cleanEnv(extra = {}) {
237
+ const env = {};
238
+ for (const [k, v] of Object.entries(process.env)) {
239
+ if (v !== undefined && !STRIPPED_ENV.includes(k))
240
+ env[k] = v;
241
+ }
242
+ return { ...env, ...extra };
243
+ }
244
+ function spawn(cmd, cwd, env, inherit) {
245
+ const r = spawnSync(cmd[0], cmd.slice(1), {
246
+ cwd,
247
+ env,
248
+ encoding: "utf8",
249
+ stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"],
250
+ maxBuffer: 1024 ** 3
251
+ });
252
+ if (r.error)
253
+ fail(`cannot run ${cmd[0]}: ${r.error.message}`);
254
+ return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
255
+ }
256
+ function repoArgs(repo) {
257
+ return [`--git-dir=${repo.gitDir}`, `--work-tree=${repo.workTree}`];
258
+ }
259
+ function git(repo, args, opts = {}) {
260
+ const literal = opts.literal ?? true;
261
+ const env = cleanEnv({ GIT_LITERAL_PATHSPECS: literal ? "1" : "0", GIT_OPTIONAL_LOCKS: "0", ...opts.env });
262
+ const result = spawn(["git", ...repoArgs(repo), ...args], opts.cwd ?? repo.workTree, env, false);
263
+ if (result.code !== 0 && !opts.allowFail) {
264
+ const msg = result.stderr.trim().split(`
265
+ `).filter(Boolean).pop() ?? `exit code ${result.code}`;
266
+ fail(`${repo.name}: git ${args[0]} failed: ${msg}`);
267
+ }
268
+ return result;
269
+ }
270
+ function gitInherit(repo, args, cwd) {
271
+ return spawn(["git", ...repoArgs(repo), ...args], cwd ?? repo.workTree, cleanEnv(), true).code;
272
+ }
273
+ function gitPlain(args, cwd) {
274
+ return spawn(["git", ...args], cwd, cleanEnv(), false);
275
+ }
276
+ function splitZ(s) {
277
+ return s.split("\x00").filter((x) => x !== "");
278
+ }
279
+ function lines(s) {
280
+ return s.split(`
281
+ `).filter((x) => x !== "");
282
+ }
283
+ function trackedUnder(repo, paths) {
284
+ if (paths.length === 0)
285
+ return [];
286
+ return splitZ(git(repo, ["ls-files", "-z", "--cached", "--", ...paths]).stdout);
287
+ }
288
+ function isIgnored(repo, path, noIndex = false) {
289
+ const args = ["check-ignore", "-q", ...noIndex ? ["--no-index"] : [], "--", path];
290
+ return git(repo, args, { allowFail: true, literal: false }).code === 0;
291
+ }
292
+ function currentBranch(repo) {
293
+ const r = git(repo, ["symbolic-ref", "--short", "-q", "HEAD"], { allowFail: true });
294
+ return r.code === 0 ? r.stdout.trim() : null;
295
+ }
296
+ function upstreamOf(repo) {
297
+ const r = git(repo, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { allowFail: true });
298
+ return r.code === 0 && r.stdout.trim() ? r.stdout.trim() : null;
299
+ }
300
+ function remotesOf(repo) {
301
+ return lines(git(repo, ["remote"]).stdout);
302
+ }
303
+
304
+ // src/excludes.ts
305
+ var BLOCK_BEGIN = "# >>> sich: managed, do not edit >>>";
306
+ var BLOCK_END = "# <<< sich <<<";
307
+ function escapePattern(p) {
308
+ return p.replace(/[\\*?[]/g, (m) => "\\" + m).replace(/ $/, "\\ ");
309
+ }
310
+ function baseExcludeLines(allClaims) {
311
+ const patterns = [...new Set(allClaims)].sort().map((c2) => "/" + escapePattern(c2));
312
+ return ["/.sich/", ...patterns];
313
+ }
314
+ function layerExcludeLines(layer, own, others) {
315
+ const root = { kind: "parent", children: new Map };
316
+ const insert = (claim) => {
317
+ const segs = bare(claim).split("/");
318
+ let node = root;
319
+ for (let i = 0;i < segs.length; i++) {
320
+ const seg = segs[i];
321
+ let child = node.children.get(seg);
322
+ if (!child) {
323
+ child = { kind: "parent", children: new Map };
324
+ node.children.set(seg, child);
325
+ }
326
+ if (child.kind !== "parent")
327
+ return;
328
+ if (i === segs.length - 1) {
329
+ child.kind = isDirClaim(claim) ? "dir" : "file";
330
+ child.children.clear();
331
+ }
332
+ node = child;
333
+ }
334
+ };
335
+ const ownWithManifest = [...own, `.sich/${layer}.paths`];
336
+ [...ownWithManifest].sort((a, b) => bare(a).split("/").length - bare(b).split("/").length).forEach(insert);
337
+ const out2 = ["/*"];
338
+ const emit = (node, prefix) => {
339
+ for (const name of [...node.children.keys()].sort()) {
340
+ const child = node.children.get(name);
341
+ const p = escapePattern(prefix + name);
342
+ if (child.kind === "dir")
343
+ out2.push(`!/${p}/`);
344
+ else if (child.kind === "file")
345
+ out2.push(`!/${p}`);
346
+ else {
347
+ out2.push(`!/${p}/`, `/${p}/*`);
348
+ emit(child, prefix + name + "/");
349
+ }
350
+ }
351
+ };
352
+ emit(root, "");
353
+ const carve = [...new Set(others)].filter((o) => !own.some((c2) => samePath(o, c2) || isInside(c2, o))).sort();
354
+ for (const o of carve)
355
+ out2.push("/" + escapePattern(o));
356
+ return out2;
357
+ }
358
+ function spliceBlock(content, body) {
359
+ const block = [BLOCK_BEGIN, ...body, BLOCK_END].join(`
360
+ `) + `
361
+ `;
362
+ const start = content.indexOf(BLOCK_BEGIN);
363
+ if (start === -1) {
364
+ const pre = content && !content.endsWith(`
365
+ `) ? content + `
366
+ ` : content;
367
+ return pre + block;
368
+ }
369
+ const end = content.indexOf(BLOCK_END, start);
370
+ let after = end === -1 ? "" : content.slice(end + BLOCK_END.length);
371
+ if (after.startsWith(`
372
+ `))
373
+ after = after.slice(1);
374
+ return content.slice(0, start) + block + after;
375
+ }
376
+ function excludeFile(repo) {
377
+ const p = git(repo, ["rev-parse", "--git-path", "info/exclude"]).stdout.trim();
378
+ return resolve(repo.workTree, p);
379
+ }
380
+ function excludeFileShown(repo) {
381
+ return relative(repo.workTree, excludeFile(repo));
382
+ }
383
+ function describeExclude(t) {
384
+ const file = excludeFileShown(t.repo);
385
+ if (t.repo.name !== "base")
386
+ return `${t.repo.name} ignores everything except its claims -> ${file}`;
387
+ const claims = t.lines.length - 1;
388
+ return `excluded .sich/${claims ? ` and ${plural(claims, "private path")}` : ""} from base -> ${file}`;
389
+ }
390
+ function applyExclude(t, write) {
391
+ const file = excludeFile(t.repo);
392
+ const current = existsSync2(file) ? readFileSync2(file, "utf8") : "";
393
+ const next = spliceBlock(current, t.lines);
394
+ if (next === current)
395
+ return false;
396
+ if (write) {
397
+ mkdirSync(dirname(file), { recursive: true });
398
+ writeFileSync2(file, next);
399
+ }
400
+ return true;
401
+ }
402
+
403
+ // src/context.ts
404
+ var COMMANDS = [
405
+ "init",
406
+ "new",
407
+ "attach",
408
+ "claim",
409
+ "unclaim",
410
+ "add",
411
+ "rm",
412
+ "which",
413
+ "ls",
414
+ "status",
415
+ "commit",
416
+ "pull",
417
+ "push",
418
+ "sync",
419
+ "check",
420
+ "help"
421
+ ];
422
+ var LAYER_NAME = /^[a-z0-9][a-z0-9._-]*$/;
423
+ function validateLayerName(name) {
424
+ if (name === "base" || COMMANDS.includes(name)) {
425
+ fail(`'${name}' is reserved and cannot be a layer name`);
426
+ }
427
+ if (!LAYER_NAME.test(name)) {
428
+ fail(`invalid layer name '${name}' (use lowercase letters, digits, '.', '_' or '-', starting with a letter or digit)`);
429
+ }
430
+ if (name.endsWith(".paths") || name.endsWith(".lock"))
431
+ fail(`invalid layer name '${name}'`);
432
+ }
433
+ function loadCtx(cwd) {
434
+ let dir = cwd;
435
+ while (!existsSync3(join(dir, ".git"))) {
436
+ const up = dirname2(dir);
437
+ if (up === dir)
438
+ fail("not inside a git repository (sich needs a base repo: run git init first)");
439
+ dir = up;
440
+ }
441
+ const r = gitPlain(["rev-parse", "--absolute-git-dir"], dir);
442
+ if (r.code !== 0)
443
+ fail(`cannot read the git repository at ${dir}: ${r.stderr.trim()}`);
444
+ const root = dir;
445
+ return {
446
+ cwd,
447
+ root,
448
+ base: { name: "base", gitDir: r.stdout.trim(), workTree: root },
449
+ sichDir: join(root, ".sich")
450
+ };
451
+ }
452
+ function isInitialized(ctx) {
453
+ return existsSync3(ctx.sichDir);
454
+ }
455
+ function requireInit(ctx) {
456
+ if (!isInitialized(ctx))
457
+ fail(`sich is not initialized in ${ctx.root} (run: sich init)`);
458
+ }
459
+ function layerRepo(ctx, name) {
460
+ return { name, gitDir: join(ctx.sichDir, name), workTree: ctx.root };
461
+ }
462
+ function manifestPath(ctx, layer) {
463
+ return join(ctx.sichDir, `${layer}.paths`);
464
+ }
465
+ function manifestRel(layer) {
466
+ return `.sich/${layer}.paths`;
467
+ }
468
+ function layerNames(ctx) {
469
+ if (!existsSync3(ctx.sichDir))
470
+ return [];
471
+ return readdirSync(ctx.sichDir, { withFileTypes: true }).filter((e) => e.isDirectory() && LAYER_NAME.test(e.name) && existsSync3(join(ctx.sichDir, e.name, "HEAD"))).map((e) => e.name).sort();
472
+ }
473
+ function layerExists(ctx, name) {
474
+ return layerNames(ctx).includes(name);
475
+ }
476
+ function requireLayer(ctx, name) {
477
+ if (!name)
478
+ fail("missing layer name");
479
+ if (name === "base")
480
+ fail("'base' is the public repo, not a layer");
481
+ if (!layerExists(ctx, name))
482
+ fail(`no such layer '${name}' (layers: ${layerNames(ctx).join(", ") || "none"})`);
483
+ return layerRepo(ctx, name);
484
+ }
485
+ function repoByName(ctx, name) {
486
+ if (name === "base")
487
+ return ctx.base;
488
+ return requireLayer(ctx, name);
489
+ }
490
+ function allClaims(ctx) {
491
+ const m = new Map;
492
+ for (const l of layerNames(ctx))
493
+ m.set(l, readManifest(manifestPath(ctx, l), l).claims);
494
+ return m;
495
+ }
496
+ function excludeTargets(ctx) {
497
+ const claims = allClaims(ctx);
498
+ const everything = [...claims.values()].flat();
499
+ const targets = [{ repo: ctx.base, lines: baseExcludeLines(everything) }];
500
+ for (const [layer, own] of claims) {
501
+ const others = [...claims].filter(([l]) => l !== layer).flatMap(([, cs]) => cs);
502
+ targets.push({ repo: layerRepo(ctx, layer), lines: layerExcludeLines(layer, own, others) });
503
+ }
504
+ return targets;
505
+ }
506
+ function syncExcludes(ctx) {
507
+ if (!isInitialized(ctx))
508
+ return;
509
+ for (const t of excludeTargets(ctx))
510
+ applyExclude(t, true);
511
+ }
512
+ function ignoresCase(ctx) {
513
+ const r = git(ctx.base, ["config", "--type=bool", "--get", "core.ignorecase"], { allowFail: true });
514
+ return r.stdout.trim() === "true";
515
+ }
516
+ function onDiskName(dir, name) {
517
+ try {
518
+ lstatSync(join(dir, name));
519
+ const entries = readdirSync(dir);
520
+ if (entries.includes(name))
521
+ return name;
522
+ return entries.find((e) => e.toLowerCase() === name.toLowerCase()) ?? name;
523
+ } catch {
524
+ return name;
525
+ }
526
+ }
527
+ function realParent(abs) {
528
+ const parent = dirname2(abs);
529
+ if (parent === abs)
530
+ return abs;
531
+ let real;
532
+ try {
533
+ real = realpathSync.native(parent);
534
+ } catch {
535
+ real = realParent(parent);
536
+ }
537
+ return join(real, onDiskName(real, basename(abs)));
538
+ }
539
+ function resolveUserPath(ctx, input, opts = {}) {
540
+ const abs = realParent(resolve2(ctx.cwd, input));
541
+ const rel = relative2(ctx.root, abs);
542
+ if (rel === "")
543
+ fail(`'${input}' is the repository root`);
544
+ if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel))
545
+ fail(`'${input}' is outside the repository`);
546
+ const posix = rel.split(sep).join("/");
547
+ const first = posix.split("/")[0];
548
+ if (first === ".git" || first === ".sich" && !opts.allowSich)
549
+ fail(`'${input}' is inside ${first}/`);
550
+ let exists = false;
551
+ let isDir = false;
552
+ try {
553
+ const st = lstatSync(abs);
554
+ exists = true;
555
+ isDir = st.isDirectory();
556
+ } catch {}
557
+ return { rel: posix, exists, isDir };
558
+ }
559
+ function collisions(ctx, paths) {
560
+ const hits = new Set;
561
+ const kind = (rel) => {
562
+ try {
563
+ return lstatSync(join(ctx.root, rel)).isDirectory() ? "dir" : "file";
564
+ } catch {
565
+ return null;
566
+ }
567
+ };
568
+ for (const p of paths) {
569
+ if (kind(p))
570
+ hits.add(p);
571
+ const segs = p.split("/");
572
+ for (let i = 1;i < segs.length; i++) {
573
+ const parent = segs.slice(0, i).join("/");
574
+ if (kind(parent) === "file")
575
+ hits.add(parent);
576
+ }
577
+ }
578
+ return [...hits].sort();
579
+ }
580
+
581
+ // src/commands/init.ts
582
+ import { chmodSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
583
+ import { dirname as dirname3, join as join2, relative as relative3, resolve as resolve3 } from "node:path";
584
+ var HOOK_HEADER = "# Installed by sich:";
585
+ var GUARD_HOOKS = {
586
+ "pre-commit": { op: "commit", check: "check --staged" },
587
+ "pre-merge-commit": { op: "merge", check: "check --staged --merge" }
588
+ };
589
+ var HOOK_NAMES = Object.keys(GUARD_HOOKS);
590
+ function hookLine(hook) {
591
+ return `"\${SICH_BIN:-sich}" ${GUARD_HOOKS[hook].check} || exit 1`;
592
+ }
593
+ var HOOK_MARKER = /sich\S*\s+check --staged/;
594
+ var BASE_HOOK = {
595
+ header: HOOK_HEADER,
596
+ body: (hook) => hookBody(`${HOOK_HEADER} stop private (layer-claimed) files from being committed to base.`, hook, "git")
597
+ };
598
+ function layerHook(layer) {
599
+ const header = `# Installed by sich for layer ${layer}:`;
600
+ return {
601
+ header,
602
+ body: (hook) => hookBody(`${header} stop files ${layer} doesn't own from being committed to it.`, hook, `sich ${layer}`)
603
+ };
604
+ }
605
+ function hookBody(header, hook, run) {
606
+ const { op, check } = GUARD_HOOKS[hook];
607
+ return `#!/bin/sh
608
+ ${header}
609
+ sich="\${SICH_BIN:-sich}"
610
+ if ! command -v "$sich" >/dev/null 2>&1; then
611
+ echo "sich: ${op} blocked: '$sich' not found; put sich on PATH or set SICH_BIN (skip once: ${run} ${op} --no-verify)" >&2
612
+ exit 1
613
+ fi
614
+ exec "$sich" ${check}
615
+ `;
616
+ }
617
+ function hookFiles(ctx, repo) {
618
+ const hooksDir = resolve3(ctx.root, git(repo, ["rev-parse", "--git-path", "hooks"]).stdout.trim());
619
+ return HOOK_NAMES.map((name) => {
620
+ const hook = join2(hooksDir, name);
621
+ return { name, hook, shown: relative3(ctx.root, hook) || hook };
622
+ });
623
+ }
624
+ function unguarded(ctx, repo) {
625
+ const names = [];
626
+ const files = [];
627
+ for (const { name, hook, shown } of hookFiles(ctx, repo)) {
628
+ if (existsSync4(hook) && HOOK_MARKER.test(readFileSync3(hook, "utf8")))
629
+ continue;
630
+ names.push(name);
631
+ files.push(shown);
632
+ }
633
+ return { names, files };
634
+ }
635
+ function hooksNamed(names) {
636
+ return `${names.join(" and ")} hook${names.length > 1 ? "s" : ""}`;
637
+ }
638
+ function ensureInit(ctx) {
639
+ const fresh = !isInitialized(ctx);
640
+ if (fresh) {
641
+ mkdirSync2(ctx.sichDir, { recursive: true });
642
+ out(`created ${c.bold(".sich/")} (holds every layer's git data and claims list)`);
643
+ }
644
+ for (const t of excludeTargets(ctx)) {
645
+ if (applyExclude(t, true))
646
+ out(describeExclude(t));
647
+ }
648
+ installHooks(ctx, ctx.base, BASE_HOOK);
649
+ for (const layer of layerNames(ctx))
650
+ installLayerHooks(ctx, layer);
651
+ }
652
+ function installLayerHooks(ctx, layer) {
653
+ installHooks(ctx, layerRepo(ctx, layer), layerHook(layer));
654
+ }
655
+ function installHooks(ctx, repo, spec) {
656
+ const hooksPath = git(repo, ["config", "--get", "core.hooksPath"], { allowFail: true }).stdout.trim();
657
+ const of = repo.name === "base" ? "" : ` of ${repo.name}`;
658
+ for (const { name, hook, shown } of hookFiles(ctx, repo)) {
659
+ const body = spec.body(name);
660
+ if (existsSync4(hook)) {
661
+ const current = readFileSync3(hook, "utf8");
662
+ if (current.includes(spec.header)) {
663
+ if (current !== body) {
664
+ writeFileSync3(hook, body);
665
+ chmodSync(hook, 493);
666
+ out(`updated ${name} hook${of} -> ${shown}`);
667
+ }
668
+ continue;
669
+ }
670
+ if (HOOK_MARKER.test(current))
671
+ continue;
672
+ out(`${c.yellow(`${name} hook${of} exists`)} (${shown}); add this line to it:`);
673
+ out(` ${hookLine(name)}`);
674
+ continue;
675
+ }
676
+ if (hooksPath) {
677
+ out(`${c.yellow(`core.hooksPath${of} is set`)} (${hooksPath}); add this line to its ${name} hook:`);
678
+ out(` ${hookLine(name)}`);
679
+ continue;
680
+ }
681
+ mkdirSync2(dirname3(hook), { recursive: true });
682
+ writeFileSync3(hook, body);
683
+ chmodSync(hook, 493);
684
+ out(`installed ${name} hook${of} -> ${shown}`);
685
+ }
686
+ }
687
+ function cmdInit(ctx, args) {
688
+ if (parseArgs(args, {}).positionals.length)
689
+ fail("usage: sich init");
690
+ const already = isInitialized(ctx);
691
+ ensureInit(ctx);
692
+ out(already ? `sich already initialized in ${ctx.root}` : `${c.green("initialized")} sich in ${ctx.root}`);
693
+ return 0;
694
+ }
695
+
696
+ // src/commands/check.ts
697
+ function hookIndexFile() {
698
+ const raw = process.env.GIT_INDEX_FILE;
699
+ if (!raw)
700
+ return null;
701
+ try {
702
+ const abs = resolve4(raw);
703
+ return resolve4(realpathSync2(dirname4(abs)), basename2(abs));
704
+ } catch {
705
+ return null;
706
+ }
707
+ }
708
+ function isInsideDir(file, dir) {
709
+ try {
710
+ return file.startsWith(realpathSync2(dir) + "/");
711
+ } catch {
712
+ return false;
713
+ }
714
+ }
715
+ function committingRepo(ctx, named, index) {
716
+ if (named !== undefined)
717
+ return repoByName(ctx, named);
718
+ if (!index || isInsideDir(index, ctx.base.gitDir))
719
+ return ctx.base;
720
+ const layer = layerNames(ctx).find((l) => isInsideDir(index, layerRepo(ctx, l).gitDir));
721
+ if (layer)
722
+ return layerRepo(ctx, layer);
723
+ if (isInsideDir(index, ctx.sichDir))
724
+ fail(`cannot tell which layer is committing (index ${index})`);
725
+ return ctx.base;
726
+ }
727
+ function mergeHints(ctx, committing, blocked, fold) {
728
+ const inLayer = committing.name !== ctx.base.name;
729
+ const run = inLayer ? `sich ${committing.name}` : "git";
730
+ const hints = [`this merge isn't committed; back it out with: ${run} merge --abort`];
731
+ const wanted = new Set(blocked.map(fold));
732
+ for (const repo of [ctx.base, ...layerNames(ctx).map((l) => layerRepo(ctx, l))]) {
733
+ if (repo.name === committing.name)
734
+ continue;
735
+ const copies = splitZ(git(repo, ["ls-files", "-z"]).stdout).filter((f) => wanted.has(fold(f)));
736
+ if (!copies.length)
737
+ continue;
738
+ const restore = repo.name === ctx.base.name ? "git restore" : `sich ${repo.name} restore`;
739
+ hints.push(`the merge may have overwritten ${copies.join(", ")} (tracked by ${repo.name}); after aborting, ` + `restore with: ${restore} -- ${copies.map(shellQuote).join(" ")}`);
740
+ }
741
+ hints.push(`don't ${inLayer ? "untrack" : "unstage"} them and commit the merge: that drops the other side's changes to them`);
742
+ hints.push(inLayer ? "fix them where they come from (claim or untrack them on that branch), then merge again" : "settle who owns them before merging again (sich pull refuses up front to overwrite such files)");
743
+ return hints;
744
+ }
745
+ function cmdCheck(ctx, args) {
746
+ const p = parseArgs(args, { "--fix": "bool", "--staged": "bool", "--merge": "bool", "--repo": "value" });
747
+ if (p.positionals.length)
748
+ fail("usage: sich check [--fix] [--staged [--merge] [--repo <repo>]]");
749
+ const repoName = str(p, "--repo");
750
+ if (repoName !== undefined && !p.flags["--staged"])
751
+ fail("--repo only applies with --staged");
752
+ if (p.flags["--merge"] && !p.flags["--staged"])
753
+ fail("--merge only applies with --staged");
754
+ if (!isInitialized(ctx)) {
755
+ if (p.flags["--staged"])
756
+ return 0;
757
+ requireInit(ctx);
758
+ }
759
+ const index = p.flags["--staged"] ? hookIndexFile() : null;
760
+ const committing = committingRepo(ctx, repoName, index);
761
+ const indexEnv = index && isInsideDir(index, committing.gitDir) ? { GIT_INDEX_FILE: index } : {};
762
+ const merging = p.flags["--staged"] === true && (p.flags["--merge"] === true || existsSync5(join3(committing.gitDir, "MERGE_HEAD")));
763
+ const mergeBlocked = [];
764
+ const problems = [];
765
+ const stale = [];
766
+ const staleWhere = [];
767
+ const fixed = [];
768
+ const hints = [];
769
+ const claims = allClaims(ctx);
770
+ const layers = layerNames(ctx);
771
+ const icase = ignoresCase(ctx);
772
+ const fold = (s) => icase ? s.toLowerCase() : s;
773
+ const owner = (f) => ownerOf(claims, f, icase);
774
+ const isSich = (f) => fold(f).startsWith(".sich/");
775
+ const flat = [...claims].flatMap(([layer, cs]) => cs.map((claim) => ({ layer, claim })));
776
+ for (const [i, a] of flat.entries()) {
777
+ for (const b of flat.slice(i + 1)) {
778
+ if (a.layer === b.layer)
779
+ continue;
780
+ const [x, y] = [fold(a.claim), fold(b.claim)];
781
+ if (samePath(x, y)) {
782
+ problems.push(`${bare(a.claim)} is claimed by more than one layer: ${a.layer}, ${b.layer}`);
783
+ } else if (covers(x, y) || covers(y, x)) {
784
+ const [outer, inner] = covers(x, y) ? [a, b] : [b, a];
785
+ problems.push(`${inner.claim} (${inner.layer}) is nested inside ${outer.claim} (${outer.layer}); ` + `claims can't nest across layers (fix: sich unclaim ${inner.layer} ${shellQuote(inner.claim)})`);
786
+ }
787
+ }
788
+ }
789
+ const stagedStray = new Map;
790
+ if (p.flags["--staged"] && committing !== ctx.base) {
791
+ const layer = committing.name;
792
+ const manifest = manifestRel(layer);
793
+ const staged = splitZ(git(committing, ["diff", "--cached", "--name-only", "--no-renames", "--diff-filter=d", "-z"], { env: indexEnv }).stdout);
794
+ const committed = git(committing, ["show", `:${manifest}`], { env: indexEnv, allowFail: true });
795
+ const committedClaims = new Map([
796
+ [layer, committed.code === 0 ? parseManifest(committed.stdout, layer).claims : []]
797
+ ]);
798
+ const unrecorded = [];
799
+ for (const f of staged) {
800
+ if (fold(f) === fold(manifest))
801
+ continue;
802
+ const by = isSich(f) ? undefined : owner(f)?.layer;
803
+ if (by === layer) {
804
+ if (!ownerOf(committedClaims, f, icase)) {
805
+ unrecorded.push(f);
806
+ problems.push(`${layer} has a staged change to ${f}, which ${manifest} as committed doesn't claim`);
807
+ }
808
+ continue;
809
+ }
810
+ const why = isSich(f) ? "which is under .sich/" : by ? `which is claimed by ${by}` : "which none of its claims cover";
811
+ stagedStray.set(f, why);
812
+ problems.push(`${layer} has a staged change to ${f}, ${why}`);
813
+ }
814
+ const files = [...stagedStray.keys()];
815
+ if (merging) {
816
+ mergeBlocked.push(...files, ...unrecorded);
817
+ } else {
818
+ if (unrecorded.length) {
819
+ hints.push(`commit ${manifest} along with it (sich ${layer} add ${manifest}; with 'commit -- <paths>', list it too)`);
820
+ }
821
+ const claimable = files.filter((f) => !isSich(f) && !owner(f));
822
+ const baseTracked = new Set(splitZ(git(ctx.base, ["ls-files", "-z"]).stdout).map(fold));
823
+ const plain = claimable.filter((f) => !baseTracked.has(fold(f)));
824
+ const moved = claimable.filter((f) => baseTracked.has(fold(f)));
825
+ if (plain.length)
826
+ hints.push(`claim with: sich claim ${layer} ${plain.map(shellQuote).join(" ")}`);
827
+ if (moved.length) {
828
+ const list = moved.map(shellQuote).join(" ");
829
+ hints.push(`claim with (base tracks ${list}): sich claim ${layer} ${list} --move`);
830
+ }
831
+ if (files.length) {
832
+ const verb = claimable.length ? "or untrack" : "untrack";
833
+ hints.push(`${verb} with: sich ${layer} rm --cached -- ${files.map(shellQuote).join(" ")}`);
834
+ }
835
+ }
836
+ }
837
+ const trackedBy = new Map;
838
+ for (const layer of layers) {
839
+ const env = layer === committing.name ? indexEnv : {};
840
+ for (const f of splitZ(git(layerRepo(ctx, layer), ["ls-files", "-z"], { env }).stdout)) {
841
+ if (f === manifestRel(layer))
842
+ continue;
843
+ trackedBy.set(f, [...trackedBy.get(f) ?? [], layer]);
844
+ }
845
+ }
846
+ for (const [f, all] of trackedBy) {
847
+ const ls = stagedStray.has(f) ? all.filter((l) => l !== committing.name) : all;
848
+ const by = owner(f)?.layer;
849
+ if (ls.length > 1) {
850
+ problems.push(`${f} is tracked by more than one layer: ${ls.join(", ")}${by ? ` (owner: ${by})` : ""}`);
851
+ } else if (ls.length === 1 && by !== ls[0]) {
852
+ const why = by ? `it belongs to ${by}` : "none of its claims cover it";
853
+ problems.push(`${ls[0]} tracks ${f} but ${why} (fix: sich ${ls[0]} rm --cached -- ${shellQuote(f)})`);
854
+ }
855
+ }
856
+ const reported = new Set;
857
+ if (p.flags["--staged"] && committing === ctx.base) {
858
+ const staged = splitZ(git(ctx.base, ["diff", "--cached", "--name-only", "--no-renames", "--diff-filter=d", "-z"], { env: indexEnv }).stdout);
859
+ const stagedPrivate = staged.filter((f) => isSich(f) || owner(f));
860
+ for (const f of stagedPrivate) {
861
+ const by = owner(f);
862
+ problems.push(`base has a staged change to private path ${f}${by ? ` (claimed by ${by.layer})` : ""}`);
863
+ reported.add(f);
864
+ }
865
+ if (merging) {
866
+ mergeBlocked.push(...stagedPrivate);
867
+ } else if (stagedPrivate.length) {
868
+ hints.push(`unstage with: git restore --staged -- ${stagedPrivate.map(shellQuote).join(" ")}`);
869
+ }
870
+ }
871
+ let sichFiles = 0;
872
+ const baseEnv = committing === ctx.base ? indexEnv : {};
873
+ for (const f of splitZ(git(ctx.base, ["ls-files", "-z"], { env: baseEnv }).stdout)) {
874
+ if (reported.has(f))
875
+ continue;
876
+ if (isSich(f)) {
877
+ sichFiles++;
878
+ continue;
879
+ }
880
+ const by = owner(f);
881
+ if (by)
882
+ problems.push(`base tracks ${f}, which is claimed by ${by.layer} (fix: git rm --cached -- ${shellQuote(f)})`);
883
+ }
884
+ if (sichFiles) {
885
+ problems.push(`base tracks ${plural(sichFiles, "file")} under .sich/ (fix: git rm -r --cached -- .sich/)`);
886
+ }
887
+ if (mergeBlocked.length)
888
+ hints.push(...mergeHints(ctx, committing, mergeBlocked, fold));
889
+ for (const t of excludeTargets(ctx)) {
890
+ if (!applyExclude(t, false))
891
+ continue;
892
+ if (p.flags["--fix"]) {
893
+ applyExclude(t, true);
894
+ fixed.push(`rewrote: ${describeExclude(t)}`);
895
+ } else {
896
+ const where = `${t.repo.name} in ${excludeFileShown(t.repo)}`;
897
+ stale.push(`stale exclude rules of ${where} (fix: sich check --fix)`);
898
+ staleWhere.push(where);
899
+ }
900
+ }
901
+ const unhooked = [];
902
+ if (!p.flags["--staged"]) {
903
+ for (const repo of [ctx.base, ...layers.map((l) => layerRepo(ctx, l))]) {
904
+ const { names, files } = unguarded(ctx, repo);
905
+ if (names.length) {
906
+ unhooked.push(`${repo.name} has no ${hooksNamed(names)} running sich check --staged (${files.join(", ")}); fix: sich init`);
907
+ }
908
+ }
909
+ }
910
+ const hook = p.flags["--staged"] === true;
911
+ const blocking = hook ? problems : [...problems, ...stale];
912
+ for (const f of fixed)
913
+ out(`${c.green("✓")} ${f}`);
914
+ if (hook && staleWhere.length) {
915
+ warn(`stale exclude rules of ${staleWhere.join(", ")} (fix: sich check --fix); not blocking`);
916
+ }
917
+ for (const u of unhooked)
918
+ warn(u);
919
+ for (const i of blocking)
920
+ out(`${c.red("✗")} ${i}`);
921
+ for (const h of hints)
922
+ out(` ${h}`);
923
+ if (blocking.length) {
924
+ out(c.red(`sich check: ${plural(blocking.length, "issue")} found`));
925
+ return 1;
926
+ }
927
+ if (!hook)
928
+ out(c.green("sich check: ok"));
929
+ return 0;
930
+ }
931
+
932
+ // src/commands/claim.ts
933
+ function cmdClaim(ctx, args) {
934
+ const p = parseArgs(args, { "--move": "bool" });
935
+ const [layer, ...inputs] = p.positionals;
936
+ if (!layer || inputs.length === 0)
937
+ fail("usage: sich claim <layer> <path...> [--move]");
938
+ const move = p.flags["--move"] === true;
939
+ const repo = requireLayer(ctx, layer);
940
+ const claims = allClaims(ctx);
941
+ const own = claims.get(layer) ?? [];
942
+ const icase = ignoresCase(ctx);
943
+ const fold = (s) => icase ? s.toLowerCase() : s;
944
+ const targets = [];
945
+ for (const input of inputs) {
946
+ const up = resolveUserPath(ctx, input);
947
+ if (!up.exists)
948
+ fail(`'${input}' does not exist`);
949
+ const claim = up.isDir ? up.rel + "/" : up.rel;
950
+ if (!storable(claim)) {
951
+ fail(`cannot claim '${up.rel}': claims can't start with '#', start or end with whitespace, ` + `or contain line breaks (rename it, or claim a directory containing it)`);
952
+ }
953
+ if (!targets.includes(claim))
954
+ targets.push(claim);
955
+ }
956
+ const moves = [];
957
+ for (const t of targets) {
958
+ const inBase = trackedUnder(ctx.base, [t]);
959
+ if (inBase.length) {
960
+ if (!move) {
961
+ fail(`${t} is tracked by base (${listSome(inBase, 3)}); use --move to move it into ${layer}`);
962
+ }
963
+ moves.push({ from: ctx.base, claim: t, files: inBase, dropClaims: [] });
964
+ }
965
+ for (const other of layerNames(ctx)) {
966
+ if (other === layer)
967
+ continue;
968
+ const theirs = claims.get(other) ?? [];
969
+ const outer = theirs.find((cl) => isDirClaim(cl) && isInside(fold(t), fold(cl)));
970
+ if (outer) {
971
+ fail(`${t} is inside ${outer}, claimed by layer ${other}; claims can't nest across layers ` + `(to move all of it: sich claim ${layer} ${outer} --move)`);
972
+ }
973
+ const inner = theirs.filter((cl) => covers(fold(t), fold(cl)));
974
+ const tracked = trackedUnder(layerRepo(ctx, other), [t]);
975
+ if (inner.length === 0 && tracked.length === 0)
976
+ continue;
977
+ if (!move) {
978
+ const why = inner.some((cl) => samePath(fold(cl), fold(t))) ? `is claimed by layer ${other}` : inner.length ? `contains ${listSome(inner, 3)}, claimed by layer ${other}` : `is tracked by layer ${other}`;
979
+ fail(`${t} ${why}; use --move to move it into ${layer}`);
980
+ }
981
+ moves.push({ from: layerRepo(ctx, other), claim: t, files: tracked, dropClaims: inner });
982
+ }
983
+ }
984
+ for (const m of moves) {
985
+ if (m.files.length)
986
+ git(m.from, ["rm", "--cached", "-q", "--", ...m.files]);
987
+ if (m.dropClaims.length) {
988
+ const file2 = manifestPath(ctx, m.from.name);
989
+ const man2 = readManifest(file2, m.from.name);
990
+ writeManifest(file2, { ...man2, claims: man2.claims.filter((cl) => !m.dropClaims.includes(cl)) });
991
+ git(m.from, ["add", "-f", "--", manifestRel(m.from.name)]);
992
+ }
993
+ if (m.from.name === "base") {
994
+ warn(`moved ${m.claim} out of base: its removal is staged in base, commit it there (git commit). ` + `Old versions remain in base's history (and on its remote, which may be public). ` + `Rewrite that history and rotate any secrets if that matters.`);
995
+ } else {
996
+ const changes = [
997
+ m.dropClaims.length && `dropped its ${plural(m.dropClaims.length, "claim")} ${listSome(m.dropClaims)} -> ${manifestRel(m.from.name)}`,
998
+ m.files.length && `untracked ${plural(m.files.length, "file")} there`
999
+ ].filter(Boolean);
1000
+ warn(`moved ${m.claim} from ${m.from.name}: ${changes.join("; ")} ` + `(commit with: sich commit ${m.from.name} -m <msg>)`);
1001
+ }
1002
+ }
1003
+ const file = manifestPath(ctx, layer);
1004
+ const man = readManifest(file, layer);
1005
+ const already = targets.filter((t) => own.some((cl) => covers(cl, t)));
1006
+ writeManifest(file, { ...man, claims: normalizeClaims([...man.claims, ...targets]) });
1007
+ syncExcludes(ctx);
1008
+ for (const t of targets)
1009
+ stage(repo, t);
1010
+ git(repo, ["add", "-f", "--", manifestRel(layer)]);
1011
+ for (const t of already)
1012
+ note(`${t} was already claimed by ${layer}`);
1013
+ out(`${c.green("claimed")} ${listSome(targets)} for ${c.bold(layer)} -> ${manifestRel(layer)}`);
1014
+ out(`hidden from base -> ${excludeFileShown(ctx.base)}`);
1015
+ note(`staged in ${layer}; commit with: sich commit ${layer} -m <msg>`);
1016
+ return 0;
1017
+ }
1018
+ function stage(repo, claim) {
1019
+ if (!isDirClaim(claim)) {
1020
+ git(repo, ["add", "-f", "--", claim]);
1021
+ return;
1022
+ }
1023
+ const candidates = splitZ(git(repo, ["ls-files", "-z", "-c", "-o", "--exclude-standard", "--", claim]).stdout);
1024
+ if (candidates.length) {
1025
+ git(repo, ["add", "-A", "--", claim]);
1026
+ } else if (isIgnored(repo, bare(claim))) {
1027
+ warn(`${claim} is ignored by a .gitignore, so nothing in it was staged; claim individual files instead`);
1028
+ } else {
1029
+ note(`${claim} has no files yet; new files in it will belong to ${repo.name}`);
1030
+ }
1031
+ }
1032
+ function cmdUnclaim(ctx, args) {
1033
+ const p = parseArgs(args, {});
1034
+ const [layer, ...inputs] = p.positionals;
1035
+ if (!layer || inputs.length === 0)
1036
+ fail("usage: sich unclaim <layer> <path...>");
1037
+ const repo = requireLayer(ctx, layer);
1038
+ const file = manifestPath(ctx, layer);
1039
+ const man = readManifest(file, layer);
1040
+ const remove = [];
1041
+ for (const input of inputs) {
1042
+ const up = resolveUserPath(ctx, input);
1043
+ const forms = up.exists ? [up.isDir ? up.rel + "/" : up.rel] : [up.rel, up.rel + "/"];
1044
+ const hit = man.claims.find((cl) => forms.includes(cl));
1045
+ if (!hit) {
1046
+ const parent = man.claims.find((cl) => covers(cl, up.rel));
1047
+ fail(`${up.rel} is not claimed by ${layer}` + (parent ? ` (it is inside the claim ${parent})` : ""));
1048
+ }
1049
+ if (!remove.includes(hit))
1050
+ remove.push(hit);
1051
+ }
1052
+ writeManifest(file, { ...man, claims: man.claims.filter((cl) => !remove.includes(cl)) });
1053
+ for (const cl of remove)
1054
+ git(repo, ["rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", cl]);
1055
+ syncExcludes(ctx);
1056
+ git(repo, ["add", "-f", "--", manifestRel(layer)]);
1057
+ out(`${c.green("released")} ${listSome(remove)} from ${c.bold(layer)} -> ${manifestRel(layer)}`);
1058
+ out(`no longer hidden from base -> ${excludeFileShown(ctx.base)}`);
1059
+ note(`staged in ${layer}; commit with: sich commit ${layer} -m <msg>`);
1060
+ note(`${plural(remove.length, "path")} left on disk; they now show as untracked in base (add, delete or .gitignore them)`);
1061
+ return 0;
1062
+ }
1063
+
1064
+ // src/commands/commit.ts
1065
+ function cmdCommit(ctx, args) {
1066
+ const p = parseArgs(args, { "-m": "value" }, { "--message": "-m" });
1067
+ const message = str(p, "-m");
1068
+ if (!message)
1069
+ fail("usage: sich commit [repo...] -m <msg>");
1070
+ const named = p.positionals.length > 0;
1071
+ const names = named ? [...new Set(p.positionals)] : ["base", ...layerNames(ctx)];
1072
+ const repos = names.map((n) => repoByName(ctx, n));
1073
+ const claims = allClaims(ctx);
1074
+ const icase = ignoresCase(ctx);
1075
+ const ownerRepo = (f) => {
1076
+ if (f.startsWith(".sich/"))
1077
+ return layerNames(ctx).find((l) => f === manifestRel(l)) ?? null;
1078
+ return ownerOf(claims, f, icase)?.layer ?? "base";
1079
+ };
1080
+ let committed = 0;
1081
+ for (const repo of repos) {
1082
+ git(repo, ["add", "-A"]);
1083
+ const changed = git(repo, ["diff", "--cached", "--name-only", "--no-renames", "--diff-filter=d", "-z"]).stdout;
1084
+ const stray = splitZ(changed).filter((f) => ownerRepo(f) !== repo.name);
1085
+ if (stray.length) {
1086
+ git(repo, ["reset", "-q", "--", ...stray]);
1087
+ warn(`${repo.name}: left out ${plural(stray.length, "file")} it doesn't own: ${listSome(stray)} (see: sich check)`);
1088
+ }
1089
+ const files = splitZ(git(repo, ["diff", "--cached", "--name-only", "-z"]).stdout);
1090
+ if (files.length === 0) {
1091
+ if (named)
1092
+ note(`${repo.name}: nothing to commit`);
1093
+ continue;
1094
+ }
1095
+ const r = git(repo, ["commit", "-q", "-m", message], { allowFail: true });
1096
+ if (r.code !== 0) {
1097
+ process.stderr.write(r.stderr + r.stdout);
1098
+ fail(`${repo.name}: commit failed (nothing committed there)`);
1099
+ }
1100
+ const sha = git(repo, ["rev-parse", "--short", "HEAD"]).stdout.trim();
1101
+ out(`${c.bold(repo.name)}: committed ${c.yellow(sha)} (${plural(files.length, "file")}): ${listSome(files)}`);
1102
+ committed++;
1103
+ }
1104
+ if (committed === 0 && !named)
1105
+ note("nothing to commit");
1106
+ return 0;
1107
+ }
1108
+
1109
+ // src/commands/inspect.ts
1110
+ function cmdWhich(ctx, args) {
1111
+ const p = parseArgs(args, {});
1112
+ const [input, ...extra] = p.positionals;
1113
+ if (!input || extra.length)
1114
+ fail("usage: sich which <path>");
1115
+ const up = resolveUserPath(ctx, input, { allowSich: true });
1116
+ const path = up.rel;
1117
+ const owner = ownerOf(allClaims(ctx), path);
1118
+ if (owner) {
1119
+ const tracked = trackedUnder(layerRepo(ctx, owner.layer), [path]).length > 0;
1120
+ out(tracked ? owner.layer : `${owner.layer} ${c.dim("(claimed, not yet committed)")}`);
1121
+ return 0;
1122
+ }
1123
+ for (const layer of layerNames(ctx)) {
1124
+ if (trackedUnder(layerRepo(ctx, layer), [path]).length) {
1125
+ out(layer);
1126
+ return 0;
1127
+ }
1128
+ }
1129
+ if (trackedUnder(ctx.base, [path]).length) {
1130
+ out("base");
1131
+ return 0;
1132
+ }
1133
+ out(isIgnored(ctx.base, path, true) ? "ignored" : "untracked");
1134
+ return 0;
1135
+ }
1136
+ function cmdLs(ctx, args) {
1137
+ const p = parseArgs(args, {});
1138
+ if (p.positionals.length > 1)
1139
+ fail("usage: sich ls [layer]");
1140
+ const only = p.positionals[0];
1141
+ if (only)
1142
+ requireLayer(ctx, only);
1143
+ const claims = allClaims(ctx);
1144
+ const layers = only ? [only] : layerNames(ctx);
1145
+ if (layers.length === 0) {
1146
+ note("no layers yet (create one with: sich new <layer>)");
1147
+ return 0;
1148
+ }
1149
+ layers.forEach((layer, i) => {
1150
+ if (i > 0)
1151
+ out();
1152
+ out(c.bold(layer));
1153
+ const own = claims.get(layer) ?? [];
1154
+ out(` claims:${own.length ? "" : c.dim(" (none)")}`);
1155
+ for (const cl of own)
1156
+ out(` ${cl}`);
1157
+ const files = splitZ(git(layerRepo(ctx, layer), ["ls-files", "-z"]).stdout);
1158
+ out(` tracked:${files.length ? "" : c.dim(" (none)")}`);
1159
+ for (const f of files)
1160
+ out(` ${f}`);
1161
+ });
1162
+ return 0;
1163
+ }
1164
+
1165
+ // src/commands/layer.ts
1166
+ import { spawnSync as spawnSync2 } from "node:child_process";
1167
+ import { existsSync as existsSync6, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
1168
+ import { basename as basename3, isAbsolute as isAbsolute2, join as join4, resolve as resolve5 } from "node:path";
1169
+ function assertFree(ctx, layer) {
1170
+ validateLayerName(layer);
1171
+ if (existsSync6(join4(ctx.sichDir, layer)))
1172
+ fail(`layer '${layer}' already exists (.sich/${layer}/)`);
1173
+ if (existsSync6(manifestPath(ctx, layer)))
1174
+ fail(`.sich/${layer}.paths already exists; remove it first`);
1175
+ }
1176
+ function createGitDir(ctx, layer) {
1177
+ const repo = layerRepo(ctx, layer);
1178
+ git(repo, ["init", "-q", "--initial-branch=main"], { cwd: ctx.root });
1179
+ git(repo, ["config", "core.worktree", "../.."]);
1180
+ git(repo, ["config", "core.bare", "false"]);
1181
+ return repo;
1182
+ }
1183
+ function remoteUrl(ctx, url) {
1184
+ if (isAbsolute2(url) || /^[a-z][a-z0-9+.-]*:\/\//i.test(url))
1185
+ return url;
1186
+ const local = resolve5(ctx.cwd, url);
1187
+ return existsSync6(local) ? local : url;
1188
+ }
1189
+ function baseRepoName(ctx) {
1190
+ const url = git(ctx.base, ["config", "--get", "remote.origin.url"], { allowFail: true }).stdout.trim();
1191
+ if (url) {
1192
+ const last = url.replace(/\/+$/, "").replace(/\.git$/, "").split(/[/:]/).pop();
1193
+ if (last)
1194
+ return last;
1195
+ }
1196
+ return basename3(ctx.root);
1197
+ }
1198
+ function runGh(args, capture) {
1199
+ const bin = process.env.SICH_GH || "gh";
1200
+ const r = spawnSync2(bin, args, {
1201
+ encoding: "utf8",
1202
+ stdio: ["inherit", capture ? "pipe" : "inherit", "inherit"]
1203
+ });
1204
+ if (r.error)
1205
+ fail(`cannot run ${bin}: ${r.error.message}`);
1206
+ if (r.status !== 0)
1207
+ fail(`${bin} ${args.join(" ")} failed (exit ${r.status ?? "?"})`);
1208
+ return r.stdout?.trim() ?? "";
1209
+ }
1210
+ function cmdNew(ctx, args) {
1211
+ const p = parseArgs(args, { "--remote": "value", "--gh": "optional" });
1212
+ const [layer, ...extra] = p.positionals;
1213
+ if (!layer || extra.length)
1214
+ fail("usage: sich new <layer> [--remote <url> | --gh [name]]");
1215
+ const given = str(p, "--remote");
1216
+ const remote = given && remoteUrl(ctx, given);
1217
+ const gh = p.flags["--gh"];
1218
+ if (remote && gh)
1219
+ fail("use either --remote or --gh, not both");
1220
+ assertFree(ctx, layer);
1221
+ ensureInit(ctx);
1222
+ let url = remote;
1223
+ if (gh) {
1224
+ const name = typeof gh === "string" ? gh : `${baseRepoName(ctx)}-${layer}`;
1225
+ runGh(["repo", "create", name, "--private"], false);
1226
+ url = runGh(["repo", "view", name, "--json", "sshUrl", "-q", ".sshUrl"], true);
1227
+ if (!url)
1228
+ fail(`could not read the SSH URL of GitHub repo ${name}`);
1229
+ }
1230
+ const repo = createGitDir(ctx, layer);
1231
+ try {
1232
+ writeFileSync4(manifestPath(ctx, layer), `# Paths owned by sich layer '${layer}': one root-relative path per line, directories end with /
1233
+ `);
1234
+ syncExcludes(ctx);
1235
+ git(repo, ["add", "-f", "--", manifestRel(layer)]);
1236
+ git(repo, ["commit", "-q", "-m", `sich: create layer ${layer}`]);
1237
+ if (url)
1238
+ git(repo, ["remote", "add", "origin", url]);
1239
+ } catch (e) {
1240
+ rmSync(repo.gitDir, { recursive: true, force: true });
1241
+ rmSync(manifestPath(ctx, layer), { force: true });
1242
+ syncExcludes(ctx);
1243
+ throw e;
1244
+ }
1245
+ out(`${c.green("created")} layer ${c.bold(layer)}: git data -> .sich/${layer}/, claims list -> ${manifestRel(layer)}`);
1246
+ if (url)
1247
+ out(`set origin of ${layer} -> ${url}`);
1248
+ installLayerHooks(ctx, layer);
1249
+ note(`next: sich claim ${layer} <path>... then sich commit ${layer} -m <msg>${url ? ` and sich push ${layer}` : ""}`);
1250
+ return 0;
1251
+ }
1252
+ function cmdAttach(ctx, args) {
1253
+ const p = parseArgs(args, {});
1254
+ const [layer, given, ...extra] = p.positionals;
1255
+ if (!layer || !given || extra.length)
1256
+ fail("usage: sich attach <layer> <url>");
1257
+ const url = remoteUrl(ctx, given);
1258
+ assertFree(ctx, layer);
1259
+ ensureInit(ctx);
1260
+ const repo = createGitDir(ctx, layer);
1261
+ const cleanup = () => rmSync(repo.gitDir, { recursive: true, force: true });
1262
+ try {
1263
+ git(repo, ["remote", "add", "origin", url]);
1264
+ const fetched = git(repo, ["fetch", "-q", "origin"], { allowFail: true });
1265
+ if (fetched.code !== 0)
1266
+ fail(`cannot fetch ${url}: ${fetched.stderr.trim().split(`
1267
+ `).pop()}`);
1268
+ const head = git(repo, ["ls-remote", "--symref", "origin", "HEAD"], { allowFail: true }).stdout;
1269
+ const branch = /^ref: refs\/heads\/(\S+)\s+HEAD$/m.exec(head)?.[1] ?? "main";
1270
+ const ref = `refs/remotes/origin/${branch}`;
1271
+ if (git(repo, ["rev-parse", "--verify", "-q", ref], { allowFail: true }).code !== 0) {
1272
+ fail(`${url} has no branch '${branch}' (is it a sich layer? create new layers with sich new)`);
1273
+ }
1274
+ const files = splitZ(git(repo, ["ls-tree", "-r", "-z", "--name-only", ref]).stdout);
1275
+ const clash = collisions(ctx, files);
1276
+ if (clash.length) {
1277
+ fail(`attach would overwrite existing files: ${listSome(clash)} (move them away and retry)`);
1278
+ }
1279
+ git(repo, ["checkout", "-q", "-B", branch, "--track", `origin/${branch}`]);
1280
+ if (!existsSync6(manifestPath(ctx, layer))) {
1281
+ warn(`${url} has no .sich/${layer}.paths; is the layer name right?`);
1282
+ }
1283
+ syncExcludes(ctx);
1284
+ out(`${c.green("attached")} layer ${c.bold(layer)} from ${url} -> .sich/${layer}/ (branch ${branch})`);
1285
+ out(`checked out ${plural(files.length, "file")}: ${listSome(files)}`);
1286
+ } catch (e) {
1287
+ cleanup();
1288
+ throw e;
1289
+ }
1290
+ installLayerHooks(ctx, layer);
1291
+ return 0;
1292
+ }
1293
+
1294
+ // src/commands/status.ts
1295
+ function parseStatus(raw) {
1296
+ const s = {
1297
+ branch: "?",
1298
+ upstream: null,
1299
+ ahead: 0,
1300
+ behind: 0,
1301
+ gone: false,
1302
+ staged: 0,
1303
+ modified: 0,
1304
+ untracked: 0,
1305
+ conflicted: 0,
1306
+ files: []
1307
+ };
1308
+ const entries = splitZ(raw);
1309
+ let sawAb = false;
1310
+ for (let i = 0;i < entries.length; i++) {
1311
+ const e = entries[i];
1312
+ if (e.startsWith("# branch.head "))
1313
+ s.branch = e.slice(14);
1314
+ else if (e.startsWith("# branch.upstream "))
1315
+ s.upstream = e.slice(18);
1316
+ else if (e.startsWith("# branch.ab ")) {
1317
+ sawAb = true;
1318
+ const m = /\+(\d+) -(\d+)/.exec(e);
1319
+ s.ahead = Number(m?.[1] ?? 0);
1320
+ s.behind = Number(m?.[2] ?? 0);
1321
+ } else if (e.startsWith("? ")) {
1322
+ s.untracked++;
1323
+ s.files.push(`?? ${e.slice(2)}`);
1324
+ } else if (e[0] === "1" || e[0] === "2" || e[0] === "u") {
1325
+ const n = e[0] === "1" ? 8 : e[0] === "2" ? 9 : 10;
1326
+ const parts = e.split(" ");
1327
+ const xy = parts[1] ?? "..";
1328
+ const path = parts.slice(n).join(" ");
1329
+ if (e[0] === "2")
1330
+ i++;
1331
+ if (e[0] === "u")
1332
+ s.conflicted++;
1333
+ else {
1334
+ if (xy[0] !== ".")
1335
+ s.staged++;
1336
+ if (xy[1] !== ".")
1337
+ s.modified++;
1338
+ }
1339
+ s.files.push(`${xy.replace(/\./g, " ")} ${path}`);
1340
+ }
1341
+ }
1342
+ s.gone = s.upstream !== null && !sawAb;
1343
+ return s;
1344
+ }
1345
+ function repoStatus(repo) {
1346
+ return parseStatus(git(repo, ["status", "--porcelain=v2", "--branch", "-z"]).stdout);
1347
+ }
1348
+ function tracking(s) {
1349
+ if (!s.upstream)
1350
+ return c.dim("no upstream");
1351
+ if (s.gone)
1352
+ return `${s.upstream} ${c.red("gone")}`;
1353
+ const parts = [];
1354
+ if (s.ahead)
1355
+ parts.push(c.green(`ahead ${s.ahead}`));
1356
+ if (s.behind)
1357
+ parts.push(c.yellow(`behind ${s.behind}`));
1358
+ return `${s.upstream} ${parts.length ? parts.join(", ") : c.dim("up to date")}`;
1359
+ }
1360
+ function changes(s) {
1361
+ const parts = [];
1362
+ if (s.conflicted)
1363
+ parts.push(c.red(`${s.conflicted} conflicted`));
1364
+ if (s.staged)
1365
+ parts.push(c.green(`${s.staged} staged`));
1366
+ if (s.modified)
1367
+ parts.push(c.yellow(`${s.modified} modified`));
1368
+ if (s.untracked)
1369
+ parts.push(c.red(`${s.untracked} untracked`));
1370
+ return parts.length ? parts.join(", ") : c.dim("clean");
1371
+ }
1372
+ var visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
1373
+ var pad = (s, w) => s + " ".repeat(Math.max(0, w - visible(s)));
1374
+ function cmdStatus(ctx, args) {
1375
+ const p = parseArgs(args, { "-v": "bool", "--fetch": "bool" }, { "--verbose": "-v" });
1376
+ if (p.positionals.length)
1377
+ fail("usage: sich status [-v] [--fetch]");
1378
+ const repos = [ctx.base, ...layerNames(ctx).map((l) => layerRepo(ctx, l))];
1379
+ if (p.flags["--fetch"]) {
1380
+ for (const r of repos) {
1381
+ if (remotesOf(r).length === 0)
1382
+ continue;
1383
+ const f = git(r, ["fetch", "--quiet"], { allowFail: true });
1384
+ if (f.code !== 0)
1385
+ warn(`${r.name}: fetch failed: ${f.stderr.trim().split(`
1386
+ `).pop()}`);
1387
+ }
1388
+ }
1389
+ const rows = repos.map((r) => ({ repo: r, s: repoStatus(r) }));
1390
+ const rendered = rows.map(({ repo, s }) => [c.bold(repo.name), c.cyan(s.branch), tracking(s), changes(s)]);
1391
+ const widths = [0, 1, 2].map((i) => Math.max(...rendered.map((cols) => visible(cols[i]))));
1392
+ rows.forEach(({ s }, idx) => {
1393
+ const cols = rendered[idx];
1394
+ out(cols.map((col, i) => i < 3 ? pad(col, widths[i]) : col).join(" "));
1395
+ if (p.flags["-v"])
1396
+ for (const f of s.files)
1397
+ out(` ${f}`);
1398
+ });
1399
+ return 0;
1400
+ }
1401
+
1402
+ // src/commands/sync.ts
1403
+ class StepFailed extends Error {
1404
+ repo;
1405
+ action;
1406
+ reason;
1407
+ constructor(repo, action, reason) {
1408
+ super(`${action} failed in ${repo}`);
1409
+ this.repo = repo;
1410
+ this.action = action;
1411
+ this.reason = reason;
1412
+ }
1413
+ }
1414
+ function header(repo, argv) {
1415
+ out(`${c.bold(repo.name)}: ${c.dim("git " + argv.join(" "))}`);
1416
+ }
1417
+ function exec(repo, action, argv) {
1418
+ const code = gitInherit(repo, argv);
1419
+ if (code !== 0)
1420
+ throw new StepFailed(repo.name, action, `git exit ${code}`);
1421
+ }
1422
+ function defaultRemote(remotes) {
1423
+ return remotes.includes("origin") ? "origin" : remotes[0];
1424
+ }
1425
+ function refuseOverwrite(ctx, repo, incoming) {
1426
+ const hasHead = git(repo, ["rev-parse", "--verify", "-q", "HEAD"], { allowFail: true }).code === 0;
1427
+ const added = hasHead ? git(repo, ["diff", "--name-only", "-z", "--no-renames", "--diff-filter=A", "HEAD", incoming]).stdout : git(repo, ["ls-tree", "-r", "-z", "--name-only", incoming]).stdout;
1428
+ const clash = collisions(ctx, splitZ(added)).filter((p) => trackedUnder(repo, [p]).length === 0);
1429
+ if (clash.length === 0)
1430
+ return;
1431
+ const claims = allClaims(ctx);
1432
+ const shown = clash.map((p) => {
1433
+ const owner = ownerOf(claims, p)?.layer;
1434
+ return owner && owner !== repo.name ? `${p} (${owner})` : p;
1435
+ });
1436
+ printError(`${repo.name}: pull would overwrite ${plural(clash.length, "local path")} it doesn't track: ` + `${listSome(shown)} (move aside and retry)`);
1437
+ throw new StepFailed(repo.name, "pull", "would overwrite local files");
1438
+ }
1439
+ function pull(ctx, repo, remotes) {
1440
+ const argv = ["pull", "--rebase", "--autostash"];
1441
+ let fetch;
1442
+ let incoming;
1443
+ if (upstreamOf(repo)) {
1444
+ fetch = [];
1445
+ incoming = "@{u}";
1446
+ } else {
1447
+ const branch = currentBranch(repo);
1448
+ if (!branch) {
1449
+ note(`${repo.name}: detached HEAD and no upstream, skipped pull`);
1450
+ return;
1451
+ }
1452
+ const remote = defaultRemote(remotes);
1453
+ const has = git(repo, ["ls-remote", "--exit-code", "--heads", remote, branch], { allowFail: true }).code === 0;
1454
+ if (!has) {
1455
+ note(`${repo.name}: no upstream and nothing on ${remote} yet, skipped pull`);
1456
+ return;
1457
+ }
1458
+ fetch = [remote, branch];
1459
+ incoming = "FETCH_HEAD";
1460
+ argv.push(remote, branch);
1461
+ }
1462
+ header(repo, argv);
1463
+ exec(repo, "pull", ["fetch", "-q", ...fetch]);
1464
+ refuseOverwrite(ctx, repo, incoming);
1465
+ exec(repo, "pull", argv);
1466
+ syncExcludes(ctx);
1467
+ }
1468
+ function push(repo, remotes) {
1469
+ if (upstreamOf(repo)) {
1470
+ header(repo, ["push"]);
1471
+ exec(repo, "push", ["push"]);
1472
+ return;
1473
+ }
1474
+ const branch = currentBranch(repo);
1475
+ if (!branch) {
1476
+ printError(`${repo.name}: detached HEAD, cannot push`);
1477
+ throw new StepFailed(repo.name, "push", "detached HEAD");
1478
+ }
1479
+ const argv = ["push", "-u", defaultRemote(remotes), branch];
1480
+ header(repo, argv);
1481
+ exec(repo, "push", argv);
1482
+ }
1483
+ function cmdRemote(ctx, command, args) {
1484
+ const p = parseArgs(args, {});
1485
+ const names = p.positionals.length ? [...new Set(p.positionals)] : ["base", ...layerNames(ctx)];
1486
+ const repos = names.map((n) => repoByName(ctx, n));
1487
+ const actions = command === "sync" ? ["pull", "push"] : [command];
1488
+ for (let i = 0;i < repos.length; i++) {
1489
+ const repo = repos[i];
1490
+ const remotes = remotesOf(repo);
1491
+ if (remotes.length === 0) {
1492
+ note(`${repo.name}: no remote, skipped`);
1493
+ continue;
1494
+ }
1495
+ try {
1496
+ for (const a of actions) {
1497
+ if (a === "pull")
1498
+ pull(ctx, repo, remotes);
1499
+ else
1500
+ push(repo, remotes);
1501
+ }
1502
+ } catch (e) {
1503
+ if (!(e instanceof StepFailed))
1504
+ throw e;
1505
+ syncExcludes(ctx);
1506
+ const rest = repos.slice(i + 1).map((r) => r.name);
1507
+ fail(`${e.action} failed in ${e.repo} (${e.reason})` + (rest.length ? `; stopped before: ${rest.join(", ")}` : ""));
1508
+ }
1509
+ }
1510
+ return 0;
1511
+ }
1512
+
1513
+ // src/help.ts
1514
+ var MAIN_HELP = `sich - private git layers that share one working tree with a normal repo
1515
+
1516
+ base your normal repo (.git), usually public
1517
+ layer a private repo in .sich/<layer>/ with its own remote and access list
1518
+ claim a file or folder a layer owns; base and other layers never see it
1519
+
1520
+ usage: sich [-C <dir>] <command> [args]
1521
+ sich [-C <dir>] <layer|base> <git args...>
1522
+
1523
+ setup
1524
+ init set up .sich/, excludes, commit guards
1525
+ new <layer> [--remote <url> | --gh [name]]
1526
+ create a layer (--gh: private GitHub repo)
1527
+ attach <layer> <url> join an existing layer (collaborators)
1528
+
1529
+ ownership
1530
+ claim <layer> <path...> [--move] claim files/folders for a layer, stage them
1531
+ (--move: take them from base/another layer)
1532
+ unclaim <layer> <path...> release claims; files stay on disk
1533
+ which <path> show who owns a path
1534
+ ls [layer] list claims and tracked files
1535
+
1536
+ everyday
1537
+ status [-v] [--fetch] per repo: branch, ahead/behind, changes
1538
+ commit [repo...] -m <msg> add -A + commit base + all layers (or named)
1539
+ pull [repo...] pull --rebase base + all layers (or named)
1540
+ push [repo...] push base + all layers (or named)
1541
+ sync [repo...] pull, then push
1542
+ check [--fix] [--staged] find leaks and ownership problems
1543
+ (--staged: what the commit/merge hooks run)
1544
+
1545
+ passthrough
1546
+ <layer> <git args...> run git in a layer, e.g. sich notes log
1547
+ base <git args...> run git in the base repo
1548
+
1549
+ options
1550
+ -C <dir> run as if started in <dir>
1551
+ -h, --help show help (also: sich <command> --help)
1552
+ -V, --version print version
1553
+
1554
+ environment
1555
+ SICH_BIN sich binary the commit/merge hooks run
1556
+ (default: sich on PATH)
1557
+ SICH_GH gh binary for new --gh (default: gh)
1558
+ NO_COLOR disable colors
1559
+
1560
+ Layer names match ^[a-z0-9][a-z0-9._-]*$; base and command names are reserved.
1561
+ Claims can't nest across layers.
1562
+ `;
1563
+ var COMMAND_HELP = {
1564
+ init: `usage: sich init
1565
+
1566
+ Creates .sich/, writes the managed blocks in each repo's info/exclude, and
1567
+ installs pre-commit and pre-merge-commit hooks in base and in every layer that
1568
+ run 'sich check --staged' (git runs pre-merge-commit instead of pre-commit for
1569
+ a merge commit made without conflicts). Fast-forwards, rebase and cherry-pick
1570
+ run no hook; plain 'sich check' catches what they bring in afterwards. If such
1571
+ a hook already exists or core.hooksPath is set, prints the line to add instead
1572
+ (each line works in base, in every layer and in a core.hooksPath they share).
1573
+ The hooks run $SICH_BIN (default: sich on PATH) and block the commit if it
1574
+ can't be found. Safe to run again; refreshes hooks sich wrote and adds missing
1575
+ ones.`,
1576
+ new: `usage: sich new <layer> [--remote <url> | --gh [name]]
1577
+
1578
+ Creates layer <layer> (.sich/<layer>/ git dir sharing this working tree) with an
1579
+ empty claims manifest .sich/<layer>.paths and an initial commit, and installs
1580
+ its pre-commit and pre-merge-commit hooks (see sich init).
1581
+ --remote <url> set origin
1582
+ --gh [name] create a private GitHub repo with gh (default name:
1583
+ <base-repo>-<layer>) and set origin to its SSH URL.
1584
+ The gh binary can be overridden with SICH_GH.`,
1585
+ attach: `usage: sich attach <layer> <url>
1586
+
1587
+ Joins an existing layer: fetches <url> and checks out its default branch into
1588
+ this working tree, and installs its pre-commit and pre-merge-commit hooks (see
1589
+ sich init). Refuses to overwrite existing files.`,
1590
+ claim: `usage: sich claim <layer> <path...> [--move]
1591
+
1592
+ Claims paths (files or directories, relative to the current directory) for a
1593
+ layer, then stages them and the manifest in that layer (no commit).
1594
+ Refuses paths tracked by base or owned by another layer unless --move is given;
1595
+ --move untracks them from the previous owner (old versions stay in its history).
1596
+ Claims can't nest across layers: a path inside another layer's claimed directory
1597
+ is always refused (move the whole directory instead).
1598
+ Not the same as 'sich <layer> add', which runs plain git add in the layer.
1599
+ Alias: sich add.`,
1600
+ unclaim: `usage: sich unclaim <layer> <path...>
1601
+
1602
+ Removes exact claims from a layer and untracks them there. The files stay on
1603
+ disk and show up as untracked in base (or, if another layer's directory claim
1604
+ contains them, now belong to that layer).
1605
+ Not the same as 'sich <layer> rm', which runs plain git rm (deleting files).
1606
+ Alias: sich rm.`,
1607
+ which: `usage: sich which <path>
1608
+
1609
+ Prints the owner of a path: a layer name, 'base', 'ignored' or 'untracked'.`,
1610
+ ls: `usage: sich ls [layer]
1611
+
1612
+ Lists each layer's claims and tracked files.`,
1613
+ status: `usage: sich status [-v] [--fetch]
1614
+
1615
+ One row per repo (base first): branch, ahead/behind upstream, and counts of
1616
+ staged / modified / untracked files.
1617
+ -v, --verbose list the files
1618
+ --fetch fetch every repo with a remote first`,
1619
+ commit: `usage: sich commit [repo...] -m <msg>
1620
+
1621
+ Stages everything (git add -A) and commits, in base and every layer with
1622
+ changes, or only in the named repos ('base' or layer names). Excludes keep each
1623
+ repo to its own files; anything a repo doesn't own (e.g. let in by a '!' rule in
1624
+ a .gitignore) is left out with a warning. Each commit runs that repo's
1625
+ pre-commit hook.`,
1626
+ pull: `usage: sich pull [repo...]
1627
+
1628
+ git pull --rebase --autostash in base and every layer (or the named repos,
1629
+ 'base' allowed). Repos without a remote are skipped. Stops at the first failure.
1630
+ Refuses to pull a repo if that would overwrite a file it doesn't track, such as
1631
+ another layer's (git would do that silently: to it, those files are ignored).`,
1632
+ push: `usage: sich push [repo...]
1633
+
1634
+ git push in base and every layer (or the named repos). Sets the upstream
1635
+ (-u origin <branch>) when there is none. Stops at the first failure.`,
1636
+ sync: `usage: sich sync [repo...]
1637
+
1638
+ pull then push, repo by repo. Stops at the first failure.`,
1639
+ check: `usage: sich check [--fix] [--staged [--merge] [--repo <repo>]]
1640
+
1641
+ Reports: paths claimed or tracked by two layers, base tracking claimed paths or
1642
+ .sich/, layers tracking files they don't own, and stale exclude blocks. Warns
1643
+ about base or layers whose pre-commit or pre-merge-commit hook doesn't run sich
1644
+ (fix: sich init).
1645
+ --fix rewrite stale exclude blocks
1646
+ --staged also check the commit in progress (this is what the
1647
+ pre-commit and pre-merge-commit hooks run; for a merge, what
1648
+ it brings in). In base, fail if it stages claimed paths or
1649
+ .sich/. In a layer, fail if it stages a path that none of its
1650
+ claims cover (in the claims list as committed too), another
1651
+ layer's, or .sich/. Staged deletions are fine. The repo is
1652
+ the one whose index git is committing (else base). Silent
1653
+ when all is well, stale excludes only warn, and a no-op where
1654
+ sich isn't set up (e.g. a linked worktree)
1655
+ --merge with --staged: the commit is a merge (the pre-merge-commit
1656
+ hooks pass it; finishing a conflicted merge counts without
1657
+ it). A blocked merge prints how to back out and restore what
1658
+ it overwrote, instead of commit hints
1659
+ --repo <repo> with --staged: check this repo ('base' or a layer) instead
1660
+ Exits 1 if any issue remains.`,
1661
+ help: `usage: sich help [command]`
1662
+ };
1663
+ COMMAND_HELP.add = COMMAND_HELP.claim;
1664
+ COMMAND_HELP.rm = COMMAND_HELP.unclaim;
1665
+
1666
+ // src/cli.ts
1667
+ var VERSION = version;
1668
+ var HANDLERS = {
1669
+ init: cmdInit,
1670
+ new: cmdNew,
1671
+ attach: cmdAttach,
1672
+ claim: cmdClaim,
1673
+ unclaim: cmdUnclaim,
1674
+ add: cmdClaim,
1675
+ rm: cmdUnclaim,
1676
+ which: cmdWhich,
1677
+ ls: cmdLs,
1678
+ status: cmdStatus,
1679
+ commit: cmdCommit,
1680
+ pull: (ctx, a) => cmdRemote(ctx, "pull", a),
1681
+ push: (ctx, a) => cmdRemote(ctx, "push", a),
1682
+ sync: (ctx, a) => cmdRemote(ctx, "sync", a),
1683
+ check: cmdCheck
1684
+ };
1685
+ var SELF_INIT = new Set(["init", "new", "attach", "check"]);
1686
+ function changeDir(cwd, dir) {
1687
+ const target = resolve6(cwd, dir);
1688
+ try {
1689
+ if (!statSync(target).isDirectory())
1690
+ fail(`-C ${dir}: not a directory`);
1691
+ return realpathSync3.native(target);
1692
+ } catch (e) {
1693
+ if (e instanceof SichError)
1694
+ throw e;
1695
+ fail(`-C ${dir}: no such directory`);
1696
+ }
1697
+ }
1698
+ function main(argv) {
1699
+ let cwd = realpathSync3.native(process.cwd());
1700
+ let i = 0;
1701
+ for (;i < argv.length; i++) {
1702
+ const a = argv[i];
1703
+ if (a === "-C") {
1704
+ const dir = argv[++i];
1705
+ if (dir === undefined)
1706
+ fail("-C needs a directory");
1707
+ cwd = changeDir(cwd, dir);
1708
+ } else if (a.startsWith("-C") && a.length > 2) {
1709
+ cwd = changeDir(cwd, a.slice(2));
1710
+ } else if (a === "-h" || a === "--help") {
1711
+ out(MAIN_HELP.trimEnd());
1712
+ return 0;
1713
+ } else if (a === "-V" || a === "--version") {
1714
+ out(`sich ${VERSION}`);
1715
+ return 0;
1716
+ } else if (a.startsWith("-")) {
1717
+ fail(`unknown option '${a}' (see sich --help)`);
1718
+ } else
1719
+ break;
1720
+ }
1721
+ const name = argv[i];
1722
+ const rest = argv.slice(i + 1);
1723
+ if (name === undefined) {
1724
+ out(MAIN_HELP.trimEnd());
1725
+ return 0;
1726
+ }
1727
+ if (name === "help") {
1728
+ const topic = rest[0];
1729
+ if (topic && !COMMAND_HELP[topic])
1730
+ fail(`no help for '${topic}'`);
1731
+ out((topic ? COMMAND_HELP[topic] : MAIN_HELP).trimEnd());
1732
+ return 0;
1733
+ }
1734
+ if (Object.hasOwn(HANDLERS, name)) {
1735
+ if (wantsHelp(rest)) {
1736
+ out(COMMAND_HELP[name]);
1737
+ return 0;
1738
+ }
1739
+ const ctx2 = loadCtx(cwd);
1740
+ if (!SELF_INIT.has(name))
1741
+ requireInit(ctx2);
1742
+ if (name !== "check")
1743
+ syncExcludes(ctx2);
1744
+ return HANDLERS[name](ctx2, rest);
1745
+ }
1746
+ const ctx = loadCtx(cwd);
1747
+ if (name !== "base" && !layerExists(ctx, name)) {
1748
+ fail(`unknown command or layer '${name}' (see sich --help)`);
1749
+ }
1750
+ syncExcludes(ctx);
1751
+ const code = gitInherit(repoByName(ctx, name), rest, ctx.cwd);
1752
+ syncExcludes(ctx);
1753
+ return code;
1754
+ }
1755
+ var code;
1756
+ try {
1757
+ code = main(process.argv.slice(2));
1758
+ } catch (e) {
1759
+ if (e instanceof SichError)
1760
+ printError(e.message);
1761
+ else
1762
+ printError(process.env.SICH_DEBUG ? String(e.stack) : String(e.message ?? e));
1763
+ code = 1;
1764
+ }
1765
+ process.exit(code);
1766
+ export {
1767
+ main
1768
+ };