Skip to content

Commit 5da3214

Browse files
fix(web): address review feedback on citation SHA pinning
- Resolve the git ref to a concrete commit before reading file content and .gitattributes, so all of source, language, and commitSha come from the same revision even if the ref moves mid-request. - Narrow the evidence-panel fallback to only retry at the symbolic ref when the pinned commit is unresolvable (INVALID_GIT_REF); surface other errors. - Pin symbol-search citations from the indexed commit carried by the same search snapshot instead of a follow-up getRepoInfoByName lookup. - Regenerate the public OpenAPI spec for the new commitSha/indexedCommitHash fields. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 01f7716 commit 5da3214

6 files changed

Lines changed: 91 additions & 29 deletions

File tree

docs/api-reference/sourcebot-public.openapi.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,9 @@
381381
},
382382
"webUrl": {
383383
"type": "string"
384+
},
385+
"indexedCommitHash": {
386+
"type": "string"
384387
}
385388
},
386389
"required": [
@@ -576,6 +579,9 @@
576579
},
577580
"externalWebUrl": {
578581
"type": "string"
582+
},
583+
"commitSha": {
584+
"type": "string"
579585
}
580586
},
581587
"required": [
@@ -931,6 +937,9 @@
931937
},
932938
"webUrl": {
933939
"type": "string"
940+
},
941+
"indexedCommitHash": {
942+
"type": "string"
934943
}
935944
},
936945
"required": [

packages/web/src/ee/features/chat/components/chatThread/referencedFileSourceListItemContainer.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getFileSource } from "@/app/api/(client)/client";
44
import { VscodeFileIcon } from "@/app/components/vscodeFileIcon";
55
import { Skeleton } from "@/components/ui/skeleton";
66
import { isServiceError, unwrapServiceError } from "@/lib/utils";
7+
import { ErrorCode } from "@/lib/errorCodes";
78
import { useQuery } from "@tanstack/react-query";
89
import { ReactCodeMirrorRef } from '@uiw/react-codemirror';
910
import { memo, useCallback } from "react";
@@ -51,9 +52,15 @@ const ReferencedFileSourceListItemContainerComponent = ({
5152
ref: fetchRef,
5253
});
5354

