Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Added `GET /api/diff` endpoint for retrieving structured diffs between two git refs ([#1063](https://github.com/sourcebot-dev/sourcebot/pull/1063))

## [4.16.3] - 2026-03-27

### Added
Expand Down
175 changes: 175 additions & 0 deletions docs/api-reference/sourcebot-public.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
"name": "Files",
"description": "File tree, file listing, and file content endpoints."
},
{
"name": "Git",
"description": "Git history and diff endpoints."
},
{
"name": "Misc",
"description": "Miscellaneous public API endpoints."
Expand Down Expand Up @@ -629,6 +633,94 @@
"revisionName"
]
},
"PublicGetDiffResponse": {
"type": "object",
"properties": {
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"oldPath": {
"type": "string",
"description": "The file path before the change. `/dev/null` for added files."
},
"newPath": {
"type": "string",
"description": "The file path after the change. `/dev/null` for deleted files."
},
"hunks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"oldRange": {
"type": "object",
"properties": {
"start": {
"type": "integer",
"description": "The 1-based line number where the range starts."
},
"lines": {
"type": "integer",
"description": "The number of lines the range spans."
}
},
"required": [
"start",
"lines"
],
"description": "The line range in the old file."
},
"newRange": {
"type": "object",
"properties": {
"start": {
"type": "integer",
"description": "The 1-based line number where the range starts."
},
"lines": {
"type": "integer",
"description": "The number of lines the range spans."
}
},
"required": [
"start",
"lines"
],
"description": "The line range in the new file."
},
"heading": {
"type": "string",
"description": "Optional context heading extracted from the @@ line, typically the enclosing function or class name."
},
"body": {
"type": "string",
"description": "The diff content, with each line prefixed by a space (context), + (addition), or - (deletion)."
}
},
"required": [
"oldRange",
"newRange",
"body"
]
},
"description": "The list of changed regions within the file."
}
},
"required": [
"oldPath",
"newPath",
"hunks"
]
},
"description": "The list of changed files."
}
},
"required": [
"files"
]
},
"PublicFileTreeNode": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -1100,6 +1192,89 @@
}
}
}
},
"/api/diff": {
"get": {
"tags": [
"Git"
],
"summary": "Get diff between two commits",
"description": "Returns a structured diff between two git refs (branches, tags, or commit SHAs) using a two-dot comparison. See [git-diff](https://git-scm.com/docs/git-diff) for details.",
"parameters": [
{
"schema": {
"type": "string",
"description": "The fully-qualified repository name."
},
"required": true,
"description": "The fully-qualified repository name.",
"name": "repo",
"in": "query"
},
{
"schema": {
"type": "string",
"description": "The base git ref (branch, tag, or commit SHA) to diff from."
},
"required": true,
"description": "The base git ref (branch, tag, or commit SHA) to diff from.",
"name": "base",
"in": "query"
},
{
"schema": {
"type": "string",
"description": "The head git ref (branch, tag, or commit SHA) to diff to."
},
"required": true,
"description": "The head git ref (branch, tag, or commit SHA) to diff to.",
"name": "head",
"in": "query"
}
],
"responses": {
"200": {
"description": "Structured diff between the two refs.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicGetDiffResponse"
}
}
}
},
"400": {
"description": "Invalid query parameters or git ref.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
},
"404": {
"description": "Repository not found.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
},
"500": {
"description": "Unexpected diff failure.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PublicApiServiceError"
}
}
}
}
}
}
}
}
}
36 changes: 36 additions & 0 deletions packages/web/src/app/api/(server)/diff/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { getDiff } from "@/features/git";
import { apiHandler } from "@/lib/apiHandler";
import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server";
import { z } from "zod";

const getDiffQueryParamsSchema = z.object({
repo: z.string(),
base: z.string(),
head: z.string(),
});

export const GET = apiHandler(async (request: NextRequest): Promise<Response> => {
const rawParams = Object.fromEntries(
Object.keys(getDiffQueryParamsSchema.shape).map(key => [
key,
request.nextUrl.searchParams.get(key) ?? undefined
])
);
const parsed = getDiffQueryParamsSchema.safeParse(rawParams);

if (!parsed.success) {
return serviceErrorResponse(
queryParamsSchemaValidationError(parsed.error)
);
}

const result = await getDiff(parsed.data);

if (isServiceError(result)) {
return serviceErrorResponse(result);
}

return Response.json(result);
});
100 changes: 100 additions & 0 deletions packages/web/src/features/git/getDiffApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { sew } from '@/actions';
import { invalidGitRef, notFound, ServiceError, unexpectedError } from '@/lib/serviceError';
import { withOptionalAuthV2 } from '@/withAuthV2';
import { getRepoPath } from '@sourcebot/shared';
import parseDiff from 'parse-diff';
import { simpleGit } from 'simple-git';
import { isGitRefValid } from './utils';

