char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somfix path-history smart query type (+ add a new obj frame type)5d40e97

main
6.7 KiB198 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  status: "added" | "deleted" | "modified";
32  oldOid: string | null;
33  newOid: string | null;
34  mode: number;
35}
36
37const TREE = 0o040000;
38const GITLINK = 0o160000;
39const isTree = (mode: number) => (mode & 0o170000) === TREE;
40
41/** the entry at `path` within `tree`; null if absent (or under a non-tree) */
42export async function objectAt(repo: GitRepo, tree: string, path: string[]): Promise<LocatedObject | null> {
43  let object: LocatedObject | null = { oid: tree, mode: TREE };
44  for (const name of path) {
45    if (!object || !isTree(object.mode)) return null;
46    object = (await repo.tree(object.oid)).find(entry => entry.name === name) ?? null;
47  }
48  return object;
49}
50
51export interface PathChange {
52  commit: Commit;
53  /** null when the commit deleted it */
54  object: LocatedObject | null;
55}
56
57/**
58 * `git log -- path`: commits whose object at `path` differs from every
59 * parent's, following only a TREESAME parent through merges that didn't
60 * touch it. the daemon computes pages when it can; otherwise the same walk
61 * runs here, picking up from whatever frontier the daemon last returned.
62 * `scanned` reports commits examined by the local walk, for progress.
63 */
64export async function* pathHistory(
65  repo: GitRepo,
66  tips: string[],
67  path: string[],
68  scanned: (count: number) => void,
69): AsyncGenerator<PathChange> {
70  let frontier = tips;
71  while (frontier.length > 0) {
72    const page = await repo.pathHistory(frontier, path, HISTORY_PAGE);
73    if (!page) return yield* walkPathHistory(repo, frontier, path, scanned);
74    for (const commit of page.commits) {
75      yield { commit, object: await objectAt(repo, commit.tree, path) };
76    }
77    frontier = page.frontier;
78  }
79}
80
81const sameObject = (a: LocatedObject | null, b: LocatedObject | null) =>
82  a?.oid === b?.oid && a?.mode === b?.mode;
83
84async function* walkPathHistory(
85  repo: GitRepo,
86  frontier: string[],
87  path: string[],
88  scanned: (count: number) => void,
89): AsyncGenerator<PathChange> {
90  const seen = new Set<string>();
91  const queue: Commit[] = []; // newest committer date first, like git
92  const located = new Map<string, LocatedObject | null>();
93  const locate = async (tree: string) => {
94    let object = located.get(tree);
95    if (object === undefined) located.set(tree, object = await objectAt(repo, tree, path));
96    return object;
97  };
98  const push = async (oid: string) => {
99    const commit = await repo.commit(oid); // peels tags
100    if (seen.has(commit.oid)) return;
101    seen.add(commit.oid);
102    const at = queue.findIndex(other => other.committer.time < commit.committer.time);
103    queue.splice(at === -1 ? queue.length : at, 0, commit);
104  };
105
106  for (const oid of frontier) await push(oid);
107  while (queue.length > 0) {
108    const commit = queue.shift()!;
109    scanned(1);
110    const object = await locate(commit.tree);
111    let treesame: string | undefined;
112    for (const parent of commit.parents) {
113      if (sameObject(object, await locate((await repo.commit(parent)).tree))) {
114        treesame = parent;
115        break;
116      }
117    }
118    if (treesame !== undefined) {
119      await push(treesame);
120      continue;
121    }
122    if (commit.parents.length === 0 && !object) continue;
123    yield { commit, object };
124    for (const parent of commit.parents) await push(parent);
125  }
126}
127
128/** diff two trees, skipping identical subtrees by oid */
129export async function treeDiff(
130  repo: GitRepo,
131  oldTree: string | null,
132  newTree: string | null,
133  prefix = "",
134): Promise<FileChange[]> {
135  if (oldTree === newTree) return [];
136  const changes: FileChange[] = [];
137  let frontier = [{ oldTree, newTree, prefix }];
138
139  while (frontier.length > 0) {
140    await repo.prefetch(
141      frontier.flatMap(pair => [pair.oldTree, pair.newTree].filter(oid => oid !== null)),
142      { depth: 0 },
143    );
144    const subtrees: typeof frontier = [];
145
146    for (const pair of frontier) {
147      if (pair.oldTree === pair.newTree) continue;
148      const [olds, news] = await Promise.all([
149        pair.oldTree ? repo.tree(pair.oldTree) : Promise.resolve([]),
150        pair.newTree ? repo.tree(pair.newTree) : Promise.resolve([]),
151      ]);
152      const oldByName = new Map(olds.map(entry => [entry.name, entry]));
153
154      for (const entry of news) {
155        const old = oldByName.get(entry.name);
156        oldByName.delete(entry.name);
157        const path = pair.prefix + entry.name;
158        if (old?.oid === entry.oid && old.mode === entry.mode) continue;
159
160        if (isTree(entry.mode) || (old && isTree(old.mode))) {
161          subtrees.push({
162            oldTree: old && isTree(old.mode) ? old.oid : null,
163            newTree: isTree(entry.mode) ? entry.oid : null,
164            prefix: path + "/",
165          });
166          // an entry that changed kind between tree and file also diffs as a file
167          if (!isTree(entry.mode) && entry.mode !== GITLINK) {
168            changes.push({ path, status: "added", oldOid: null, newOid: entry.oid, mode: entry.mode });
169          }
170          if (old && !isTree(old.mode) && old.mode !== GITLINK) {
171            changes.push({ path, status: "deleted", oldOid: old.oid, newOid: null, mode: old.mode });
172          }
173          continue;
174        }
175        if (entry.mode === GITLINK || old?.mode === GITLINK) continue;
176        changes.push({
177          path,
178          status: old ? "modified" : "added",
179          oldOid: old?.oid ?? null,
180          newOid: entry.oid,
181          mode: entry.mode,
182        });
183      }
184
185      for (const old of oldByName.values()) {
186        const path = pair.prefix + old.name;
187        if (isTree(old.mode)) {
188          subtrees.push({ oldTree: old.oid, newTree: null, prefix: path + "/" });
189        } else if (old.mode !== GITLINK) {
190          changes.push({ path, status: "deleted", oldOid: old.oid, newOid: null, mode: old.mode });
191        }
192      }
193    }
194    frontier = subtrees;
195  }
196
197  return changes.sort((a, b) => (a.path < b.path ? -1 : 1));
198}