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/merges.js
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { relative } from "node:path";
|
|
4
|
+
import { parseDoc } from "./frontmatter.js";
|
|
5
|
+
import { CARD_ID } from "./schema.js";
|
|
6
|
+
import { citedIn } from "./trailers.js";
|
|
7
|
+
/** The branches no merge is ever of: Baesian's own on the relay, and a detached HEAD's. */
|
|
8
|
+
const notABranch = (b) => b === "relay" || b.startsWith("detached-");
|
|
9
|
+
function run(top, args, input) {
|
|
10
|
+
try {
|
|
11
|
+
return execFileSync("git", args, { cwd: top, encoding: "utf8", stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024, ...(input !== undefined ? { input } : {}) });
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const iso = (t) => new Date(t).toISOString();
|
|
18
|
+
/** A's first-parent line from `from` (excluded) to `to`, oldest first. */
|
|
19
|
+
function chainOf(top, from, to) {
|
|
20
|
+
const out = run(top, ["rev-list", "--first-parent", "--reverse", "--format=%P%x00%cI", `${from}..${to}`]) ?? "";
|
|
21
|
+
const lines = out.split("\n");
|
|
22
|
+
const chain = [];
|
|
23
|
+
for (let i = 0; i < lines.length; i++) {
|
|
24
|
+
const m = /^commit ([0-9a-f]+)$/.exec(lines[i]);
|
|
25
|
+
if (!m)
|
|
26
|
+
continue;
|
|
27
|
+
const [parents = "", at = ""] = (lines[i + 1] ?? "").split("\0");
|
|
28
|
+
chain.push({ commit: m[1], parents: parents.split(" ").filter(Boolean), at: iso(at) });
|
|
29
|
+
}
|
|
30
|
+
return chain;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A's line, with each merge of A's own upstream (`git pull` on a branch that had moved on here) opened up: the upstream's
|
|
34
|
+
* first-parent line comes before the pull's merge, so a merge made there is found at its own commit, not the pull's.
|
|
35
|
+
*/
|
|
36
|
+
function withUpstream(top, chain, into) {
|
|
37
|
+
if (!chain.some((c) => c.parents.length > 1))
|
|
38
|
+
return chain;
|
|
39
|
+
const upstream = new Set((run(top, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/remotes"]) ?? "")
|
|
40
|
+
.split("\n")
|
|
41
|
+
.flatMap((l) => {
|
|
42
|
+
const [sha, ref] = l.split(" ");
|
|
43
|
+
return sha && ref && ref.split("/").slice(3).join("/") === into ? [sha] : [];
|
|
44
|
+
}));
|
|
45
|
+
if (!upstream.size)
|
|
46
|
+
return chain;
|
|
47
|
+
return chain.flatMap((c) => (c.parents.length > 1 && upstream.has(c.parents[1]) ? [...chainOf(top, c.parents[0], c.parents[1]), c] : [c]));
|
|
48
|
+
}
|
|
49
|
+
/** Several blobs at once (`git cat-file --batch`): each spec's text, or undefined where there's none. */
|
|
50
|
+
function blobs(top, specs) {
|
|
51
|
+
if (!specs.length)
|
|
52
|
+
return [];
|
|
53
|
+
let out;
|
|
54
|
+
try {
|
|
55
|
+
out = execFileSync("git", ["cat-file", "--batch"], { cwd: top, input: `${specs.join("\n")}\n`, stdio: ["pipe", "pipe", "ignore"], maxBuffer: 256 * 1024 * 1024 });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return specs.map(() => undefined);
|
|
59
|
+
}
|
|
60
|
+
const texts = [];
|
|
61
|
+
let at = 0;
|
|
62
|
+
for (let i = 0; i < specs.length; i++) {
|
|
63
|
+
const nl = out.indexOf(10, at);
|
|
64
|
+
if (nl < 0)
|
|
65
|
+
break;
|
|
66
|
+
const head = out.subarray(at, nl).toString("utf8");
|
|
67
|
+
at = nl + 1;
|
|
68
|
+
const m = /^\S+ blob (\d+)$/.exec(head);
|
|
69
|
+
if (!m) {
|
|
70
|
+
texts.push(undefined); // "<spec> missing"
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const size = Number(m[1]);
|
|
74
|
+
texts.push(out.subarray(at, at + size).toString("utf8"));
|
|
75
|
+
at += size + 1;
|
|
76
|
+
}
|
|
77
|
+
return texts;
|
|
78
|
+
}
|
|
79
|
+
const metaOf = (text) => {
|
|
80
|
+
if (!text)
|
|
81
|
+
return undefined;
|
|
82
|
+
try {
|
|
83
|
+
return parseDoc(text).meta;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const field = (text, key) => {
|
|
90
|
+
const v = metaOf(text)?.[key];
|
|
91
|
+
return typeof v === "string" ? v : undefined;
|
|
92
|
+
};
|
|
93
|
+
const emptyGot = () => ({ cards: new Set(), cited: new Set() });
|
|
94
|
+
/** Note what a branch brought at an arrival: a card, a commit citing one, the tip that landed; nothing, for a deck. */
|
|
95
|
+
const add = (a, branch, what = {}) => {
|
|
96
|
+
const got = a.by.get(branch) ?? emptyGot();
|
|
97
|
+
if (what.card)
|
|
98
|
+
got.cards.add(what.card);
|
|
99
|
+
if (what.cited)
|
|
100
|
+
got.cited.add(what.cited);
|
|
101
|
+
if (what.tip)
|
|
102
|
+
got.tip = what.tip;
|
|
103
|
+
a.by.set(branch, got);
|
|
104
|
+
};
|
|
105
|
+
/** Fold one `Got` into another: a later tip replaces an earlier one. */
|
|
106
|
+
const fold = (into, got) => {
|
|
107
|
+
for (const c of got.cards)
|
|
108
|
+
into.cards.add(c);
|
|
109
|
+
for (const c of got.cited)
|
|
110
|
+
into.cited.add(c);
|
|
111
|
+
if (got.tip)
|
|
112
|
+
into.tip = got.tip;
|
|
113
|
+
};
|
|
114
|
+
/** Several detectors' arrivals as one list, in the line's order: what each found at the same commit, together. */
|
|
115
|
+
function combine(chain, ...lists) {
|
|
116
|
+
const at = new Map();
|
|
117
|
+
for (const list of lists) {
|
|
118
|
+
for (const a of list) {
|
|
119
|
+
let into = at.get(a.commit);
|
|
120
|
+
if (!into)
|
|
121
|
+
at.set(a.commit, (into = { commit: a.commit, parents: a.parents, at: a.at, by: new Map() }));
|
|
122
|
+
for (const [branch, got] of a.by) {
|
|
123
|
+
const there = into.by.get(branch) ?? emptyGot();
|
|
124
|
+
fold(there, got);
|
|
125
|
+
into.by.set(branch, there);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return chain.flatMap((c) => at.get(c.commit) ?? []);
|
|
130
|
+
}
|
|
131
|
+
/** The ledger's arrivals with their cards taken out: where the record is committed, a merge's cards are the record's. */
|
|
132
|
+
const commitsOnly = (arrivals) => arrivals.map((a) => ({ ...a, by: new Map([...a.by].map(([b, g]) => [b, { ...g, cards: new Set() }])) }));
|
|
133
|
+
/** The record's folder, relative to the repo's top, with forward slashes. */
|
|
134
|
+
const recordDir = (store, top) => relative(top, store.record).split("\\").join("/");
|
|
135
|
+
/**
|
|
136
|
+
* The committed record's cards (shared mode): the cards (each's first revision) and decks each commit on A's line added
|
|
137
|
+
* to the record, compared with its first parent, by the branch each was filed on. A card attached to on A came in by
|
|
138
|
+
* `baesian attach`, not a merge, so it's left out.
|
|
139
|
+
*/
|
|
140
|
+
function filedArrivals(store, top, chain, m) {
|
|
141
|
+
const rec = recordDir(store, top);
|
|
142
|
+
// Each commit's files added, against its first parent: one git call for the whole line.
|
|
143
|
+
const out = run(top, ["log", "--stdin", "--no-walk=unsorted", "--diff-merges=first-parent", "--name-only", "--diff-filter=A", `--relative=${rec}/`, "--format=%x1e%H"], `${chain.map((c) => c.commit).join("\n")}\n`) ?? "";
|
|
144
|
+
const added = new Map();
|
|
145
|
+
for (const block of out.split("\x1e").slice(1)) {
|
|
146
|
+
const [commit = "", ...names] = block.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
147
|
+
const cards = names.flatMap((n) => /^cards\/(B-[^/]+)\/r1\.md$/.exec(n)?.[1] ?? []).filter((id) => CARD_ID.test(id));
|
|
148
|
+
const decks = names.filter((n) => /^decks\/[a-z0-9][a-z0-9-]*\.md$/.test(n));
|
|
149
|
+
if (cards.length || decks.length)
|
|
150
|
+
added.set(commit, { cards, decks });
|
|
151
|
+
}
|
|
152
|
+
if (!added.size)
|
|
153
|
+
return [];
|
|
154
|
+
// Who filed each: from the commit's own blobs, so a card this machine never had still counts.
|
|
155
|
+
const specs = [...added].flatMap(([commit, a]) => [...a.cards.map((id) => `cards/${id}/r1.md`), ...a.decks].map((p) => `${commit}:${rec}/${p}`));
|
|
156
|
+
const texts = new Map(blobs(top, specs).map((t, i) => [specs[i], t]));
|
|
157
|
+
const attached = (id) => store.attachments(id).some((x) => x.branch === m.into);
|
|
158
|
+
const arrivals = [];
|
|
159
|
+
for (const c of chain) {
|
|
160
|
+
const a = added.get(c.commit);
|
|
161
|
+
if (!a)
|
|
162
|
+
continue;
|
|
163
|
+
const arrival = { ...c, by: new Map() };
|
|
164
|
+
// `baesian attach` copies the card, and its deck, into the record.
|
|
165
|
+
const byAttach = new Set();
|
|
166
|
+
for (const id of a.cards) {
|
|
167
|
+
const branch = field(texts.get(`${c.commit}:${rec}/cards/${id}/r1.md`), "source_branch");
|
|
168
|
+
if (!branch || branch === m.into || notABranch(branch))
|
|
169
|
+
continue;
|
|
170
|
+
if (attached(id))
|
|
171
|
+
byAttach.add(branch);
|
|
172
|
+
else
|
|
173
|
+
add(arrival, branch, { card: id });
|
|
174
|
+
}
|
|
175
|
+
for (const deck of a.decks) {
|
|
176
|
+
const branch = field(texts.get(`${c.commit}:${rec}/${deck}`), "branch");
|
|
177
|
+
if (branch && branch !== m.into && !notABranch(branch) && !byAttach.has(branch))
|
|
178
|
+
add(arrival, branch);
|
|
179
|
+
}
|
|
180
|
+
if (arrival.by.size)
|
|
181
|
+
arrivals.push(arrival);
|
|
182
|
+
}
|
|
183
|
+
return arrivals;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* The committed record (shared mode): the cards each commit on A's line brought into the record (`filedArrivals()`), and,
|
|
187
|
+
* since B-w41p7, the commits citing cards it made reachable, from the ledger, which are a merge even when they bring no
|
|
188
|
+
* card: a later fast-forward of the same branch is one. Cards that came at a commit no commit of their branch came with
|
|
189
|
+
* (a squash or a rebase made elsewhere) have their branch's commits matched by patch ID there, for the tip that landed.
|
|
190
|
+
*/
|
|
191
|
+
export const RECORD = {
|
|
192
|
+
name: "record",
|
|
193
|
+
arrivals(store, top, chain, m, ledger) {
|
|
194
|
+
const filed = filedArrivals(store, top, chain, m);
|
|
195
|
+
// A plain commit brings no other branch's commits: the ledger is read for a merge, a squash or a movement only.
|
|
196
|
+
if (!m.deep && !m.moved && !m.squashed && chain.every((c) => c.parents.length < 2))
|
|
197
|
+
return filed;
|
|
198
|
+
const byCommit = ledger();
|
|
199
|
+
const direct = citedArrivals(top, chain, m, byCommit);
|
|
200
|
+
const came = new Set(direct.arrivals.flatMap((a) => [...a.by.keys()]));
|
|
201
|
+
const focus = new Map(filed.flatMap((a) => {
|
|
202
|
+
const branches = [...a.by.keys()].filter((b) => !came.has(b));
|
|
203
|
+
return a.parents.length === 1 && branches.length ? [[a.commit, new Set(branches)]] : [];
|
|
204
|
+
}));
|
|
205
|
+
const matched = m.deep && focus.size ? patchArrivals(top, chain.filter((c) => focus.has(c.commit)), m, byCommit, (c) => focus.get(c)) : [];
|
|
206
|
+
return combine(chain, filed, commitsOnly(direct.arrivals), commitsOnly(matched));
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
/** Commits newly reachable at a commit on A's line: itself, or for a merge, everything it brought. */
|
|
210
|
+
function broughtBy(top, c) {
|
|
211
|
+
if (c.parents.length < 2)
|
|
212
|
+
return [c.commit];
|
|
213
|
+
return (run(top, ["rev-list", `${c.parents[0]}..${c.commit}`]) ?? "").split("\n").filter(Boolean);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The ledger's entries by commit: the card each cites, the branch it was made on (the card's, for a detached HEAD), and
|
|
217
|
+
* when it was written. From the live layer; and where the record is committed, from the record at `recordAt` too
|
|
218
|
+
* (B-w41p7), for the entries another machine committed that this one hasn't pulled.
|
|
219
|
+
*/
|
|
220
|
+
function ledgerByCommit(store, top, recordAt) {
|
|
221
|
+
const out = new Map();
|
|
222
|
+
const seen = new Set();
|
|
223
|
+
const put = (commit, card, branch, at) => {
|
|
224
|
+
if (typeof commit !== "string" || typeof card !== "string" || typeof branch !== "string" || typeof at !== "string")
|
|
225
|
+
return;
|
|
226
|
+
let b = branch;
|
|
227
|
+
if (b.startsWith("detached-"))
|
|
228
|
+
b = store.revisions(card)[0]?.meta.source_branch ?? b;
|
|
229
|
+
const key = `${commit} ${card} ${b}`;
|
|
230
|
+
if (seen.has(key))
|
|
231
|
+
return;
|
|
232
|
+
seen.add(key);
|
|
233
|
+
out.set(commit, [...(out.get(commit) ?? []), { card, branch: b, at }]);
|
|
234
|
+
};
|
|
235
|
+
for (const e of store.ledgerEntries())
|
|
236
|
+
put(e.meta.commit, e.meta.card, e.meta.branch, e.meta.at);
|
|
237
|
+
if (recordAt) {
|
|
238
|
+
const rec = recordDir(store, top);
|
|
239
|
+
const here = new Set(store.storage.list("ledger"));
|
|
240
|
+
const names = (run(top, ["ls-tree", "--name-only", `${recordAt}:${rec}/ledger`]) ?? "").split("\n").filter((n) => /^B-[^/]+\.md$/.test(n) && !here.has(n));
|
|
241
|
+
const texts = blobs(top, names.map((n) => `${recordAt}:${rec}/ledger/${n}`));
|
|
242
|
+
for (const text of texts) {
|
|
243
|
+
const meta = metaOf(text);
|
|
244
|
+
if (meta?.type === "ledger")
|
|
245
|
+
put(meta.commit, meta.card, meta.branch, meta.at instanceof Date ? meta.at.toISOString() : meta.at);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The commits the ledger says cite cards, made on another branch, that each commit on A's line made reachable; a squash's
|
|
252
|
+
* commit brings the commits its marker names. And the commits that brought none and aren't the ledger's (`unmatched`),
|
|
253
|
+
* for matching by patch ID.
|
|
254
|
+
*/
|
|
255
|
+
function citedArrivals(top, chain, m, byCommit) {
|
|
256
|
+
const arrivals = [];
|
|
257
|
+
const unmatched = [];
|
|
258
|
+
for (const c of chain) {
|
|
259
|
+
const squash = m.squashed && c.commit === m.to ? m.squashed : undefined;
|
|
260
|
+
const brought = squash ? squash.commits : broughtBy(top, c);
|
|
261
|
+
const arrival = { ...c, ...(squash ? { parents: [...c.parents, squash.tip] } : {}), by: new Map() };
|
|
262
|
+
for (const commit of brought) {
|
|
263
|
+
for (const e of byCommit.get(commit) ?? []) {
|
|
264
|
+
if (e.branch !== m.into && !notABranch(e.branch))
|
|
265
|
+
add(arrival, e.branch, { card: e.card, cited: commit, ...(squash ? { tip: squash.tip } : {}) });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (arrival.by.size)
|
|
269
|
+
arrivals.push(arrival);
|
|
270
|
+
else if (c.parents.length === 1 && !byCommit.has(c.commit))
|
|
271
|
+
unmatched.push(c);
|
|
272
|
+
}
|
|
273
|
+
return { arrivals, unmatched };
|
|
274
|
+
}
|
|
275
|
+
/** Patch IDs (`git patch-id --stable`) of commits, by commit; merges have none. */
|
|
276
|
+
function patchIds(top, commits) {
|
|
277
|
+
const out = new Map();
|
|
278
|
+
if (!commits.length)
|
|
279
|
+
return out;
|
|
280
|
+
const diffs = run(top, ["log", "--no-walk=unsorted", "--no-merges", "-p", "--format=commit %H", ...commits]);
|
|
281
|
+
if (!diffs)
|
|
282
|
+
return out;
|
|
283
|
+
for (const line of (run(top, ["patch-id", "--stable"], diffs) ?? "").split("\n")) {
|
|
284
|
+
const [pid, commit] = line.split(" ");
|
|
285
|
+
if (pid && commit)
|
|
286
|
+
out.set(commit, pid);
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
/** The patch ID of the diff between two commits: a branch's whole change, to match a squash of it. */
|
|
291
|
+
function rangePatchId(top, base, tip) {
|
|
292
|
+
const diff = run(top, ["diff", base, tip]);
|
|
293
|
+
if (!diff)
|
|
294
|
+
return undefined;
|
|
295
|
+
return (run(top, ["patch-id", "--stable"], diff) ?? "").split(" ")[0] || undefined;
|
|
296
|
+
}
|
|
297
|
+
/** How many of A's new commits, and of other branches' ledger commits, patch IDs are worked out for: a pull stays quick. */
|
|
298
|
+
const PATCH_LIMIT = { commits: 50, branches: 10, entries: 200 };
|
|
299
|
+
/**
|
|
300
|
+
* Squashes and rebases made elsewhere, which make commits the ledger never saw: each of `commits` that matches, by patch
|
|
301
|
+
* ID, a ledger commit of another branch's not reachable from where A was (a rebase or cherry-pick of it, GitHub's "rebase
|
|
302
|
+
* and merge", a one-commit squash) brings that commit, which is the tip that landed; one that matches that branch's
|
|
303
|
+
* whole diff, from its merge base to its newest such commit (a squash), brings all of them, and that newest is the tip.
|
|
304
|
+
* `only` limits each commit to the branches it names.
|
|
305
|
+
*/
|
|
306
|
+
function patchArrivals(top, commits, m, byCommit, only) {
|
|
307
|
+
const allowed = only ? new Set(commits.flatMap((c) => [...(only(c.commit) ?? [])])) : undefined;
|
|
308
|
+
const ours = (e) => e.branch !== m.into && !notABranch(e.branch) && (!allowed || allowed.has(e.branch));
|
|
309
|
+
const candidates = [...byCommit.entries()].filter(([, es]) => es.some(ours));
|
|
310
|
+
if (!candidates.length)
|
|
311
|
+
return [];
|
|
312
|
+
const known = new Set((run(top, ["cat-file", "--batch-check=%(objectname) %(objecttype)"], `${candidates.map(([c]) => c).join("\n")}\n`) ?? "").split("\n").flatMap((l) => (l.endsWith(" commit") ? [l.split(" ")[0]] : [])));
|
|
313
|
+
const theirs = candidates.filter(([commit]) => known.has(commit)).slice(-PATCH_LIMIT.entries);
|
|
314
|
+
if (!theirs.length)
|
|
315
|
+
return [];
|
|
316
|
+
const reached = new Set((run(top, ["rev-list", ...theirs.map(([c]) => c), "--not", m.from]) ?? "").split("\n").filter(Boolean));
|
|
317
|
+
const open = theirs.filter(([c]) => reached.has(c));
|
|
318
|
+
if (!open.length)
|
|
319
|
+
return [];
|
|
320
|
+
const mine = commits.slice(-PATCH_LIMIT.commits);
|
|
321
|
+
const pids = patchIds(top, [...mine.map((c) => c.commit), ...open.map(([c]) => c)]);
|
|
322
|
+
const pidToLedger = new Map(open.flatMap(([c, es]) => (pids.get(c) ? [[pids.get(c), { commit: c, es }]] : [])));
|
|
323
|
+
// Each branch's newest open ledger commit, for its whole diff, with all of its open ones.
|
|
324
|
+
const tips = new Map();
|
|
325
|
+
for (const [c, es] of open) {
|
|
326
|
+
for (const e of es) {
|
|
327
|
+
if (!ours(e))
|
|
328
|
+
continue;
|
|
329
|
+
const t = tips.get(e.branch) ?? { commit: c, cards: new Set(), cited: new Set() };
|
|
330
|
+
t.commit = c;
|
|
331
|
+
t.cards.add(e.card);
|
|
332
|
+
t.cited.add(c);
|
|
333
|
+
tips.set(e.branch, t);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const branches = [...tips.entries()].slice(-PATCH_LIMIT.branches);
|
|
337
|
+
const out = [];
|
|
338
|
+
for (const c of mine) {
|
|
339
|
+
const here = only?.(c.commit);
|
|
340
|
+
const may = (branch) => !here || here.has(branch);
|
|
341
|
+
const arrival = { ...c, by: new Map() };
|
|
342
|
+
const pid = pids.get(c.commit);
|
|
343
|
+
const match = pid ? pidToLedger.get(pid) : undefined;
|
|
344
|
+
for (const e of match?.es ?? [])
|
|
345
|
+
if (ours(e) && may(e.branch))
|
|
346
|
+
add(arrival, e.branch, { card: e.card, cited: match.commit, tip: match.commit });
|
|
347
|
+
if (!arrival.by.size && pid) {
|
|
348
|
+
for (const [branch, t] of branches) {
|
|
349
|
+
if (!may(branch))
|
|
350
|
+
continue;
|
|
351
|
+
const base = run(top, ["merge-base", c.parents[0], t.commit])?.trim();
|
|
352
|
+
if (base && rangePatchId(top, base, t.commit) === pid) {
|
|
353
|
+
for (const card of t.cards)
|
|
354
|
+
add(arrival, branch, { card });
|
|
355
|
+
for (const cited of t.cited)
|
|
356
|
+
add(arrival, branch, { cited });
|
|
357
|
+
add(arrival, branch, { tip: t.commit });
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (arrival.by.size)
|
|
363
|
+
out.push(arrival);
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The ledger (personal mode, B-4rwyz): the commits the ledger says cite cards, made on another branch, that each commit
|
|
369
|
+
* on A's line made reachable. A squash commit with the marker brings the commits it squashed. With `deep` (a merge, a
|
|
370
|
+
* pull or a rebase), a new commit that matches by patch ID one of another branch's ledger commits, or that branch's whole
|
|
371
|
+
* diff, brings that branch's cited cards too (`patchArrivals()`).
|
|
372
|
+
*/
|
|
373
|
+
export const LEDGER = {
|
|
374
|
+
name: "ledger",
|
|
375
|
+
arrivals(_store, top, chain, m, ledger) {
|
|
376
|
+
const byCommit = ledger();
|
|
377
|
+
if (!byCommit.size)
|
|
378
|
+
return [];
|
|
379
|
+
const direct = citedArrivals(top, chain, m, byCommit);
|
|
380
|
+
if (!m.deep || !direct.unmatched.length)
|
|
381
|
+
return direct.arrivals;
|
|
382
|
+
return combine(chain, direct.arrivals, patchArrivals(top, direct.unmatched, m, byCommit));
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
/** Whether the commit's tree holds the record: then the record is committed (shared mode), and merges are found from it. */
|
|
386
|
+
export function recordCommitted(store, commit) {
|
|
387
|
+
const rec = relative(store.repo.toplevel, store.record).split("\\").join("/");
|
|
388
|
+
return run(store.repo.toplevel, ["cat-file", "-e", `${commit}:${rec}/config.json`]) !== undefined;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Which way to find merges at a commit (D181): from the record where it's committed, from the ledger where it isn't
|
|
392
|
+
* (personal mode, B-4rwyz).
|
|
393
|
+
*/
|
|
394
|
+
export function detectorFor(store, commit) {
|
|
395
|
+
return recordCommitted(store, commit) ? RECORD : LEDGER;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* What came in, as branches: the names of the branches, local or remote-tracking, whose tips the movement made reachable
|
|
399
|
+
* and that no other such tip contains; and a branch one of those began from (D8: its tip on that one's first-parent line,
|
|
400
|
+
* and no merge recorded of it into that one, which a fast-forward into it would have), since its whole tip came in with it.
|
|
401
|
+
* A's own upstream among them (`upstream`) is a pull: it's left out, and if it's the only one, this is undefined, as it is
|
|
402
|
+
* when nothing is named; then each merge on A's line says what it merged by the cards it brought.
|
|
403
|
+
*/
|
|
404
|
+
function cameIn(top, m, recorded) {
|
|
405
|
+
const refs = (run(top, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/heads", "refs/remotes"]) ?? "").split("\n").filter(Boolean);
|
|
406
|
+
const range = new Set((run(top, ["rev-list", `${m.from}..${m.to}`]) ?? "").split("\n").filter(Boolean));
|
|
407
|
+
if (m.squashed)
|
|
408
|
+
for (const c of m.squashed.commits)
|
|
409
|
+
range.add(c);
|
|
410
|
+
const inRange = refs
|
|
411
|
+
.map((l) => l.split(" "))
|
|
412
|
+
.filter(([sha, ref]) => range.has(sha) && ref !== `refs/heads/${m.into}` && !ref.endsWith("/HEAD"));
|
|
413
|
+
if (!inRange.length)
|
|
414
|
+
return undefined;
|
|
415
|
+
const independent = new Set((run(top, ["merge-base", "--independent", ...new Set(inRange.map(([sha]) => sha))]) ?? "").split("\n").filter(Boolean));
|
|
416
|
+
const nameOf = (ref) => (ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref.split("/").slice(3).join("/"));
|
|
417
|
+
const names = new Set();
|
|
418
|
+
const tips = new Map();
|
|
419
|
+
for (const [sha, ref] of inRange) {
|
|
420
|
+
if (!independent.has(sha))
|
|
421
|
+
continue;
|
|
422
|
+
names.add(nameOf(ref));
|
|
423
|
+
tips.set(nameOf(ref), sha);
|
|
424
|
+
}
|
|
425
|
+
const lines = new Map();
|
|
426
|
+
const lineOf = (tip) => lines.get(tip) ?? lines.set(tip, new Set((run(top, ["rev-list", "--first-parent", tip, "--not", m.from]) ?? "").split("\n").filter(Boolean))).get(tip);
|
|
427
|
+
for (const [sha, ref] of inRange) {
|
|
428
|
+
const name = nameOf(ref);
|
|
429
|
+
if (independent.has(sha) || names.has(name) || name === m.into)
|
|
430
|
+
continue;
|
|
431
|
+
const from = [...tips].find(([n, tip]) => n !== m.into && lineOf(tip).has(sha) && !recorded.some((r) => r.branch === name && r.into === n));
|
|
432
|
+
if (from)
|
|
433
|
+
names.add(name), tips.set(name, sha);
|
|
434
|
+
}
|
|
435
|
+
const upstream = names.delete(m.into);
|
|
436
|
+
if (!names.size)
|
|
437
|
+
return undefined;
|
|
438
|
+
return { names, tips, upstream };
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Which branches a merge commit on A's line merged itself, of those whose cards or citing commits it brought (D7): those
|
|
442
|
+
* with a card filed, or a citing commit made, on its other parent's first-parent line, the merged branch's own. One that
|
|
443
|
+
* came only through a merge on that line was merged into the merged branch first: that merge is its own, as the named
|
|
444
|
+
* path has it. With none on that line to tell by, every one.
|
|
445
|
+
*/
|
|
446
|
+
function mergedHere(store, top, a) {
|
|
447
|
+
const all = new Set(a.by.keys());
|
|
448
|
+
if (all.size < 2 || a.parents.length < 2)
|
|
449
|
+
return all;
|
|
450
|
+
const line = (run(top, ["rev-list", "--first-parent", a.parents[1], "--not", a.parents[0]]) ?? "").split("\n").filter(Boolean);
|
|
451
|
+
if (!line.length)
|
|
452
|
+
return all;
|
|
453
|
+
const onLine = new Set(line);
|
|
454
|
+
const added = run(top, ["log", "--stdin", "--no-walk=unsorted", "--no-merges", "--name-only", "--diff-filter=A", `--relative=${recordDir(store, top)}/`, "--format="], `${line.join("\n")}\n`) ?? "";
|
|
455
|
+
const filed = new Set(added.split("\n").flatMap((n) => /^cards\/(B-[^/]+)\/r1\.md$/.exec(n.trim())?.[1] ?? []));
|
|
456
|
+
const direct = new Set([...a.by].filter(([, got]) => [...got.cards].some((c) => filed.has(c)) || [...got.cited].some((c) => onLine.has(c))).map(([b]) => b));
|
|
457
|
+
return direct.size ? direct : all;
|
|
458
|
+
}
|
|
459
|
+
/** The first commit on A's line that made `tip` reachable: the tip itself, or the merge that brought it. */
|
|
460
|
+
function whereReached(top, chain, tip, squash) {
|
|
461
|
+
if (squash)
|
|
462
|
+
return chain.find((c) => c.commit === squash);
|
|
463
|
+
const direct = chain.find((c) => c.commit === tip);
|
|
464
|
+
if (direct)
|
|
465
|
+
return direct;
|
|
466
|
+
return chain.find((c) => c.parents.length > 1 && run(top, ["merge-base", "--is-ancestor", tip, c.commit]) !== undefined && run(top, ["merge-base", "--is-ancestor", tip, c.parents[0]]) === undefined);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Whether a branch has work of its own (B-c4efj): a card filed on it, or a commit the ledger says was made on it that
|
|
470
|
+
* cites one, in the live layer or, where the record is committed, in the record before or after the movement.
|
|
471
|
+
*/
|
|
472
|
+
function ownWork(store, top, branch, m, ledger, recordCommittedHere) {
|
|
473
|
+
const mine = (byCommit) => [...byCommit.values()].some((es) => es.some((e) => e.branch === branch));
|
|
474
|
+
if (store.cards().some((c) => c.meta.source_branch === branch))
|
|
475
|
+
return true;
|
|
476
|
+
if (mine(ledger()))
|
|
477
|
+
return true;
|
|
478
|
+
return recordCommittedHere && mine(ledgerByCommit(store, top, m.from));
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Check a merge against git's ancestry as it's written (B-w41p7): a commit is part of it if it's reachable from the tip
|
|
482
|
+
* that landed and wasn't reachable from A before. Those made on the merged branch that cite cards are its `commits`, oldest
|
|
483
|
+
* first; where the record isn't committed, the cards they cite are among its `cards`. One dated by the last commit it
|
|
484
|
+
* brought (found in a pull, after the fact) is dated no earlier than the ledger's entries for its commits, which are
|
|
485
|
+
* written as each commit is made, before anything can merge it.
|
|
486
|
+
*/
|
|
487
|
+
function landed(top, into, d, byCommit, citedCards) {
|
|
488
|
+
const range = run(top, ["rev-list", "--reverse", "--topo-order", d.tip, "--not", d.base]);
|
|
489
|
+
const reachable = range === undefined ? [...d.got.cited] : range.split("\n").filter(Boolean);
|
|
490
|
+
// The tip's own ledger entry is written by post-commit, after it: no later commit on the branch carried it into the
|
|
491
|
+
// record, so a machine that didn't make the tip has none (D5). Its trailers say what it cites, as the gate reads them.
|
|
492
|
+
const tipCites = byCommit.has(d.tip) || !reachable.includes(d.tip) ? undefined : citedIn(top, d.tip);
|
|
493
|
+
const entries = (c) => (c === d.tip && tipCites ? tipCites.cards.map((card) => ({ card, branch: d.branch, at: iso(tipCites.at) })) : (byCommit.get(c) ?? []));
|
|
494
|
+
const mine = (c) => entries(c).filter((e) => e.branch === d.branch);
|
|
495
|
+
const commits = reachable.filter((c) => mine(c).length);
|
|
496
|
+
const cards = new Set(d.got.cards);
|
|
497
|
+
if (citedCards)
|
|
498
|
+
for (const c of commits)
|
|
499
|
+
for (const e of mine(c))
|
|
500
|
+
cards.add(e.card);
|
|
501
|
+
let at = d.at;
|
|
502
|
+
if (d.dated === "commit") {
|
|
503
|
+
for (const c of commits)
|
|
504
|
+
for (const e of mine(c))
|
|
505
|
+
if (Date.parse(e.at) > Date.parse(at))
|
|
506
|
+
at = iso(e.at);
|
|
507
|
+
}
|
|
508
|
+
return { branch: d.branch, into, commit: d.commit, tip: d.tip, at, cards: [...cards].sort(), commits };
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* The merges a movement of A made (D181), found the way the commit's tree says (`detectorFor()`):
|
|
512
|
+
* - **when a branch came in by name** (`git merge feat`, a fast-forward to it, a rebase onto it, a squash of it): one
|
|
513
|
+
* merge of each such branch, at the commit that brought it (the merge or squash commit, or the tip a fast-forward or
|
|
514
|
+
* rebase moved to), with every card of its that arrived, and its tip, the ref's. Cards of other branches that came with
|
|
515
|
+
* it were merged into it first, not into A, so they're left out;
|
|
516
|
+
* - **otherwise** (a pull of A's own upstream, or nothing named): each merge commit on A's line is a merge into A of every
|
|
517
|
+
* branch whose cards or citing commits it brought, its other parent the tip; and a run of other commits bringing one
|
|
518
|
+
* branch's (a fast-forward or a squash done elsewhere) is one merge, at the run's last commit, which is the tip.
|
|
519
|
+
* Each is checked against git's ancestry before it's returned (`landed()`, B-w41p7). A later fast-forward of a branch
|
|
520
|
+
* merged before brings its commits that cite cards, so it's a merge of its own, at its own commit; one that brings none
|
|
521
|
+
* of those, nor a card, isn't recorded. Returns nothing for a detached HEAD, or when A didn't move forward.
|
|
522
|
+
*/
|
|
523
|
+
export function findMerges(store, m, detector) {
|
|
524
|
+
if (!store.hasRepo || notABranch(m.into))
|
|
525
|
+
return [];
|
|
526
|
+
const top = store.repo.toplevel;
|
|
527
|
+
const chain = withUpstream(top, chainOf(top, m.from, m.to), m.into);
|
|
528
|
+
if (!chain.length)
|
|
529
|
+
return [];
|
|
530
|
+
const det = detector ?? detectorFor(store, m.to);
|
|
531
|
+
let read;
|
|
532
|
+
const ledger = () => (read ??= ledgerByCommit(store, top, det === RECORD ? m.to : undefined));
|
|
533
|
+
// A card, or a commit citing one, arrives once: where a pull merged A's own upstream, its line came first, and the
|
|
534
|
+
// pull's merge brings it again.
|
|
535
|
+
const seenCards = new Set();
|
|
536
|
+
const seenCited = new Set();
|
|
537
|
+
const arrivals = det.arrivals(store, top, chain, m, ledger).flatMap((a) => {
|
|
538
|
+
for (const [branch, got] of a.by) {
|
|
539
|
+
const cards = [...got.cards].filter((c) => !seenCards.has(c));
|
|
540
|
+
const cited = [...got.cited].filter((c) => !seenCited.has(c));
|
|
541
|
+
for (const c of cards)
|
|
542
|
+
seenCards.add(c);
|
|
543
|
+
for (const c of cited)
|
|
544
|
+
seenCited.add(c);
|
|
545
|
+
if (got.cards.size + got.cited.size && !cards.length && !cited.length)
|
|
546
|
+
a.by.delete(branch);
|
|
547
|
+
else
|
|
548
|
+
a.by.set(branch, { ...got, cards: new Set(cards), cited: new Set(cited) });
|
|
549
|
+
}
|
|
550
|
+
return a.by.size ? [a] : [];
|
|
551
|
+
});
|
|
552
|
+
if (!arrivals.length)
|
|
553
|
+
return [];
|
|
554
|
+
const now = (m.now ?? new Date()).toISOString();
|
|
555
|
+
const named = m.after ? undefined : cameIn(top, m, store.merges());
|
|
556
|
+
// A pull of A's own upstream that also brought a branch named at the tip (D1: v1/next, pushed to the remote's main,
|
|
557
|
+
// and main brought up to it) is that branch's merge, if it brought anything; otherwise the pull is read merge by merge.
|
|
558
|
+
const names = named && (named.upstream ? [...named.names].filter((b) => arrivals.some((a) => a.by.has(b))) : [...named.names]);
|
|
559
|
+
const drafts = [];
|
|
560
|
+
// B-c4efj ("Where it began"): a branch with nothing of its own yet, brought up to another's tip by a fast-forward (or a
|
|
561
|
+
// rebase onto it, which is one), began there, as a worktree made from a stale base and brought up to v1/next as it
|
|
562
|
+
// starts does: no merge is recorded, and the branches panel draws its start. One with work of its own brought up to
|
|
563
|
+
// another is still a merge, and the repo's default branch is never one that's beginning, cards of its own or none.
|
|
564
|
+
let began;
|
|
565
|
+
const beganHere = () => (began ??= m.into !== store.defaultBranch() && !ownWork(store, top, m.into, m, ledger, det === RECORD));
|
|
566
|
+
if (named && names?.length) {
|
|
567
|
+
for (const branch of names) {
|
|
568
|
+
let got;
|
|
569
|
+
for (const a of arrivals) {
|
|
570
|
+
const g = a.by.get(branch);
|
|
571
|
+
if (!g)
|
|
572
|
+
continue;
|
|
573
|
+
got ??= emptyGot();
|
|
574
|
+
fold(got, g);
|
|
575
|
+
}
|
|
576
|
+
if (!got)
|
|
577
|
+
continue;
|
|
578
|
+
const tip = named.tips.get(branch);
|
|
579
|
+
const at = whereReached(top, chain, tip, m.squashed ? m.to : undefined) ?? chain.at(-1);
|
|
580
|
+
// A merge or squash commit this movement made says when; a fast-forward or a rebase onto the branch happened now,
|
|
581
|
+
// even onto a merge commit made earlier elsewhere (D4, G-6), which is the tip itself.
|
|
582
|
+
const byGit = (at.parents.length > 1 && at.commit !== tip) || !!m.squashed;
|
|
583
|
+
if (!byGit && beganHere())
|
|
584
|
+
continue;
|
|
585
|
+
drafts.push({ branch, commit: at.commit, at: byGit ? at.at : now, dated: byGit ? "git" : "now", got, tip: m.squashed?.tip ?? tip, base: m.from });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
// A run of commits bringing one branch ends where the next run, or a merge, begins, or at A's tip: commits on A's line
|
|
590
|
+
// that brought nothing after its last arrival came with it (D6: a fast-forward's plain last commit), so the merge is
|
|
591
|
+
// at the tip the merging machine fast-forwarded to, and in the same file.
|
|
592
|
+
const index = new Map(chain.map((c, i) => [c.commit, i]));
|
|
593
|
+
const arrived = new Set(arrivals.map((a) => a.commit));
|
|
594
|
+
// In git's history (B-dezkk), A's own commits come between runs: a run ends before A's next one.
|
|
595
|
+
const own = (c) => !!m.after && (ledger().get(c) ?? []).some((e) => e.branch === m.into);
|
|
596
|
+
const runEnd = (last) => {
|
|
597
|
+
let i = index.get(last.commit);
|
|
598
|
+
while (i + 1 < chain.length && !arrived.has(chain[i + 1].commit) && chain[i + 1].parents.length < 2 && !own(chain[i + 1].commit))
|
|
599
|
+
i++;
|
|
600
|
+
return chain[i];
|
|
601
|
+
};
|
|
602
|
+
let span;
|
|
603
|
+
const flush = () => {
|
|
604
|
+
if (span) {
|
|
605
|
+
const end = runEnd(span.last);
|
|
606
|
+
drafts.push({ branch: span.branch, commit: end.commit, at: end.at, dated: "commit", got: span.got, tip: span.got.tip ?? end.commit, base: span.first.parents[0] ?? m.from });
|
|
607
|
+
}
|
|
608
|
+
span = undefined;
|
|
609
|
+
};
|
|
610
|
+
for (const a of arrivals) {
|
|
611
|
+
if (a.parents.length > 1) {
|
|
612
|
+
flush();
|
|
613
|
+
// Each branch the merge brought, but one merged into the merged branch first (D7): that one's merge is its own.
|
|
614
|
+
const direct = mergedHere(store, top, a);
|
|
615
|
+
for (const [branch, got] of a.by)
|
|
616
|
+
if (direct.has(branch))
|
|
617
|
+
drafts.push({ branch, commit: a.commit, at: a.at, dated: "git", got, tip: got.tip ?? a.parents[1], base: a.parents[0] });
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (a.by.size !== 1 || (span && !a.by.has(span.branch)))
|
|
621
|
+
flush();
|
|
622
|
+
for (const [branch, got] of a.by) {
|
|
623
|
+
if (a.by.size > 1) {
|
|
624
|
+
drafts.push({ branch, commit: a.commit, at: a.at, dated: "commit", got, tip: got.tip ?? a.commit, base: a.parents[0] ?? m.from });
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
span ??= { branch, first: a, last: a, got: emptyGot() };
|
|
628
|
+
span.last = a;
|
|
629
|
+
fold(span.got, got);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
flush();
|
|
633
|
+
}
|
|
634
|
+
return drafts.map((d) => landed(top, m.into, d, ledger(), det === LEDGER));
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* The branches, local and remote-tracking (`all`), each named as a merge names it, without its remote; and one per name
|
|
638
|
+
* (`branches`): the local branch, where there's one of the name, else the remote's.
|
|
639
|
+
*/
|
|
640
|
+
function branchRefs(top) {
|
|
641
|
+
const all = [];
|
|
642
|
+
const one = new Map();
|
|
643
|
+
for (const l of (run(top, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/heads", "refs/remotes"]) ?? "").split("\n")) {
|
|
644
|
+
const [tip, ref] = l.split(" ");
|
|
645
|
+
if (!tip || !ref || ref.endsWith("/HEAD"))
|
|
646
|
+
continue;
|
|
647
|
+
const local = ref.startsWith("refs/heads/");
|
|
648
|
+
const name = local ? ref.slice("refs/heads/".length) : ref.split("/").slice(3).join("/");
|
|
649
|
+
if (!name || notABranch(name))
|
|
650
|
+
continue;
|
|
651
|
+
all.push({ name, tip, local });
|
|
652
|
+
if (!one.get(name)?.local)
|
|
653
|
+
one.set(name, { name, tip, local });
|
|
654
|
+
}
|
|
655
|
+
return { branches: [...one.values()], all };
|
|
656
|
+
}
|
|
657
|
+
/** A merge's subject as git writes one, for the branch it merged: `Merge branch 'x'`, a remote-tracking one, a pull request's. */
|
|
658
|
+
function mergedName(subject) {
|
|
659
|
+
const m = /^Merge (?:remote-tracking )?branch '([^']+)'/.exec(subject) ??
|
|
660
|
+
/^Merge pull request #\d+ from [^/\s]+\/(\S+)/.exec(subject);
|
|
661
|
+
return m?.[1]?.replace(/^(?:origin|upstream)\//, "");
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* The history scan (B-dezkk, "The history scan finds them"): the merges git's history shows that no hook saw, made before
|
|
665
|
+
* this repo had Baesian's merge hooks, or where it isn't installed. Each branch's own line is read as a pull of it reads
|
|
666
|
+
* it (`findMerges()`, `after`): each merge commit on it merged the branches whose cards or citing commits it brought, and
|
|
667
|
+
* each run of another branch's commits on it was that branch's fast-forward. A branch's own line is its first-parent line
|
|
668
|
+
* from where it left the lines read before it: the default branch's first, from its first commit, then each other branch
|
|
669
|
+
* by how much history it holds, so one fast-forwarded into another adds nothing of its own. The line a merge commit
|
|
670
|
+
* brought is read in turn, as the merged branch's (named by a ref at it, its subject, or the one branch it brought), so a
|
|
671
|
+
* branch merged into another before both reached the default branch is found merged into that other, at its own merge
|
|
672
|
+
* commit, and it's read from no further back than the first card or ledger entry here, a day early: nothing older brought
|
|
673
|
+
* a card.
|
|
674
|
+
*
|
|
675
|
+
* What's recorded already isn't found again: a merge of the same branch at the same commit, or one listing a card or a
|
|
676
|
+
* commit a merge of that branch recorded already lists (the same merge, read another way: a hook's fast-forward at its
|
|
677
|
+
* tip, or an older reading of the same run). With `write`, each merge found is recorded (`Store.writeMerge`).
|
|
678
|
+
*/
|
|
679
|
+
export function scanHistory(store, o = {}) {
|
|
680
|
+
if (!store.hasRepo)
|
|
681
|
+
return { found: [], known: 0 };
|
|
682
|
+
const top = store.repo.toplevel;
|
|
683
|
+
// Nothing older than the first card, or ledger entry, brought one.
|
|
684
|
+
const firsts = [...store.cards().map((c) => c.meta.created), ...store.ledgerEntries().map((e) => e.meta.at)].map((t) => Date.parse(t)).filter((t) => !Number.isNaN(t));
|
|
685
|
+
if (!firsts.length)
|
|
686
|
+
return { found: [], known: 0 };
|
|
687
|
+
const since = Math.min(...firsts) - 86_400_000;
|
|
688
|
+
const trunk = store.defaultBranch();
|
|
689
|
+
const { branches, all } = branchRefs(top);
|
|
690
|
+
const count = (tip) => Number(run(top, ["rev-list", "--count", tip])?.trim() ?? 0);
|
|
691
|
+
const order = branches.map((r) => ({ ...r, n: count(r.tip) })).sort((a, b) => Number(b.name === trunk) - Number(a.name === trunk) || b.n - a.n || a.name.localeCompare(b.name));
|
|
692
|
+
// First-parent lines, as sets, for telling whose a merged line is.
|
|
693
|
+
const lines = new Map();
|
|
694
|
+
const lineOf = (tip) => lines.get(tip) ?? lines.set(tip, new Set((run(top, ["rev-list", "--first-parent", tip]) ?? "").split("\n").filter(Boolean))).get(tip);
|
|
695
|
+
const found = [];
|
|
696
|
+
const read = new Set();
|
|
697
|
+
const scanLine = (into, from, to) => {
|
|
698
|
+
const key = `${into}\0${from}\0${to}`;
|
|
699
|
+
if (read.has(key))
|
|
700
|
+
return;
|
|
701
|
+
read.add(key);
|
|
702
|
+
const here = findMerges(store, { into, from, to, deep: true, after: true });
|
|
703
|
+
const merges = (run(top, ["rev-list", "--first-parent", "--merges", "--format=%P", `${from}..${to}`]) ?? "").split("\n");
|
|
704
|
+
const sides = [];
|
|
705
|
+
for (let i = 0; i < merges.length; i++) {
|
|
706
|
+
const m = /^commit ([0-9a-f]+)$/.exec(merges[i]);
|
|
707
|
+
if (!m)
|
|
708
|
+
continue;
|
|
709
|
+
const commit = m[1];
|
|
710
|
+
const [p1, ...others] = (merges[i + 1] ?? "").split(" ").filter(Boolean);
|
|
711
|
+
const at = here.filter((f) => f.commit === commit);
|
|
712
|
+
if (!p1 || !at.length)
|
|
713
|
+
continue;
|
|
714
|
+
const candidates = new Set(at.map((f) => f.branch));
|
|
715
|
+
for (const side of others) {
|
|
716
|
+
// Whose line it brought: a branch whose ref is on it (A's own upstream, pulled, is A's), the branch its subject
|
|
717
|
+
// names, or the one branch it brought.
|
|
718
|
+
const byRef = new Set(all.filter((r) => (r.name === into || candidates.has(r.name)) && (r.tip === side || lineOf(r.tip).has(side)) && !lineOf(r.tip).has(commit)).map((r) => r.name));
|
|
719
|
+
const named = mergedName(run(top, ["log", "-1", "--format=%s", commit])?.trim() ?? "");
|
|
720
|
+
const owner = byRef.size === 1 ? [...byRef][0] : named && (named === into || candidates.has(named)) ? named : candidates.size === 1 ? [...candidates][0] : undefined;
|
|
721
|
+
if (!owner)
|
|
722
|
+
continue;
|
|
723
|
+
// The branches it brought that came to the owner first are found on the owner's line, merged into it.
|
|
724
|
+
for (let j = here.length - 1; j >= 0; j--)
|
|
725
|
+
if (here[j].commit === commit && here[j].branch !== owner)
|
|
726
|
+
here.splice(j, 1);
|
|
727
|
+
if (owner === into)
|
|
728
|
+
for (let j = here.length - 1; j >= 0; j--)
|
|
729
|
+
if (here[j].commit === commit)
|
|
730
|
+
here.splice(j, 1);
|
|
731
|
+
sides.push({ into: owner, from: p1, to: side });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
found.push(...here);
|
|
735
|
+
for (const s of sides)
|
|
736
|
+
scanLine(s.into, s.from, s.to);
|
|
737
|
+
};
|
|
738
|
+
const done = [];
|
|
739
|
+
for (const r of order) {
|
|
740
|
+
const own = (run(top, ["rev-list", "--first-parent", "--reverse", "--format=%P%x00%ct", r.tip, ...(done.length ? ["--not", ...done] : [])]) ?? "").split("\n");
|
|
741
|
+
done.push(r.tip);
|
|
742
|
+
// From the parent of the first commit of its own, or the last commit before the first card, whichever is later.
|
|
743
|
+
let from;
|
|
744
|
+
let first = true;
|
|
745
|
+
for (let i = 0; i < own.length; i++) {
|
|
746
|
+
const m = /^commit ([0-9a-f]+)$/.exec(own[i]);
|
|
747
|
+
if (!m)
|
|
748
|
+
continue;
|
|
749
|
+
const [parents = "", t = "0"] = (own[i + 1] ?? "").split("\0");
|
|
750
|
+
if (first)
|
|
751
|
+
from = parents.split(" ")[0] || undefined;
|
|
752
|
+
first = false;
|
|
753
|
+
if (Number(t) * 1000 < since)
|
|
754
|
+
from = m[1];
|
|
755
|
+
else
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
if (first)
|
|
759
|
+
continue; // nothing of its own
|
|
760
|
+
if (!from)
|
|
761
|
+
from = (run(top, ["rev-list", "--max-parents=0", r.tip]) ?? "").split("\n").filter(Boolean).at(-1);
|
|
762
|
+
if (from && from !== r.tip)
|
|
763
|
+
scanLine(r.name, from, r.tip);
|
|
764
|
+
}
|
|
765
|
+
// What's recorded already, by branch: the commits its merges are at, and the cards and commits they list.
|
|
766
|
+
const knownOf = new Map();
|
|
767
|
+
const note = (f) => {
|
|
768
|
+
const k = knownOf.get(f.branch) ?? { at: new Set(), listed: new Set() };
|
|
769
|
+
k.at.add(f.commit);
|
|
770
|
+
for (const x of [...f.cards, ...f.commits])
|
|
771
|
+
k.listed.add(x);
|
|
772
|
+
knownOf.set(f.branch, k);
|
|
773
|
+
};
|
|
774
|
+
for (const m of store.merges())
|
|
775
|
+
note(m);
|
|
776
|
+
const out = [];
|
|
777
|
+
let known = 0;
|
|
778
|
+
const seen = new Set();
|
|
779
|
+
for (const f of found.sort((a, b) => a.at.localeCompare(b.at) || a.commit.localeCompare(b.commit))) {
|
|
780
|
+
const id = `${f.commit} ${f.branch} ${f.into}`;
|
|
781
|
+
if (seen.has(id))
|
|
782
|
+
continue;
|
|
783
|
+
seen.add(id);
|
|
784
|
+
const k = knownOf.get(f.branch);
|
|
785
|
+
if (f.branch === f.into || (k && (k.at.has(f.commit) || [...f.cards, ...f.commits].some((x) => k.listed.has(x))))) {
|
|
786
|
+
known += 1;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
note(f);
|
|
790
|
+
out.push(f);
|
|
791
|
+
if (o.write)
|
|
792
|
+
store.writeMerge(f);
|
|
793
|
+
}
|
|
794
|
+
return { found: out, known };
|
|
795
|
+
}
|
|
796
|
+
// ---------------------------------------------------------------- the squash marker
|
|
797
|
+
/** Where `post-merge` notes a squash for the commit that follows: this worktree's own git directory, as `baesian-staged.json`. */
|
|
798
|
+
function markerPath(top) {
|
|
799
|
+
return run(top, ["rev-parse", "--path-format=absolute", "--git-path", "baesian-squash.json"])?.trim();
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* `git merge --squash` stages the change and commits nothing; its `post-merge` (with the squash flag) runs while
|
|
803
|
+
* `SQUASH_MSG` lists the commits squashed. They're noted beside the index, with HEAD, so the commit that follows on it
|
|
804
|
+
* (`post-commit`) knows what it squashed, and from which branch. Nothing leaves the machine.
|
|
805
|
+
*/
|
|
806
|
+
export function noteSquash(store) {
|
|
807
|
+
const top = store.repo.toplevel;
|
|
808
|
+
const msg = run(top, ["rev-parse", "--path-format=absolute", "--git-path", "SQUASH_MSG"])?.trim();
|
|
809
|
+
const path = markerPath(top);
|
|
810
|
+
if (!msg || !path || !existsSync(msg))
|
|
811
|
+
return;
|
|
812
|
+
const commits = [...readFileSync(msg, "utf8").matchAll(/^commit ([0-9a-f]{40,64})$/gm)].map((x) => x[1]);
|
|
813
|
+
const head = run(top, ["rev-parse", "HEAD"])?.trim();
|
|
814
|
+
if (!commits.length || !head)
|
|
815
|
+
return;
|
|
816
|
+
writeFileSync(path, `${JSON.stringify({ head, tip: commits[0], commits })}\n`);
|
|
817
|
+
}
|
|
818
|
+
/** The squash noted for a commit whose parent is `parent`, if there's one; the marker is removed either way. */
|
|
819
|
+
export function takeSquash(store, parent) {
|
|
820
|
+
const path = markerPath(store.repo.toplevel);
|
|
821
|
+
if (!path || !existsSync(path))
|
|
822
|
+
return undefined;
|
|
823
|
+
try {
|
|
824
|
+
const m = JSON.parse(readFileSync(path, "utf8"));
|
|
825
|
+
if (m.head !== parent || typeof m.tip !== "string" || !Array.isArray(m.commits))
|
|
826
|
+
return undefined;
|
|
827
|
+
return { tip: m.tip, commits: m.commits.filter((c) => typeof c === "string") };
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
return undefined;
|
|
831
|
+
}
|
|
832
|
+
finally {
|
|
833
|
+
rmSync(path, { force: true });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/** The branch a rebase in progress is rewriting, when HEAD is detached for it: its commits are that branch's. */
|
|
837
|
+
export function rebasing(store) {
|
|
838
|
+
const top = store.repo.toplevel;
|
|
839
|
+
for (const dir of ["rebase-merge", "rebase-apply"]) {
|
|
840
|
+
const p = run(top, ["rev-parse", "--path-format=absolute", "--git-path", `${dir}/head-name`])?.trim();
|
|
841
|
+
if (p && existsSync(p)) {
|
|
842
|
+
const ref = readFileSync(p, "utf8").trim();
|
|
843
|
+
if (ref.startsWith("refs/heads/"))
|
|
844
|
+
return ref.slice("refs/heads/".length);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
//# sourceMappingURL=merges.js.map
|