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.
- package/README.md +78 -31
- package/dist/cli.js +629 -236
- 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,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/,
|
|
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
|
-
|
|
100
|
+
claim <layer> <path...> [--move] claim files/folders for a layer, stage them
|
|
77
101
|
(--move: take them from base/another layer)
|
|
78
|
-
|
|
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
|
|
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
|
|
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.**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
it
|
|
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
|
-
##
|
|
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.
|
|
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
|
|
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/
|
|
573
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1000
|
-
import { basename as basename3, isAbsolute as isAbsolute2, join as
|
|
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 (
|
|
1189
|
+
if (existsSync6(join4(ctx.sichDir, layer)))
|
|
1004
1190
|
fail(`layer '${layer}' already exists (.sich/${layer}/)`);
|
|
1005
|
-
if (
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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/,
|
|
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
|
-
|
|
1709
|
+
claim <layer> <path...> [--move] claim files/folders for a layer, stage them
|
|
1361
1710
|
(--move: take them from base/another layer)
|
|
1362
|
-
|
|
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
|
|
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
|
|
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
|
|
1397
|
-
installs
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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
|
|
1413
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
|
|
1477
|
-
|
|
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);
|