git-sich 0.1.0 → 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.
- package/README.md +77 -31
- package/dist/cli.js +432 -234
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# sich
|
|
2
2
|
|
|
3
|
+
[](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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
sich
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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,15 @@ 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/,
|
|
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)
|
|
74
97
|
|
|
75
98
|
ownership
|
|
76
|
-
|
|
99
|
+
claim <layer> <path...> [--move] claim files/folders for a layer, stage them
|
|
77
100
|
(--move: take them from base/another layer)
|
|
78
|
-
|
|
101
|
+
unclaim <layer> <path...> release claims; files stay on disk
|
|
79
102
|
which <path> show who owns a path
|
|
80
103
|
ls [layer] list claims and tracked files
|
|
81
104
|
|
|
@@ -86,7 +109,7 @@ everyday
|
|
|
86
109
|
push [repo...] push base + all layers (or named)
|
|
87
110
|
sync [repo...] pull, then push
|
|
88
111
|
check [--fix] [--staged] find leaks and ownership problems
|
|
89
|
-
(--staged: what the
|
|
112
|
+
(--staged: what the commit/merge hooks run)
|
|
90
113
|
|
|
91
114
|
passthrough
|
|
92
115
|
<layer> <git args...> run git in a layer, e.g. sich notes log
|
|
@@ -98,7 +121,7 @@ options
|
|
|
98
121
|
-V, --version print version
|
|
99
122
|
|
|
100
123
|
environment
|
|
101
|
-
SICH_BIN sich binary the
|
|
124
|
+
SICH_BIN sich binary the commit/merge hooks run
|
|
102
125
|
(default: sich on PATH)
|
|
103
126
|
SICH_GH gh binary for new --gh (default: gh)
|
|
104
127
|
NO_COLOR disable colors
|
|
@@ -110,8 +133,6 @@ Claims can't nest across layers.
|
|
|
110
133
|
## How it works
|
|
111
134
|
|
|
112
135
|
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
136
|
|
|
116
137
|
**Layers.** Layer `L` is a regular, non-bare git dir at `.sich/L/` with
|
|
117
138
|
`core.worktree = ../..`, so its working tree is the project root. sich always
|
|
@@ -142,13 +163,35 @@ the same folder can still belong to different layers.
|
|
|
142
163
|
Because each layer only "sees" its claims, `git add -A` in a layer is safe, and
|
|
143
164
|
new files created inside a claimed directory automatically belong to that layer.
|
|
144
165
|
|
|
145
|
-
**Guard.**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
it
|
|
166
|
+
**Guard.** Base and every layer get a `pre-commit` and a `pre-merge-commit`
|
|
167
|
+
hook that run `sich check --staged`, since a layer may be public or shared with
|
|
168
|
+
a different audience too. It checks the repo whose index git is committing, so
|
|
169
|
+
the same lines also work in a `core.hooksPath` shared by every repo. In base it
|
|
170
|
+
blocks a commit that stages a claimed path or anything under `.sich/` (e.g.
|
|
171
|
+
after `git add -f`; on case-insensitive filesystems under any spelling). In
|
|
172
|
+
layer `L` it blocks a commit that stages a path `L` doesn't own: unclaimed (e.g.
|
|
173
|
+
after `sich L add -f`) or not yet in the claims list being committed, claimed by
|
|
174
|
+
another layer, or under `.sich/` (other than `L`'s own claims list). Staged
|
|
175
|
+
deletions never block, so untracking a stray file (`sich L rm --cached -- <path>`)
|
|
176
|
+
can be committed. Every hook also blocks while ownership between layers is
|
|
177
|
+
ambiguous (overlapping claims, a file tracked by the wrong layer), since that
|
|
178
|
+
could leak one layer's files into another. Stale exclude rules only warn. Where
|
|
179
|
+
sich isn't set up (a linked `git worktree`) the hooks do nothing. Skip the check
|
|
180
|
+
once with `git commit --no-verify` (in a layer: `sich L commit --no-verify`).
|
|
181
|
+
|
|
182
|
+
**Merges.** Merge commits are guarded too: git runs `pre-merge-commit` for a
|
|
183
|
+
merge commit made without conflicts (`git merge`, `git pull` without
|
|
184
|
+
`--rebase`), and `pre-commit` when you finish a conflicted merge with
|
|
185
|
+
`git commit`. The check covers what the merge brings in, e.g. a stray file
|
|
186
|
+
committed on a branch with `--no-verify`. By then git has already written the
|
|
187
|
+
merge result to the working tree, possibly over another repo's copy of an
|
|
188
|
+
incoming path, so a blocked merge says how to back out (`git merge --abort`)
|
|
189
|
+
and restore that copy afterwards, rather than how to unstage it. Skip the check
|
|
190
|
+
once with `git merge --no-verify` (in a layer: `sich L merge --no-verify`).
|
|
191
|
+
Fast-forward merges, rebase and cherry-pick run no hook at all, so what they
|
|
192
|
+
bring in isn't checked as it arrives; plain `sich check` catches it afterwards.
|
|
193
|
+
`sich check` also warns about any repo whose hooks don't run sich, and
|
|
194
|
+
`sich init` installs the missing ones.
|
|
152
195
|
|
|
153
196
|
## FAQ
|
|
154
197
|
|
|
@@ -167,10 +210,13 @@ That said, sometimes you do want a base commit to pin a submodule version: when
|
|
|
167
210
|
No. Visibility is set per repo, and anyone who can clone a repo gets every file in it.
|
|
168
211
|
Their per-repo access control is great, though, and sich builds on it!
|
|
169
212
|
|
|
213
|
+
**What's the difference between `sich claim notes x` and `sich notes add x`?**
|
|
214
|
+
`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.
|
|
215
|
+
|
|
170
216
|
**Is this like git-crypt or sops?**
|
|
171
217
|
No. sich doesn't encrypt anything; it relies on who can access each repo. You can combine the two for real secrets.
|
|
172
218
|
|
|
173
|
-
##
|
|
219
|
+
## Contributing
|
|
174
220
|
|
|
175
221
|
[CONTRIBUTING.md](CONTRIBUTING.md)
|
|
176
222
|
|
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.
|
|
7
|
+
var version = "0.2.0";
|
|
8
8
|
|
|
9
9
|
// src/ui.ts
|
|
10
10
|
class SichError extends Error {
|
|
@@ -115,6 +115,10 @@ function wantsHelp(argv) {
|
|
|
115
115
|
return false;
|
|
116
116
|
}
|
|
117
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
|
+
|
|
118
122
|
// src/claims.ts
|
|
119
123
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
120
124
|
var isDirClaim = (c2) => c2.endsWith("/");
|
|
@@ -160,9 +164,12 @@ function warnOnce(message) {
|
|
|
160
164
|
function readManifest(file, layer) {
|
|
161
165
|
if (!existsSync(file))
|
|
162
166
|
return { claims: [], comments: [] };
|
|
167
|
+
return parseManifest(readFileSync(file, "utf8"), layer);
|
|
168
|
+
}
|
|
169
|
+
function parseManifest(text, layer) {
|
|
163
170
|
const claims = [];
|
|
164
171
|
const comments = [];
|
|
165
|
-
for (const line of
|
|
172
|
+
for (const line of text.split(`
|
|
166
173
|
`)) {
|
|
167
174
|
const t = line.trim();
|
|
168
175
|
if (!t)
|
|
@@ -398,6 +405,8 @@ var COMMANDS = [
|
|
|
398
405
|
"init",
|
|
399
406
|
"new",
|
|
400
407
|
"attach",
|
|
408
|
+
"claim",
|
|
409
|
+
"unclaim",
|
|
401
410
|
"add",
|
|
402
411
|
"rm",
|
|
403
412
|
"which",
|
|
@@ -569,12 +578,363 @@ function collisions(ctx, paths) {
|
|
|
569
578
|
return [...hits].sort();
|
|
570
579
|
}
|
|
571
580
|
|
|
572
|
-
// src/commands/
|
|
573
|
-
|
|
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) {
|
|
574
934
|
const p = parseArgs(args, { "--move": "bool" });
|
|
575
935
|
const [layer, ...inputs] = p.positionals;
|
|
576
936
|
if (!layer || inputs.length === 0)
|
|
577
|
-
fail("usage: sich
|
|
937
|
+
fail("usage: sich claim <layer> <path...> [--move]");
|
|
578
938
|
const move = p.flags["--move"] === true;
|
|
579
939
|
const repo = requireLayer(ctx, layer);
|
|
580
940
|
const claims = allClaims(ctx);
|
|
@@ -608,7 +968,7 @@ function cmdAdd(ctx, args) {
|
|
|
608
968
|
const theirs = claims.get(other) ?? [];
|
|
609
969
|
const outer = theirs.find((cl) => isDirClaim(cl) && isInside(fold(t), fold(cl)));
|
|
610
970
|
if (outer) {
|
|
611
|
-
fail(`${t} is inside ${outer}, claimed by layer ${other}; claims can't nest across layers ` + `(to move all of it: sich
|
|
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)`);
|
|
612
972
|
}
|
|
613
973
|
const inner = theirs.filter((cl) => covers(fold(t), fold(cl)));
|
|
614
974
|
const tracked = trackedUnder(layerRepo(ctx, other), [t]);
|
|
@@ -669,11 +1029,11 @@ function stage(repo, claim) {
|
|
|
669
1029
|
note(`${claim} has no files yet; new files in it will belong to ${repo.name}`);
|
|
670
1030
|
}
|
|
671
1031
|
}
|
|
672
|
-
function
|
|
1032
|
+
function cmdUnclaim(ctx, args) {
|
|
673
1033
|
const p = parseArgs(args, {});
|
|
674
1034
|
const [layer, ...inputs] = p.positionals;
|
|
675
1035
|
if (!layer || inputs.length === 0)
|
|
676
|
-
fail("usage: sich
|
|
1036
|
+
fail("usage: sich unclaim <layer> <path...>");
|
|
677
1037
|
const repo = requireLayer(ctx, layer);
|
|
678
1038
|
const file = manifestPath(ctx, layer);
|
|
679
1039
|
const man = readManifest(file, layer);
|
|
@@ -701,131 +1061,6 @@ function cmdRm(ctx, args) {
|
|
|
701
1061
|
return 0;
|
|
702
1062
|
}
|
|
703
1063
|
|
|
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
1064
|
// src/commands/commit.ts
|
|
830
1065
|
function cmdCommit(ctx, args) {
|
|
831
1066
|
const p = parseArgs(args, { "-m": "value" }, { "--message": "-m" });
|
|
@@ -871,73 +1106,6 @@ function cmdCommit(ctx, args) {
|
|
|
871
1106
|
return 0;
|
|
872
1107
|
}
|
|
873
1108
|
|
|
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
1109
|
// src/commands/inspect.ts
|
|
942
1110
|
function cmdWhich(ctx, args) {
|
|
943
1111
|
const p = parseArgs(args, {});
|
|
@@ -996,13 +1164,13 @@ function cmdLs(ctx, args) {
|
|
|
996
1164
|
|
|
997
1165
|
// src/commands/layer.ts
|
|
998
1166
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
999
|
-
import { existsSync as
|
|
1000
|
-
import { basename as basename3, isAbsolute as isAbsolute2, join as
|
|
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";
|
|
1001
1169
|
function assertFree(ctx, layer) {
|
|
1002
1170
|
validateLayerName(layer);
|
|
1003
|
-
if (
|
|
1171
|
+
if (existsSync6(join4(ctx.sichDir, layer)))
|
|
1004
1172
|
fail(`layer '${layer}' already exists (.sich/${layer}/)`);
|
|
1005
|
-
if (
|
|
1173
|
+
if (existsSync6(manifestPath(ctx, layer)))
|
|
1006
1174
|
fail(`.sich/${layer}.paths already exists; remove it first`);
|
|
1007
1175
|
}
|
|
1008
1176
|
function createGitDir(ctx, layer) {
|
|
@@ -1016,7 +1184,7 @@ function remoteUrl(ctx, url) {
|
|
|
1016
1184
|
if (isAbsolute2(url) || /^[a-z][a-z0-9+.-]*:\/\//i.test(url))
|
|
1017
1185
|
return url;
|
|
1018
1186
|
const local = resolve5(ctx.cwd, url);
|
|
1019
|
-
return
|
|
1187
|
+
return existsSync6(local) ? local : url;
|
|
1020
1188
|
}
|
|
1021
1189
|
function baseRepoName(ctx) {
|
|
1022
1190
|
const url = git(ctx.base, ["config", "--get", "remote.origin.url"], { allowFail: true }).stdout.trim();
|
|
@@ -1077,7 +1245,8 @@ function cmdNew(ctx, args) {
|
|
|
1077
1245
|
out(`${c.green("created")} layer ${c.bold(layer)}: git data -> .sich/${layer}/, claims list -> ${manifestRel(layer)}`);
|
|
1078
1246
|
if (url)
|
|
1079
1247
|
out(`set origin of ${layer} -> ${url}`);
|
|
1080
|
-
|
|
1248
|
+
installLayerHooks(ctx, layer);
|
|
1249
|
+
note(`next: sich claim ${layer} <path>... then sich commit ${layer} -m <msg>${url ? ` and sich push ${layer}` : ""}`);
|
|
1081
1250
|
return 0;
|
|
1082
1251
|
}
|
|
1083
1252
|
function cmdAttach(ctx, args) {
|
|
@@ -1108,17 +1277,18 @@ function cmdAttach(ctx, args) {
|
|
|
1108
1277
|
fail(`attach would overwrite existing files: ${listSome(clash)} (move them away and retry)`);
|
|
1109
1278
|
}
|
|
1110
1279
|
git(repo, ["checkout", "-q", "-B", branch, "--track", `origin/${branch}`]);
|
|
1111
|
-
if (!
|
|
1280
|
+
if (!existsSync6(manifestPath(ctx, layer))) {
|
|
1112
1281
|
warn(`${url} has no .sich/${layer}.paths; is the layer name right?`);
|
|
1113
1282
|
}
|
|
1114
1283
|
syncExcludes(ctx);
|
|
1115
1284
|
out(`${c.green("attached")} layer ${c.bold(layer)} from ${url} -> .sich/${layer}/ (branch ${branch})`);
|
|
1116
1285
|
out(`checked out ${plural(files.length, "file")}: ${listSome(files)}`);
|
|
1117
|
-
return 0;
|
|
1118
1286
|
} catch (e) {
|
|
1119
1287
|
cleanup();
|
|
1120
1288
|
throw e;
|
|
1121
1289
|
}
|
|
1290
|
+
installLayerHooks(ctx, layer);
|
|
1291
|
+
return 0;
|
|
1122
1292
|
}
|
|
1123
1293
|
|
|
1124
1294
|
// src/commands/status.ts
|
|
@@ -1351,15 +1521,15 @@ usage: sich [-C <dir>] <command> [args]
|
|
|
1351
1521
|
sich [-C <dir>] <layer|base> <git args...>
|
|
1352
1522
|
|
|
1353
1523
|
setup
|
|
1354
|
-
init set up .sich/,
|
|
1524
|
+
init set up .sich/, excludes, commit guards
|
|
1355
1525
|
new <layer> [--remote <url> | --gh [name]]
|
|
1356
1526
|
create a layer (--gh: private GitHub repo)
|
|
1357
1527
|
attach <layer> <url> join an existing layer (collaborators)
|
|
1358
1528
|
|
|
1359
1529
|
ownership
|
|
1360
|
-
|
|
1530
|
+
claim <layer> <path...> [--move] claim files/folders for a layer, stage them
|
|
1361
1531
|
(--move: take them from base/another layer)
|
|
1362
|
-
|
|
1532
|
+
unclaim <layer> <path...> release claims; files stay on disk
|
|
1363
1533
|
which <path> show who owns a path
|
|
1364
1534
|
ls [layer] list claims and tracked files
|
|
1365
1535
|
|
|
@@ -1370,7 +1540,7 @@ everyday
|
|
|
1370
1540
|
push [repo...] push base + all layers (or named)
|
|
1371
1541
|
sync [repo...] pull, then push
|
|
1372
1542
|
check [--fix] [--staged] find leaks and ownership problems
|
|
1373
|
-
(--staged: what the
|
|
1543
|
+
(--staged: what the commit/merge hooks run)
|
|
1374
1544
|
|
|
1375
1545
|
passthrough
|
|
1376
1546
|
<layer> <git args...> run git in a layer, e.g. sich notes log
|
|
@@ -1382,7 +1552,7 @@ options
|
|
|
1382
1552
|
-V, --version print version
|
|
1383
1553
|
|
|
1384
1554
|
environment
|
|
1385
|
-
SICH_BIN sich binary the
|
|
1555
|
+
SICH_BIN sich binary the commit/merge hooks run
|
|
1386
1556
|
(default: sich on PATH)
|
|
1387
1557
|
SICH_GH gh binary for new --gh (default: gh)
|
|
1388
1558
|
NO_COLOR disable colors
|
|
@@ -1393,15 +1563,21 @@ Claims can't nest across layers.
|
|
|
1393
1563
|
var COMMAND_HELP = {
|
|
1394
1564
|
init: `usage: sich init
|
|
1395
1565
|
|
|
1396
|
-
Creates .sich/, writes the managed
|
|
1397
|
-
installs
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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.`,
|
|
1401
1576
|
new: `usage: sich new <layer> [--remote <url> | --gh [name]]
|
|
1402
1577
|
|
|
1403
1578
|
Creates layer <layer> (.sich/<layer>/ git dir sharing this working tree) with an
|
|
1404
|
-
empty claims manifest .sich/<layer>.paths and an initial commit
|
|
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).
|
|
1405
1581
|
--remote <url> set origin
|
|
1406
1582
|
--gh [name] create a private GitHub repo with gh (default name:
|
|
1407
1583
|
<base-repo>-<layer>) and set origin to its SSH URL.
|
|
@@ -1409,20 +1585,25 @@ empty claims manifest .sich/<layer>.paths and an initial commit.
|
|
|
1409
1585
|
attach: `usage: sich attach <layer> <url>
|
|
1410
1586
|
|
|
1411
1587
|
Joins an existing layer: fetches <url> and checks out its default branch into
|
|
1412
|
-
this working tree
|
|
1413
|
-
|
|
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]
|
|
1414
1591
|
|
|
1415
1592
|
Claims paths (files or directories, relative to the current directory) for a
|
|
1416
1593
|
layer, then stages them and the manifest in that layer (no commit).
|
|
1417
1594
|
Refuses paths tracked by base or owned by another layer unless --move is given;
|
|
1418
1595
|
--move untracks them from the previous owner (old versions stay in its history).
|
|
1419
1596
|
Claims can't nest across layers: a path inside another layer's claimed directory
|
|
1420
|
-
is always refused (move the whole directory instead)
|
|
1421
|
-
|
|
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...>
|
|
1422
1601
|
|
|
1423
1602
|
Removes exact claims from a layer and untracks them there. The files stay on
|
|
1424
1603
|
disk and show up as untracked in base (or, if another layer's directory claim
|
|
1425
|
-
contains them, now belong to that layer)
|
|
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.`,
|
|
1426
1607
|
which: `usage: sich which <path>
|
|
1427
1608
|
|
|
1428
1609
|
Prints the owner of a path: a layer name, 'base', 'ignored' or 'untracked'.`,
|
|
@@ -1440,7 +1621,8 @@ staged / modified / untracked files.
|
|
|
1440
1621
|
Stages everything (git add -A) and commits, in base and every layer with
|
|
1441
1622
|
changes, or only in the named repos ('base' or layer names). Excludes keep each
|
|
1442
1623
|
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.
|
|
1624
|
+
a .gitignore) is left out with a warning. Each commit runs that repo's
|
|
1625
|
+
pre-commit hook.`,
|
|
1444
1626
|
pull: `usage: sich pull [repo...]
|
|
1445
1627
|
|
|
1446
1628
|
git pull --rebase --autostash in base and every layer (or the named repos,
|
|
@@ -1454,18 +1636,32 @@ git push in base and every layer (or the named repos). Sets the upstream
|
|
|
1454
1636
|
sync: `usage: sich sync [repo...]
|
|
1455
1637
|
|
|
1456
1638
|
pull then push, repo by repo. Stops at the first failure.`,
|
|
1457
|
-
check: `usage: sich check [--fix] [--staged]
|
|
1639
|
+
check: `usage: sich check [--fix] [--staged [--merge] [--repo <repo>]]
|
|
1458
1640
|
|
|
1459
1641
|
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
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
|
1466
1660
|
Exits 1 if any issue remains.`,
|
|
1467
1661
|
help: `usage: sich help [command]`
|
|
1468
1662
|
};
|
|
1663
|
+
COMMAND_HELP.add = COMMAND_HELP.claim;
|
|
1664
|
+
COMMAND_HELP.rm = COMMAND_HELP.unclaim;
|
|
1469
1665
|
|
|
1470
1666
|
// src/cli.ts
|
|
1471
1667
|
var VERSION = version;
|
|
@@ -1473,8 +1669,10 @@ var HANDLERS = {
|
|
|
1473
1669
|
init: cmdInit,
|
|
1474
1670
|
new: cmdNew,
|
|
1475
1671
|
attach: cmdAttach,
|
|
1476
|
-
|
|
1477
|
-
|
|
1672
|
+
claim: cmdClaim,
|
|
1673
|
+
unclaim: cmdUnclaim,
|
|
1674
|
+
add: cmdClaim,
|
|
1675
|
+
rm: cmdUnclaim,
|
|
1478
1676
|
which: cmdWhich,
|
|
1479
1677
|
ls: cmdLs,
|
|
1480
1678
|
status: cmdStatus,
|