git-sich 0.1.0 → 0.3.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 (3) hide show
  1. package/README.md +78 -31
  2. package/dist/cli.js +629 -236
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # sich
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/git-sich)](https://www.npmjs.com/package/git-sich)
4
+
3
5
  CLI for managing multiple git repositories within a single base git repo.
4
6
 
5
7
  Useful for:
@@ -13,7 +15,7 @@ Works with GitHub out of the box: every new layer can automatically become a new
13
15
  Plain `git` commands only ever see **base** files. Files from other layers never appear in the
14
16
  **base** repo: not in commits, not in `.gitignore`. Other contributors never even know that `sich` is in use or that the other repositories exist; everything works for them as usual.
15
17
 
16
- **The name** is a shortened (easier-to-type) version of *sietch*, the hidden cave communities of the Fremen in Frank Herbert's *Dune*, where only the tribe knows the way in. Such are the layers of `sich`: only visible to those with access to them.
18
+ **The name** is a shortened (easier-to-type) version of *sietch*, the hidden cave communities of the Fremen in Frank Herbert's *Dune*, where only the tribe knows the way in. Such are the layers of `sich`: only visible to those with access to them. It's pronounced like *sietch* ("seech").
17
19
 
18
20
  ## Why
19
21
 
@@ -33,25 +35,46 @@ npm install -g git-sich # the package is git-sich; the command is sich
33
35
  ## Quickstart
34
36
 
35
37
  ```sh
36
- cd myproject # an existing git repo, called base from now on; say it has a public remote at git@github.com:user/myproject.git
37
- sich init # creates an empty .sich folder, excludes it from base (.git/info/exclude) and installs the pre-commit guard
38
- sich new personal --gh # creates a layer (a sietch) called "personal": .sich/personal is a separate git repo, in the same format as the top-level .git that defines base
39
- # with --gh it also creates the private GitHub repo user/myproject-personal and sets it as the remote (git@github.com:user/myproject-personal.git)
40
- sich add personal my-notes.md # the personal layer claims my-notes.md; base now ignores it automatically
41
- sich personal commit -m "adding notes" # commits in the personal repo; or `sich commit -m "..."` commits base + every layer with changes at once
42
- sich push personal # pushes personal to its remote (setting its upstream the first time); or `sich push` pushes base + all layers
43
-
44
- sich new team --gh # creates another layer, "team", and the private repo user/myproject-team, which you can share with your team
45
- sich add team roadmap.md api-keys.env llm-transcripts/ # claims these for the team layer; base's exclude now hides all three
46
- # ...
47
-
48
- # A collaborator with sich installed and access to user/myproject-team:
38
+ # Start in an existing git repo, called base from now on.
39
+ # Say it has a public remote at git@github.com:user/myproject.git.
40
+ cd myproject
41
+
42
+ # Create an empty .sich folder, exclude it from base (.git/info/exclude)
43
+ # and install the commit guard hooks (every layer gets them too).
44
+ sich init
45
+
46
+ # Create a layer (a sietch) called "personal": .sich/personal is a separate
47
+ # git repo, in the same format as the top-level .git that defines base.
48
+ # --gh also creates the private GitHub repo user/myproject-personal
49
+ # and sets it as the layer's remote.
50
+ sich new personal --gh
51
+
52
+ # The personal layer claims my-notes.md; base now ignores it automatically.
53
+ sich claim personal my-notes.md
54
+
55
+ # Commit in the personal repo.
56
+ # (`sich commit -m "..."` commits base + every layer with changes at once.)
57
+ sich personal commit -m "adding notes"
58
+
59
+ # Push personal to its remote, setting its upstream the first time.
60
+ # (`sich push` pushes base + all layers.)
61
+ sich push personal
62
+
63
+ # Create another layer, "team", and the private repo user/myproject-team,
64
+ # which you can share with your team.
65
+ sich new team --gh
66
+
67
+ # Claim these for the team layer; base's exclude now hides all three.
68
+ sich claim team roadmap.md api-keys.env llm-transcripts/
69
+
70
+ # A collaborator with sich installed and access to user/myproject-team
71
+ # clones base, then attaches the team layer.
49
72
  git clone git@github.com:user/myproject.git && cd myproject
50
73
  sich attach team git@github.com:user/myproject-team.git
51
74
  ```
52
75
 
53
- For a full walkthrough with real output, the resulting files and every file sich
54
- generates, see **[docs/example.md](docs/example.md)**.
76
+ To see every file this generates, look at
77
+ **[section 4 of the example](docs/example.md#4-the-files-sich-generates)**.
55
78
 
56
79
  ## CLI
57
80
 
@@ -67,15 +90,16 @@ usage: sich [-C <dir>] <command> [args]
67
90
  sich [-C <dir>] <layer|base> <git args...>
68
91
 
69
92
  setup
70
- init set up .sich/, base excludes, commit guard
93
+ init set up .sich/, excludes, commit guards
71
94
  new <layer> [--remote <url> | --gh [name]]
72
95
  create a layer (--gh: private GitHub repo)
73
96
  attach <layer> <url> join an existing layer (collaborators)
97
+ detach <layer> [--force] [--yes] remove a layer here; files and remote stay
74
98
 
75
99
  ownership
76
- add <layer> <path...> [--move] claim files/folders for a layer, stage them
100
+ claim <layer> <path...> [--move] claim files/folders for a layer, stage them
77
101
  (--move: take them from base/another layer)
78
- rm <layer> <path...> release claims; files stay on disk
102
+ unclaim <layer> <path...> release claims; files stay on disk
79
103
  which <path> show who owns a path
80
104
  ls [layer] list claims and tracked files
81
105
 
@@ -86,7 +110,7 @@ everyday
86
110
  push [repo...] push base + all layers (or named)
87
111
  sync [repo...] pull, then push
88
112
  check [--fix] [--staged] find leaks and ownership problems
89
- (--staged: what the pre-commit hook runs)
113
+ (--staged: what the commit/merge hooks run)
90
114
 
91
115
  passthrough
92
116
  <layer> <git args...> run git in a layer, e.g. sich notes log
@@ -98,7 +122,7 @@ options
98
122
  -V, --version print version
99
123
 
100
124
  environment
101
- SICH_BIN sich binary the pre-commit hook runs
125
+ SICH_BIN sich binary the commit/merge hooks run
102
126
  (default: sich on PATH)
103
127
  SICH_GH gh binary for new --gh (default: gh)
104
128
  NO_COLOR disable colors
@@ -110,8 +134,6 @@ Claims can't nest across layers.
110
134
  ## How it works
111
135
 
112
136
  It's plain git all the way down; `sich` is a tiny wrapper on top.
113
- To see every file it generates for a real project, look at
114
- [section 4 of the example](docs/example.md#4-the-files-sich-generates).
115
137
 
116
138
  **Layers.** Layer `L` is a regular, non-bare git dir at `.sich/L/` with
117
139
  `core.worktree = ../..`, so its working tree is the project root. sich always
@@ -142,13 +164,35 @@ the same folder can still belong to different layers.
142
164
  Because each layer only "sees" its claims, `git add -A` in a layer is safe, and
143
165
  new files created inside a claimed directory automatically belong to that layer.
144
166
 
145
- **Guard.** The base `pre-commit` hook runs `sich check --staged`. It blocks a
146
- base commit that stages a claimed path or anything under `.sich/` (e.g. after
147
- `git add -f`; on case-insensitive filesystems under any spelling), and it blocks
148
- while ownership between layers is ambiguous (overlapping claims, a file tracked
149
- by the wrong layer), since that could leak one layer's files into another.
150
- Stale exclude rules only warn. Where sich isn't set up (a linked `git worktree`)
151
- it does nothing. Skip the check once with `git commit --no-verify`.
167
+ **Guard.** Base and every layer get a `pre-commit` and a `pre-merge-commit`
168
+ hook that run `sich check --staged`, since a layer may be public or shared with
169
+ a different audience too. It checks the repo whose index git is committing, so
170
+ the same lines also work in a `core.hooksPath` shared by every repo. In base it
171
+ blocks a commit that stages a claimed path or anything under `.sich/` (e.g.
172
+ after `git add -f`; on case-insensitive filesystems under any spelling). In
173
+ layer `L` it blocks a commit that stages a path `L` doesn't own: unclaimed (e.g.
174
+ after `sich L add -f`) or not yet in the claims list being committed, claimed by
175
+ another layer, or under `.sich/` (other than `L`'s own claims list). Staged
176
+ deletions never block, so untracking a stray file (`sich L rm --cached -- <path>`)
177
+ can be committed. Every hook also blocks while ownership between layers is
178
+ ambiguous (overlapping claims, a file tracked by the wrong layer), since that
179
+ could leak one layer's files into another. Stale exclude rules only warn. Where
180
+ sich isn't set up (a linked `git worktree`) the hooks do nothing. Skip the check
181
+ once with `git commit --no-verify` (in a layer: `sich L commit --no-verify`).
182
+
183
+ **Merges.** Merge commits are guarded too: git runs `pre-merge-commit` for a
184
+ merge commit made without conflicts (`git merge`, `git pull` without
185
+ `--rebase`), and `pre-commit` when you finish a conflicted merge with
186
+ `git commit`. The check covers what the merge brings in, e.g. a stray file
187
+ committed on a branch with `--no-verify`. By then git has already written the
188
+ merge result to the working tree, possibly over another repo's copy of an
189
+ incoming path, so a blocked merge says how to back out (`git merge --abort`)
190
+ and restore that copy afterwards, rather than how to unstage it. Skip the check
191
+ once with `git merge --no-verify` (in a layer: `sich L merge --no-verify`).
192
+ Fast-forward merges, rebase and cherry-pick run no hook at all, so what they
193
+ bring in isn't checked as it arrives; plain `sich check` catches it afterwards.
194
+ `sich check` also warns about any repo whose hooks don't run sich, and
195
+ `sich init` installs the missing ones.
152
196
 
153
197
  ## FAQ
154
198
 
@@ -167,10 +211,13 @@ That said, sometimes you do want a base commit to pin a submodule version: when
167
211
  No. Visibility is set per repo, and anyone who can clone a repo gets every file in it.
168
212
  Their per-repo access control is great, though, and sich builds on it!
169
213
 
214
+ **What's the difference between `sich claim notes x` and `sich notes add x`?**
215
+ `sich claim` gives `x` to the layer: it records the claim in `.sich/notes.paths`, hides `x` from base and the other layers, and stages it. `sich notes add` is plain `git add` run in the layer, which refuses files the layer hasn't claimed. Likewise `sich unclaim` releases a claim and keeps the file on disk, while `sich notes rm` is plain `git rm` and deletes it.
216
+
170
217
  **Is this like git-crypt or sops?**
171
218
  No. sich doesn't encrypt anything; it relies on who can access each repo. You can combine the two for real secrets.
172
219
 
173
- ## Caveats and contributing
220
+ ## Contributing
174
221
 
175
222
  [CONTRIBUTING.md](CONTRIBUTING.md)
176
223
 
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { realpathSync as realpathSync3, statSync } from "node:fs";
5
5
  import { resolve as resolve6 } from "node:path";
6
6
  // package.json
7
- var version = "0.1.0";
7
+ var version = "0.3.0";
8
8
 
9
9
  // src/ui.ts
10
10
  class SichError extends Error {
@@ -42,6 +42,23 @@ function printError(message) {
42
42
  process.stderr.write(`${ce.red("sich:")} ${message}
43
43
  `);
44
44
  }
45
+ async function confirm(question) {
46
+ process.stderr.write(`${question} [y/N] `);
47
+ let answer = "";
48
+ try {
49
+ for await (const chunk of process.stdin) {
50
+ answer += String(chunk);
51
+ if (answer.includes(`
52
+ `))
53
+ break;
54
+ }
55
+ } catch {}
56
+ if (!process.stdin.isTTY)
57
+ process.stderr.write(`
58
+ `);
59
+ return /^y(es)?$/i.test(answer.split(`
60
+ `)[0].trim());
61
+ }
45
62
  function plural(n, word, pluralWord = word + "s") {
46
63
  return `${n} ${n === 1 ? word : pluralWord}`;
47
64
  }
@@ -115,6 +132,10 @@ function wantsHelp(argv) {
115
132
  return false;
116
133
  }
117
134
 
135
+ // src/commands/check.ts
136
+ import { existsSync as existsSync5, realpathSync as realpathSync2 } from "node:fs";
137
+ import { basename as basename2, dirname as dirname4, join as join3, resolve as resolve4 } from "node:path";
138
+
118
139
  // src/claims.ts
119
140
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
120
141
  var isDirClaim = (c2) => c2.endsWith("/");
@@ -160,9 +181,12 @@ function warnOnce(message) {
160
181
  function readManifest(file, layer) {
161
182
  if (!existsSync(file))
162
183
  return { claims: [], comments: [] };
184
+ return parseManifest(readFileSync(file, "utf8"), layer);
185
+ }
186
+ function parseManifest(text, layer) {
163
187
  const claims = [];
164
188
  const comments = [];
165
- for (const line of readFileSync(file, "utf8").split(`
189
+ for (const line of text.split(`
166
190
  `)) {
167
191
  const t = line.trim();
168
192
  if (!t)
@@ -398,6 +422,9 @@ var COMMANDS = [
398
422
  "init",
399
423
  "new",
400
424
  "attach",
425
+ "detach",
426
+ "claim",
427
+ "unclaim",
401
428
  "add",
402
429
  "rm",
403
430
  "which",
@@ -569,12 +596,363 @@ function collisions(ctx, paths) {
569
596
  return [...hits].sort();
570
597
  }
571
598
 
572
- // src/commands/add.ts
573
- function cmdAdd(ctx, args) {
599
+ // src/commands/init.ts
600
+ import { chmodSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
601
+ import { dirname as dirname3, join as join2, relative as relative3, resolve as resolve3 } from "node:path";
602
+ var HOOK_HEADER = "# Installed by sich:";
603
+ var GUARD_HOOKS = {
604
+ "pre-commit": { op: "commit", check: "check --staged" },
605
+ "pre-merge-commit": { op: "merge", check: "check --staged --merge" }
606
+ };
607
+ var HOOK_NAMES = Object.keys(GUARD_HOOKS);
608
+ function hookLine(hook) {
609
+ return `"\${SICH_BIN:-sich}" ${GUARD_HOOKS[hook].check} || exit 1`;
610
+ }
611
+ var HOOK_MARKER = /sich\S*\s+check --staged/;
612
+ var BASE_HOOK = {
613
+ header: HOOK_HEADER,
614
+ body: (hook) => hookBody(`${HOOK_HEADER} stop private (layer-claimed) files from being committed to base.`, hook, "git")
615
+ };
616
+ function layerHook(layer) {
617
+ const header = `# Installed by sich for layer ${layer}:`;
618
+ return {
619
+ header,
620
+ body: (hook) => hookBody(`${header} stop files ${layer} doesn't own from being committed to it.`, hook, `sich ${layer}`)
621
+ };
622
+ }
623
+ function hookBody(header, hook, run) {
624
+ const { op, check } = GUARD_HOOKS[hook];
625
+ return `#!/bin/sh
626
+ ${header}
627
+ sich="\${SICH_BIN:-sich}"
628
+ if ! command -v "$sich" >/dev/null 2>&1; then
629
+ echo "sich: ${op} blocked: '$sich' not found; put sich on PATH or set SICH_BIN (skip once: ${run} ${op} --no-verify)" >&2
630
+ exit 1
631
+ fi
632
+ exec "$sich" ${check}
633
+ `;
634
+ }
635
+ function hookFiles(ctx, repo) {
636
+ const hooksDir = resolve3(ctx.root, git(repo, ["rev-parse", "--git-path", "hooks"]).stdout.trim());
637
+ return HOOK_NAMES.map((name) => {
638
+ const hook = join2(hooksDir, name);
639
+ return { name, hook, shown: relative3(ctx.root, hook) || hook };
640
+ });
641
+ }
642
+ function unguarded(ctx, repo) {
643
+ const names = [];
644
+ const files = [];
645
+ for (const { name, hook, shown } of hookFiles(ctx, repo)) {
646
+ if (existsSync4(hook) && HOOK_MARKER.test(readFileSync3(hook, "utf8")))
647
+ continue;
648
+ names.push(name);
649
+ files.push(shown);
650
+ }
651
+ return { names, files };
652
+ }
653
+ function hooksNamed(names) {
654
+ return `${names.join(" and ")} hook${names.length > 1 ? "s" : ""}`;
655
+ }
656
+ function ensureInit(ctx) {
657
+ const fresh = !isInitialized(ctx);
658
+ if (fresh) {
659
+ mkdirSync2(ctx.sichDir, { recursive: true });
660
+ out(`created ${c.bold(".sich/")} (holds every layer's git data and claims list)`);
661
+ }
662
+ for (const t of excludeTargets(ctx)) {
663
+ if (applyExclude(t, true))
664
+ out(describeExclude(t));
665
+ }
666
+ installHooks(ctx, ctx.base, BASE_HOOK);
667
+ for (const layer of layerNames(ctx))
668
+ installLayerHooks(ctx, layer);
669
+ }
670
+ function installLayerHooks(ctx, layer) {
671
+ installHooks(ctx, layerRepo(ctx, layer), layerHook(layer));
672
+ }
673
+ function installHooks(ctx, repo, spec) {
674
+ const hooksPath = git(repo, ["config", "--get", "core.hooksPath"], { allowFail: true }).stdout.trim();
675
+ const of = repo.name === "base" ? "" : ` of ${repo.name}`;
676
+ for (const { name, hook, shown } of hookFiles(ctx, repo)) {
677
+ const body = spec.body(name);
678
+ if (existsSync4(hook)) {
679
+ const current = readFileSync3(hook, "utf8");
680
+ if (current.includes(spec.header)) {
681
+ if (current !== body) {
682
+ writeFileSync3(hook, body);
683
+ chmodSync(hook, 493);
684
+ out(`updated ${name} hook${of} -> ${shown}`);
685
+ }
686
+ continue;
687
+ }
688
+ if (HOOK_MARKER.test(current))
689
+ continue;
690
+ out(`${c.yellow(`${name} hook${of} exists`)} (${shown}); add this line to it:`);
691
+ out(` ${hookLine(name)}`);
692
+ continue;
693
+ }
694
+ if (hooksPath) {
695
+ out(`${c.yellow(`core.hooksPath${of} is set`)} (${hooksPath}); add this line to its ${name} hook:`);
696
+ out(` ${hookLine(name)}`);
697
+ continue;
698
+ }
699
+ mkdirSync2(dirname3(hook), { recursive: true });
700
+ writeFileSync3(hook, body);
701
+ chmodSync(hook, 493);
702
+ out(`installed ${name} hook${of} -> ${shown}`);
703
+ }
704
+ }
705
+ function cmdInit(ctx, args) {
706
+ if (parseArgs(args, {}).positionals.length)
707
+ fail("usage: sich init");
708
+ const already = isInitialized(ctx);
709
+ ensureInit(ctx);
710
+ out(already ? `sich already initialized in ${ctx.root}` : `${c.green("initialized")} sich in ${ctx.root}`);
711
+ return 0;
712
+ }
713
+
714
+ // src/commands/check.ts
715
+ function hookIndexFile() {
716
+ const raw = process.env.GIT_INDEX_FILE;
717
+ if (!raw)
718
+ return null;
719
+ try {
720
+ const abs = resolve4(raw);
721
+ return resolve4(realpathSync2(dirname4(abs)), basename2(abs));
722
+ } catch {
723
+ return null;
724
+ }
725
+ }
726
+ function isInsideDir(file, dir) {
727
+ try {
728
+ return file.startsWith(realpathSync2(dir) + "/");
729
+ } catch {
730
+ return false;
731
+ }
732
+ }
733
+ function committingRepo(ctx, named, index) {
734
+ if (named !== undefined)
735
+ return repoByName(ctx, named);
736
+ if (!index || isInsideDir(index, ctx.base.gitDir))
737
+ return ctx.base;
738
+ const layer = layerNames(ctx).find((l) => isInsideDir(index, layerRepo(ctx, l).gitDir));
739
+ if (layer)
740
+ return layerRepo(ctx, layer);
741
+ if (isInsideDir(index, ctx.sichDir))
742
+ fail(`cannot tell which layer is committing (index ${index})`);
743
+ return ctx.base;
744
+ }
745
+ function mergeHints(ctx, committing, blocked, fold) {
746
+ const inLayer = committing.name !== ctx.base.name;
747
+ const run = inLayer ? `sich ${committing.name}` : "git";
748
+ const hints = [`this merge isn't committed; back it out with: ${run} merge --abort`];
749
+ const wanted = new Set(blocked.map(fold));
750
+ for (const repo of [ctx.base, ...layerNames(ctx).map((l) => layerRepo(ctx, l))]) {
751
+ if (repo.name === committing.name)
752
+ continue;
753
+ const copies = splitZ(git(repo, ["ls-files", "-z"]).stdout).filter((f) => wanted.has(fold(f)));
754
+ if (!copies.length)
755
+ continue;
756
+ const restore = repo.name === ctx.base.name ? "git restore" : `sich ${repo.name} restore`;
757
+ hints.push(`the merge may have overwritten ${copies.join(", ")} (tracked by ${repo.name}); after aborting, ` + `restore with: ${restore} -- ${copies.map(shellQuote).join(" ")}`);
758
+ }
759
+ hints.push(`don't ${inLayer ? "untrack" : "unstage"} them and commit the merge: that drops the other side's changes to them`);
760
+ 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)");
761
+ return hints;
762
+ }
763
+ function cmdCheck(ctx, args) {
764
+ const p = parseArgs(args, { "--fix": "bool", "--staged": "bool", "--merge": "bool", "--repo": "value" });
765
+ if (p.positionals.length)
766
+ fail("usage: sich check [--fix] [--staged [--merge] [--repo <repo>]]");
767
+ const repoName = str(p, "--repo");
768
+ if (repoName !== undefined && !p.flags["--staged"])
769
+ fail("--repo only applies with --staged");
770
+ if (p.flags["--merge"] && !p.flags["--staged"])
771
+ fail("--merge only applies with --staged");
772
+ if (!isInitialized(ctx)) {
773
+ if (p.flags["--staged"])
774
+ return 0;
775
+ requireInit(ctx);
776
+ }
777
+ const index = p.flags["--staged"] ? hookIndexFile() : null;
778
+ const committing = committingRepo(ctx, repoName, index);
779
+ const indexEnv = index && isInsideDir(index, committing.gitDir) ? { GIT_INDEX_FILE: index } : {};
780
+ const merging = p.flags["--staged"] === true && (p.flags["--merge"] === true || existsSync5(join3(committing.gitDir, "MERGE_HEAD")));
781
+ const mergeBlocked = [];
782
+ const problems = [];
783
+ const stale = [];
784
+ const staleWhere = [];
785
+ const fixed = [];
786
+ const hints = [];
787
+ const claims = allClaims(ctx);
788
+ const layers = layerNames(ctx);
789
+ const icase = ignoresCase(ctx);
790
+ const fold = (s) => icase ? s.toLowerCase() : s;
791
+ const owner = (f) => ownerOf(claims, f, icase);
792
+ const isSich = (f) => fold(f).startsWith(".sich/");
793
+ const flat = [...claims].flatMap(([layer, cs]) => cs.map((claim) => ({ layer, claim })));
794
+ for (const [i, a] of flat.entries()) {
795
+ for (const b of flat.slice(i + 1)) {
796
+ if (a.layer === b.layer)
797
+ continue;
798
+ const [x, y] = [fold(a.claim), fold(b.claim)];
799
+ if (samePath(x, y)) {
800
+ problems.push(`${bare(a.claim)} is claimed by more than one layer: ${a.layer}, ${b.layer}`);
801
+ } else if (covers(x, y) || covers(y, x)) {
802
+ const [outer, inner] = covers(x, y) ? [a, b] : [b, a];
803
+ 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)})`);
804
+ }
805
+ }
806
+ }
807
+ const stagedStray = new Map;
808
+ if (p.flags["--staged"] && committing !== ctx.base) {
809
+ const layer = committing.name;
810
+ const manifest = manifestRel(layer);
811
+ const staged = splitZ(git(committing, ["diff", "--cached", "--name-only", "--no-renames", "--diff-filter=d", "-z"], { env: indexEnv }).stdout);
812
+ const committed = git(committing, ["show", `:${manifest}`], { env: indexEnv, allowFail: true });
813
+ const committedClaims = new Map([
814
+ [layer, committed.code === 0 ? parseManifest(committed.stdout, layer).claims : []]
815
+ ]);
816
+ const unrecorded = [];
817
+ for (const f of staged) {
818
+ if (fold(f) === fold(manifest))
819
+ continue;
820
+ const by = isSich(f) ? undefined : owner(f)?.layer;
821
+ if (by === layer) {
822
+ if (!ownerOf(committedClaims, f, icase)) {
823
+ unrecorded.push(f);
824
+ problems.push(`${layer} has a staged change to ${f}, which ${manifest} as committed doesn't claim`);
825
+ }
826
+ continue;
827
+ }
828
+ const why = isSich(f) ? "which is under .sich/" : by ? `which is claimed by ${by}` : "which none of its claims cover";
829
+ stagedStray.set(f, why);
830
+ problems.push(`${layer} has a staged change to ${f}, ${why}`);
831
+ }
832
+ const files = [...stagedStray.keys()];
833
+ if (merging) {
834
+ mergeBlocked.push(...files, ...unrecorded);
835
+ } else {
836
+ if (unrecorded.length) {
837
+ hints.push(`commit ${manifest} along with it (sich ${layer} add ${manifest}; with 'commit -- <paths>', list it too)`);
838
+ }
839
+ const claimable = files.filter((f) => !isSich(f) && !owner(f));
840
+ const baseTracked = new Set(splitZ(git(ctx.base, ["ls-files", "-z"]).stdout).map(fold));
841
+ const plain = claimable.filter((f) => !baseTracked.has(fold(f)));
842
+ const moved = claimable.filter((f) => baseTracked.has(fold(f)));
843
+ if (plain.length)
844
+ hints.push(`claim with: sich claim ${layer} ${plain.map(shellQuote).join(" ")}`);
845
+ if (moved.length) {
846
+ const list = moved.map(shellQuote).join(" ");
847
+ hints.push(`claim with (base tracks ${list}): sich claim ${layer} ${list} --move`);
848
+ }
849
+ if (files.length) {
850
+ const verb = claimable.length ? "or untrack" : "untrack";
851
+ hints.push(`${verb} with: sich ${layer} rm --cached -- ${files.map(shellQuote).join(" ")}`);
852
+ }
853
+ }
854
+ }
855
+ const trackedBy = new Map;
856
+ for (const layer of layers) {
857
+ const env = layer === committing.name ? indexEnv : {};
858
+ for (const f of splitZ(git(layerRepo(ctx, layer), ["ls-files", "-z"], { env }).stdout)) {
859
+ if (f === manifestRel(layer))
860
+ continue;
861
+ trackedBy.set(f, [...trackedBy.get(f) ?? [], layer]);
862
+ }
863
+ }
864
+ for (const [f, all] of trackedBy) {
865
+ const ls = stagedStray.has(f) ? all.filter((l) => l !== committing.name) : all;
866
+ const by = owner(f)?.layer;
867
+ if (ls.length > 1) {
868
+ problems.push(`${f} is tracked by more than one layer: ${ls.join(", ")}${by ? ` (owner: ${by})` : ""}`);
869
+ } else if (ls.length === 1 && by !== ls[0]) {
870
+ const why = by ? `it belongs to ${by}` : "none of its claims cover it";
871
+ problems.push(`${ls[0]} tracks ${f} but ${why} (fix: sich ${ls[0]} rm --cached -- ${shellQuote(f)})`);
872
+ }
873
+ }
874
+ const reported = new Set;
875
+ if (p.flags["--staged"] && committing === ctx.base) {
876
+ const staged = splitZ(git(ctx.base, ["diff", "--cached", "--name-only", "--no-renames", "--diff-filter=d", "-z"], { env: indexEnv }).stdout);
877
+ const stagedPrivate = staged.filter((f) => isSich(f) || owner(f));
878
+ for (const f of stagedPrivate) {
879
+ const by = owner(f);
880
+ problems.push(`base has a staged change to private path ${f}${by ? ` (claimed by ${by.layer})` : ""}`);
881
+ reported.add(f);
882
+ }
883
+ if (merging) {
884
+ mergeBlocked.push(...stagedPrivate);
885
+ } else if (stagedPrivate.length) {
886
+ hints.push(`unstage with: git restore --staged -- ${stagedPrivate.map(shellQuote).join(" ")}`);
887
+ }
888
+ }
889
+ let sichFiles = 0;
890
+ const baseEnv = committing === ctx.base ? indexEnv : {};
891
+ for (const f of splitZ(git(ctx.base, ["ls-files", "-z"], { env: baseEnv }).stdout)) {
892
+ if (reported.has(f))
893
+ continue;
894
+ if (isSich(f)) {
895
+ sichFiles++;
896
+ continue;
897
+ }
898
+ const by = owner(f);
899
+ if (by)
900
+ problems.push(`base tracks ${f}, which is claimed by ${by.layer} (fix: git rm --cached -- ${shellQuote(f)})`);
901
+ }
902
+ if (sichFiles) {
903
+ problems.push(`base tracks ${plural(sichFiles, "file")} under .sich/ (fix: git rm -r --cached -- .sich/)`);
904
+ }
905
+ if (mergeBlocked.length)
906
+ hints.push(...mergeHints(ctx, committing, mergeBlocked, fold));
907
+ for (const t of excludeTargets(ctx)) {
908
+ if (!applyExclude(t, false))
909
+ continue;
910
+ if (p.flags["--fix"]) {
911
+ applyExclude(t, true);
912
+ fixed.push(`rewrote: ${describeExclude(t)}`);
913
+ } else {
914
+ const where = `${t.repo.name} in ${excludeFileShown(t.repo)}`;
915
+ stale.push(`stale exclude rules of ${where} (fix: sich check --fix)`);
916
+ staleWhere.push(where);
917
+ }
918
+ }
919
+ const unhooked = [];
920
+ if (!p.flags["--staged"]) {
921
+ for (const repo of [ctx.base, ...layers.map((l) => layerRepo(ctx, l))]) {
922
+ const { names, files } = unguarded(ctx, repo);
923
+ if (names.length) {
924
+ unhooked.push(`${repo.name} has no ${hooksNamed(names)} running sich check --staged (${files.join(", ")}); fix: sich init`);
925
+ }
926
+ }
927
+ }
928
+ const hook = p.flags["--staged"] === true;
929
+ const blocking = hook ? problems : [...problems, ...stale];
930
+ for (const f of fixed)
931
+ out(`${c.green("✓")} ${f}`);
932
+ if (hook && staleWhere.length) {
933
+ warn(`stale exclude rules of ${staleWhere.join(", ")} (fix: sich check --fix); not blocking`);
934
+ }
935
+ for (const u of unhooked)
936
+ warn(u);
937
+ for (const i of blocking)
938
+ out(`${c.red("✗")} ${i}`);
939
+ for (const h of hints)
940
+ out(` ${h}`);
941
+ if (blocking.length) {
942
+ out(c.red(`sich check: ${plural(blocking.length, "issue")} found`));
943
+ return 1;
944
+ }
945
+ if (!hook)
946
+ out(c.green("sich check: ok"));
947
+ return 0;
948
+ }
949
+
950
+ // src/commands/claim.ts
951
+ function cmdClaim(ctx, args) {
574
952
  const p = parseArgs(args, { "--move": "bool" });
575
953
  const [layer, ...inputs] = p.positionals;
576
954
  if (!layer || inputs.length === 0)
577
- fail("usage: sich add <layer> <path...> [--move]");
955
+ fail("usage: sich claim <layer> <path...> [--move]");
578
956
  const move = p.flags["--move"] === true;
579
957
  const repo = requireLayer(ctx, layer);
580
958
  const claims = allClaims(ctx);
@@ -608,7 +986,7 @@ function cmdAdd(ctx, args) {
608
986
  const theirs = claims.get(other) ?? [];
609
987
  const outer = theirs.find((cl) => isDirClaim(cl) && isInside(fold(t), fold(cl)));
610
988
  if (outer) {
611
- fail(`${t} is inside ${outer}, claimed by layer ${other}; claims can't nest across layers ` + `(to move all of it: sich add ${layer} ${outer} --move)`);
989
+ 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)`);
612
990
  }
613
991
  const inner = theirs.filter((cl) => covers(fold(t), fold(cl)));
614
992
  const tracked = trackedUnder(layerRepo(ctx, other), [t]);
@@ -669,11 +1047,11 @@ function stage(repo, claim) {
669
1047
  note(`${claim} has no files yet; new files in it will belong to ${repo.name}`);
670
1048
  }
671
1049
  }
672
- function cmdRm(ctx, args) {
1050
+ function cmdUnclaim(ctx, args) {
673
1051
  const p = parseArgs(args, {});
674
1052
  const [layer, ...inputs] = p.positionals;
675
1053
  if (!layer || inputs.length === 0)
676
- fail("usage: sich rm <layer> <path...>");
1054
+ fail("usage: sich unclaim <layer> <path...>");
677
1055
  const repo = requireLayer(ctx, layer);
678
1056
  const file = manifestPath(ctx, layer);
679
1057
  const man = readManifest(file, layer);
@@ -701,131 +1079,6 @@ function cmdRm(ctx, args) {
701
1079
  return 0;
702
1080
  }
703
1081
 
704
- // src/commands/check.ts
705
- import { realpathSync as realpathSync2 } from "node:fs";
706
- import { basename as basename2, dirname as dirname3, resolve as resolve3 } from "node:path";
707
- function hookIndexEnv(ctx) {
708
- const raw = process.env.GIT_INDEX_FILE;
709
- if (!raw)
710
- return {};
711
- try {
712
- const abs = resolve3(raw);
713
- const real = resolve3(realpathSync2(dirname3(abs)), basename2(abs));
714
- const gitDir = realpathSync2(ctx.base.gitDir);
715
- return real.startsWith(gitDir + "/") ? { GIT_INDEX_FILE: real } : {};
716
- } catch {
717
- return {};
718
- }
719
- }
720
- function cmdCheck(ctx, args) {
721
- const p = parseArgs(args, { "--fix": "bool", "--staged": "bool" });
722
- if (p.positionals.length)
723
- fail("usage: sich check [--fix] [--staged]");
724
- if (!isInitialized(ctx)) {
725
- if (p.flags["--staged"])
726
- return 0;
727
- requireInit(ctx);
728
- }
729
- const problems = [];
730
- const stale = [];
731
- const fixed = [];
732
- const hints = [];
733
- const claims = allClaims(ctx);
734
- const layers = layerNames(ctx);
735
- const indexEnv = hookIndexEnv(ctx);
736
- const icase = ignoresCase(ctx);
737
- const fold = (s) => icase ? s.toLowerCase() : s;
738
- const owner = (f) => ownerOf(claims, f, icase);
739
- const isSich = (f) => fold(f).startsWith(".sich/");
740
- const flat = [...claims].flatMap(([layer, cs]) => cs.map((claim) => ({ layer, claim })));
741
- for (const [i, a] of flat.entries()) {
742
- for (const b of flat.slice(i + 1)) {
743
- if (a.layer === b.layer)
744
- continue;
745
- const [x, y] = [fold(a.claim), fold(b.claim)];
746
- if (samePath(x, y)) {
747
- problems.push(`${bare(a.claim)} is claimed by more than one layer: ${a.layer}, ${b.layer}`);
748
- } else if (covers(x, y) || covers(y, x)) {
749
- const [outer, inner] = covers(x, y) ? [a, b] : [b, a];
750
- problems.push(`${inner.claim} (${inner.layer}) is nested inside ${outer.claim} (${outer.layer}); ` + `claims can't nest across layers (fix: sich rm ${inner.layer} ${shellQuote(inner.claim)})`);
751
- }
752
- }
753
- }
754
- const trackedBy = new Map;
755
- for (const layer of layers) {
756
- for (const f of splitZ(git(layerRepo(ctx, layer), ["ls-files", "-z"]).stdout)) {
757
- if (f === manifestRel(layer))
758
- continue;
759
- trackedBy.set(f, [...trackedBy.get(f) ?? [], layer]);
760
- }
761
- }
762
- for (const [f, ls] of trackedBy) {
763
- const by = owner(f)?.layer;
764
- if (ls.length > 1) {
765
- problems.push(`${f} is tracked by more than one layer: ${ls.join(", ")}${by ? ` (owner: ${by})` : ""}`);
766
- } else if (by !== ls[0]) {
767
- const why = by ? `it belongs to ${by}` : "none of its claims cover it";
768
- problems.push(`${ls[0]} tracks ${f} but ${why} (fix: sich ${ls[0]} rm --cached -- ${shellQuote(f)})`);
769
- }
770
- }
771
- const reported = new Set;
772
- if (p.flags["--staged"]) {
773
- const staged = splitZ(git(ctx.base, ["diff", "--cached", "--name-only", "--no-renames", "--diff-filter=d", "-z"], { env: indexEnv }).stdout);
774
- const stagedPrivate = staged.filter((f) => isSich(f) || owner(f));
775
- for (const f of stagedPrivate) {
776
- const by = owner(f);
777
- problems.push(`base has a staged change to private path ${f}${by ? ` (claimed by ${by.layer})` : ""}`);
778
- reported.add(f);
779
- }
780
- if (stagedPrivate.length) {
781
- hints.push(`unstage with: git restore --staged -- ${stagedPrivate.map(shellQuote).join(" ")}`);
782
- }
783
- }
784
- let sichFiles = 0;
785
- for (const f of splitZ(git(ctx.base, ["ls-files", "-z"], { env: indexEnv }).stdout)) {
786
- if (reported.has(f))
787
- continue;
788
- if (isSich(f)) {
789
- sichFiles++;
790
- continue;
791
- }
792
- const by = owner(f);
793
- if (by)
794
- problems.push(`base tracks ${f}, which is claimed by ${by.layer} (fix: git rm --cached -- ${shellQuote(f)})`);
795
- }
796
- if (sichFiles) {
797
- problems.push(`base tracks ${plural(sichFiles, "file")} under .sich/ (fix: git rm -r --cached -- .sich/)`);
798
- }
799
- for (const t of excludeTargets(ctx)) {
800
- if (!applyExclude(t, false))
801
- continue;
802
- if (p.flags["--fix"]) {
803
- applyExclude(t, true);
804
- fixed.push(`rewrote: ${describeExclude(t)}`);
805
- } else {
806
- stale.push(`stale exclude rules of ${t.repo.name} in ${excludeFileShown(t.repo)} (fix: sich check --fix)`);
807
- }
808
- }
809
- const hook = p.flags["--staged"] === true;
810
- const blocking = hook ? problems : [...problems, ...stale];
811
- for (const f of fixed)
812
- out(`${c.green("✓")} ${f}`);
813
- if (hook)
814
- for (const s of stale)
815
- warn(`${s}; not blocking this commit`);
816
- for (const i of blocking)
817
- out(`${c.red("✗")} ${i}`);
818
- for (const h of hints)
819
- out(` ${h}`);
820
- if (blocking.length) {
821
- out(c.red(`sich check: ${plural(blocking.length, "issue")} found`));
822
- return 1;
823
- }
824
- if (!hook)
825
- out(c.green("sich check: ok"));
826
- return 0;
827
- }
828
-
829
1082
  // src/commands/commit.ts