54-
// The pinned commit can disappear (e.g. a force-push + GC prunes it).
55-
// Fall back once to the symbolic ref so the file still renders.
56-
if (isServiceError(pinned) && fetchRef !== fileSource.revision) {
55+
// The pinned commit can disappear (e.g. a force-push + GC prunes it),
56+
// which surfaces as an unresolvable git ref. Only that case falls
57+
// back to the symbolic ref; other errors (repo/path/access) are
58+
// surfaced as-is so we don't silently render the wrong revision.
59+
if (
60+
isServiceError(pinned) &&
61+
pinned.errorCode === ErrorCode.INVALID_GIT_REF &&
62+
fetchRef !== fileSource.revision
63+
) {
5764
return unwrapServiceError(getFileSource({
5865
path: fileSource.path,
5966
repo: fileSource.repo,

packages/web/src/features/git/getFileSourceApi.test.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,13 @@ describe('getFileSourceForRepo', () => {
9292
mockSimpleGit.mockReturnValue({ cwd: mockCwd });
9393
mockFindFirst.mockResolvedValue(MOCK_REPO);
9494

95-
// Default: file show succeeds; .gitattributes not present
95+
// Default: ref resolves to a concrete sha, file show succeeds, and
96+
// .gitattributes is absent. The SUT resolves the ref first (rev-parse),
97+
// then reads content + .gitattributes at the resolved sha.
9698
mockGitRaw.mockImplementation(async (args: string[]) => {
99+
if (args[0] === 'rev-parse') {
100+
return 'resolvedsha\n';
101+
}
97102
if (args[1]?.endsWith('.gitattributes')) {
98103
throw new Error('does not exist in HEAD');
99104
}
@@ -170,7 +175,7 @@ describe('getFileSourceForRepo', () => {
170175

171176
describe('git error handling', () => {
172177
it('returns FILE_NOT_FOUND when git reports the file does not exist', async () => {
173-
mockGitRaw.mockRejectedValueOnce(
178+
mockGitRaw.mockRejectedValue(
174179
new Error("fatal: path 'src/missing.ts' does not exist in 'main'"),
175180
);
176181

@@ -183,7 +188,7 @@ describe('getFileSourceForRepo', () => {
183188
});
184189

185190
it('returns FILE_NOT_FOUND for "fatal: path" errors', async () => {
186-
mockGitRaw.mockRejectedValueOnce(new Error('fatal: path not found'));
191+
mockGitRaw.mockRejectedValue(new Error('fatal: path not found'));
187192

188193
const result = await getFileSourceForRepo(
189194
{ path: 'src/index.ts', repo: 'github.com/owner/repo' },
@@ -196,9 +201,9 @@ describe('getFileSourceForRepo', () => {
196201
it('returns INVALID_GIT_REF with an unresolved-ref message when head_sha has not been fetched ("unknown revision")', async () => {
197202
// This is the scenario from the v4.16.14 regression: the review agent passes
198203
// pr_payload.head_sha as ref, but the bare clone hasn't fetched it yet.
199-
mockGitRaw.mockRejectedValueOnce(
204+
mockGitRaw.mockRejectedValue(
200205
new Error("fatal: ambiguous argument 'deadbeef': unknown revision or path not in the working tree"),
201-
);
206+
); // rejects rev-parse (swallowed) and the show, which drives the result
202207

203208
const result = await getFileSourceForRepo(
204209
{ path: 'src/index.ts', repo: 'github.com/owner/repo', ref: 'deadbeef' },
@@ -212,7 +217,7 @@ describe('getFileSourceForRepo', () => {
212217
});
213218

214219
it('returns INVALID_GIT_REF with an unresolved-ref message for "bad revision" errors', async () => {
215-
mockGitRaw.mockRejectedValueOnce(new Error('fatal: bad revision'));
220+
mockGitRaw.mockRejectedValue(new Error('fatal: bad revision'));
216221

217222
const result = await getFileSourceForRepo(
218223
{ path: 'src/index.ts', repo: 'github.com/owner/repo', ref: 'nonexistent' },
@@ -226,7 +231,7 @@ describe('getFileSourceForRepo', () => {
226231
});
227232

228233
it('returns INVALID_GIT_REF with an unresolved-ref message for "invalid object name" errors', async () => {
229-
mockGitRaw.mockRejectedValueOnce(new Error('fatal: invalid object name HEAD'));
234+
mockGitRaw.mockRejectedValue(new Error('fatal: invalid object name HEAD'));
230235

231236
const result = await getFileSourceForRepo(
232237
{ path: 'src/index.ts', repo: 'github.com/owner/repo' },
@@ -243,7 +248,7 @@ describe('getFileSourceForRepo', () => {
243248
// Before the fix, getFileSourceForRepo re-threw unknown errors.
244249
// Outside sew(), this caused a fatal Next.js task-runner exception.
245250
// After the fix, all errors are returned as ServiceError.
246-
mockGitRaw.mockRejectedValueOnce(new Error('I/O error: device busy'));
251+
mockGitRaw.mockRejectedValue(new Error('I/O error: device busy'));
247252

248253
const result = await getFileSourceForRepo(
249254
{ path: 'src/index.ts', repo: 'github.com/owner/repo' },
@@ -254,7 +259,7 @@ describe('getFileSourceForRepo', () => {
254259
});
255260

256261
it('never rejects its returned promise for unrecognised git errors', async () => {
257-
mockGitRaw.mockRejectedValueOnce(new Error('transient I/O error'));
262+
mockGitRaw.mockRejectedValue(new Error('transient I/O error'));
258263

259264
await expect(
260265
getFileSourceForRepo(
@@ -280,13 +285,16 @@ describe('getFileSourceForRepo', () => {
280285
});
281286
});
282287

283-
it('uses the provided ref for the git show command', async () => {
288+
it('resolves the provided ref to a commit, then reads content at it', async () => {
284289
await getFileSourceForRepo(
285290
{ path: 'src/index.ts', repo: 'github.com/owner/repo', ref: 'abc123sha' },
286291
{ org: MOCK_ORG, prisma: mockPrisma },
287292
);
288293

289-
expect(mockGitRaw).toHaveBeenCalledWith(['show', 'abc123sha:src/index.ts']);
294+
// The provided ref is resolved up front...
295+
expect(mockGitRaw).toHaveBeenCalledWith(['rev-parse', 'abc123sha^{commit}']);
296+
// ...and content is read at the resolved sha, not the symbolic ref.
297+
expect(mockGitRaw).toHaveBeenCalledWith(['show', 'resolvedsha:src/index.ts']);
290298
});
291299

292300
it('falls back to defaultBranch when ref is omitted', async () => {
@@ -295,7 +303,7 @@ describe('getFileSourceForRepo', () => {
295303
{ org: MOCK_ORG, prisma: mockPrisma },
296304
);
297305

298-
expect(mockGitRaw).toHaveBeenCalledWith(['show', 'main:src/index.ts']);
306+
expect(mockGitRaw).toHaveBeenCalledWith(['rev-parse', 'main^{commit}']);
299307
});
300308

301309
it('falls back to HEAD when both ref and defaultBranch are absent', async () => {
@@ -306,7 +314,28 @@ describe('getFileSourceForRepo', () => {
306314
{ org: MOCK_ORG, prisma: mockPrisma },
307315
);
308316

309-
expect(mockGitRaw).toHaveBeenCalledWith(['show', 'HEAD:src/index.ts']);
317+
expect(mockGitRaw).toHaveBeenCalledWith(['rev-parse', 'HEAD^{commit}']);
318+
});
319+
320+
it('reads content at the symbolic ref when rev-parse fails', async () => {
321+
mockGitRaw.mockImplementation(async (args: string[]) => {
322+
if (args[0] === 'rev-parse') {
323+
throw new Error('unknown revision');
324+
}
325+
if (args[1]?.endsWith('.gitattributes')) {
326+
throw new Error('does not exist');
327+
}
328+
return 'console.log("hello");';
329+
});
330+
331+
const result = await getFileSourceForRepo(
332+
{ path: 'src/index.ts', repo: 'github.com/owner/repo', ref: 'main' },
333+
{ org: MOCK_ORG, prisma: mockPrisma },
334+
);
335+
336+
expect(mockGitRaw).toHaveBeenCalledWith(['show', 'main:src/index.ts']);
337+
expect(result).toMatchObject({ source: 'console.log("hello");' });
338+
expect((result as { commitSha?: string }).commitSha).toBeUndefined();
310339
});
311340

312341
it('uses the repo path from getRepoPath for the git working directory', async () => {

packages/web/src/features/git/getFileSourceApi.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,21 @@ export const getFileSourceForRepo = async (
4747

4848
const gitRef = ref ?? repo.defaultBranch ?? 'HEAD';
4949

50+
// Resolve the symbolic ref to a concrete commit up front so the content,
51+
// language, and commitSha all come from the same revision even if the ref
52+
// moves mid-request. `^{commit}` peels annotated tags. Reads below fall back
53+
// to the symbolic ref when resolution fails.
54+
let commitSha: string | undefined;
55+
try {
56+
commitSha = (await git.raw(['rev-parse', `${gitRef}^{commit}`])).trim();
57+
} catch {
58+
// Leave unpinned; the reads below use the symbolic ref.
59+
}
60+
const readRef = commitSha ?? gitRef;
61+
5062
let fileContent: string;
5163
try {
52-
fileContent = await git.raw(['show', `${gitRef}:${filePath}`]);
64+
fileContent = await git.raw(['show', `${readRef}:${filePath}`]);
5365
} catch (error: unknown) {
5466
const errorMessage = error instanceof Error ? error.message : String(error);
5567
if (errorMessage.includes('does not exist') || errorMessage.includes('fatal: path')) {
@@ -61,18 +73,9 @@ export const getFileSourceForRepo = async (
6173
return unexpectedError(errorMessage);
6274
}
6375

64-
// Resolve the symbolic ref to a concrete commit SHA so callers can pin a
65-
// citation to the exact code read. `^{commit}` peels annotated tags.
66-
let commitSha: string | undefined;
67-
try {
68-
commitSha = (await git.raw(['rev-parse', `${gitRef}^{commit}`])).trim();
69-
} catch {
70-
// Leave unpinned if the ref can't be resolved.
71-
}
72-
7376
let gitattributesContent: string | undefined;
7477
try {
75-
gitattributesContent = await git.raw(['show', `${gitRef}:.gitattributes`]);
78+
gitattributesContent = await git.raw(['show', `${readRef}:.gitattributes`]);
7679
} catch {
7780
// No .gitattributes in this repo/ref, that's fine
7881
}

packages/web/src/features/tools/findSymbolDefinitions.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ export const findSymbolDefinitionsDefinition: ToolDefinition<
6363
codeHostType: repoInfoResult.codeHostType,
6464
};
6565

66+
// Pin to the indexed commit carried by the same search snapshot that
67+
// produced these matches, rather than a follow-up repo-info lookup that
68+
// could drift if the index advances in between.
69+
const indexedCommitShaByRepo = new Map(
70+
response.repositoryInfo.map((info) => [info.name, info.indexedCommitHash]),
71+
);
72+
6673
const metadata: FindSymbolDefinitionsMetadata = {
6774
symbol,
6875
matchCount,
@@ -72,7 +79,7 @@ export const findSymbolDefinitionsDefinition: ToolDefinition<
7279
fileName: file.fileName,
7380
repo: file.repository,
7481
revision,
75-
commitSha: repoInfoResult.indexedCommitHash,
82+
commitSha: indexedCommitShaByRepo.get(file.repository),
7683
})),
7784
};
7885

packages/web/src/features/tools/findSymbolReferences.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ export const findSymbolReferencesDefinition: ToolDefinition<
7474
codeHostType: repoInfoResult.codeHostType,
7575
};
7676

77+
// Pin to the indexed commit carried by the same search snapshot that
78+
// produced these matches, rather than a follow-up repo-info lookup that
79+
// could drift if the index advances in between.
80+
const indexedCommitShaByRepo = new Map(
81+
response.repositoryInfo.map((info) => [info.name, info.indexedCommitHash]),
82+
);
83+
7784
const metadata: FindSymbolReferencesMetadata = {
7885
symbol,
7986
matchCount,
@@ -83,7 +90,7 @@ export const findSymbolReferencesDefinition: ToolDefinition<
8390
fileName: file.fileName,
8491
repo: file.repository,
8592
revision,
86-
commitSha: repoInfoResult.indexedCommitHash,
93+
commitSha: indexedCommitShaByRepo.get(file.repository),
8794
})),
8895
};
8996

0 commit comments

Comments
 (0)