-
Notifications
You must be signed in to change notification settings - Fork 307
Expand file tree
/
Copy pathgetFileBlameApi.ts
More file actions
214 lines (188 loc) · 9.2 KB
/
Copy pathgetFileBlameApi.ts
File metadata and controls
214 lines (188 loc) · 9.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
import { sew } from "@/middleware/sew";
import { getAuditService } from '@/ee/features/audit/factory';
import { ServiceError, notFound, fileNotFound, invalidGitRef, unresolvedGitRef, unexpectedError } from '@/lib/serviceError';
import { withOptionalAuth } from '@/middleware/withAuth';
import { getRepoPath } from '@sourcebot/shared';
import { headers } from 'next/headers';
import simpleGit from 'simple-git';
import type z from 'zod';
import { isGitRefValid, isPathValid } from './utils';
import { fileBlameRequestSchema, fileBlameResponseSchema } from './schemas';
export { fileBlameRequestSchema, fileBlameResponseSchema } from './schemas';
export type FileBlameRequest = z.infer<typeof fileBlameRequestSchema>;
export type FileBlameResponse = z.infer<typeof fileBlameResponseSchema>;
type CommitMeta = FileBlameResponse['commits'][string];
/**
* Parses `git blame --porcelain` output into ranges and deduplicated commit metadata.
*
* Format reference: each blamed line produces an entry of the form
*
* <hash> <orig-line> <final-line> [<num-lines>] (4-field header → first line of a group)
* [author <name> (metadata block, emitted only on
* author-mail <<email>> the first global appearance of a
* author-time <unix-ts> commit; subsequent groups for the
* author-tz <+/-HHMM> same commit are header-only. With
* committer ... -C/-M, `filename` may be re-emitted
* summary <subject> if it differs from the prior value.)
* previous <hash> <path> (optional)
* filename <path>]
* \t<line content>
*
* Within a contiguous group of lines from the same commit, only the first line's
* header carries `<num-lines>`; subsequent lines have a 3-field header. We detect
* group boundaries via the presence of `<num-lines>` and emit one range per group.
*
* Because `filename` is emitted per-commit (not per-group), we cache it in
* `filenameByHash` and look it up when pushing a range.
*/
const parsePorcelainBlame = (output: string): FileBlameResponse => {
const ranges: FileBlameResponse['ranges'] = [];
const commits: Record<string, CommitMeta> = {};
const filenameByHash = new Map<string, string>();
if (output.length === 0) {
return { ranges, commits };
}
const rawLines = output.split('\n');
let i = 0;
while (i < rawLines.length) {
const headerLine = rawLines[i];
if (headerLine.length === 0) {
i++;
continue;
}
const headerParts = headerLine.split(' ');
const hash = headerParts[0];
const finalLine = parseInt(headerParts[2], 10);
if (!hash || Number.isNaN(finalLine)) {
throw new Error(`Malformed git blame porcelain header: "${headerLine}"`);
}
// Group-start headers carry a 4th field with the group size; intra-group
// continuation headers have only 3 fields and don't start a new range.
const isGroupStart = headerParts.length >= 4;
const lineCount = isGroupStart ? parseInt(headerParts[3], 10) : NaN;
if (isGroupStart && Number.isNaN(lineCount)) {
throw new Error(`Malformed git blame porcelain header (bad num-lines): "${headerLine}"`);
}
i++;
// Metadata lines may appear after any header but only the first time we
// see a given commit. Accumulate whatever's there until the "\t<content>"
// sentinel; for continuation lines this loop usually exits immediately.
let authorName: string | undefined;
let authorMail: string | undefined;
let date: string | undefined;
let message: string | undefined;
let previous: CommitMeta['previous'] | undefined;
while (i < rawLines.length && !rawLines[i].startsWith('\t')) {
const fieldLine = rawLines[i];
const spaceIdx = fieldLine.indexOf(' ');
const key = spaceIdx === -1 ? fieldLine : fieldLine.substring(0, spaceIdx);
const value = spaceIdx === -1 ? '' : fieldLine.substring(spaceIdx + 1);
if (key === 'author') {
authorName = value;
} else if (key === 'author-mail') {
authorMail = value.replace(/^<|>$/g, '');
} else if (key === 'author-time') {
date = new Date(parseInt(value, 10) * 1000).toISOString();
} else if (key === 'summary') {
message = value;
} else if (key === 'previous') {
// "previous <hash> <path>" — path may contain spaces, so split once.
const sep = value.indexOf(' ');
if (sep !== -1) {
previous = {
hash: value.substring(0, sep),
path: value.substring(sep + 1),
};
}
} else if (key === 'filename') {
filenameByHash.set(hash, value);
}
// committer*, boundary are intentionally ignored.
i++;
}
// Skip the "\t<content>" sentinel; the file source is fetched separately.
if (i < rawLines.length && rawLines[i].startsWith('\t')) {
i++;
}
if (!commits[hash] && authorName !== undefined && authorMail !== undefined && date !== undefined && message !== undefined) {
commits[hash] = {
hash,
date,
message,
authorName,
authorEmail: authorMail,
...(previous ? { previous } : {}),
};
}
if (isGroupStart) {
const path = filenameByHash.get(hash);
if (path === undefined) {
throw new Error(`Malformed git blame porcelain output: missing "filename" for commit ${hash}`);
}
ranges.push({ hash, path, startLine: finalLine, lineCount });
}
}
// Coalesce adjacent same-commit ranges. Porcelain emits a fresh group
// whenever the source-line numbering is discontinuous in the commit's
// snapshot, even when the final-file lines are contiguous and attributed
// to the same commit.
const coalescedRanges: FileBlameResponse['ranges'] = [];
for (const range of ranges) {
const last = coalescedRanges[coalescedRanges.length - 1];
if (last && last.hash === range.hash && last.path === range.path && last.startLine + last.lineCount === range.startLine) {
last.lineCount += range.lineCount;
} else {
coalescedRanges.push({ ...range });
}
}
return { ranges: coalescedRanges, commits };
};
export const getFileBlame = async ({ path: filePath, repo: repoName, ref }: FileBlameRequest, { source }: { source?: string } = {}): Promise<FileBlameResponse | ServiceError> =>
sew(() =>
withOptionalAuth(async ({ org, prisma, user }) => {
if (user) {
const resolvedSource = source ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
await getAuditService().createAudit({
action: 'user.fetched_file_blame',
actor: { id: user.id, type: 'user' },
target: { id: org.id.toString(), type: 'org' },
orgId: org.id,
metadata: { source: resolvedSource },
});
}
const repo = await prisma.repo.findFirst({
where: { name: repoName, orgId: org.id },
});
if (!repo) {
return notFound(`Repository "${repoName}" not found.`);
}
if (!isPathValid(filePath)) {
return fileNotFound(filePath, repoName);
}
if (ref !== undefined && !isGitRefValid(ref)) {
return invalidGitRef(ref);
}
const { path: repoPath } = getRepoPath(repo);
const git = simpleGit().cwd(repoPath);
const gitRef = ref ?? repo.defaultBranch ?? 'HEAD';
let porcelain: string;
try {
porcelain = await git.raw(['blame', '--porcelain', gitRef, '--', filePath]);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('no such path') || errorMessage.includes('does not exist') || errorMessage.includes('fatal: path') || errorMessage.includes('no such file')) {
return fileNotFound(filePath, repoName);
}
if (errorMessage.includes('unknown revision') || errorMessage.includes('bad revision') || errorMessage.includes('invalid object name')) {
return unresolvedGitRef(gitRef);
}
return unexpectedError(errorMessage);
}
try {
return parsePorcelainBlame(porcelain) satisfies FileBlameResponse;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
return unexpectedError(`Failed to parse git blame output: ${errorMessage}`);
}
})
);