1/**
2 * Pure recursive tree diff (the server-side diff surface, DESIGN.md §5).
3 *
4 * Compares two trees keyed by entry **name** (NOT a two-pointer merge —
5 * git's canonical sort treats directories as `name/`, so a name that is a
6 * blob in one tree and a tree in the other would mis-pair). Only descends
7 * into subtree pairs whose oids differ; identical oids (with identical
8 * modes) are skipped without reading the subtree at all. That pruning is
9 * what makes a 12k-file tree cost O(changed) — a commit touching k files
10 * opens only the ~k·depth trees on the changed root-to-leaf paths (each a
11 * single SQLite row read + inflate of a few KB).
12 *
13 * Worst case (root commit: everything added) is bounded by the caps:
14 * recursion **stops descending** once {@link MAX_DIFF_FILES} entries are
15 * emitted (it does not enumerate-then-truncate) and
16 * {@link MAX_DIFF_TREE_READS} bounds tree opens defensively.
17 *
18 * Like the rest of `src/Git/Protocol/`, this module depends only on data-shaped
19 * store contracts so it stays unit-testable with in-memory fakes.
20 */
21import * as Effect from "effect/Effect";
22import {
23 encodeTree,
24 GITLINK_MODE,
25 hashObject,
26 ObjectType,
27 parseTree,
28 TREE_MODE,
29 treeEntryKind,
30 type Oid,
31 type TreeEntry,
32} from "./ObjectCodec.ts";
33import { StoreError } from "./Store.ts";
34
35/** Max files emitted by a tree diff before `truncated: true`. */
36export const MAX_DIFF_FILES = 1000;
37
38/**
39 * Defensive bound on tree objects opened per diff (a 12k-file repo with
40 * identical-subtree pruning opens O(changed-paths · depth), typically <50).
41 */
42export const MAX_DIFF_TREE_READS = 5000;
43
44/** One changed file (see the REST `DiffEntry` schema). */
45export interface DiffEntryData {
46 readonly path: string;
47 readonly status: "added" | "removed" | "modified";
48 readonly oldOid?: string | undefined;
49 readonly newOid?: string | undefined;
50 readonly oldMode?: string | undefined;
51 readonly newMode?: string | undefined;
52 readonly oldSize?: number | undefined;
53 readonly newSize?: number | undefined;
54}
55
56/** Result of {@link diffTrees}. */
57export interface TreeDiffResult {
58 readonly files: Array<DiffEntryData>;
59 readonly truncated: boolean;
60}
61
62/**
63 * The read surface {@link diffTrees} needs — a structural subset of
64 * `ObjectStore` (`readContent` + `getMetaBatch`).
65 */
66export interface TreeDiffObjects {
67 /** Reads and inflates the full object content. */
68 readonly readContent: (oid: Oid) => Effect.Effect<Uint8Array, StoreError>;
69 /** Batched metadata; missing oids are simply absent from the map. */
70 readonly getMetaBatch: (
71 oids: ReadonlyArray<Oid>,
72 ) => Effect.Effect<ReadonlyMap<Oid, { readonly size: number }>, StoreError>;
73}
74
75/**
76 * Recursive tree diff. `oldTree`/`newTree` are tree oids; `undefined`
77 * means the empty tree on that side (a root commit diffs against
78 * `undefined` and every file comes out `added`).
79 *
80 * Mode-only changes (same blob oid, different mode) still report
81 * `modified`. Kind changes (blob↔tree, gitlink↔tree) emit the removal of
82 * everything on the old side and the addition of everything on the new
83 * side. Gitlink (mode `160000`) entries are leaves whose oids are commit
84 * oids — they carry no sizes.
85 */
86export const diffTrees = (
87 objects: TreeDiffObjects,
88 oldTree: Oid | undefined,
89 newTree: Oid | undefined,
90): Effect.Effect<TreeDiffResult, StoreError> =>
91 Effect.gen(function* () {
92 const files: Array<DiffEntryData> = [];
93 let treeReads = 0;
94 let truncated = false;
95
96 const readTree = Effect.fn(function* (oid: Oid) {
97 if (++treeReads > MAX_DIFF_TREE_READS) {
98 truncated = true;
99 return [] as ReadonlyArray<TreeEntry>;
100 }
101 const content = yield* objects.readContent(oid);
102 return yield* parseTree(content).pipe(
103 Effect.mapError((e) => new StoreError({ reason: e.reason })),
104 );
105 });
106
107 const emit = (entry: DiffEntryData): boolean => {
108 if (files.length >= MAX_DIFF_FILES) {
109 truncated = true;
110 return false;
111 }
112 files.push(entry);
113 return true;
114 };
115
116 /** Emit every blob/gitlink under `tree` as added or removed. */
117 const emitAll = (
118 tree: Oid,
119 prefix: string,
120 side: "added" | "removed",
121 ): Effect.Effect<void, StoreError> =>
122 Effect.gen(function* () {
123 if (truncated) return;
124 const entries = yield* readTree(tree);
125 for (const e of entries) {
126 if (truncated) return;
127 const path = prefix + e.name;
128 const kind = treeEntryKind(e.mode);
129 if (kind === "tree") {
130 yield* emitAll(e.oid, `${path}/`, side);
131 continue;
132 }
133 // blob or gitlink — a leaf either way
134 emit(
135 side === "added"
136 ? { path, status: "added", newOid: e.oid, newMode: e.mode }
137 : { path, status: "removed", oldOid: e.oid, oldMode: e.mode },
138 );
139 }
140 });
141
142 const walk = (
143 oldOid: Oid | undefined,
144 newOid: Oid | undefined,
145 prefix: string,
146 ): Effect.Effect<void, StoreError> =>
147 Effect.gen(function* () {
148 if (truncated) return;
149 if (oldOid === newOid) return; // identical-subtree prune (root incl.)
150 if (oldOid === undefined) {
151 if (newOid !== undefined) yield* emitAll(newOid, prefix, "added");
152 return;
153 }
154 if (newOid === undefined) {
155 yield* emitAll(oldOid, prefix, "removed");
156 return;
157 }
158 const oldEntries = yield* readTree(oldOid);
159 const newEntries = yield* readTree(newOid);
160 const oldByName = new Map(oldEntries.map((e) => [e.name, e]));
161 const newByName = new Map(newEntries.map((e) => [e.name, e]));
162 const names = Array.from(
163 new Set([...oldByName.keys(), ...newByName.keys()]),
164 ).sort(); // deterministic output order
165 for (const name of names) {
166 if (truncated) return;
167 const o = oldByName.get(name);
168 const n = newByName.get(name);
169 const path = prefix + name;
170 if (o === undefined) {
171 // added on the new side
172 const kind = treeEntryKind(n!.mode);
173 if (kind === "tree") yield* emitAll(n!.oid, `${path}/`, "added");
174 else {
175 emit({ path, status: "added", newOid: n!.oid, newMode: n!.mode });
176 }
177 continue;
178 }
179 if (n === undefined) {
180 const kind = treeEntryKind(o.mode);
181 if (kind === "tree") yield* emitAll(o.oid, `${path}/`, "removed");
182 else {
183 emit({ path, status: "removed", oldOid: o.oid, oldMode: o.mode });
184 }
185 continue;
186 }
187 if (o.oid === n.oid && o.mode === n.mode) continue; // untouched — prune
188 const oKind = treeEntryKind(o.mode);
189 const nKind = treeEntryKind(n.mode);
190 if (oKind === "tree" && nKind === "tree") {
191 yield* walk(o.oid, n.oid, `${path}/`); // descend: oids differ
192 } else if (oKind === "tree" || nKind === "tree") {
193 // kind change (blob↔tree, gitlink↔tree): removed + added
194 if (oKind === "tree") yield* emitAll(o.oid, `${path}/`, "removed");
195 else {
196 emit({ path, status: "removed", oldOid: o.oid, oldMode: o.mode });
197 }
198 if (nKind === "tree") yield* emitAll(n.oid, `${path}/`, "added");
199 else {
200 emit({ path, status: "added", newOid: n.oid, newMode: n.mode });
201 }
202 } else {
203 // blob/gitlink on both sides: content and/or mode change.
204 // Mode-only change (o.oid === n.oid) still reports `modified`.
205 emit({
206 path,
207 status: "modified",
208 oldOid: o.oid,
209 newOid: n.oid,
210 oldMode: o.mode,
211 newMode: n.mode,
212 });
213 }
214 }
215 });
216
217 yield* walk(oldTree, newTree, "");
218
219 // Fill sizes for blob entries in ONE chunked metadata query so clients
220 // can gate binary/oversize files without fetching. Gitlink oids won't
221 // be in `objects` and simply stay size-less.
222 const blobOids = new Set<Oid>();
223 for (const f of files) {
224 if (f.oldOid !== undefined && f.oldMode !== GITLINK_MODE) {
225 blobOids.add(f.oldOid);
226 }
227 if (f.newOid !== undefined && f.newMode !== GITLINK_MODE) {
228 blobOids.add(f.newOid);
229 }
230 }
231 const metas = yield* objects.getMetaBatch(Array.from(blobOids));
232 const sized = files.map((f): DiffEntryData => ({
233 ...f,
234 oldSize: f.oldOid !== undefined ? metas.get(f.oldOid)?.size : undefined,
235 newSize: f.newOid !== undefined ? metas.get(f.newOid)?.size : undefined,
236 }));
237 return { files: sized, truncated };
238 });
239
240// ─────────────────────────────────────────────────────────────────────────────
241// conflictingPaths — the trivial-merge admissibility test
242// ─────────────────────────────────────────────────────────────────────────────
243
244/** `true` when both sides made the identical change (same result). */
245const identicalChange = (a: DiffEntryData, b: DiffEntryData): boolean =>
246 a.status === b.status && a.newOid === b.newOid && a.newMode === b.newMode;
247
248/**
249 * The paths that make a three-way merge non-trivial: changed on **both**
250 * sides relative to the merge base (minus paths where both sides made the
251 * identical change — same new oid+mode, or both deleted), plus
252 * directory/file collisions where a leaf now occupies `p` on one side
253 * while the other side changed something under `p/`. Sorted.
254 */
255export const conflictingPaths = (
256 baseChanges: ReadonlyArray<DiffEntryData>,
257 headChanges: ReadonlyArray<DiffEntryData>,
258): Array<string> => {
259 const conflicts = new Set<string>();
260 const baseBy = new Map(baseChanges.map((f) => [f.path, f]));
261 const headBy = new Map(headChanges.map((f) => [f.path, f]));
262 for (const [path, head] of headBy) {
263 const base = baseBy.get(path);
264 if (base !== undefined && !identicalChange(base, head)) {
265 conflicts.add(path);
266 }
267 }
268 // Directory/file collision: one side's add/modify puts a *leaf* at `dir`
269 // while the other side changes a path under `dir/` — the leaf and the
270 // directory cannot both exist. (A `removed` ancestor never collides: at
271 // the merge base a name is either a leaf or a directory, never both.)
272 const addAncestorConflicts = (
273 leaves: ReadonlyMap<string, DiffEntryData>,
274 others: ReadonlyMap<string, DiffEntryData>,
275 ) => {
276 for (const path of others.keys()) {
277 let idx = path.lastIndexOf("/");
278 while (idx > 0) {
279 const dir = path.slice(0, idx);
280 const leaf = leaves.get(dir);
281 if (leaf !== undefined && leaf.newOid !== undefined) {
282 conflicts.add(path);
283 }
284 idx = dir.lastIndexOf("/");
285 }
286 }
287 };
288 addAncestorConflicts(baseBy, headBy);
289 addAncestorConflicts(headBy, baseBy);
290 return Array.from(conflicts).sort();
291};
292
293// ─────────────────────────────────────────────────────────────────────────────
294// applyTreeChanges — the trivial-merge tree builder
295// ─────────────────────────────────────────────────────────────────────────────
296
297/** A tree object created by {@link applyTreeChanges} (bottom-up order). */
298export interface NewTreeObject {
299 readonly oid: Oid;
300 readonly content: Uint8Array;
301}
302
303/** Result of {@link applyTreeChanges}. */
304export interface ApplyTreeChangesResult {
305 /** Root tree oid after the changes (may equal `baseTree` on a no-op). */
306 readonly root: Oid;
307 /**
308 * Every tree object whose content was (re)built, bottom-up. Some may
309 * already exist in the store — staging is idempotent, so callers just
310 * stage them all.
311 */
312 readonly newTrees: ReadonlyArray<NewTreeObject>;
313}
314
315/** One directory level of the nested change tree. */
316interface ChangeNode {
317 /** Leaf change addressed exactly at this name. */
318 leaf?: DiffEntryData;
319 /** Changes below this name (it is a directory on some side). */
320 children?: Map<string, ChangeNode>;
321}
322
323/**
324 * Applies leaf-level changes (a {@link diffTrees} file list, e.g. the
325 * mergeBase→head changes of a trivial three-way merge) onto `baseTree`,
326 * rebuilding every touched directory bottom-up with the canonical
327 * {@link encodeTree}. Directories that become empty are dropped from their
328 * parent (git has no empty trees in practice).
329 *
330 * Kind changes compose from the diff's leaf pairs without special cases:
331 * dir→file arrives as deletions under `p/` plus a leaf add at `p` (the
332 * emptied subtree is dropped, then the blob wins); file→dir is the mirror
333 * (the rebuilt subtree replaces the removed blob).
334 *
335 * **No blob is ever created** — every blob/gitlink oid referenced by an
336 * add/modify must already exist in the store (it came in with the head
337 * push). Gitlink oids are commit oids and are exempt from the existence
338 * assertion; any missing *blob* fails with {@link StoreError} (connectivity
339 * invariant).
340 */
341export const applyTreeChanges = (
342 objects: TreeDiffObjects,
343 baseTree: Oid,
344 changes: ReadonlyArray<DiffEntryData>,
345): Effect.Effect<ApplyTreeChangesResult, StoreError> =>
346 Effect.gen(function* () {
347 // Connectivity invariant: referenced blobs must already exist.
348 const requiredBlobs = new Set<Oid>();
349 for (const change of changes) {
350 if (
351 change.newOid !== undefined &&
352 change.newMode !== undefined &&
353 treeEntryKind(change.newMode) !== "commit"
354 ) {
355 requiredBlobs.add(change.newOid);
356 }
357 }
358 if (requiredBlobs.size > 0) {
359 const metas = yield* objects.getMetaBatch(Array.from(requiredBlobs));
360 for (const oid of requiredBlobs) {
361 if (!metas.has(oid)) {
362 return yield* new StoreError({
363 reason: `applyTreeChanges: referenced blob ${oid} is missing from the store`,
364 });
365 }
366 }
367 }
368
369 // Build the nested change tree keyed by path segment.
370 const rootNode: ChangeNode = { children: new Map() };
371 for (const change of changes) {
372 const segments = change.path.split("/");
373 let node = rootNode;
374 for (let i = 0; i < segments.length; i++) {
375 node.children ??= new Map();
376 const name = segments[i]!;
377 let next = node.children.get(name);
378 if (next === undefined) {
379 next = {};
380 node.children.set(name, next);
381 }
382 node = next;
383 }
384 node.leaf = change;
385 }
386
387 const newTrees: Array<NewTreeObject> = [];
388
389 const readEntries = Effect.fn(function* (oid: Oid) {
390 const content = yield* objects.readContent(oid);
391 return yield* parseTree(content).pipe(
392 Effect.mapError((e) => new StoreError({ reason: e.reason })),
393 );
394 });
395
396 /**
397 * Rebuilds one directory: `baseOid` is the base-side subtree (absent
398 * when the directory is new), `node.children` the changes below it.
399 * Returns the resulting tree oid, or `undefined` when it came out
400 * empty.
401 */
402 const build = (
403 baseOid: Oid | undefined,
404 children: Map<string, ChangeNode>,
405 ): Effect.Effect<Oid | undefined, StoreError> =>
406 Effect.gen(function* () {
407 const baseEntries =
408 baseOid === undefined ? [] : yield* readEntries(baseOid);
409 const byName = new Map(baseEntries.map((e) => [e.name, e]));
410 for (const [name, node] of children) {
411 const base = byName.get(name);
412 // 1. children first: rebuild the subtree below this name.
413 let subtree: Oid | undefined;
414 if (node.children !== undefined) {
415 const subtreeBase =
416 base !== undefined && treeEntryKind(base.mode) === "tree"
417 ? base.oid
418 : undefined;
419 subtree = yield* build(subtreeBase, node.children);
420 }
421 // 2. the leaf op at this exact name decides the entry.
422 if (
423 node.leaf !== undefined &&
424 node.leaf.newOid !== undefined &&
425 node.leaf.newMode !== undefined
426 ) {
427 // added/modified blob or gitlink — the leaf wins.
428 byName.set(name, {
429 mode: node.leaf.newMode,
430 name,
431 oid: node.leaf.newOid,
432 });
433 } else if (node.children !== undefined) {
434 // subtree result (also covers file→dir: the removed blob is
435 // replaced by the rebuilt subtree, or dropped when empty).
436 if (subtree === undefined) byName.delete(name);
437 else byName.set(name, { mode: TREE_MODE, name, oid: subtree });
438 } else if (node.leaf !== undefined) {
439 // removed leaf
440 byName.delete(name);
441 }
442 }
443 const entries = Array.from(byName.values());
444 if (entries.length === 0) return undefined;
445 const content = yield* encodeTree(entries).pipe(
446 Effect.mapError((e) => new StoreError({ reason: e.reason })),
447 );
448 const oid = yield* hashObject(ObjectType.tree, content);
449 if (oid !== baseOid) {
450 newTrees.push({ oid, content });
451 }
452 return oid;
453 });
454
455 const root = yield* build(baseTree, rootNode.children ?? new Map());
456 if (root === undefined) {
457 // Everything was deleted: the canonical empty tree.
458 const content = new Uint8Array(0);
459 const oid = yield* hashObject(ObjectType.tree, content);
460 newTrees.push({ oid, content });
461 return { root: oid, newTrees };
462 }
463 return { root, newTrees };
464 });