830
1083
  function cmdCommit(ctx, args) {
831
1084
  const p = parseArgs(args, { "-m": "value" }, { "--message": "-m" });
@@ -871,73 +1124,6 @@ function cmdCommit(ctx, args) {
871
1124
  return 0;
872
1125
  }
873
1126
 
874
- // src/commands/init.ts
875
- import { chmodSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
876
- import { join as join2, relative as relative3, resolve as resolve4 } from "node:path";
877
- var HOOK_MARKER = /sich\S*\s+check --staged/;
878
- var HOOK_HEADER = "# Installed by sich:";
879
- var HOOK_LINE = '"${SICH_BIN:-sich}" check --staged || exit 1';
880
- var HOOK_BODY = `#!/bin/sh
881
- ${HOOK_HEADER} stop private (layer-claimed) files from being committed to base.
882
- sich="\${SICH_BIN:-sich}"
883
- if ! command -v "$sich" >/dev/null 2>&1; then
884
- echo "sich: commit blocked: '$sich' not found; put sich on PATH or set SICH_BIN (skip once: git commit --no-verify)" >&2
885
- exit 1
886
- fi
887
- exec "$sich" check --staged
888
- `;
889
- function ensureInit(ctx) {
890
- const fresh = !isInitialized(ctx);
891
- if (fresh) {
892
- mkdirSync2(ctx.sichDir, { recursive: true });
893
- out(`created ${c.bold(".sich/")} (holds every layer's git data and claims list)`);
894
- }
895
- for (const t of excludeTargets(ctx)) {
896
- if (applyExclude(t, true))
897
- out(describeExclude(t));
898
- }
899
- installHook(ctx);
900
- }
901
- function installHook(ctx) {
902
- const hooksDir = resolve4(ctx.root, git(ctx.base, ["rev-parse", "--git-path", "hooks"]).stdout.trim());
903
- const hook = join2(hooksDir, "pre-commit");
904
- const shown = relative3(ctx.root, hook) || hook;
905
- const hooksPath = git(ctx.base, ["config", "--get", "core.hooksPath"], { allowFail: true }).stdout.trim();
906
- if (existsSync4(hook)) {
907
- const current = readFileSync3(hook, "utf8");
908
- if (current.includes(HOOK_HEADER)) {
909
- if (current !== HOOK_BODY) {
910
- writeFileSync3(hook, HOOK_BODY);
911
- chmodSync(hook, 493);
912
- out(`updated pre-commit hook -> ${shown}`);
913
- }
914
- return;
915
- }
916
- if (HOOK_MARKER.test(current))
917
- return;
918
- out(`${c.yellow("pre-commit hook exists")} (${shown}); add this line to it:`);
919
- out(` ${HOOK_LINE}`);
920
- return;
921
- }
922
- if (hooksPath) {
923
- out(`${c.yellow("core.hooksPath is set")} (${hooksPath}); add this line to its pre-commit hook:`);
924
- out(` ${HOOK_LINE}`);
925
- return;
926
- }
927
- mkdirSync2(hooksDir, { recursive: true });
928
- writeFileSync3(hook, HOOK_BODY);
929
- chmodSync(hook, 493);
930
- out(`installed pre-commit hook -> ${shown}`);
931
- }
932
- function cmdInit(ctx, args) {
933
- if (parseArgs(args, {}).positionals.length)
934
- fail("usage: sich init");
935
- const already = isInitialized(ctx);
936
- ensureInit(ctx);
937
- out(already ? `sich already initialized in ${ctx.root}` : `${c.green("initialized")} sich in ${ctx.root}`);
938
- return 0;
939
- }
940
-
941
1127
  // src/commands/inspect.ts
942
1128
  function cmdWhich(ctx, args) {
943
1129
  const p = parseArgs(args, {});
@@ -996,13 +1182,13 @@ function cmdLs(ctx, args) {
996
1182
 
997
1183
  // src/commands/layer.ts
998
1184
  import { spawnSync as spawnSync2 } from "node:child_process";
999
- import { existsSync as existsSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
1000
- import { basename as basename3, isAbsolute as isAbsolute2, join as join3, resolve as resolve5 } from "node:path";
1185
+ import { existsSync as existsSync6, readdirSync as readdirSync2, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
1186
+ import { basename as basename3, isAbsolute as isAbsolute2, join as join4, resolve as resolve5 } from "node:path";
1001
1187
  function assertFree(ctx, layer) {
1002
1188
  validateLayerName(layer);
1003
- if (existsSync5(join3(ctx.sichDir, layer)))
1189
+ if (existsSync6(join4(ctx.sichDir, layer)))
1004
1190
  fail(`layer '${layer}' already exists (.sich/${layer}/)`);
1005
- if (existsSync5(manifestPath(ctx, layer)))
1191
+ if (existsSync6(manifestPath(ctx, layer)))
1006
1192
  fail(`.sich/${layer}.paths already exists; remove it first`);
1007
1193
  }
1008
1194
  function createGitDir(ctx, layer) {
@@ -1016,7 +1202,7 @@ function remoteUrl(ctx, url) {
1016
1202
  if (isAbsolute2(url) || /^[a-z][a-z0-9+.-]*:\/\//i.test(url))
1017
1203
  return url;
1018
1204
  const local = resolve5(ctx.cwd, url);
1019
- return existsSync5(local) ? local : url;
1205
+ return existsSync6(local) ? local : url;
1020
1206
  }
1021
1207
  function baseRepoName(ctx) {
1022
1208
  const url = git(ctx.base, ["config", "--get", "remote.origin.url"], { allowFail: true }).stdout.trim();
@@ -1077,7 +1263,8 @@ function cmdNew(ctx, args) {
1077
1263
  out(`${c.green("created")} layer ${c.bold(layer)}: git data -> .sich/${layer}/, claims list -> ${manifestRel(layer)}`);
1078
1264
  if (url)
1079
1265
  out(`set origin of ${layer} -> ${url}`);
1080
- note(`next: sich add ${layer} <path>... then sich commit ${layer} -m <msg>${url ? ` and sich push ${layer}` : ""}`);
1266
+ installLayerHooks(ctx, layer);
1267
+ note(`next: sich claim ${layer} <path>... then sich commit ${layer} -m <msg>${url ? ` and sich push ${layer}` : ""}`);
1081
1268
  return 0;
1082
1269
  }
1083
1270
  function cmdAttach(ctx, args) {
@@ -1108,17 +1295,178 @@ function cmdAttach(ctx, args) {
1108
1295
  fail(`attach would overwrite existing files: ${listSome(clash)} (move them away and retry)`);
1109
1296
  }
1110
1297
  git(repo, ["checkout", "-q", "-B", branch, "--track", `origin/${branch}`]);
1111
- if (!existsSync5(manifestPath(ctx, layer))) {
1298
+ if (!existsSync6(manifestPath(ctx, layer))) {
1112
1299
  warn(`${url} has no .sich/${layer}.paths; is the layer name right?`);
1113
1300
  }
1114
1301
  syncExcludes(ctx);
1115
1302
  out(`${c.green("attached")} layer ${c.bold(layer)} from ${url} -> .sich/${layer}/ (branch ${branch})`);
1116
1303
  out(`checked out ${plural(files.length, "file")}: ${listSome(files)}`);
1117
- return 0;
1118
1304
  } catch (e) {
1119
1305
  cleanup();
1120
1306
  throw e;
1121
1307
  }
1308
+ installLayerHooks(ctx, layer);
1309
+ return 0;
1310
+ }
1311
+ var IN_PROGRESS = [
1312
+ ["MERGE_HEAD", "merge"],
1313
+ ["rebase-merge", "rebase"],
1314
+ ["rebase-apply/applying", "am"],
1315
+ ["rebase-apply", "rebase"],
1316
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
1317
+ ["REVERT_HEAD", "revert"]
1318
+ ];
1319
+ var preferredRemote = (remotes) => remotes.includes("origin") ? "origin" : remotes[0];
1320
+ function onlyInGitDir(repo) {
1321
+ const run = `sich ${repo.name}`;
1322
+ const losses = [];
1323
+ const count = (revs, allowFail = false) => Number(git(repo, ["rev-list", "--count", ...revs, "--"], { allowFail }).stdout.trim() || 0);
1324
+ const remote = preferredRemote(remotesOf(repo));
1325
+ if (!remote) {
1326
+ const n = count(["--exclude=refs/stash", "--all"]);
1327
+ losses.push({
1328
+ what: `${plural(n, "commit")} with no remote to push to`,
1329
+ fix: `${run} remote add origin <url>, then sich push ${repo.name}`
1330
+ });
1331
+ } else {
1332
+ const current = currentBranch(repo);
1333
+ const refs = lines(git(repo, ["for-each-ref", "--format=%(objectname) %(refname)"]).stdout).map((l) => {
1334
+ const [oid, ref] = l.split(" ");
1335
+ return { oid, ref };
1336
+ });
1337
+ for (const { ref } of refs.filter((r) => r.ref.startsWith("refs/heads/"))) {
1338
+ const n = count([ref, "--not", "--remotes"]);
1339
+ if (!n)
1340
+ continue;
1341
+ const branch = ref.slice("refs/heads/".length);
1342
+ losses.push({
1343
+ what: `${plural(n, "commit")} on ${branch} not on any remote`,
1344
+ fix: branch === current ? `sich push ${repo.name}` : `${run} push -u ${remote} ${shellQuote(branch)}`
1345
+ });
1346
+ }
1347
+ let advertised;
1348
+ const remoteHas = (oid) => {
1349
+ if (advertised === undefined) {
1350
+ const r = git(repo, ["ls-remote", remote], { allowFail: true });
1351
+ advertised = r.code === 0 ? new Set(lines(r.stdout).map((l) => l.split("\t")[0])) : null;
1352
+ }
1353
+ return advertised?.has(oid) ?? false;
1354
+ };
1355
+ const others = refs.filter((r) => !/^refs\/(heads|remotes|prefetch)\//.test(r.ref) && r.ref !== "refs/stash");
1356
+ for (const { oid, ref } of others) {
1357
+ const n = count([`${ref}^{commit}`, "--not", "--remotes", "--branches"], true);
1358
+ if (!n || remoteHas(oid))
1359
+ continue;
1360
+ const name = ref.startsWith("refs/tags/") ? ref.slice("refs/tags/".length) : ref;
1361
+ const unchecked = advertised === null ? ` (could not reach ${remote} to check)` : "";
1362
+ losses.push({
1363
+ what: `${plural(n, "commit")} only on ${name === ref ? ref : `tag ${name}`}${unchecked}`,
1364
+ fix: `${run} push ${remote} ${shellQuote(name)}`
1365
+ });
1366
+ }
1367
+ if (current === null) {
1368
+ const n = count(["HEAD", "--not", "--remotes", "--branches", "--tags"]);
1369
+ if (n) {
1370
+ losses.push({ what: `${plural(n, "commit")} only on the detached HEAD`, fix: `${run} switch -c <branch>` });
1371
+ }
1372
+ }
1373
+ }
1374
+ const stashes = lines(git(repo, ["stash", "list"]).stdout).length;
1375
+ if (stashes) {
1376
+ losses.push({ what: plural(stashes, "stash entry", "stash entries"), fix: `${run} stash pop, then commit` });
1377
+ }
1378
+ const staged = splitZ(git(repo, ["diff", "--cached", "--name-only", "-z", "--no-renames", "--diff-filter=du"]).stdout);
1379
+ const changed = new Set(splitZ(git(repo, ["diff", "--name-only", "-z", "--no-renames"]).stdout));
1380
+ for (const e of splitZ(git(repo, ["ls-files", "-v", "-z"]).stdout)) {
1381
+ if (/^[a-zS] /.test(e))
1382
+ changed.add(e.slice(2));
1383
+ }
1384
+ const stagedOnly = staged.filter((f) => changed.has(f));
1385
+ if (stagedOnly.length) {
1386
+ losses.push({
1387
+ what: `staged versions of ${listSome(stagedOnly)} that differ from what is on disk`,
1388
+ fix: `${run} commit -m <msg>, then sich push ${repo.name}`
1389
+ });
1390
+ }
1391
+ const worktrees = lines(git(repo, ["worktree", "list", "--porcelain"]).stdout).filter((l) => l.startsWith("worktree ")).slice(1).map((l) => l.slice("worktree ".length));
1392
+ if (worktrees.length) {
1393
+ losses.push({
1394
+ what: `${plural(worktrees.length, "linked worktree")}: ${listSome(worktrees)}`,
1395
+ fix: `${run} worktree remove <path>`
1396
+ });
1397
+ }
1398
+ const modules = join4(repo.gitDir, "modules");
1399
+ if (existsSync6(modules) && readdirSync2(modules).length) {
1400
+ losses.push({ what: `submodule repos in .sich/${repo.name}/modules/`, fix: `push and remove the submodules` });
1401
+ }
1402
+ const op = IN_PROGRESS.find(([file]) => existsSync6(join4(repo.gitDir, file)))?.[1];
1403
+ if (op)
1404
+ losses.push({ what: `a ${op} in progress`, fix: `finish it, or ${run} ${op} --abort` });
1405
+ return losses;
1406
+ }
1407
+ async function cmdDetach(ctx, args) {
1408
+ const p = parseArgs(args, { "--force": "bool", "--yes": "bool" }, { "-y": "--yes" });
1409
+ const [layer, ...extra] = p.positionals;
1410
+ if (!layer || extra.length)
1411
+ fail("usage: sich detach <layer> [--force] [--yes]");
1412
+ const repo = requireLayer(ctx, layer);
1413
+ const losses = onlyInGitDir(repo);
1414
+ if (losses.length && !p.flags["--force"]) {
1415
+ for (const l of losses)
1416
+ out(`${c.red("✗")} ${l.what} (fix: ${l.fix})`);
1417
+ fail(`detaching ${layer} would lose what exists only in .sich/${layer}/ (see above); --force discards it`);
1418
+ }
1419
+ const claims = readManifest(manifestPath(ctx, layer), layer).claims;
1420
+ const remote = preferredRemote(remotesOf(repo));
1421
+ const url = remote && git(repo, ["remote", "get-url", remote], { allowFail: true }).stdout.trim();
1422
+ if (p.flags["--yes"]) {
1423
+ for (const l of losses)
1424
+ warn(`discarding ${l.what}`);
1425
+ } else {
1426
+ const onDisk = claims.filter((cl) => existsSync6(join4(ctx.root, bare(cl))));
1427
+ const row = (label, text) => out(` ${label}${" ".repeat(10 - label.length)}${text}`);
1428
+ out(`detaching ${c.bold(layer)}:`);
1429
+ row("deletes", `.sich/${layer}/ (its git data: local history, index, hooks, config)`);
1430
+ if (existsSync6(manifestPath(ctx, layer))) {
1431
+ row("deletes", `${manifestRel(layer)} (claims list: ${claims.length ? listSome(claims) : "empty"})`);
1432
+ }
1433
+ for (const l of losses)
1434
+ row("discards", l.what);
1435
+ row("keeps", onDisk.length ? `${listSome(onDisk)} on disk, no longer hidden from base` : "files: none on disk");
1436
+ if (url)
1437
+ row("keeps", `remote ${remote} (${url}) and everything pushed to it`);
1438
+ if (!await confirm(`Detach ${layer}?`)) {
1439
+ fail(process.stdin.isTTY ? "not detached" : "not detached (no answer on stdin; --yes skips the question)");
1440
+ }
1441
+ const shown = new Set(losses.map((l) => l.what));
1442
+ const added = onlyInGitDir(repo).filter((l) => !shown.has(l.what));
1443
+ if (added.length) {
1444
+ for (const l of added)
1445
+ out(`${c.red("✗")} ${l.what} (fix: ${l.fix})`);
1446
+ fail(`${layer} changed while waiting for the answer (see above); not detached`);
1447
+ }
1448
+ }
1449
+ const manifest = existsSync6(manifestPath(ctx, layer));
1450
+ rmSync(repo.gitDir, { recursive: true, force: true });
1451
+ rmSync(manifestPath(ctx, layer), { force: true });
1452
+ const deleted = `git data -> .sich/${layer}/${manifest ? `, claims list -> ${manifestRel(layer)}` : ""}`;
1453
+ out(`${c.green("detached")} layer ${c.bold(layer)}: deleted ${deleted}`);
1454
+ for (const t of excludeTargets(ctx)) {
1455
+ if (!applyExclude(t, true))
1456
+ continue;
1457
+ const file = excludeFileShown(t.repo);
1458
+ out(t.repo.name === "base" ? `no longer hidden from base -> ${file}` : `updated the excludes of ${t.repo.name} -> ${file}`);
1459
+ }
1460
+ const untracked = ["ls-files", "-z", "--others", "--exclude-standard", "--directory", "--no-empty-directory"];
1461
+ const visible = claims.length ? splitZ(git(ctx.base, [...untracked, "--", ...claims]).stdout) : [];
1462
+ if (visible.length) {
1463
+ warn(`base now sees ${listSome(visible)} as untracked ` + `(delete, .gitignore or claim ${visible.length === 1 ? "it" : "them"} before committing base)`);
1464
+ }
1465
+ if (url) {
1466
+ const away = claims.some((cl) => existsSync6(join4(ctx.root, bare(cl)))) ? "move its files away, then " : "";
1467
+ note(`its remote is untouched; to attach again: ${away}sich attach ${layer} ${shellQuote(url)}`);
1468
+ }
1469
+ return 0;
1122
1470
  }
1123
1471
 
1124
1472
  // src/commands/status.ts
@@ -1351,15 +1699,16 @@ usage: sich [-C <dir>] <command> [args]
1351
1699
  sich [-C <dir>] <layer|base> <git args...>
1352
1700
 
1353
1701
  setup
1354
- init set up .sich/, base excludes, commit guard
1702
+ init set up .sich/, excludes, commit guards
1355
1703
  new <layer> [--remote <url> | --gh [name]]
1356
1704
  create a layer (--gh: private GitHub repo)
1357
1705
  attach <layer> <url> join an existing layer (collaborators)
1706
+ detach <layer> [--force] [--yes] remove a layer here; files and remote stay
1358
1707
 
1359
1708
  ownership
1360
- add <layer> <path...> [--move] claim files/folders for a layer, stage them
1709
+ claim <layer> <path...> [--move] claim files/folders for a layer, stage them
1361
1710
  (--move: take them from base/another layer)
1362
- rm <layer> <path...> release claims; files stay on disk
1711
+ unclaim <layer> <path...> release claims; files stay on disk
1363
1712
  which <path> show who owns a path
1364
1713
  ls [layer] list claims and tracked files
1365
1714
 
@@ -1370,7 +1719,7 @@ everyday
1370
1719
  push [repo...] push base + all layers (or named)
1371
1720
  sync [repo...] pull, then push
1372
1721
  check [--fix] [--staged] find leaks and ownership problems
1373
- (--staged: what the pre-commit hook runs)
1722
+ (--staged: what the commit/merge hooks run)
1374
1723
 
1375
1724
  passthrough
1376
1725
  <layer> <git args...> run git in a layer, e.g. sich notes log
@@ -1382,7 +1731,7 @@ options
1382
1731
  -V, --version print version
1383
1732
 
1384
1733
  environment
1385
- SICH_BIN sich binary the pre-commit hook runs
1734
+ SICH_BIN sich binary the commit/merge hooks run
1386
1735
  (default: sich on PATH)
1387
1736
  SICH_GH gh binary for new --gh (default: gh)
1388
1737
  NO_COLOR disable colors
@@ -1393,15 +1742,21 @@ Claims can't nest across layers.
1393
1742
  var COMMAND_HELP = {
1394
1743
  init: `usage: sich init
1395
1744
 
1396
- Creates .sich/, writes the managed block in the base repo's info/exclude, and
1397
- installs a pre-commit hook that runs 'sich check --staged'. If a pre-commit hook
1398
- already exists or core.hooksPath is set, prints the line to add instead.
1399
- The hook runs $SICH_BIN (default: sich on PATH) and blocks the commit if it
1400
- can't be found. Safe to run again.`,
1745
+ Creates .sich/, writes the managed blocks in each repo's info/exclude, and
1746
+ installs pre-commit and pre-merge-commit hooks in base and in every layer that
1747
+ run 'sich check --staged' (git runs pre-merge-commit instead of pre-commit for
1748
+ a merge commit made without conflicts). Fast-forwards, rebase and cherry-pick
1749
+ run no hook; plain 'sich check' catches what they bring in afterwards. If such
1750
+ a hook already exists or core.hooksPath is set, prints the line to add instead
1751
+ (each line works in base, in every layer and in a core.hooksPath they share).
1752
+ The hooks run $SICH_BIN (default: sich on PATH) and block the commit if it
1753
+ can't be found. Safe to run again; refreshes hooks sich wrote and adds missing
1754
+ ones.`,
1401
1755
  new: `usage: sich new <layer> [--remote <url> | --gh [name]]
1402
1756
 
1403
1757
  Creates layer <layer> (.sich/<layer>/ git dir sharing this working tree) with an
1404
- empty claims manifest .sich/<layer>.paths and an initial commit.
1758
+ empty claims manifest .sich/<layer>.paths and an initial commit, and installs
1759
+ its pre-commit and pre-merge-commit hooks (see sich init).
1405
1760
  --remote <url> set origin
1406
1761
  --gh [name] create a private GitHub repo with gh (default name:
1407
1762
  <base-repo>-<layer>) and set origin to its SSH URL.
@@ -1409,20 +1764,40 @@ empty claims manifest .sich/<layer>.paths and an initial commit.
1409
1764
  attach: `usage: sich attach <layer> <url>
1410
1765
 
1411
1766
  Joins an existing layer: fetches <url> and checks out its default branch into
1412
- this working tree. Refuses to overwrite existing files.`,
1413
- add: `usage: sich add <layer> <path...> [--move]
1767
+ this working tree, and installs its pre-commit and pre-merge-commit hooks (see
1768
+ sich init). Refuses to overwrite existing files.`,
1769
+ detach: `usage: sich detach <layer> [--force] [--yes]
1770
+
1771
+ The opposite of attach: removes a layer from this working tree by deleting its
1772
+ git data (.sich/<layer>/) and claims list (.sich/<layer>.paths). Its files stay
1773
+ on disk, no longer hidden from base (nothing stops base from committing them
1774
+ any more), and its remote is left alone: attach it again any time.
1775
+ Refuses if that would lose what exists only in .sich/<layer>/: commits on no
1776
+ remote (for branches: as of the last fetch or push), stashes, staged versions
1777
+ of files that changed again on disk, linked worktrees, submodule repos, or a
1778
+ merge, rebase or similar in progress. Uncommitted changes to files don't
1779
+ count, since the files stay.
1780
+ Lists what it deletes and what it keeps, then asks before doing it (no answer,
1781
+ e.g. without a terminal, means no).
1782
+ --force detach anyway, discarding all of that
1783
+ -y, --yes don't ask`,
1784
+ claim: `usage: sich claim <layer> <path...> [--move]
1414
1785
 
1415
1786
  Claims paths (files or directories, relative to the current directory) for a
1416
1787
  layer, then stages them and the manifest in that layer (no commit).
1417
1788
  Refuses paths tracked by base or owned by another layer unless --move is given;
1418
1789
  --move untracks them from the previous owner (old versions stay in its history).
1419
1790
  Claims can't nest across layers: a path inside another layer's claimed directory
1420
- is always refused (move the whole directory instead).`,
1421
- rm: `usage: sich rm <layer> <path...>
1791
+ is always refused (move the whole directory instead).
1792
+ Not the same as 'sich <layer> add', which runs plain git add in the layer.
1793
+ Alias: sich add.`,
1794
+ unclaim: `usage: sich unclaim <layer> <path...>
1422
1795
 
1423
1796
  Removes exact claims from a layer and untracks them there. The files stay on
1424
1797
  disk and show up as untracked in base (or, if another layer's directory claim
1425
- contains them, now belong to that layer).`,
1798
+ contains them, now belong to that layer).
1799
+ Not the same as 'sich <layer> rm', which runs plain git rm (deleting files).
1800
+ Alias: sich rm.`,
1426
1801
  which: `usage: sich which <path>
1427
1802
 
1428
1803
  Prints the owner of a path: a layer name, 'base', 'ignored' or 'untracked'.`,
@@ -1440,7 +1815,8 @@ staged / modified / untracked files.
1440
1815
  Stages everything (git add -A) and commits, in base and every layer with
1441
1816
  changes, or only in the named repos ('base' or layer names). Excludes keep each
1442
1817
  repo to its own files; anything a repo doesn't own (e.g. let in by a '!' rule in
1443
- a .gitignore) is left out with a warning. Base commits run its pre-commit hook.`,
1818
+ a .gitignore) is left out with a warning. Each commit runs that repo's
1819
+ pre-commit hook.`,
1444
1820
  pull: `usage: sich pull [repo...]
1445
1821
 
1446
1822
  git pull --rebase --autostash in base and every layer (or the named repos,
@@ -1454,18 +1830,32 @@ git push in base and every layer (or the named repos). Sets the upstream
1454
1830
  sync: `usage: sich sync [repo...]
1455
1831
 
1456
1832
  pull then push, repo by repo. Stops at the first failure.`,
1457
- check: `usage: sich check [--fix] [--staged]
1833
+ check: `usage: sich check [--fix] [--staged [--merge] [--repo <repo>]]
1458
1834
 
1459
1835
  Reports: paths claimed or tracked by two layers, base tracking claimed paths or
1460
- .sich/, layers tracking files they don't own, and stale exclude blocks.
1461
- --fix rewrite stale exclude blocks
1462
- --staged also fail if base's index stages claimed paths or .sich/
1463
- (this is what the pre-commit hook runs; silent when all is well,
1464
- stale excludes only warn, and a no-op where sich isn't set up,
1465
- e.g. a linked worktree)
1836
+ .sich/, layers tracking files they don't own, and stale exclude blocks. Warns
1837
+ about base or layers whose pre-commit or pre-merge-commit hook doesn't run sich
1838
+ (fix: sich init).
1839
+ --fix rewrite stale exclude blocks
1840
+ --staged also check the commit in progress (this is what the
1841
+ pre-commit and pre-merge-commit hooks run; for a merge, what
1842
+ it brings in). In base, fail if it stages claimed paths or
1843
+ .sich/. In a layer, fail if it stages a path that none of its
1844
+ claims cover (in the claims list as committed too), another
1845
+ layer's, or .sich/. Staged deletions are fine. The repo is
1846
+ the one whose index git is committing (else base). Silent
1847
+ when all is well, stale excludes only warn, and a no-op where
1848
+ sich isn't set up (e.g. a linked worktree)
1849
+ --merge with --staged: the commit is a merge (the pre-merge-commit
1850
+ hooks pass it; finishing a conflicted merge counts without
1851
+ it). A blocked merge prints how to back out and restore what
1852
+ it overwrote, instead of commit hints
1853
+ --repo <repo> with --staged: check this repo ('base' or a layer) instead
1466
1854
  Exits 1 if any issue remains.`,
1467
1855
  help: `usage: sich help [command]`
1468
1856
  };
1857
+ COMMAND_HELP.add = COMMAND_HELP.claim;
1858
+ COMMAND_HELP.rm = COMMAND_HELP.unclaim;
1469
1859
 
1470
1860
  // src/cli.ts
1471
1861
  var VERSION = version;
@@ -1473,8 +1863,11 @@ var HANDLERS = {
1473
1863
  init: cmdInit,
1474
1864
  new: cmdNew,
1475
1865
  attach: cmdAttach,
1476
- add: cmdAdd,
1477
- rm: cmdRm,
1866
+ detach: cmdDetach,
1867
+ claim: cmdClaim,
1868
+ unclaim: cmdUnclaim,
1869
+ add: cmdClaim,
1870
+ rm: cmdUnclaim,
1478
1871
  which: cmdWhich,
1479
1872
  ls: cmdLs,
1480
1873
  status: cmdStatus,
@@ -1497,7 +1890,7 @@ function changeDir(cwd, dir) {
1497
1890
  fail(`-C ${dir}: no such directory`);
1498
1891
  }
1499
1892
  }
1500
- function main(argv) {
1893
+ async function main(argv) {
1501
1894
  let cwd = realpathSync3.native(process.cwd());
1502
1895
  let i = 0;
1503
1896
  for (;i < argv.length; i++) {
@@ -1556,7 +1949,7 @@ function main(argv) {
1556
1949
  }
1557
1950
  var code;
1558
1951
  try {
1559
- code = main(process.argv.slice(2));
1952
+ code = await main(process.argv.slice(2));
1560
1953
  } catch (e) {
1561
1954
  if (e instanceof SichError)
1562
1955
  printError(e.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-sich",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Private git layers sharing one working tree with a normal (public) repo",
5
5
  "keywords": [
6
6
  "git",