forks/sorcery

Mirror of https://git.t4t.associates/char/sorcery

git clone https://git.gae.moe/forks/sorcery

Charlotte Somshow renames and stat overview in the commit diff view768100d

main
10.4 KiB287 linesraw
1import type { Commit, LocatedObject } from "./types.ts";
2import type { GitRepo } from "./repo.ts";
3
4/**
5 * changes per path-history request. each comes with its trees along the
6 * path, and the daemon's bundles cap out at 128 objects.
7 */
8const HISTORY_PAGE = 10;
9
10/** breadth-first walk matching gix's default revision order */
11export async function* log(repo: GitRepo, tips: string[], pageSize: number): AsyncGenerator<Commit> {
12  const seen = new Set<string>(tips);
13  const frontier = [...tips];
14
15  while (frontier.length > 0) {
16    await repo.prefetch(frontier.slice(0, pageSize), { smart: "commit-pagination", limit: pageSize });
17    for (let i = 0; i < pageSize && frontier.length > 0; i++) {
18      const commit = await repo.commit(frontier.shift()!);
19      yield commit;
20      for (const parent of commit.parents) {
21        if (seen.has(parent)) continue;
22        seen.add(parent);
23        frontier.push(parent);
24      }
25    }
26  }
27}
28
29export interface FileChange {
30  path: string;
31  from?: string;
32  status: "added" | "deleted" | "modified" | "renamed";
33  oldOid: string | null;
34  newOid: string | null;
35  mode: number;
36}
37
38const TREE = 0o040000;
39const GITLINK = 0o160000;
40const isTree = (mode: number) => (mode & 0o170000) === TREE;
41
42/** the entry at `path` within `tree`; null if absent (or under a non-tree) */
43export async function objectAt(repo: GitRepo, tree: string, path: string[]): Promise<LocatedObject | null> {
44  let object: LocatedObject | null = { oid: tree, mode: TREE };
45  for (const name of path) {
46    if (!object || !isTree(object.mode)) return null;
47    object = (await repo.tree(object.oid)).find(entry => entry.name === name) ?? null;
48  }
49  return object;
50}
51
52export interface PathChange {
53  commit: Commit;
54  /** null when the commit deleted it */
55  object: LocatedObject | null;
56}
57
58/**
59 * `git log -- path`: commits whose object at `path` differs from every
60 * parent's, following only a TREESAME parent through merges that didn't
61 * touch it. the daemon computes pages when it can; otherwise the same walk
62 * runs here, picking up from whatever frontier the daemon last returned.
63 * `scanned` reports commits examined by the local walk, for progress.
64 */
65export async function* pathHistory(
66  repo: GitRepo,
67  tips: string[],
68  path: string[],
69  scanned: (count: number) => void,
70): AsyncGenerator<PathChange> {
71  let frontier = tips;
72  while (frontier.length > 0) {
73    const page = await repo.pathHistory(frontier, path, HISTORY_PAGE);
74    if (!page) return yield* walkPathHistory(repo, frontier, path, scanned);
75    for (const commit of page.commits) {
76      yield { commit, object: await objectAt(repo, commit.tree, path) };
77    }
78    frontier = page.frontier;
79  }
80}
81
82const sameObject = (a: LocatedObject | null, b: LocatedObject | null) =>
83  a?.oid === b?.oid && a?.mode === b?.mode;
84
85async function* walkPathHistory(
86  repo: GitRepo,
87  frontier: string[],
88  path: string[],
89  scanned: (count: number) => void,
90): AsyncGenerator<PathChange> {
91  const seen = new Set<string>();
92  const queue: Commit[] = []; // newest committer date first, like git
93  const located = new Map<string, LocatedObject | null>();
94  const locate = async (tree: string) => {
95    let object = located.get(tree);
96    if (object === undefined) located.set(tree, object = await objectAt(repo, tree, path));
97    return object;
98  };
99  const push = async (oid: string) => {
100    const commit = await repo.commit(oid); // peels tags
101    if (seen.has(commit.oid)) return;
102    seen.add(commit.oid);
103    const at = queue.findIndex(other => other.committer.time < commit.committer.time);
104    queue.splice(at === -1 ? queue.length : at, 0, commit);
105  };
106
107  for (const oid of frontier) await push(oid);
108  while (queue.length > 0) {
109    const commit = queue.shift()!;
110    scanned(1);
111    const object = await locate(commit.tree);
112    let treesame: string | undefined;
113    for (const parent of commit.parents) {
114      if (sameObject(object, await locate((await repo.commit(parent)).tree))) {
115        treesame = parent;
116        break;
117      }
118    }
119    if (treesame !== undefined) {
120      await push(treesame);
121      continue;
122    }
123    if (commit.parents.length === 0 && !object) continue;
124    yield { commit, object };
125    for (const parent of commit.parents) await push(parent);
126  }
127}
128
129/** diff two trees, skipping identical subtrees by oid */
130export async function treeDiff(
131  repo: GitRepo,
132  oldTree: string | null,
133  newTree: string | null,
134  prefix = "",
135): Promise<FileChange[]> {
136  if (oldTree === newTree) return [];
137  const changes: FileChange[] = [];
138  let frontier = [{ oldTree, newTree, prefix }];
139
140  while (frontier.length > 0) {
141    await repo.prefetch(
142      frontier.flatMap(pair => [pair.oldTree, pair.newTree].filter(oid => oid !== null)),
143      { depth: 0 },
144    );
145    const subtrees: typeof frontier = [];
146
147    for (const pair of frontier) {
148      if (pair.oldTree === pair.newTree) continue;
149      const [olds, news] = await Promise.all([
150        pair.oldTree ? repo.tree(pair.oldTree) : Promise.resolve([]),
151        pair.newTree ? repo.tree(pair.newTree) : Promise.resolve([]),
152      ]);
153      const oldByName = new Map(olds.map(entry => [entry.name, entry]));
154
155      for (const entry of news) {
156        const old = oldByName.get(entry.name);
157        oldByName.delete(entry.name);
158        const path = pair.prefix + entry.name;
159        if (old?.oid === entry.oid && old.mode === entry.mode) continue;
160
161        if (isTree(entry.mode) || (old && isTree(old.mode))) {
162          subtrees.push({
163            oldTree: old && isTree(old.mode) ? old.oid : null,
164            newTree: isTree(entry.mode) ? entry.oid : null,
165            prefix: path + "/",
166          });
167          // an entry that changed kind between tree and file also diffs as a file
168          if (!isTree(entry.mode) && entry.mode !== GITLINK) {
169            changes.push({ path, status: "added", oldOid: null, newOid: entry.oid, mode: entry.mode });
170          }
171          if (old && !isTree(old.mode) && old.mode !== GITLINK) {
172            changes.push({ path, status: "deleted", oldOid: old.oid, newOid: null, mode: old.mode });
173          }
174          continue;
175        }
176        if (entry.mode === GITLINK || old?.mode === GITLINK) continue;
177        changes.push({
178          path,
179          status: old ? "modified" : "added",
180          oldOid: old?.oid ?? null,
181          newOid: entry.oid,
182          mode: entry.mode,
183        });
184      }
185
186      for (const old of oldByName.values()) {
187        const path = pair.prefix + old.name;
188        if (isTree(old.mode)) {
189          subtrees.push({ oldTree: old.oid, newTree: null, prefix: path + "/" });
190        } else if (old.mode !== GITLINK) {
191          changes.push({ path, status: "deleted", oldOid: old.oid, newOid: null, mode: old.mode });
192        }
193      }
194    }
195    frontier = subtrees;
196  }
197
198  return detectRenames(repo, changes.sort((a, b) => (a.path < b.path ? -1 : 1)));
199}
200
201const RENAME_SIMILARITY = 0.5;
202
203type LineProfile = { counts: Map<string, number>; lines: number };
204
205function profile(text: string): LineProfile {
206  const lines = text === "" ? [] : text.split("\n");
207  if (lines.at(-1) === "") lines.pop();
208  const counts = new Map<string, number>();
209  for (const line of lines) counts.set(line, (counts.get(line) ?? 0) + 1);
210  return { counts, lines: lines.length };
211}
212
213/**
214 * git-style rename detection over a tree diff's deletions and additions
215 * 1. pair identical blobs at differing paths
216 * 2. pair most-similar blobs that have >=50% shared lines
217 */
218async function detectRenames(repo: GitRepo, changes: FileChange[]): Promise<FileChange[]> {
219  // a path that changed kind is both deleted and added; its halves belong to each other, not to a rename
220  const addedPaths = new Set(changes.filter(c => c.status === "added").map(c => c.path));
221  const deleted = changes.filter(c => c.status === "deleted" && !addedPaths.has(c.path));
222  const deletedPaths = new Set(changes.filter(c => c.status === "deleted").map(c => c.path));
223  const added = changes.filter(c => c.status === "added" && !deletedPaths.has(c.path));
224  if (deleted.length === 0 || added.length === 0) return changes;
225
226  // pairing converts the added entry in place; `changes` is sorted by the new path, so it stays sorted
227  const gone = new Set<FileChange>();
228  const taken = new Set<FileChange>();
229  const pair = (from: FileChange, to: FileChange) => {
230    gone.add(from);
231    taken.add(to);
232    to.status = "renamed";
233    to.from = from.path;
234    to.oldOid = from.oldOid;
235  };
236
237  const addedByOid = new Map<string, FileChange[]>();
238  for (const change of added) {
239    const queue = addedByOid.get(change.newOid!) ?? [];
240    queue.push(change);
241    addedByOid.set(change.newOid!, queue);
242  }
243  for (const change of deleted) {
244    const match = addedByOid.get(change.oldOid!)?.shift();
245    if (match) pair(change, match);
246  }
247
248  const leftoverDeleted = deleted.filter(c => !gone.has(c));
249  const leftoverAdded = added.filter(c => !taken.has(c));
250  if (leftoverDeleted.length > 0 && leftoverAdded.length > 0) {
251    const leftover = [...leftoverDeleted, ...leftoverAdded];
252    await repo.prefetch(leftover.map(c => (c.oldOid ?? c.newOid)!), { depth: 0 });
253    const profiles = new Map<FileChange, LineProfile | null>();
254    await Promise.all(leftover.map(async change => {
255      const data = await repo.blob((change.oldOid ?? change.newOid)!);
256      profiles.set(
257        change,
258        data.subarray(0, 8192).includes(0) ? null : profile(new TextDecoder().decode(data)),
259      );
260    }));
261    const candidates: { from: FileChange; to: FileChange; score: number }[] = [];
262    for (const from of leftoverDeleted) {
263      const a = profiles.get(from);
264      if (!a || a.lines === 0) continue;
265      for (const to of leftoverAdded) {
266        const b = profiles.get(to);
267        if (!b || b.lines === 0) continue;
268        if (Math.min(a.lines, b.lines) / Math.max(a.lines, b.lines) < RENAME_SIMILARITY) continue;
269        let shared = 0;
270        const [smaller, larger] = a.counts.size <= b.counts.size ? [a, b] : [b, a];
271        for (const [line, count] of smaller.counts) {
272          shared += Math.min(count, larger.counts.get(line) ?? 0);
273        }
274        const score = shared / Math.max(a.lines, b.lines);
275        if (score >= RENAME_SIMILARITY) candidates.push({ from, to, score });
276      }
277    }
278    candidates.sort((x, y) =>
279      y.score - x.score || (x.from.path < y.from.path ? -1 : 1) || (x.to.path < y.to.path ? -1 : 1)
280    );
281    for (const { from, to } of candidates) {
282      if (!gone.has(from) && !taken.has(to)) pair(from, to);
283    }
284  }
285
286  return gone.size === 0 ? changes : changes.filter(c => !gone.has(c));
287}