197 lines · 5.8 KB
1 /**
2 * Repository browsing service.
3 * Walks git objects in R2 to support tree/blob/commit viewing.
4 */
5
6 import { parseCommit, parseTree, type ParsedCommit, type TreeEntry } from '@gitfastr/shared';
7 import { ObjectStore } from './object-store.js';
8 import { RefStore } from './ref-store.js';
9 import { NotFoundError } from '@gitfastr/shared/utils/errors.js';
10
11 export interface FileEntry {
12 name: string;
13 type: 'file' | 'dir' | 'symlink';
14 mode: string;
15 sha: string;
16 }
17
18 export interface CommitInfo {
19 sha: string;
20 message: string;
21 author: string;
22 committer: string;
23 parents: string[];
24 tree: string;
25 }
26
27 export class BrowseService {
28 constructor(
29 private objectStore: ObjectStore,
30 private refStore: RefStore
31 ) {}
32
33 /** Resolve a ref name (branch/tag) or raw SHA to a commit SHA. */
34 async resolveRef(ref: string): Promise<string> {
35 // If it looks like a full SHA, use it directly
36 if (/^[0-9a-f]{40}$/.test(ref)) {
37 return ref;
38 }
39
40 // Try as a branch
41 const branchRef = await this.refStore.get(`refs/heads/${ref}`);
42 if (branchRef) return branchRef.sha;
43
44 // Try as a tag
45 const tagRef = await this.refStore.get(`refs/tags/${ref}`);
46 if (tagRef) return tagRef.sha;
47
48 throw new NotFoundError(`Ref not found: ${ref}`);
49 }
50
51 /** Get a parsed commit object. */
52 async getCommit(sha: string): Promise<CommitInfo> {
53 const obj = await this.objectStore.get(sha);
54 if (!obj || obj.type !== 'commit') {
55 throw new NotFoundError(`Commit not found: ${sha}`);
56 }
57 const parsed = parseCommit(obj.content);
58 return {
59 sha,
60 message: parsed.message,
61 author: parsed.author,
62 committer: parsed.committer,
63 parents: parsed.parents,
64 tree: parsed.tree,
65 };
66 }
67
68 /** List files/dirs at a path within a commit's tree. */
69 async listTree(commitSha: string, path: string): Promise<FileEntry[]> {
70 const commit = await this.getCommit(commitSha);
71 const treeSha = await this.resolveTreePath(commit.tree, path);
72
73 const obj = await this.objectStore.get(treeSha);
74 if (!obj || obj.type !== 'tree') {
75 throw new NotFoundError(`Tree not found at path: ${path}`);
76 }
77
78 const entries = parseTree(obj.content);
79 return entries.map((e) => ({
80 name: e.name,
81 type: (e.mode === '040000' || e.mode === '40000' ? 'dir'
82 : e.mode === '120000' ? 'symlink'
83 : 'file') as 'file' | 'dir' | 'symlink',
84 mode: e.mode,
85 sha: e.sha,
86 })).sort((a, b) => {
87 // Directories first, then files
88 if (a.type === 'dir' && b.type !== 'dir') return -1;
89 if (a.type !== 'dir' && b.type === 'dir') return 1;
90 return a.name.localeCompare(b.name);
91 });
92 }
93
94 /** Get a blob's content at a path within a commit's tree. */
95 async getBlob(commitSha: string, path: string): Promise<{ content: Uint8Array; sha: string }> {
96 const commit = await this.getCommit(commitSha);
97 const blobSha = await this.resolveBlobPath(commit.tree, path);
98
99 const obj = await this.objectStore.get(blobSha);
100 if (!obj || obj.type !== 'blob') {
101 throw new NotFoundError(`Blob not found at path: ${path}`);
102 }
103
104 return { content: obj.content, sha: blobSha };
105 }
106
107 /** Get commit log starting from a SHA, walking parents. */
108 async getCommitLog(startSha: string, limit: number = 20): Promise<CommitInfo[]> {
109 const commits: CommitInfo[] = [];
110 const visited = new Set<string>();
111 const queue = [startSha];
112
113 while (queue.length > 0 && commits.length < limit) {
114 const sha = queue.shift()!;
115 if (visited.has(sha)) continue;
116 visited.add(sha);
117
118 try {
119 const commit = await this.getCommit(sha);
120 commits.push(commit);
121 // Add parents to queue (BFS for chronological-ish order)
122 for (const parent of commit.parents) {
123 if (!visited.has(parent)) {
124 queue.push(parent);
125 }
126 }
127 } catch {
128 // Missing commit — stop walking this branch
129 }
130 }
131
132 return commits;
133 }
134
135 /**
136 * Resolve a path within a tree to a sub-tree SHA.
137 * Path segments like "src/components" walk through nested trees.
138 */
139 private async resolveTreePath(rootTreeSha: string, path: string): Promise<string> {
140 if (!path || path === '' || path === '/') {
141 return rootTreeSha;
142 }
143
144 const segments = path.split('/').filter(Boolean);
145 let currentSha = rootTreeSha;
146
147 for (const segment of segments) {
148 const obj = await this.objectStore.get(currentSha);
149 if (!obj || obj.type !== 'tree') {
150 throw new NotFoundError(`Path not found: ${path}`);
151 }
152
153 const entries = parseTree(obj.content);
154 const entry = entries.find((e) => e.name === segment);
155 if (!entry) {
156 throw new NotFoundError(`Path not found: ${path}`);
157 }
158
159 currentSha = entry.sha;
160 }
161
162 return currentSha;
163 }
164
165 /**
166 * Resolve a file path to a blob SHA.
167 * Walks the tree to the parent directory, then finds the file entry.
168 */
169 private async resolveBlobPath(rootTreeSha: string, path: string): Promise<string> {
170 const segments = path.split('/').filter(Boolean);
171 if (segments.length === 0) {
172 throw new NotFoundError('Empty path');
173 }
174
175 // Walk to the parent directory
176 const dirSegments = segments.slice(0, -1);
177 const fileName = segments[segments.length - 1];
178
179 const dirSha = dirSegments.length > 0
180 ? await this.resolveTreePath(rootTreeSha, dirSegments.join('/'))
181 : rootTreeSha;
182
183 const obj = await this.objectStore.get(dirSha);
184 if (!obj || obj.type !== 'tree') {
185 throw new NotFoundError(`Directory not found for path: ${path}`);
186 }
187
188 const entries = parseTree(obj.content);
189 const entry = entries.find((e) => e.name === fileName);
190 if (!entry) {
191 throw new NotFoundError(`File not found: ${path}`);
192 }
193
194 return entry.sha;
195 }
196 }
197