baesian 0.0.0-stage → 1.0.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/LICENSE.md +105 -0
- package/README.md +217 -2
- package/dist/agents.js +249 -0
- package/dist/api.js +13 -0
- package/dist/bin.js +14 -0
- package/dist/ci.js +82 -0
- package/dist/cite.js +66 -0
- package/dist/cli.js +831 -0
- package/dist/client/assets/alegreya-cyrillic-ext-wght-italic-DzWRXZb0.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-ext-wght-normal-CN0-BfWD.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-wght-italic-Dhrv2xfk.woff2 +0 -0
- package/dist/client/assets/alegreya-cyrillic-wght-normal-CU5lb38f.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-ext-wght-italic-CoUI9EEu.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-ext-wght-normal-C8PPRmwJ.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-wght-italic-CVt9-Jr4.woff2 +0 -0
- package/dist/client/assets/alegreya-greek-wght-normal-BPx8yWcZ.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-ext-wght-italic-BIuo_2sR.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-ext-wght-normal-o8axeAGu.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-wght-italic-Bxkcgmge.woff2 +0 -0
- package/dist/client/assets/alegreya-latin-wght-normal-BG9rTJo4.woff2 +0 -0
- package/dist/client/assets/alegreya-vietnamese-wght-italic-BAPuWgnQ.woff2 +0 -0
- package/dist/client/assets/alegreya-vietnamese-wght-normal-DOV9JYup.woff2 +0 -0
- package/dist/client/assets/archivo-latin-ext-wdth-normal-7khWdh9v.woff2 +0 -0
- package/dist/client/assets/archivo-latin-wdth-normal-DY7AcnAa.woff2 +0 -0
- package/dist/client/assets/archivo-vietnamese-wdth-normal-rJmnGBSt.woff2 +0 -0
- package/dist/client/assets/index-B3CFhLg-.css +1 -0
- package/dist/client/assets/index-DUcPUPI_.js +15 -0
- package/dist/client/index.html +15 -0
- package/dist/facts.js +278 -0
- package/dist/folder-storage.js +71 -0
- package/dist/frontmatter.js +38 -0
- package/dist/gate-paths.js +77 -0
- package/dist/gate.js +237 -0
- package/dist/git.js +100 -0
- package/dist/globs.js +120 -0
- package/dist/hookblock.js +146 -0
- package/dist/hooks.js +911 -0
- package/dist/ids.js +23 -0
- package/dist/login.js +301 -0
- package/dist/mcp.js +303 -0
- package/dist/merges.js +849 -0
- package/dist/modes.js +245 -0
- package/dist/notify.js +91 -0
- package/dist/ops.js +381 -0
- package/dist/passkey.js +86 -0
- package/dist/personal.js +163 -0
- package/dist/practice-answers.js +42 -0
- package/dist/proposals.js +197 -0
- package/dist/quote.js +28 -0
- package/dist/report.js +261 -0
- package/dist/routing.js +411 -0
- package/dist/schema.js +1178 -0
- package/dist/server.js +514 -0
- package/dist/similarity.js +45 -0
- package/dist/sshsig.js +159 -0
- package/dist/state.js +135 -0
- package/dist/storage.js +61 -0
- package/dist/store.js +2259 -0
- package/dist/style.js +408 -0
- package/dist/sync.js +797 -0
- package/dist/trailers.js +121 -0
- package/dist/trust.js +222 -0
- package/dist/usage-fields.js +161 -0
- package/dist/vault.js +849 -0
- package/dist/verify.js +443 -0
- package/dist/version.js +70 -0
- package/dist/wait.js +449 -0
- package/dist/wikilinks.js +25 -0
- package/package.json +71 -4
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fchmodSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { citeIn } from "./cite.js";
|
|
5
|
+
import { PLAIN, failed, next, note, render, text, warning } from "./style.js";
|
|
6
|
+
import { describeReport, detectStaged, evaluate, verdictsSinceLastCommit } from "./gate.js";
|
|
7
|
+
import { git } from "./git.js";
|
|
8
|
+
import { stagedSnapshot } from "./facts.js";
|
|
9
|
+
import { BEGIN, END, FALLBACK_KEY, HOOKS, NEW_HOOK, hookBlock, writtenBlock } from "./hookblock.js";
|
|
10
|
+
import { LIVE_DIR, RECORD_DIR, ValidationError } from "./store.js";
|
|
11
|
+
import { syncRelay } from "./sync.js";
|
|
12
|
+
import { parseTrailers } from "./trailers.js";
|
|
13
|
+
import { DECISIONS, RULES, vaultOf, writeNotes } from "./vault.js";
|
|
14
|
+
import { findMerges, noteSquash, rebasing, recordCommitted, takeSquash } from "./merges.js";
|
|
15
|
+
import { clearCited, noteCited, rewrites, takeCited, withoutBaesianTrailers } from "./personal.js";
|
|
16
|
+
import { ledgerBody } from "./schema.js";
|
|
17
|
+
// Baesian's hooks (D54) are installed here; their block's text is in src/hookblock.ts.
|
|
18
|
+
export { FALLBACK_KEY, HOOKS, hookBlock } from "./hookblock.js";
|
|
19
|
+
function sq(s) {
|
|
20
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
21
|
+
}
|
|
22
|
+
/** Record (or clear) this clone's fallback command. Local config: shared by its worktrees, never committed. */
|
|
23
|
+
export function setFallback(store, fallback) {
|
|
24
|
+
const top = store.repo.toplevel;
|
|
25
|
+
if (fallback.length) {
|
|
26
|
+
execFileSync("git", ["config", "--local", FALLBACK_KEY, fallback.map(sq).join(" ")], { cwd: top });
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
try {
|
|
30
|
+
execFileSync("git", ["config", "--local", "--unset", FALLBACK_KEY], { cwd: top, stdio: "ignore" });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// wasn't set
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Insert or refresh the block in one hook file. */
|
|
38
|
+
export function writeHook(path, block) {
|
|
39
|
+
if (!existsSync(path)) {
|
|
40
|
+
writeFileSync(path, NEW_HOOK(block));
|
|
41
|
+
chmodSync(path, 0o755);
|
|
42
|
+
return "created";
|
|
43
|
+
}
|
|
44
|
+
const text = readFileSync(path, "utf8");
|
|
45
|
+
const start = text.indexOf(BEGIN);
|
|
46
|
+
const end = text.indexOf(END);
|
|
47
|
+
let next;
|
|
48
|
+
let result;
|
|
49
|
+
if (start >= 0 && end > start) {
|
|
50
|
+
next = text.slice(0, start) + block + text.slice(end + END.length);
|
|
51
|
+
result = "updated";
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const lines = text.split("\n");
|
|
55
|
+
if (lines[0]?.startsWith("#!"))
|
|
56
|
+
next = [lines[0], block, ...lines.slice(1)].join("\n");
|
|
57
|
+
else
|
|
58
|
+
next = ["#!/bin/sh", block, ...lines].join("\n");
|
|
59
|
+
result = "inserted";
|
|
60
|
+
}
|
|
61
|
+
if (next === text)
|
|
62
|
+
return "unchanged";
|
|
63
|
+
writeFileSync(path, next);
|
|
64
|
+
chmodSync(path, 0o755);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Install into the hooks directory git actually uses. With no `core.hooksPath`, create a versioned
|
|
69
|
+
* `.githooks/` and point git at it, as livedocs does (D14), unless `.git/hooks` already holds real
|
|
70
|
+
* hooks, which switching would silently disable; then install there and say it isn't versioned.
|
|
71
|
+
*/
|
|
72
|
+
export function installHooks(store, fallback) {
|
|
73
|
+
const top = store.repo.toplevel;
|
|
74
|
+
let dir = git(top, ["rev-parse", "--git-path", "hooks"]);
|
|
75
|
+
if (!isAbsolute(dir))
|
|
76
|
+
dir = join(top, dir);
|
|
77
|
+
const inGitDir = !relative(store.repo.commonDir, dir).startsWith("..");
|
|
78
|
+
let setHooksPath = false;
|
|
79
|
+
if (inGitDir) {
|
|
80
|
+
const real = existsSync(dir) ? readdirSync(dir).filter((f) => !f.endsWith(".sample")) : [];
|
|
81
|
+
if (real.length === 0) {
|
|
82
|
+
dir = join(top, ".githooks");
|
|
83
|
+
execFileSync("git", ["config", "core.hooksPath", ".githooks"], { cwd: top });
|
|
84
|
+
setHooksPath = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
mkdirSync(dir, { recursive: true });
|
|
88
|
+
const files = {};
|
|
89
|
+
// The block this release writes: while a change to one is new, the block before (B-amxkp).
|
|
90
|
+
for (const name of HOOKS)
|
|
91
|
+
files[name] = writeHook(join(dir, name), writtenBlock(name));
|
|
92
|
+
setFallback(store, fallback);
|
|
93
|
+
return { dir, versioned: !relative(top, dir).startsWith(".."), setHooksPath, files };
|
|
94
|
+
}
|
|
95
|
+
// ---------------------------------------------------------------- personal mode's hooks (B-4rwyz)
|
|
96
|
+
/** Personal mode: the clone's own hooks folder, in the live layer (`.git/baesian/hooks/`), which `core.hooksPath` names. */
|
|
97
|
+
export const PERSONAL_HOOKS = "hooks";
|
|
98
|
+
/**
|
|
99
|
+
* Personal mode: a git config file of Baesian's own, in the live layer (`.git/baesian/git.config`), that sets
|
|
100
|
+
* `core.hooksPath` to the clone's own hooks. The clone's `.git/config` includes it at its end (`PERSONAL_INCLUDE`), and git
|
|
101
|
+
* reads an include where it appears, the last value of a key winning: so Baesian's value wins over the clone's own
|
|
102
|
+
* `core.hooksPath`, which a team's setup script sets at will, and which names the repo's hooks, run after Baesian's.
|
|
103
|
+
*/
|
|
104
|
+
export const PERSONAL_GIT_CONFIG = "git.config";
|
|
105
|
+
/** The `include.path` in the clone's `.git/config` that reads `PERSONAL_GIT_CONFIG`: relative, so to the config's own folder. */
|
|
106
|
+
export const PERSONAL_INCLUDE = `${LIVE_DIR}/${PERSONAL_GIT_CONFIG}`;
|
|
107
|
+
/** The first words of each of the clone's own hooks: a repo's hook that has them is one of these, never run again from one. */
|
|
108
|
+
const PERSONAL_MARK = "# Baesian, in personal mode (B-4rwyz): this clone's own";
|
|
109
|
+
/** The command that stops this clone's git running Baesian's hooks: its `.git/config` no longer includes Baesian's setting. */
|
|
110
|
+
export const PERSONAL_STOP = `git config --unset-all include.path ${PERSONAL_INCLUDE}`;
|
|
111
|
+
/**
|
|
112
|
+
* Per-clone git config an older `baesian init --personal` wrote, when it set the clone's `core.hooksPath` itself: the
|
|
113
|
+
* folder that named before. Read only to put it back (`restoreRepoSetting()`), then unset.
|
|
114
|
+
*/
|
|
115
|
+
const OLD_REPO_HOOKS_KEY = "baesian.repohooks";
|
|
116
|
+
/** Every hook git runs from a hooks folder (githooks(5), git 2.56). */
|
|
117
|
+
const GIT_HOOKS = new Set([
|
|
118
|
+
"applypatch-msg", "pre-applypatch", "post-applypatch", "pre-commit", "pre-merge-commit", "prepare-commit-msg", "commit-msg",
|
|
119
|
+
"post-commit", "pre-rebase", "post-checkout", "post-merge", "pre-push", "pre-receive", "update", "proc-receive", "post-receive",
|
|
120
|
+
"post-update", "reference-transaction", "push-to-checkout", "pre-auto-gc", "post-rewrite", "sendemail-validate",
|
|
121
|
+
"fsmonitor-watchman", "p4-changelist", "p4-prepare-changelist", "p4-post-changelist", "p4-pre-submit", "post-index-change",
|
|
122
|
+
]);
|
|
123
|
+
/**
|
|
124
|
+
* The hooks a working clone runs, each passed on to the repo's own whether or not it has one yet, so a hook the team adds
|
|
125
|
+
* later still runs. Others the repo has (`reference-transaction`, say) are passed on too, from when `baesian init
|
|
126
|
+
* --personal` sees them: one runs on every ref update, so it isn't there unless the repo has it.
|
|
127
|
+
*/
|
|
128
|
+
const PASSED_ON = [
|
|
129
|
+
"applypatch-msg", "pre-applypatch", "post-applypatch", "pre-commit", "pre-merge-commit", "prepare-commit-msg", "commit-msg",
|
|
130
|
+
"post-commit", "pre-rebase", "post-checkout", "post-merge", "pre-push", "post-rewrite", "pre-auto-gc", "sendemail-validate",
|
|
131
|
+
];
|
|
132
|
+
/** This clone's hooks folder in personal mode. */
|
|
133
|
+
export function personalHooksDir(store) {
|
|
134
|
+
return join(store.live, PERSONAL_HOOKS);
|
|
135
|
+
}
|
|
136
|
+
/** The config file that names it as `core.hooksPath`, which the clone's `.git/config` includes. */
|
|
137
|
+
export function personalGitConfig(store) {
|
|
138
|
+
return join(store.live, PERSONAL_GIT_CONFIG);
|
|
139
|
+
}
|
|
140
|
+
/** The clone's own git config, `config` in its common directory: every worktree of the clone reads it. */
|
|
141
|
+
function cloneConfig(store) {
|
|
142
|
+
return join(store.repo.commonDir, "config");
|
|
143
|
+
}
|
|
144
|
+
/** A git config value, as git reads it from every level and include, or from one file alone; undefined when it isn't set. */
|
|
145
|
+
function gitConfig(top, key, file) {
|
|
146
|
+
try {
|
|
147
|
+
return execFileSync("git", ["config", ...(file ? ["--file", file] : []), "--get", key], { cwd: top, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).replace(/\n$/, "");
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** One config file's entries, in the order git reads them, its includes not followed: lower-case keys, and values. */
|
|
154
|
+
function configEntries(top, file) {
|
|
155
|
+
let out;
|
|
156
|
+
try {
|
|
157
|
+
out = execFileSync("git", ["config", "--file", file, "--no-includes", "--list", "-z"], { cwd: top, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
.split("\0")
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
.map((e) => {
|
|
166
|
+
const i = e.indexOf("\n");
|
|
167
|
+
return i < 0 ? [e, ""] : [e.slice(0, i), e.slice(i + 1)];
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** Whether two paths name the same folder. */
|
|
171
|
+
function samePath(a, b) {
|
|
172
|
+
const real = (p) => {
|
|
173
|
+
try {
|
|
174
|
+
return realpathSync(p);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return p;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
return real(a) === real(b);
|
|
181
|
+
}
|
|
182
|
+
/** Whether git runs this clone's hooks from its personal-mode folder. */
|
|
183
|
+
function pointsAtPersonal(store) {
|
|
184
|
+
try {
|
|
185
|
+
return samePath(git(store.repo.toplevel, ["rev-parse", "--path-format=absolute", "--git-path", "hooks"]), personalHooksDir(store));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Whether a `core.hooksPath` names the clone's own hooks folder: as an older `baesian init --personal` set it (absolute,
|
|
193
|
+
* and where the clone was, if it has moved since), or as anything naming the same folder.
|
|
194
|
+
*/
|
|
195
|
+
function namesPersonal(store, value) {
|
|
196
|
+
if (isAbsolute(value) && /[\\/]baesian[\\/]hooks[\\/]?$/.test(value))
|
|
197
|
+
return true;
|
|
198
|
+
return samePath(isAbsolute(value) ? value : join(store.repo.toplevel, value), personalHooksDir(store));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The repo's own hooks folder (B-4rwyz), found as the clone's hooks find it each time they run (`personalHook()`): the
|
|
202
|
+
* last `core.hooksPath` git reads, at any level and through any include, other than the value Baesian's own file sets;
|
|
203
|
+
* else git's own, `hooks/` in the common directory. So it's whatever the clone's `.git/config` names now (where a team's
|
|
204
|
+
* setup script sets it), or a global setting, as git would have used without Baesian.
|
|
205
|
+
*/
|
|
206
|
+
export function repoHooks(store) {
|
|
207
|
+
const top = store.repo.toplevel;
|
|
208
|
+
const ours = gitConfig(top, "core.hooksPath", personalGitConfig(store)) ?? personalHooksDir(store);
|
|
209
|
+
let values = [];
|
|
210
|
+
try {
|
|
211
|
+
values = execFileSync("git", ["config", "--type=path", "--get-all", "core.hooksPath"], { cwd: top, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\n");
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// none set
|
|
215
|
+
}
|
|
216
|
+
const configured = values.filter((v) => v && v !== ours).at(-1);
|
|
217
|
+
if (!configured) {
|
|
218
|
+
const dir = join(store.repo.commonDir, "hooks");
|
|
219
|
+
return { configured: undefined, dir, shown: shownPath(top, dir) };
|
|
220
|
+
}
|
|
221
|
+
return { configured, dir: isAbsolute(configured) ? configured : join(top, configured), shown: configured };
|
|
222
|
+
}
|
|
223
|
+
/** The hooks a folder has that git would run: files named for a hook. */
|
|
224
|
+
function hookNames(dir) {
|
|
225
|
+
try {
|
|
226
|
+
return readdirSync(dir).filter((f) => GIT_HOOKS.has(f) && statSync(join(dir, f)).isFile());
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* One of this clone's hooks in personal mode (B-4rwyz): Baesian's block, for the hooks Baesian installs, then the repo's
|
|
234
|
+
* own hook of that name, if it has one git would run, with the same arguments and standard input, exiting as it does.
|
|
235
|
+
* The repo's folder is found as the hook runs, as `repoHooks()` finds it (the last `core.hooksPath` git reads other than
|
|
236
|
+
* `at.hooks`, Baesian's own, else `at.gitHooks`, git's own), so when a team points `core.hooksPath` somewhere new, its
|
|
237
|
+
* hooks run from there. A hook that is one of these (`PERSONAL_MARK`: from Baesian's own folder, however it's named, or
|
|
238
|
+
* a copy or a link of one) is never run from here, so the chain can't loop. `post-rewrite`'s and `pre-push`'s input is
|
|
239
|
+
* read first, so Baesian and the repo's hook each get it.
|
|
240
|
+
*/
|
|
241
|
+
export function personalHook(name, at) {
|
|
242
|
+
const gate = HOOKS.includes(name);
|
|
243
|
+
const rewrite = name === "post-rewrite" || name === "pre-push";
|
|
244
|
+
const lines = [
|
|
245
|
+
"#!/bin/sh",
|
|
246
|
+
`${PERSONAL_MARK} ${name} hook, written by \`baesian init --personal\`, never`,
|
|
247
|
+
`# committed. ${gate ? "It runs Baesian's gate, then" : "It runs"} the repo's own ${name}, with the same arguments and input, from the`,
|
|
248
|
+
"# folder core.hooksPath names apart from Baesian's own setting (git's own hooks folder where it names none).",
|
|
249
|
+
];
|
|
250
|
+
if (rewrite)
|
|
251
|
+
lines.push(`baesian_input="$(cat; echo .)"; baesian_input="\${baesian_input%.}"`);
|
|
252
|
+
if (gate)
|
|
253
|
+
lines.push(hookBlock(name, { restore: PERSONAL_STOP }));
|
|
254
|
+
const runs = `[ -f "$repo_hook" ] && [ -x "$repo_hook" ] && ! grep -qF ${sq(PERSONAL_MARK)} "$repo_hook" 2>/dev/null`;
|
|
255
|
+
lines.push(`repo_hooks="$(git config --type=path --get-all core.hooksPath 2>/dev/null | grep -vxF ${sq(at.hooks)} | tail -n 1)"`, `[ -n "$repo_hooks" ] || repo_hooks=${sq(at.gitHooks)}`, `repo_hook="$repo_hooks"/${sq(name)}`, rewrite ? `if ${runs}; then printf '%s' "$baesian_input" | "$repo_hook" "$@"; exit $?; fi` : `if ${runs}; then exec "$repo_hook" "$@"; fi`);
|
|
256
|
+
return `${lines.join("\n")}\n`;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Put the repo's own `core.hooksPath` back in the clone's `.git/config` (B-4rwyz), where an older `baesian init
|
|
260
|
+
* --personal` had set it to the clone's own hooks and kept the repo's in `baesian.repohooks` (unset where it had none):
|
|
261
|
+
* Baesian's setting is in a file of its own now. A `core.hooksPath` there that names Baesian's folder some other way is
|
|
262
|
+
* taken out the same way, since the repo's hooks never run from it. Returns whether it changed anything.
|
|
263
|
+
*/
|
|
264
|
+
function restoreRepoSetting(store) {
|
|
265
|
+
const top = store.repo.toplevel;
|
|
266
|
+
const file = cloneConfig(store);
|
|
267
|
+
const kept = gitConfig(top, OLD_REPO_HOOKS_KEY, file);
|
|
268
|
+
const set = gitConfig(top, "core.hooksPath", file);
|
|
269
|
+
let changed = false;
|
|
270
|
+
if (set !== undefined && namesPersonal(store, set)) {
|
|
271
|
+
if (kept)
|
|
272
|
+
execFileSync("git", ["config", "--file", file, "--replace-all", "core.hooksPath", kept], { cwd: top });
|
|
273
|
+
else
|
|
274
|
+
execFileSync("git", ["config", "--file", file, "--unset-all", "core.hooksPath"], { cwd: top });
|
|
275
|
+
changed = true;
|
|
276
|
+
}
|
|
277
|
+
if (kept !== undefined) {
|
|
278
|
+
execFileSync("git", ["config", "--file", file, "--unset-all", OLD_REPO_HOOKS_KEY], { cwd: top });
|
|
279
|
+
changed = true;
|
|
280
|
+
}
|
|
281
|
+
return changed;
|
|
282
|
+
}
|
|
283
|
+
/** Write Baesian's own config file: `core.hooksPath`, absolute, since the clone's linked worktrees read it too. */
|
|
284
|
+
function writePersonalGitConfig(store) {
|
|
285
|
+
const file = personalGitConfig(store);
|
|
286
|
+
writeFileSync(file, [
|
|
287
|
+
"# Baesian, in personal mode (B-4rwyz): written by `baesian init --personal`, never committed. The clone's .git/config",
|
|
288
|
+
"# includes it at its end, so this core.hooksPath wins over the one there, which names the repo's own hooks: they run",
|
|
289
|
+
"# after Baesian's. `baesian init --shared` takes it out.",
|
|
290
|
+
"",
|
|
291
|
+
].join("\n"));
|
|
292
|
+
execFileSync("git", ["config", "--file", file, "core.hooksPath", personalHooksDir(store)], { cwd: store.repo.toplevel });
|
|
293
|
+
}
|
|
294
|
+
/** Take the include of Baesian's setting out of the clone's `.git/config` (and its section, when nothing else is in it). */
|
|
295
|
+
function removeInclude(store) {
|
|
296
|
+
try {
|
|
297
|
+
execFileSync("git", ["config", "--file", cloneConfig(store), "--unset-all", "include.path", `^${PERSONAL_INCLUDE.replace(/\./g, "\\.")}$`], { cwd: store.repo.toplevel, stdio: "ignore" });
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// wasn't there
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/** Append text to a git config file, holding git's own lock on it (`config.lock`), as git does when it writes one. */
|
|
304
|
+
function appendToGitConfig(file, text) {
|
|
305
|
+
const lock = `${file}.lock`;
|
|
306
|
+
let fd;
|
|
307
|
+
try {
|
|
308
|
+
fd = openSync(lock, "wx");
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
if (e.code === "EEXIST") {
|
|
312
|
+
throw new ValidationError(`${lock} exists: a git command is changing this clone's config. Run \`baesian init --personal\` again once it's done (or, if no git command is running, delete the lock first).`);
|
|
313
|
+
}
|
|
314
|
+
throw e;
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const before = readFileSync(file, "utf8");
|
|
318
|
+
writeSync(fd, `${before}${before && !before.endsWith("\n") ? "\n" : ""}${text}`);
|
|
319
|
+
fchmodSync(fd, statSync(file).mode & 0o7777);
|
|
320
|
+
closeSync(fd);
|
|
321
|
+
renameSync(lock, file);
|
|
322
|
+
}
|
|
323
|
+
catch (e) {
|
|
324
|
+
try {
|
|
325
|
+
closeSync(fd);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// closed already
|
|
329
|
+
}
|
|
330
|
+
rmSync(lock, { force: true });
|
|
331
|
+
throw e;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Have the clone's `.git/config` include Baesian's setting at its end, once (B-4rwyz). `git config core.hooksPath …`, as a
|
|
336
|
+
* team's setup script runs it, edits the `[core]` section above, so Baesian's value is still read last. An include that
|
|
337
|
+
* something able to set `core.hooksPath` again has come after (that setting itself, or another include) is moved to the
|
|
338
|
+
* end. Returns whether it changed anything.
|
|
339
|
+
*/
|
|
340
|
+
function includeLast(store) {
|
|
341
|
+
const top = store.repo.toplevel;
|
|
342
|
+
const file = cloneConfig(store);
|
|
343
|
+
const entries = configEntries(top, file);
|
|
344
|
+
const at = entries.findLastIndex(([k, v]) => k === "include.path" && v === PERSONAL_INCLUDE);
|
|
345
|
+
const overriding = (k) => k === "core.hookspath" || k === "include.path" || /^includeif\..+\.path$/.test(k);
|
|
346
|
+
if (at >= 0 && !entries.slice(at + 1).some(([k]) => overriding(k)))
|
|
347
|
+
return false;
|
|
348
|
+
if (at >= 0)
|
|
349
|
+
removeInclude(store);
|
|
350
|
+
appendToGitConfig(file, `[include]\n\tpath = ${PERSONAL_INCLUDE}\n`);
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
/** Whether the clone's `.git/config` includes Baesian's setting. */
|
|
354
|
+
function included(store) {
|
|
355
|
+
return configEntries(store.repo.toplevel, cloneConfig(store)).some(([k, v]) => k === "include.path" && v === PERSONAL_INCLUDE);
|
|
356
|
+
}
|
|
357
|
+
/** A path as people read it: relative to the worktree when it's inside it. */
|
|
358
|
+
function shownPath(top, path) {
|
|
359
|
+
const rel = relative(top, path);
|
|
360
|
+
return rel && !rel.startsWith("..") ? rel : path;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Why git doesn't run this clone's own hooks, when it doesn't (B-4rwyz), for a warning: the setting an older `init
|
|
364
|
+
* --personal` made was replaced; the clone's `.git/config` no longer includes Baesian's; Baesian's file is missing or
|
|
365
|
+
* names another folder (the clone moved); or something read after the include sets `core.hooksPath` again (a
|
|
366
|
+
* worktree's own config, or `git -c`), which only taking it out puts right.
|
|
367
|
+
*/
|
|
368
|
+
function gateOff(store) {
|
|
369
|
+
const top = store.repo.toplevel;
|
|
370
|
+
const file = cloneConfig(store);
|
|
371
|
+
const now = gitConfig(top, "core.hooksPath");
|
|
372
|
+
const is = now === undefined ? "unset" : `\`${now}\``;
|
|
373
|
+
if (gitConfig(top, OLD_REPO_HOOKS_KEY, file) !== undefined)
|
|
374
|
+
return `core.hooksPath is ${is}, not this clone's own hooks`;
|
|
375
|
+
if (!included(store)) {
|
|
376
|
+
return `the clone's .git/config no longer includes Baesian's setting (\`[include] path = ${PERSONAL_INCLUDE}\`), so core.hooksPath is ${is}`;
|
|
377
|
+
}
|
|
378
|
+
const own = personalGitConfig(store);
|
|
379
|
+
const set = gitConfig(top, "core.hooksPath", own);
|
|
380
|
+
if (set === undefined)
|
|
381
|
+
return `Baesian's setting, ${shownPath(top, own)}, is missing, so core.hooksPath is ${is}`;
|
|
382
|
+
if (!samePath(set, personalHooksDir(store)))
|
|
383
|
+
return `Baesian's setting, ${shownPath(top, own)}, names \`${set}\`, not this clone's own hooks (did the clone move?)`;
|
|
384
|
+
let origin = "";
|
|
385
|
+
try {
|
|
386
|
+
origin = execFileSync("git", ["config", "--show-origin", "--get", "core.hooksPath"], { cwd: top, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).split("\t")[0] ?? "";
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// unset
|
|
390
|
+
}
|
|
391
|
+
const where = origin.startsWith("file:") ? `in ${origin.slice("file:".length)}` : origin ? `on ${origin.replace(/:$/, "")}` : "somewhere";
|
|
392
|
+
return `core.hooksPath is ${is}, set ${where}, which git reads after Baesian's setting`;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Personal mode's gate (B-4rwyz): this clone's own hooks, in the live layer, each running Baesian's block and then the
|
|
396
|
+
* repo's own hook, from whichever folder the repo's setting names when it runs. Git finds them through Baesian's own
|
|
397
|
+
* config file, which the clone's `.git/config` includes at its end, so a team's setup script that sets `core.hooksPath`
|
|
398
|
+
* leaves the gate on. Nothing in the repo's hook files changes. Safe to run again, and it moves a clone an older
|
|
399
|
+
* `init --personal` set up (`core.hooksPath` itself naming the clone's hooks) to the included file.
|
|
400
|
+
*/
|
|
401
|
+
export function installPersonalHooks(store, fallback) {
|
|
402
|
+
const dir = personalHooksDir(store);
|
|
403
|
+
const was = pointsAtPersonal(store);
|
|
404
|
+
const wasOff = !was && existsSync(dir) ? gateOff(store) : undefined;
|
|
405
|
+
const moved = restoreRepoSetting(store);
|
|
406
|
+
mkdirSync(dir, { recursive: true });
|
|
407
|
+
writePersonalGitConfig(store);
|
|
408
|
+
// The fallback first: a first `[baesian]` section goes at the end of the clone's .git/config, and the include after it.
|
|
409
|
+
setFallback(store, fallback);
|
|
410
|
+
includeLast(store);
|
|
411
|
+
const repo = repoHooks(store);
|
|
412
|
+
const hooks = [...new Set([...PASSED_ON, ...hookNames(repo.dir)])];
|
|
413
|
+
for (const f of readdirSync(dir))
|
|
414
|
+
if (!hooks.includes(f))
|
|
415
|
+
rmSync(join(dir, f), { recursive: true, force: true });
|
|
416
|
+
const at = { hooks: dir, gitHooks: join(store.repo.commonDir, "hooks") };
|
|
417
|
+
for (const name of hooks) {
|
|
418
|
+
writeFileSync(join(dir, name), personalHook(name, at));
|
|
419
|
+
chmodSync(join(dir, name), 0o755);
|
|
420
|
+
}
|
|
421
|
+
return { dir, config: personalGitConfig(store), repo, pointed: !was, wasOff, moved, stillOff: pointsAtPersonal(store) ? undefined : gateOff(store), hooks };
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Take personal mode's hooks out (B-4rwyz), when the clone switches to shared mode: the clone's `.git/config` no longer
|
|
425
|
+
* includes Baesian's setting, whose file goes, as does the folder. The repo's own `core.hooksPath` was never changed
|
|
426
|
+
* (an older `init --personal`'s is put back first), so git runs the repo's hooks again.
|
|
427
|
+
*/
|
|
428
|
+
export function uninstallPersonalHooks(store) {
|
|
429
|
+
restoreRepoSetting(store);
|
|
430
|
+
removeInclude(store);
|
|
431
|
+
rmSync(personalGitConfig(store), { force: true });
|
|
432
|
+
rmSync(personalHooksDir(store), { recursive: true, force: true });
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* What's wrong with a personal-mode clone's setup, for any command to say (B-4rwyz): its gate is off (`gateOff()`: the
|
|
436
|
+
* clone's `.git/config` no longer includes Baesian's setting, say); its gate is set as an older `init --personal` set
|
|
437
|
+
* it, where a team's setup script turns it off; the repo's hooks folder has a hook this clone's don't pass on; or a
|
|
438
|
+
* `.baesian/` turned up in the worktree. A clone set up with `--no-hooks` has no gate to be off.
|
|
439
|
+
*/
|
|
440
|
+
export function personalProblems(store) {
|
|
441
|
+
if (!store.personal)
|
|
442
|
+
return [];
|
|
443
|
+
const top = store.repo.toplevel;
|
|
444
|
+
const dir = personalHooksDir(store);
|
|
445
|
+
const out = [];
|
|
446
|
+
if (existsSync(dir)) {
|
|
447
|
+
const set = gitConfig(top, "core.hooksPath", cloneConfig(store));
|
|
448
|
+
if (!pointsAtPersonal(store)) {
|
|
449
|
+
out.push(`this clone's commit gate is off: ${gateOff(store)}, so git runs the repo's hooks without Baesian's (personal mode${citeIn("B-4rwyz")}). Run \`baesian init --personal\` again: the repo's hooks still run, after Baesian's.`);
|
|
450
|
+
}
|
|
451
|
+
else if (set !== undefined && namesPersonal(store, set)) {
|
|
452
|
+
out.push(included(store)
|
|
453
|
+
? `this clone's core.hooksPath, in its .git/config, names Baesian's own hooks, so the repo's own hooks don't run after Baesian's (personal mode${citeIn("B-4rwyz")}). Set it to the repo's hooks folder, or run \`baesian init --personal\` again, which takes it out.`
|
|
454
|
+
: `this clone's core.hooksPath, in its .git/config, names its own hooks, as an older \`baesian init --personal\` set it, so a setup script that sets core.hooksPath turns the commit gate off (personal mode${citeIn("B-4rwyz")}). Run \`baesian init --personal\` again: it puts the repo's setting back, and Baesian's in a file of its own that .git/config includes.`);
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
const repo = repoHooks(store);
|
|
458
|
+
const missing = samePath(repo.dir, dir) ? [] : hookNames(repo.dir).filter((n) => !existsSync(join(dir, n)));
|
|
459
|
+
if (missing.length)
|
|
460
|
+
out.push(`the repo's hooks folder, ${repo.shown}, has ${missing.join(" and ")}, which this clone's own hooks don't run yet (personal mode${citeIn("B-4rwyz")}): run \`baesian init --personal\` again`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (existsSync(join(top, RECORD_DIR))) {
|
|
464
|
+
out.push(`this worktree has a ${RECORD_DIR}/, though this clone uses Baesian in personal mode, which writes none: git ignores it here (.git/info/exclude), so it won't be committed. Delete it.`);
|
|
465
|
+
}
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
/** What a hook says, on standard error, each line after `baesian: ` but the trailers to paste, so git's own lines can be told apart. */
|
|
469
|
+
function say(io, lines) {
|
|
470
|
+
const all = Array.isArray(lines) ? lines : [lines];
|
|
471
|
+
for (const l of render(all, { ...(io.how ?? PLAIN), prefix: "baesian: " }))
|
|
472
|
+
io.err(l);
|
|
473
|
+
}
|
|
474
|
+
function mergeInProgress(store) {
|
|
475
|
+
try {
|
|
476
|
+
git(store.repo.toplevel, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** The entries in `git status` under some paths: each path, its index column (`X`; `?` when untracked) and its worktree column (`Y`). */
|
|
484
|
+
function recordStatus(top, rel) {
|
|
485
|
+
const out = execFileSync("git", ["status", "--porcelain", "-z", "--untracked-files=all", "--", ...[rel].flat()], { cwd: top, encoding: "utf8" });
|
|
486
|
+
const parts = out.split("\0");
|
|
487
|
+
const entries = [];
|
|
488
|
+
for (let i = 0; i < parts.length; i++) {
|
|
489
|
+
const e = parts[i];
|
|
490
|
+
if (e.length < 4)
|
|
491
|
+
continue;
|
|
492
|
+
entries.push({ x: e[0], y: e[1], path: e.slice(3) });
|
|
493
|
+
if (e[0] === "R" || e[0] === "C")
|
|
494
|
+
i++; // the old path of a rename or copy comes next
|
|
495
|
+
}
|
|
496
|
+
return entries;
|
|
497
|
+
}
|
|
498
|
+
/** Where pre-commit notes what it staged: this worktree's own git directory, beside its index. */
|
|
499
|
+
function stagedListPath(top) {
|
|
500
|
+
return git(top, ["rev-parse", "--path-format=absolute", "--git-path", "baesian-staged.json"]);
|
|
501
|
+
}
|
|
502
|
+
function writeStaged(top, paths) {
|
|
503
|
+
const path = stagedListPath(top);
|
|
504
|
+
if (paths.length)
|
|
505
|
+
writeFileSync(path, `${JSON.stringify(paths)}\n`);
|
|
506
|
+
else
|
|
507
|
+
rmSync(path, { force: true });
|
|
508
|
+
}
|
|
509
|
+
/** Unstage what pre-commit staged for a commit the gate blocked: back to the last commit's version, or out of the index. */
|
|
510
|
+
function unstageOurs(top) {
|
|
511
|
+
const path = stagedListPath(top);
|
|
512
|
+
if (!existsSync(path))
|
|
513
|
+
return;
|
|
514
|
+
const paths = JSON.parse(readFileSync(path, "utf8"));
|
|
515
|
+
if (!paths.length)
|
|
516
|
+
return;
|
|
517
|
+
try {
|
|
518
|
+
git(top, ["rev-parse", "-q", "--verify", "HEAD"]);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
// The first commit: nothing to go back to.
|
|
522
|
+
git(top, ["rm", "-q", "--cached", "--ignore-unmatch", "--", ...paths]);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
git(top, ["reset", "-q", "--", ...paths]);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Write the vault's notes for the commit and stage them (D170). A note with changes nobody has staged is left as it
|
|
529
|
+
* is: staging it would commit someone's unfinished edit. Returns the notes it staged that nobody had staged before,
|
|
530
|
+
* for a blocked commit to unstage (D122). A missing vault is a warning, never a block.
|
|
531
|
+
*/
|
|
532
|
+
function stageNotes(store, io) {
|
|
533
|
+
const top = store.repo.toplevel;
|
|
534
|
+
const v = vaultOf(store);
|
|
535
|
+
if (!v)
|
|
536
|
+
return [];
|
|
537
|
+
const folders = "problem" in v ? [] : [DECISIONS, RULES].map((f) => relative(top, join(v.dir, f))).filter((f) => existsSync(join(top, f)));
|
|
538
|
+
const before = new Map(folders.length ? recordStatus(top, folders).map((e) => [e.path, e]) : []);
|
|
539
|
+
const unstaged = (n) => {
|
|
540
|
+
const e = before.get(n.path);
|
|
541
|
+
return e !== undefined && e.x !== "?" && (e.y === "M" || e.y === "T");
|
|
542
|
+
};
|
|
543
|
+
// A note's unstaged change is Baesian's own when bringing the index's version up to date gives the same note: as
|
|
544
|
+
// after a blocked commit unstaged it, or `baesian sync` extended it. Otherwise it's someone's edit.
|
|
545
|
+
const ours = new Map();
|
|
546
|
+
const onlyOurs = (n) => {
|
|
547
|
+
let same = ours.get(n.path);
|
|
548
|
+
if (same === undefined) {
|
|
549
|
+
try {
|
|
550
|
+
const index = execFileSync("git", ["show", `:${n.path}`], { cwd: top, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
551
|
+
same = n.update(index) === n.next;
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
same = false;
|
|
555
|
+
}
|
|
556
|
+
ours.set(n.path, same);
|
|
557
|
+
}
|
|
558
|
+
return same;
|
|
559
|
+
};
|
|
560
|
+
const r = writeNotes(store, { mayWrite: (n) => !unstaged(n) || onlyOurs(n) });
|
|
561
|
+
for (const w of r.warnings)
|
|
562
|
+
say(io, warning(w));
|
|
563
|
+
for (const path of r.skipped)
|
|
564
|
+
say(io, note(`left ${path} as it is: it has changes you haven't staged. Stage them, and the next commit brings it up to date.`));
|
|
565
|
+
const written = new Set(r.written);
|
|
566
|
+
const planned = new Map(r.planned.map((n) => [n.path, n]));
|
|
567
|
+
// What this commit should carry: the notes written now, any written earlier (by `baesian sync`) and never added, and
|
|
568
|
+
// those whose only unstaged change is Baesian's.
|
|
569
|
+
const toStage = r.notes.filter((p) => {
|
|
570
|
+
const n = planned.get(p);
|
|
571
|
+
return written.has(p) || before.get(p)?.x === "?" || (n !== undefined && unstaged(n) && onlyOurs(n));
|
|
572
|
+
});
|
|
573
|
+
if (!toStage.length)
|
|
574
|
+
return [];
|
|
575
|
+
try {
|
|
576
|
+
execFileSync("git", ["add", "--", ...toStage], { cwd: top, stdio: ["ignore", "ignore", "pipe"] });
|
|
577
|
+
}
|
|
578
|
+
catch (e) {
|
|
579
|
+
say(io, warning(`couldn't stage the vault's notes (${e instanceof Error ? e.message.split("\n")[0] : String(e)}); they're written, so add them yourself`));
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
say(io, text(`staged ${toStage.length} vault note${toStage.length === 1 ? "" : "s"} (${v.rel}/)`));
|
|
583
|
+
return toStage.filter((p) => {
|
|
584
|
+
const e = before.get(p);
|
|
585
|
+
return !e || e.x === " " || e.x === "?";
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
/** How long `pre-commit` waits for the relay's log in a linked repo (Hosted Service section 1): a commit never waits long. */
|
|
589
|
+
export const PRE_COMMIT_PULL_MS = 3000;
|
|
590
|
+
/**
|
|
591
|
+
* Find the merges a hook's movement of the branch made, and record each (D181): `post-merge` and `post-rewrite` from
|
|
592
|
+
* where the branch was (`ORIG_HEAD`), `post-commit` from the commit's first parent, for a squash commit or a merge
|
|
593
|
+
* whose conflicts were committed. Where the record isn't committed (personal mode), a plain commit can't merge anything,
|
|
594
|
+
* so it isn't looked at. A later fast-forward of a branch merged before is found as any movement is, and recorded at its
|
|
595
|
+
* own commit (B-w41p7). Never fails the hook. Returns how many it recorded.
|
|
596
|
+
*/
|
|
597
|
+
function recordMerges(store, hook, io) {
|
|
598
|
+
const top = store.repo.toplevel;
|
|
599
|
+
const into = store.repo.branch;
|
|
600
|
+
if (into.startsWith("detached-"))
|
|
601
|
+
return 0;
|
|
602
|
+
try {
|
|
603
|
+
const to = git(top, ["rev-parse", "HEAD"]);
|
|
604
|
+
let from;
|
|
605
|
+
let squashed;
|
|
606
|
+
if (hook === "post-commit") {
|
|
607
|
+
const parents = git(top, ["rev-list", "--parents", "-n", "1", "HEAD"]).split(" ").slice(1);
|
|
608
|
+
if (!parents.length)
|
|
609
|
+
return 0;
|
|
610
|
+
from = parents[0];
|
|
611
|
+
squashed = takeSquash(store, from);
|
|
612
|
+
if (parents.length < 2 && !squashed && !recordCommitted(store, to))
|
|
613
|
+
return 0;
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
try {
|
|
617
|
+
from = git(top, ["rev-parse", "-q", "--verify", "ORIG_HEAD^{commit}"]);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return 0;
|
|
621
|
+
}
|
|
622
|
+
if (from === to)
|
|
623
|
+
return 0;
|
|
624
|
+
}
|
|
625
|
+
let n = 0;
|
|
626
|
+
for (const f of findMerges(store, { into, from, to, ...(squashed ? { squashed } : {}), deep: hook !== "post-commit" })) {
|
|
627
|
+
if (!store.writeMerge(f))
|
|
628
|
+
continue;
|
|
629
|
+
n += 1;
|
|
630
|
+
say(io, recordedLine(f));
|
|
631
|
+
}
|
|
632
|
+
return n;
|
|
633
|
+
}
|
|
634
|
+
catch (e) {
|
|
635
|
+
say(io, warning(`couldn't look for merges (${e instanceof Error ? e.message.split("\n")[0] : String(e)})`));
|
|
636
|
+
return 0;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/** What a hook says of a merge it recorded: the branches, what it brought, and its commit. */
|
|
640
|
+
function recordedLine(f, how = "") {
|
|
641
|
+
const what = [f.cards.length ? `${f.cards.length} card${f.cards.length === 1 ? "" : "s"}` : "", f.commits.length ? `${f.commits.length} commit${f.commits.length === 1 ? "" : "s"} citing cards` : ""].filter(Boolean).join(" and ");
|
|
642
|
+
return text(`recorded ${f.branch} merged into ${f.into}${how}${what ? `, with ${what}` : ""} (${f.commit.slice(0, 7)})`);
|
|
643
|
+
}
|
|
644
|
+
/** An object name of all zeros: in a push's input, a ref created (the remote's) or deleted (the local). */
|
|
645
|
+
const NONE = /^0+$/;
|
|
646
|
+
/** `pre-push`'s standard input: a line per ref update, `<local ref> <local object> <remote ref> <remote object>`. */
|
|
647
|
+
export function pushedRefs(input) {
|
|
648
|
+
return input.split("\n").flatMap((line) => {
|
|
649
|
+
const [localRef, local, remoteRef, remote] = line.trim().split(/\s+/);
|
|
650
|
+
return localRef && local && remoteRef && remote ? [{ localRef, local, remoteRef, remote }] : [];
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
/** The remote's default branch (B-jr8mk): its HEAD, as the last fetch saw it, else the repo's own, as git says it. */
|
|
654
|
+
function remoteDefault(store, remote) {
|
|
655
|
+
if (remote) {
|
|
656
|
+
try {
|
|
657
|
+
const ref = git(store.repo.toplevel, ["symbolic-ref", "-q", `refs/remotes/${remote}/HEAD`]);
|
|
658
|
+
if (ref.startsWith(`refs/remotes/${remote}/`))
|
|
659
|
+
return ref.slice(`refs/remotes/${remote}/`.length);
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
// a URL, or a remote whose HEAD isn't known here
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return store.defaultBranch();
|
|
666
|
+
}
|
|
667
|
+
/** The branch a push sent: its local ref's, HEAD's when that's what was pushed, or the one local branch at the object. */
|
|
668
|
+
function pushedBranch(store, u, target) {
|
|
669
|
+
const top = store.repo.toplevel;
|
|
670
|
+
if (u.localRef.startsWith("refs/heads/"))
|
|
671
|
+
return u.localRef.slice("refs/heads/".length);
|
|
672
|
+
if (u.localRef === "HEAD") {
|
|
673
|
+
try {
|
|
674
|
+
return git(top, ["symbolic-ref", "-q", "--short", "HEAD"]);
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
// detached: look for the branch at the object
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const at = git(top, ["for-each-ref", "--points-at", u.local, "--format=%(refname:short)", "refs/heads"])
|
|
681
|
+
.split("\n")
|
|
682
|
+
.filter((b) => b && b !== target);
|
|
683
|
+
return at.length === 1 ? at[0] : undefined;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* B-h7rsv ("Record it at the push"): a push that moves the remote's default branch to a branch's tip, as
|
|
687
|
+
* `git push origin v1/next:main` does, records that branch merged into it, at the push, as a fast-forward of the default
|
|
688
|
+
* branch here is recorded (`findMerges()`). Only a fast-forward of the remote's branch, from a tip this clone has: a
|
|
689
|
+
* forced push isn't a merge, and one the remote would refuse isn't recorded. Pushing the default branch itself records
|
|
690
|
+
* nothing: its own movement here was recorded when it moved. Nothing goes to the relay here, so the push never waits on
|
|
691
|
+
* it: the merge goes up at the next sync. Never fails, and says nothing when it couldn't look. Returns how many it
|
|
692
|
+
* recorded.
|
|
693
|
+
*/
|
|
694
|
+
function recordPush(store, remote, input, io) {
|
|
695
|
+
try {
|
|
696
|
+
const top = store.repo.toplevel;
|
|
697
|
+
const updates = pushedRefs(input);
|
|
698
|
+
if (!updates.length)
|
|
699
|
+
return 0;
|
|
700
|
+
const target = remoteDefault(store, remote);
|
|
701
|
+
if (!target)
|
|
702
|
+
return 0;
|
|
703
|
+
let n = 0;
|
|
704
|
+
for (const u of updates) {
|
|
705
|
+
if (u.remoteRef !== `refs/heads/${target}` || NONE.test(u.local) || NONE.test(u.remote) || u.local === u.remote)
|
|
706
|
+
continue;
|
|
707
|
+
const branch = pushedBranch(store, u, target);
|
|
708
|
+
if (!branch || branch === target)
|
|
709
|
+
continue;
|
|
710
|
+
try {
|
|
711
|
+
git(top, ["merge-base", "--is-ancestor", u.remote, u.local]);
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
continue; // not a fast-forward, or the remote's tip isn't here
|
|
715
|
+
}
|
|
716
|
+
for (const f of findMerges(store, { into: target, from: u.remote, to: u.local, moved: true })) {
|
|
717
|
+
if (f.branch !== branch || !store.writeMerge(f))
|
|
718
|
+
continue;
|
|
719
|
+
n += 1;
|
|
720
|
+
say(io, recordedLine(f, `, as it's pushed to ${remote ?? "the remote"}`));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return n;
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
return 0;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/** Send new merges to the relay, in a linked repo, as briefly as `pre-commit` pulls; offline, they go at the next sync. */
|
|
730
|
+
async function sendMerges(store, io, http) {
|
|
731
|
+
const r = await syncRelay(store, { ...http, signal: http.signal ?? AbortSignal.timeout(PRE_COMMIT_PULL_MS) });
|
|
732
|
+
if (r.state === "synced")
|
|
733
|
+
say(io, r.notes.map((n) => text(n)));
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* The hooks Baesian installs that this repo's hooks folder lacks, its block included (D181 added two, and B-h7rsv
|
|
737
|
+
* `pre-push`): a repo set up before them gets them by running `baesian init` again, which `pre-commit` says. A clone in
|
|
738
|
+
* personal mode has a `pre-push` of its own already, which passes the push on to the repo's: it lacks the block, so it's
|
|
739
|
+
* listed too, and `baesian init`, which keeps a personal clone personal, writes it again.
|
|
740
|
+
*/
|
|
741
|
+
export function missingHooks(store) {
|
|
742
|
+
let dir;
|
|
743
|
+
try {
|
|
744
|
+
dir = git(store.repo.toplevel, ["rev-parse", "--path-format=absolute", "--git-path", "hooks"]);
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
return [];
|
|
748
|
+
}
|
|
749
|
+
return HOOKS.filter((name) => {
|
|
750
|
+
const path = join(dir, name);
|
|
751
|
+
return !existsSync(path) || !readFileSync(path, "utf8").includes(BEGIN);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Personal mode (B-4rwyz): take the Baesian trailers out of the commit message, so the shared history never shows them,
|
|
756
|
+
* and note the cards it cites for `post-commit` to write the ledger from, as it reads the trailers in shared mode. Acks
|
|
757
|
+
* cover the commit's gate facts, and are checked here: nothing keeps them after.
|
|
758
|
+
*/
|
|
759
|
+
function moveTrailers(store, path, raw, trailers, io) {
|
|
760
|
+
const top = store.repo.toplevel;
|
|
761
|
+
const cards = trailers.cards.filter((id) => store.card(id));
|
|
762
|
+
noteCited(top, cards);
|
|
763
|
+
const next = withoutBaesianTrailers(raw, top);
|
|
764
|
+
if (next === raw)
|
|
765
|
+
return;
|
|
766
|
+
writeFileSync(path, next);
|
|
767
|
+
const what = [cards.length ? `the card${cards.length === 1 ? "" : "s"} it cites (${cards.join(", ")})` : "", trailers.acks.size ? `its ack${trailers.acks.size === 1 ? "" : "s"}` : ""].filter(Boolean).join(" and ");
|
|
768
|
+
say(io, text(`took ${what || "Baesian's trailers"} out of the commit message (personal mode${citeIn("B-4rwyz")}): the shared history doesn't show them${cards.length ? ", and this clone's ledger records the cards" : ""}`));
|
|
769
|
+
}
|
|
770
|
+
/** Write a ledger entry for each card a commit cites (D15), on the branch it was made on. */
|
|
771
|
+
function ledgerFor(store, sha, cards) {
|
|
772
|
+
const branch = store.repo.branch.startsWith("detached-") ? rebasing(store) : undefined;
|
|
773
|
+
for (const id of new Set(cards)) {
|
|
774
|
+
if (!store.card(id))
|
|
775
|
+
continue;
|
|
776
|
+
store.writeLedger(id, { commit: sha, ...(branch ? { branch } : {}) }, ledgerBody(sha, id));
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Personal mode (B-4rwyz): commits an amend or a rebase rewrote keep the cards they cited, as their trailers would in
|
|
781
|
+
* shared mode (B-9v5xj): each ledger entry for a rewritten commit gets one for the commit it became, on the same branch.
|
|
782
|
+
* Returns how many it wrote.
|
|
783
|
+
*/
|
|
784
|
+
function carryLedger(store, input) {
|
|
785
|
+
const map = rewrites(input);
|
|
786
|
+
if (!map.size)
|
|
787
|
+
return 0;
|
|
788
|
+
const entries = store.ledgerEntries();
|
|
789
|
+
const have = new Set(entries.map((e) => `${e.meta.card} ${e.meta.commit}`));
|
|
790
|
+
let n = 0;
|
|
791
|
+
for (const e of entries) {
|
|
792
|
+
const to = e.meta.commit ? map.get(e.meta.commit) : undefined;
|
|
793
|
+
if (!to || have.has(`${e.meta.card} ${to}`) || !store.card(e.meta.card))
|
|
794
|
+
continue;
|
|
795
|
+
store.writeLedger(e.meta.card, { commit: to, branch: e.meta.branch }, ledgerBody(to, e.meta.card));
|
|
796
|
+
have.add(`${e.meta.card} ${to}`);
|
|
797
|
+
n += 1;
|
|
798
|
+
}
|
|
799
|
+
return n;
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Run one hook. Returns the exit code git sees. A repo without Baesian set up is never blocked. `input` is the hook's
|
|
803
|
+
* standard input, which `pre-push` reads (the refs being pushed, B-h7rsv), as personal mode's `post-rewrite` does
|
|
804
|
+
* (B-4rwyz).
|
|
805
|
+
*/
|
|
806
|
+
export async function runHook(store, name, args, io, http = {}, input = "") {
|
|
807
|
+
if (!store.isInitialised())
|
|
808
|
+
return 0;
|
|
809
|
+
const top = store.repo.toplevel;
|
|
810
|
+
switch (name) {
|
|
811
|
+
case "pre-commit": {
|
|
812
|
+
// M9d: in a linked repo, pull first, so answers given since come into the record with this commit. Briefly, and
|
|
813
|
+
// never failing the commit: offline, it commits what's here.
|
|
814
|
+
const pulled = await syncRelay(store, { ...http, push: false, signal: http.signal ?? AbortSignal.timeout(PRE_COMMIT_PULL_MS) });
|
|
815
|
+
say(io, pulled.notes.map((n) => text(n)));
|
|
816
|
+
// A repo set up before D181 lacks the hooks that find merges until `baesian init` runs again.
|
|
817
|
+
const missing = missingHooks(store);
|
|
818
|
+
const lacking = missing.length < 2 ? missing.join("") : `${missing.slice(0, -1).join(", ")} and ${missing.at(-1)}`;
|
|
819
|
+
if (missing.length)
|
|
820
|
+
say(io, [warning(`this repo's hooks lack ${lacking}, which find merges`), next("baesian init", `to add ${missing.length === 1 ? "it" : "them"}`)]);
|
|
821
|
+
// Personal mode (B-4rwyz) commits no record and no notes: the live layer is the record. Each commit starts afresh.
|
|
822
|
+
if (store.personal) {
|
|
823
|
+
clearCited(top);
|
|
824
|
+
say(io, personalProblems(store).map((p) => warning(p)));
|
|
825
|
+
writeStaged(top, []);
|
|
826
|
+
return 0;
|
|
827
|
+
}
|
|
828
|
+
// D37: bring the record up to date, then stage all of it. Staging only what this hook copied
|
|
829
|
+
// would miss the cards `baesian file` wrote into the record when they were filed.
|
|
830
|
+
store.syncRecord();
|
|
831
|
+
if (!existsSync(store.record)) {
|
|
832
|
+
writeStaged(top, []);
|
|
833
|
+
return 0;
|
|
834
|
+
}
|
|
835
|
+
const rel = relative(top, store.record);
|
|
836
|
+
const pending = recordStatus(top, rel);
|
|
837
|
+
// What this hook stages that nobody had staged, so a blocked commit can unstage it again (D122).
|
|
838
|
+
const ours = pending.filter((p) => p.x === " " || p.x === "?").map((p) => p.path);
|
|
839
|
+
if (pending.length) {
|
|
840
|
+
execFileSync("git", ["add", "--", rel], { cwd: top });
|
|
841
|
+
say(io, text(`staged ${pending.length} record file${pending.length === 1 ? "" : "s"} (${rel}/)`));
|
|
842
|
+
}
|
|
843
|
+
// M6 (D170): then the vault's notes, from the record just updated, staged with it.
|
|
844
|
+
ours.push(...stageNotes(store, io));
|
|
845
|
+
writeStaged(top, ours);
|
|
846
|
+
return 0;
|
|
847
|
+
}
|
|
848
|
+
case "commit-msg": {
|
|
849
|
+
const file = args[0];
|
|
850
|
+
if (!file) {
|
|
851
|
+
say(io, failed("the commit-msg hook needs the message file"));
|
|
852
|
+
return 1;
|
|
853
|
+
}
|
|
854
|
+
const path = isAbsolute(file) ? file : join(top, file);
|
|
855
|
+
const raw = readFileSync(path, "utf8");
|
|
856
|
+
const trailers = parseTrailers(raw, top);
|
|
857
|
+
// Merge commits bring in commits that were each checked; CI checks them again (M3b).
|
|
858
|
+
if (mergeInProgress(store)) {
|
|
859
|
+
if (store.personal)
|
|
860
|
+
moveTrailers(store, path, raw, trailers, io);
|
|
861
|
+
return 0;
|
|
862
|
+
}
|
|
863
|
+
const report = evaluate(store, detectStaged(store, stagedSnapshot(top)), trailers);
|
|
864
|
+
say(io, describeReport(report, { news: verdictsSinceLastCommit(store), for: "commit" }));
|
|
865
|
+
if (!report.ok)
|
|
866
|
+
unstageOurs(top);
|
|
867
|
+
writeStaged(top, []);
|
|
868
|
+
// Personal mode (B-4rwyz): the trailers did their work here, and go to this clone's ledger, not the shared history.
|
|
869
|
+
if (report.ok && store.personal)
|
|
870
|
+
moveTrailers(store, path, raw, trailers, io);
|
|
871
|
+
return report.ok ? 0 : 1;
|
|
872
|
+
}
|
|
873
|
+
case "post-commit": {
|
|
874
|
+
// D15: the ledger. Which commits build on which cards. Just the commit (D120): what it changed is git's to say, and
|
|
875
|
+
// its subject and message never leave the machine (D181). A commit a rebase makes is its branch's, though HEAD is
|
|
876
|
+
// detached while it runs. In personal mode (B-4rwyz), the cards commit-msg took out of the message, too.
|
|
877
|
+
const message = git(top, ["log", "-1", "--format=%B", "HEAD"]);
|
|
878
|
+
const sha = git(top, ["rev-parse", "HEAD"]);
|
|
879
|
+
ledgerFor(store, sha, [...parseTrailers(message, top).cards, ...(store.personal ? takeCited(top, sha) : [])]);
|
|
880
|
+
// D181: a squash commit, or a merge whose conflicts were resolved and committed, merges a branch.
|
|
881
|
+
recordMerges(store, "post-commit", io);
|
|
882
|
+
return 0;
|
|
883
|
+
}
|
|
884
|
+
case "post-merge":
|
|
885
|
+
// A merge commit's own trailers, in personal mode, which commit-msg took out of its message (B-4rwyz).
|
|
886
|
+
if (store.personal && args[0] !== "1")
|
|
887
|
+
ledgerFor(store, git(top, ["rev-parse", "HEAD"]), takeCited(top, "HEAD"));
|
|
888
|
+
// D181: a merge or a fast-forward, `git pull` included. A squash (`$1` is 1) moved nothing yet: noted for its commit.
|
|
889
|
+
if (args[0] === "1")
|
|
890
|
+
noteSquash(store);
|
|
891
|
+
else if (recordMerges(store, "post-merge", io))
|
|
892
|
+
await sendMerges(store, io, http);
|
|
893
|
+
return 0;
|
|
894
|
+
case "post-rewrite":
|
|
895
|
+
// Personal mode (B-4rwyz): the ledger follows the commits an amend or a rebase rewrote.
|
|
896
|
+
if (store.personal)
|
|
897
|
+
carryLedger(store, input);
|
|
898
|
+
// D181: a rebase, `git pull --rebase` included, may bring in merges made upstream. An amend brings nothing.
|
|
899
|
+
if (args[0] === "rebase" && recordMerges(store, "post-rewrite", io))
|
|
900
|
+
await sendMerges(store, io, http);
|
|
901
|
+
return 0;
|
|
902
|
+
case "pre-push":
|
|
903
|
+
// B-h7rsv: a branch pushed to the remote's default branch merged into it. Never stops the push.
|
|
904
|
+
recordPush(store, args[0], input, io);
|
|
905
|
+
return 0;
|
|
906
|
+
default:
|
|
907
|
+
say(io, failed(`unknown hook "${name}"`));
|
|
908
|
+
return 1;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
//# sourceMappingURL=hooks.js.map
|