180 lines · 5.3 KB
1 /**
2 * Diff service: compute file-level and line-level diffs between commits.
3 */
4
5 import { parseTree, parseCommit, type TreeEntry } from '@gitfastr/shared';
6 import { computeDiff, type DiffHunk } from '@gitfastr/shared/git/diff.js';
7 import { ObjectStore } from './object-store.js';
8
9 const decoder = new TextDecoder();
10
11 export type FileChangeStatus = 'added' | 'deleted' | 'modified';
12
13 export interface FileDiff {
14 path: string;
15 status: FileChangeStatus;
16 oldSha: string | null;
17 newSha: string | null;
18 hunks: DiffHunk[];
19 isBinary: boolean;
20 }
21
22 export class DiffService {
23 constructor(private objectStore: ObjectStore) {}
24
25 /**
26 * Compute diffs between two commits.
27 * Returns a list of changed files with their line-level diffs.
28 */
29 async diffCommits(oldCommitSha: string, newCommitSha: string): Promise<FileDiff[]> {
30 const [oldCommit, newCommit] = await Promise.all([
31 this.getCommitTree(oldCommitSha),
32 this.getCommitTree(newCommitSha),
33 ]);
34
35 return this.diffTrees(oldCommit, newCommit, '');
36 }
37
38 /**
39 * Compute diff for a single commit against its first parent.
40 * For root commits, diff against empty tree.
41 */
42 async diffCommitAgainstParent(commitSha: string): Promise<FileDiff[]> {
43 const obj = await this.objectStore.get(commitSha);
44 if (!obj || obj.type !== 'commit') return [];
45
46 const commit = parseCommit(obj.content);
47 const newTreeSha = commit.tree;
48
49 if (commit.parents.length === 0) {
50 // Root commit — diff against empty tree
51 return this.diffTrees(new Map(), await this.flattenTree(newTreeSha, ''), '');
52 }
53
54 const parentObj = await this.objectStore.get(commit.parents[0]);
55 if (!parentObj || parentObj.type !== 'commit') return [];
56 const parentCommit = parseCommit(parentObj.content);
57
58 const [oldTree, newTree] = await Promise.all([
59 this.flattenTree(parentCommit.tree, ''),
60 this.flattenTree(newTreeSha, ''),
61 ]);
62
63 return this.diffTrees(oldTree, newTree, '');
64 }
65
66 private async getCommitTree(commitSha: string): Promise<Map<string, { sha: string; mode: string }>> {
67 const obj = await this.objectStore.get(commitSha);
68 if (!obj || obj.type !== 'commit') return new Map();
69 const commit = parseCommit(obj.content);
70 return this.flattenTree(commit.tree, '');
71 }
72
73 /**
74 * Flatten a tree into a map of path -> {sha, mode}.
75 * Recursively walks subtrees.
76 */
77 private async flattenTree(
78 treeSha: string,
79 prefix: string
80 ): Promise<Map<string, { sha: string; mode: string }>> {
81 const result = new Map<string, { sha: string; mode: string }>();
82 const obj = await this.objectStore.get(treeSha);
83 if (!obj || obj.type !== 'tree') return result;
84
85 const entries = parseTree(obj.content);
86 for (const entry of entries) {
87 const fullPath = prefix ? `${prefix}/${entry.name}` : entry.name;
88 if (entry.mode === '40000' || entry.mode === '040000') {
89 // Recurse into subtree
90 const subTree = await this.flattenTree(entry.sha, fullPath);
91 for (const [path, info] of subTree) {
92 result.set(path, info);
93 }
94 } else {
95 result.set(fullPath, { sha: entry.sha, mode: entry.mode });
96 }
97 }
98
99 return result;
100 }
101
102 /**
103 * Compare two flattened trees and produce file diffs.
104 */
105 private async diffTrees(
106 oldTree: Map<string, { sha: string; mode: string }>,
107 newTree: Map<string, { sha: string; mode: string }>,
108 _prefix: string
109 ): Promise<FileDiff[]> {
110 const allPaths = new Set([...oldTree.keys(), ...newTree.keys()]);
111 const diffs: FileDiff[] = [];
112
113 for (const path of [...allPaths].sort()) {
114 const oldEntry = oldTree.get(path);
115 const newEntry = newTree.get(path);
116
117 if (!oldEntry && newEntry) {
118 // Added
119 const diff = await this.makeFileDiff(path, 'added', null, newEntry.sha);
120 diffs.push(diff);
121 } else if (oldEntry && !newEntry) {
122 // Deleted
123 const diff = await this.makeFileDiff(path, 'deleted', oldEntry.sha, null);
124 diffs.push(diff);
125 } else if (oldEntry && newEntry && oldEntry.sha !== newEntry.sha) {
126 // Modified
127 const diff = await this.makeFileDiff(path, 'modified', oldEntry.sha, newEntry.sha);
128 diffs.push(diff);
129 }
130 }
131
132 return diffs;
133 }
134
135 private async makeFileDiff(
136 path: string,
137 status: FileChangeStatus,
138 oldSha: string | null,
139 newSha: string | null
140 ): Promise<FileDiff> {
141 let oldContent = '';
142 let newContent = '';
143 let isBinary = false;
144
145 if (oldSha) {
146 const obj = await this.objectStore.get(oldSha);
147 if (obj && obj.type === 'blob') {
148 if (this.isBinaryContent(obj.content)) {
149 isBinary = true;
150 } else {
151 oldContent = decoder.decode(obj.content);
152 }
153 }
154 }
155
156 if (newSha) {
157 const obj = await this.objectStore.get(newSha);
158 if (obj && obj.type === 'blob') {
159 if (this.isBinaryContent(obj.content)) {
160 isBinary = true;
161 } else {
162 newContent = decoder.decode(obj.content);
163 }
164 }
165 }
166
167 const hunks = isBinary ? [] : computeDiff(oldContent, newContent);
168
169 return { path, status, oldSha, newSha, hunks, isBinary };
170 }
171
172 private isBinaryContent(content: Uint8Array): boolean {
173 const checkLen = Math.min(content.length, 8000);
174 for (let i = 0; i < checkLen; i++) {
175 if (content[i] === 0) return true;
176 }
177 return false;
178 }
179 }
180