@fungi.computer/bonsai 0.0.0-stage → 0.0.1
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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +196 -2
- package/backend/background.ts +259 -0
- package/backend/change-copy.ts +99 -0
- package/backend/changes.ts +530 -0
- package/backend/diff.ts +228 -0
- package/backend/file-edits.ts +850 -0
- package/backend/files-copy.ts +361 -0
- package/backend/gills.ts +125 -0
- package/backend/git.ts +123 -0
- package/backend/graph-commit-copies.ts +321 -0
- package/backend/history.ts +178 -0
- package/backend/index.ts +144 -0
- package/backend/insights.ts +256 -0
- package/backend/merge.ts +999 -0
- package/backend/methods.ts +835 -0
- package/backend/policy.ts +160 -0
- package/backend/read-copy-page.ts +77 -0
- package/backend/repository-copy.ts +70 -0
- package/backend/reviews.ts +317 -0
- package/backend/service.ts +892 -0
- package/backend/state.ts +914 -0
- package/demo/koi-pond/README.md +17 -0
- package/demo/koi-pond/index.html +11 -0
- package/demo/koi-pond/package.json +13 -0
- package/demo/koi-pond/pnpm-lock.yaml +450 -0
- package/demo/koi-pond/src/fish.ts +32 -0
- package/demo/koi-pond/src/main.ts +24 -0
- package/demo/koi-pond/src/pond.ts +27 -0
- package/docs/media/bonsai-insights.png +0 -0
- package/docs/media/overview-phone.png +0 -0
- package/docs/media/overview.png +0 -0
- package/package.json +152 -3
- package/scripts/agent.sh +55 -0
- package/scripts/demo.sh +233 -0
- package/scripts/rebuild-copies.mjs +34 -0
- package/scripts/release.mjs +52 -0
- package/scripts/runtime-proof.mjs +299 -0
- package/scripts/seed-demo.mjs +724 -0
- package/scripts/seed-step.mjs +37 -0
- package/scripts/shed-terminal-proof.mjs +774 -0
- package/shared/contract.ts +404 -0
- package/shared/hooks.ts +208 -0
- package/shared/insights.ts +90 -0
- package/shared/picker-options.ts +23 -0
- package/shared/presentation.ts +21 -0
- package/shared/repository-deletion.ts +4 -0
- package/test/apps-build-install.test.ts +302 -0
- package/test/background-runtime.test.ts +206 -0
- package/test/browser/board.browser.mjs +52 -0
- package/test/browser/capture.browser.mjs +1264 -0
- package/test/browser/edit-in-inkcap.browser.mjs +83 -0
- package/test/browser/repository-settings-fixture.tsx +108 -0
- package/test/browser/repository-settings.browser.mjs +136 -0
- package/test/browser/repository-settings.html +10 -0
- package/test/browser/visual-fixture.tsx +667 -0
- package/test/browser/visual.html +10 -0
- package/test/browser/vite.config.mjs +9 -0
- package/test/change-page.test.ts +916 -0
- package/test/changes.test.ts +1063 -0
- package/test/checks-workflow-template.test.mjs +234 -0
- package/test/diff.test.ts +70 -0
- package/test/edit-in-inkcap.test.ts +227 -0
- package/test/file-edits.test.ts +2714 -0
- package/test/files-read-copy-calls.test.ts +374 -0
- package/test/fork-adoption.test.ts +111 -0
- package/test/gills.test.ts +125 -0
- package/test/git-binding.fixture.ts +30 -0
- package/test/git-push.config.jsonc +9 -0
- package/test/git-push.native.mjs +413 -0
- package/test/git-push.worker.ts +280 -0
- package/test/graph-commit-read-copy-calls.test.ts +401 -0
- package/test/home-page.test.ts +170 -0
- package/test/hooks.test.ts +250 -0
- package/test/insight-data.test.ts +51 -0
- package/test/insight-layout.test.mjs +230 -0
- package/test/insights.test.ts +154 -0
- package/test/merge-background.test.ts +756 -0
- package/test/merge-conflict.test.ts +337 -0
- package/test/merge-declaration.test.ts +46 -0
- package/test/merge-repro.test.ts +561 -0
- package/test/merge-template-build.test.mjs +234 -0
- package/test/network-layout.test.ts +200 -0
- package/test/outbox.test.ts +424 -0
- package/test/parts.test.ts +86 -0
- package/test/pkt-line.test.ts +54 -0
- package/test/policy.test.ts +172 -0
- package/test/read-copy-calls.test.ts +691 -0
- package/test/read-copy-composer.test.ts +388 -0
- package/test/read-copy-lifetimes.test.ts +214 -0
- package/test/read-copy-rpc.config.jsonc +12 -0
- package/test/read-copy-rpc.native.mjs +47 -0
- package/test/read-copy-rpc.worker.ts +92 -0
- package/test/recent-activity.test.ts +62 -0
- package/test/repository-deletion.test.ts +500 -0
- package/test/seed-resume.test.mjs +91 -0
- package/test/storyboard-save-https-git.fixture.mjs +119 -0
- package/test/storyboard-save-template.test.mjs +545 -0
- package/test/ui-errors.test.ts +976 -0
- package/test/workflow-store.test.ts +115 -0
- package/tsconfig.json +36 -0
- package/turbo.jsonc +25 -0
- package/vite.config.ts +19 -0
- package/vitest.config.ts +5 -0
- package/web/api.ts +224 -0
- package/web/app-build-panel.tsx +171 -0
- package/web/board.tsx +259 -0
- package/web/bonsai.css +642 -0
- package/web/change-page.tsx +715 -0
- package/web/change-rows.tsx +135 -0
- package/web/code-tab.tsx +267 -0
- package/web/commit-tree.tsx +152 -0
- package/web/format.ts +54 -0
- package/web/home-page.tsx +274 -0
- package/web/index.html +17 -0
- package/web/insight-data.ts +76 -0
- package/web/insight-tiles.tsx +534 -0
- package/web/insights.tsx +250 -0
- package/web/issues-panel.tsx +305 -0
- package/web/main.tsx +72 -0
- package/web/network-graph.tsx +647 -0
- package/web/overview.tsx +339 -0
- package/web/pages.tsx +469 -0
- package/web/parts.tsx +381 -0
- package/web/public/favicon.svg +1 -0
- package/web/repo-navigation.tsx +256 -0
- package/web/repo-settings.tsx +145 -0
- package/web/router.tsx +151 -0
- package/web/transport.ts +91 -0
- package/web/views.tsx +151 -0
- package/web/working-now.tsx +98 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/** Bounded first-parent line history using the existing parsed Git reader. */
|
|
2
|
+
import { diffLines } from "diff";
|
|
3
|
+
import type { Commit } from "@fungi.computer/agar/roles/files";
|
|
4
|
+
import { ageBandLabels, type CodeInsights } from "../shared/insights.js";
|
|
5
|
+
import { decodeText, isBinary } from "./diff.js";
|
|
6
|
+
import type { GitStore } from "./git.js";
|
|
7
|
+
|
|
8
|
+
const HISTORY = 40;
|
|
9
|
+
const FILES = 80;
|
|
10
|
+
const READS = 600;
|
|
11
|
+
const BYTES = 8_000_000;
|
|
12
|
+
const FILE_BYTES = 100_000;
|
|
13
|
+
class SampleLimit extends Error {}
|
|
14
|
+
|
|
15
|
+
const lineCount = (text: string) =>
|
|
16
|
+
text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
|
|
17
|
+
|
|
18
|
+
/** Preserve unchanged line ages; removed lines vanish, additions take commit time. */
|
|
19
|
+
export function advanceLineAges(
|
|
20
|
+
before: string,
|
|
21
|
+
after: string,
|
|
22
|
+
ages: readonly (number | null)[],
|
|
23
|
+
at: number,
|
|
24
|
+
): (number | null)[] | null {
|
|
25
|
+
const parts = diffLines(before, after, { timeout: 40, maxEditLength: 4000 });
|
|
26
|
+
if (!parts) return null;
|
|
27
|
+
let offset = 0;
|
|
28
|
+
const next: (number | null)[] = [];
|
|
29
|
+
for (const part of parts) {
|
|
30
|
+
if (part.added) next.push(...Array.from({ length: part.count }, () => at));
|
|
31
|
+
else {
|
|
32
|
+
if (!part.removed) next.push(...ages.slice(offset, offset + part.count));
|
|
33
|
+
offset += part.count;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Unchanged lines continue to age between snapshots without rereading a blob. */
|
|
40
|
+
function ageBandsAt(
|
|
41
|
+
births: ReadonlyMap<number | null, number>,
|
|
42
|
+
at: number,
|
|
43
|
+
): number[] {
|
|
44
|
+
const bands = ageBandLabels.map(() => 0);
|
|
45
|
+
for (const [born, count] of births) {
|
|
46
|
+
const days = born === null ? null : Math.max(0, (at - born) / 86400);
|
|
47
|
+
const band =
|
|
48
|
+
days === null ? 4 : days < 7 ? 0 : days < 28 ? 1 : days < 90 ? 2 : 3;
|
|
49
|
+
bands[band] = (bands[band] ?? 0) + count;
|
|
50
|
+
}
|
|
51
|
+
return bands;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function analyze(
|
|
55
|
+
store: GitStore,
|
|
56
|
+
repo: string,
|
|
57
|
+
head: Commit | null,
|
|
58
|
+
now: number,
|
|
59
|
+
): Promise<CodeInsights> {
|
|
60
|
+
const result: CodeInsights = {
|
|
61
|
+
head: head?.hash ?? null,
|
|
62
|
+
commits: 0,
|
|
63
|
+
files: [],
|
|
64
|
+
ageHistory: [],
|
|
65
|
+
omittedFiles: 0,
|
|
66
|
+
partial: false,
|
|
67
|
+
};
|
|
68
|
+
if (!head) return result;
|
|
69
|
+
let reads = 0;
|
|
70
|
+
let bytesRead = 0;
|
|
71
|
+
let entriesRead = 0;
|
|
72
|
+
const spend = () => {
|
|
73
|
+
if (++reads > READS) throw new SampleLimit();
|
|
74
|
+
};
|
|
75
|
+
const history: Commit[] = [head];
|
|
76
|
+
while (history.length < HISTORY) {
|
|
77
|
+
const parent = history.at(-1)?.parents[0];
|
|
78
|
+
if (!parent) break;
|
|
79
|
+
spend();
|
|
80
|
+
const commit = await store.commit(repo, parent);
|
|
81
|
+
if (!commit) {
|
|
82
|
+
result.partial = true;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
history.push(commit);
|
|
86
|
+
}
|
|
87
|
+
result.commits = history.length;
|
|
88
|
+
result.partial ||= (history.at(-1)?.parents.length ?? 0) > 0;
|
|
89
|
+
const chronological = [...history].reverse();
|
|
90
|
+
const snapshotTimes = chronological.map((commit) => commit.committedAt);
|
|
91
|
+
if (now / 1000 > head.committedAt) snapshotTimes.push(now / 1000);
|
|
92
|
+
result.ageHistory = snapshotTimes.map((at) => ({
|
|
93
|
+
at,
|
|
94
|
+
lines: ageBandLabels.map(() => 0),
|
|
95
|
+
}));
|
|
96
|
+
// Cache trees within this analysis; no cross-repository Git object assumptions.
|
|
97
|
+
const trees = new Map<string, Awaited<ReturnType<GitStore["tree"]>>>();
|
|
98
|
+
const tree = async (hash: string) => {
|
|
99
|
+
if (trees.has(hash)) return trees.get(hash) ?? null;
|
|
100
|
+
spend();
|
|
101
|
+
const entries = await store.tree(repo, hash);
|
|
102
|
+
if (!entries) throw new Error("Git tree is missing");
|
|
103
|
+
entriesRead += entries.length;
|
|
104
|
+
if (entriesRead > 20_000) throw new SampleLimit();
|
|
105
|
+
trees.set(hash, entries);
|
|
106
|
+
return entries;
|
|
107
|
+
};
|
|
108
|
+
const paths: string[] = [];
|
|
109
|
+
const queue = [{ hash: head.treeHash, prefix: "", depth: 0 }];
|
|
110
|
+
try {
|
|
111
|
+
while (queue.length > 0) {
|
|
112
|
+
const item = queue.shift();
|
|
113
|
+
if (!item) break;
|
|
114
|
+
for (const entry of (await tree(item.hash)) ?? []) {
|
|
115
|
+
if (entry.type === "blob" && entry.mode !== "120000") {
|
|
116
|
+
if (paths.length < FILES) paths.push(item.prefix + entry.name);
|
|
117
|
+
else result.omittedFiles++;
|
|
118
|
+
} else if (entry.type === "tree") {
|
|
119
|
+
if (item.depth >= 20 || queue.length >= 200) {
|
|
120
|
+
result.partial = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
queue.push({
|
|
124
|
+
hash: entry.hash,
|
|
125
|
+
prefix: `${item.prefix}${entry.name}/`,
|
|
126
|
+
depth: item.depth + 1,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!(error instanceof SampleLimit)) throw error;
|
|
133
|
+
result.partial = true;
|
|
134
|
+
}
|
|
135
|
+
const blobAt = async (commit: Commit, path: string) => {
|
|
136
|
+
let hash = commit.treeHash;
|
|
137
|
+
const segments = path.split("/");
|
|
138
|
+
for (const [index, segment] of segments.entries()) {
|
|
139
|
+
const entry = (await tree(hash))?.find((item) => item.name === segment);
|
|
140
|
+
if (!entry) return null;
|
|
141
|
+
if (index === segments.length - 1)
|
|
142
|
+
return entry.type === "blob" ? entry.hash : null;
|
|
143
|
+
if (entry.type !== "tree") return null;
|
|
144
|
+
hash = entry.hash;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
};
|
|
148
|
+
const text = async (hash: string | null) => {
|
|
149
|
+
if (hash === null) return "";
|
|
150
|
+
spend();
|
|
151
|
+
const bytes = await store.blob(repo, hash);
|
|
152
|
+
if (bytes === null) throw new Error("Git blob is missing");
|
|
153
|
+
bytesRead += bytes.length;
|
|
154
|
+
if (bytesRead > BYTES) throw new SampleLimit();
|
|
155
|
+
if (bytes.length > FILE_BYTES || isBinary(bytes)) return null;
|
|
156
|
+
const decoded = decodeText(bytes);
|
|
157
|
+
return decoded.kind === "text" ? decoded.text : null;
|
|
158
|
+
};
|
|
159
|
+
for (const [pathIndex, path] of paths.entries()) {
|
|
160
|
+
try {
|
|
161
|
+
let previousHash: string | null | undefined;
|
|
162
|
+
let previousText = "";
|
|
163
|
+
let ages: (number | null)[] = [];
|
|
164
|
+
let changes = 0;
|
|
165
|
+
let usable = true;
|
|
166
|
+
const samples: number[][] = [];
|
|
167
|
+
let births = new Map<number | null, number>();
|
|
168
|
+
for (const [index, commit] of chronological.entries()) {
|
|
169
|
+
const hash = await blobAt(commit, path);
|
|
170
|
+
if (hash !== previousHash) {
|
|
171
|
+
const current = await text(hash);
|
|
172
|
+
if (current === null) {
|
|
173
|
+
usable = false;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
if (index === 0 && commit.parents.length > 0) {
|
|
177
|
+
ages = Array.from({ length: lineCount(current) }, () => null);
|
|
178
|
+
} else {
|
|
179
|
+
const next = advanceLineAges(
|
|
180
|
+
previousText,
|
|
181
|
+
current,
|
|
182
|
+
ages,
|
|
183
|
+
commit.committedAt,
|
|
184
|
+
);
|
|
185
|
+
if (next === null) {
|
|
186
|
+
usable = false;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
ages = next;
|
|
190
|
+
if (!(index === 0 && hash === null)) changes++;
|
|
191
|
+
}
|
|
192
|
+
births = new Map();
|
|
193
|
+
for (const at of ages) births.set(at, (births.get(at) ?? 0) + 1);
|
|
194
|
+
previousText = current;
|
|
195
|
+
previousHash = hash;
|
|
196
|
+
}
|
|
197
|
+
samples.push(ageBandsAt(births, commit.committedAt));
|
|
198
|
+
}
|
|
199
|
+
if (snapshotTimes.length > chronological.length)
|
|
200
|
+
samples.push(ageBandsAt(births, now / 1000));
|
|
201
|
+
if (!usable) {
|
|
202
|
+
result.omittedFiles++;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
result.files.push({ path, lines: ages.length, changes });
|
|
206
|
+
for (const [index, bands] of samples.entries()) {
|
|
207
|
+
const snapshot = result.ageHistory[index];
|
|
208
|
+
if (snapshot)
|
|
209
|
+
bands.forEach((count, band) => {
|
|
210
|
+
snapshot.lines[band] = (snapshot.lines[band] ?? 0) + count;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (!(error instanceof SampleLimit)) throw error;
|
|
215
|
+
result.omittedFiles += paths.length - pathIndex;
|
|
216
|
+
result.partial = true;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
result.partial ||= result.omittedFiles > 0;
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Each live installation owns at most eight recent snapshots. Failed work is
|
|
225
|
+
// evicted; simultaneous readers share one promise. Heads are resolved afresh.
|
|
226
|
+
const caches = new WeakMap<
|
|
227
|
+
GitStore,
|
|
228
|
+
Map<string, { until: number; value: Promise<CodeInsights> }>
|
|
229
|
+
>();
|
|
230
|
+
export async function readCodeInsights(
|
|
231
|
+
store: GitStore,
|
|
232
|
+
repo: string,
|
|
233
|
+
ref: string,
|
|
234
|
+
now = Date.now(),
|
|
235
|
+
): Promise<CodeInsights> {
|
|
236
|
+
const head = (await store.log(repo, ref, 1))[0] ?? null;
|
|
237
|
+
let cache = caches.get(store);
|
|
238
|
+
if (!cache) {
|
|
239
|
+
cache = new Map();
|
|
240
|
+
caches.set(store, cache);
|
|
241
|
+
}
|
|
242
|
+
const key = JSON.stringify([repo, head?.hash ?? null]);
|
|
243
|
+
const cached = cache.get(key);
|
|
244
|
+
if (cached && cached.until > now) return cached.value;
|
|
245
|
+
if (cache.size >= 8) {
|
|
246
|
+
const oldest = cache.keys().next().value;
|
|
247
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
248
|
+
}
|
|
249
|
+
const owner = cache;
|
|
250
|
+
const value = analyze(store, repo, head, now).catch((error: unknown) => {
|
|
251
|
+
if (owner.get(key)?.value === value) owner.delete(key);
|
|
252
|
+
throw error;
|
|
253
|
+
});
|
|
254
|
+
cache.set(key, { until: now + 60_000, value });
|
|
255
|
+
return value;
|
|
256
|
+
}
|