export interface HunkRange {
start: number;
lines: number;
}

export interface DiffHunk {
oldRange: HunkRange;
newRange: HunkRange;
heading?: string;
body: string;
}

export interface FileDiff {
oldPath: string;
newPath: string;
hunks: DiffHunk[];
}

export interface GetDiffResult {
files: FileDiff[];
}

type GetDiffRequest = {
repo: string;
base: string;
head: string;
}

export const getDiff = async ({
repo: repoName,
base,
head,
}: GetDiffRequest): Promise<GetDiffResult | ServiceError> => sew(() =>
withOptionalAuthV2(async ({ org, prisma }) => {
if (!isGitRefValid(base)) {
return invalidGitRef(base);
}

if (!isGitRefValid(head)) {
return invalidGitRef(head);
}

const repo = await prisma.repo.findFirst({
where: {
name: repoName,
orgId: org.id,
},
});

if (!repo) {
return notFound(`Repository "${repoName}" not found.`);
}

const { path: repoPath } = getRepoPath(repo);
const git = simpleGit().cwd(repoPath);

try {
const rawDiff = await git.raw(['diff', base, head]);
const files = parseDiff(rawDiff);

const nodes: FileDiff[] = files.map((file) => ({
oldPath: file.from ?? '/dev/null',
newPath: file.to ?? '/dev/null',
hunks: file.chunks.map((chunk) => {
// chunk.content is the full @@ header line, e.g.:
// "@@ -7,6 +7,8 @@ some heading text"
// The heading is the optional text after the second @@.
const headingMatch = chunk.content.match(/^@@ .+ @@ (.+)$/);
const heading = headingMatch ? headingMatch[1].trim() : undefined;

return {
oldRange: { start: chunk.oldStart, lines: chunk.oldLines },
newRange: { start: chunk.newStart, lines: chunk.newLines },
heading,
body: chunk.changes.map((change) => change.content).join('\n'),
};
}),
}));

return {
files: nodes,
};
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);

if (message.includes('unknown revision') || message.includes('bad revision')) {
return invalidGitRef(`${base}..${head}`);
}

return unexpectedError(`Failed to compute diff for ${repoName}: ${message}`);
}
}));
1 change: 1 addition & 0 deletions packages/web/src/features/git/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './getDiffApi';
export * from './getFilesApi';
export * from './getFolderContentsApi';
export * from './getTreeApi';
Expand Down
28 changes: 28 additions & 0 deletions packages/web/src/features/git/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,31 @@ export const fileSourceResponseSchema = z.object({
webUrl: z.string(),
externalWebUrl: z.string().optional(),
});

export const getDiffRequestSchema = z.object({
repo: z.string().describe('The fully-qualified repository name.'),
base: z.string().describe('The base git ref (branch, tag, or commit SHA) to diff from.'),
head: z.string().describe('The head git ref (branch, tag, or commit SHA) to diff to.'),
});

const hunkRangeSchema = z.object({
start: z.number().int().describe('The 1-based line number where the range starts.'),
lines: z.number().int().describe('The number of lines the range spans.'),
});

const diffHunkSchema = z.object({
oldRange: hunkRangeSchema.describe('The line range in the old file.'),
newRange: hunkRangeSchema.describe('The line range in the new file.'),
heading: z.string().optional().describe('Optional context heading extracted from the @@ line, typically the enclosing function or class name.'),
body: z.string().describe('The diff content, with each line prefixed by a space (context), + (addition), or - (deletion).'),
});

const fileDiffSchema = z.object({
oldPath: z.string().describe('The file path before the change. `/dev/null` for added files.'),
newPath: z.string().describe('The file path after the change. `/dev/null` for deleted files.'),
hunks: z.array(diffHunkSchema).describe('The list of changed regions within the file.'),
});

export const getDiffResponseSchema = z.object({
files: z.array(fileDiffSchema).describe('The list of changed files.'),
});
Loading
Loading