diff --git a/CHANGELOG.md b/CHANGELOG.md index 19358afd3..214d269f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Send anonymous server-side PostHog events as personless so unauthenticated requests don't inflate person counts. [#1367](https://github.com/sourcebot-dev/sourcebot/pull/1367) - [EE] Fixed Ask Sourcebot mermaid diagrams overflowing their container by contain-fitting them to both width and height, and made revealing a diagram from the answer jump it into view instantly to avoid over/undershooting. [#1373](https://github.com/sourcebot-dev/sourcebot/pull/1373) - Passed Zoekt index parameters via argv to preserve revision names with punctuation. [#1376](https://github.com/sourcebot-dev/sourcebot/pull/1376) +- Show a clear empty repository state in the browse tree instead of generic tree loading errors. [#1380](https://github.com/sourcebot-dev/sourcebot/pull/1380) ## [5.0.4] - 2026-06-18 diff --git a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.tsx index 3f7c0f473..5dcefe6bb 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.tsx @@ -9,11 +9,20 @@ import { FileTreeItem } from "@/features/git"; interface PureTreePreviewPanelProps { items: FileTreeItem[]; + isRepositoryEmpty: boolean; } -export const PureTreePreviewPanel = ({ items }: PureTreePreviewPanelProps) => { +export const PureTreePreviewPanel = ({ items, isRepositoryEmpty }: PureTreePreviewPanelProps) => { const { repoName, revisionName } = useBrowseParams(); const scrollAreaRef = useRef(null); + + if (isRepositoryEmpty) { + return ( +
+ This repository is empty +
+ ); + } return ( { ))} ) -} \ No newline at end of file +} diff --git a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanel.tsx index 5762e0f50..02f20c9bf 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanel.tsx @@ -43,7 +43,10 @@ export const TreePreviewPanel = async ({ path, repoName, revisionName }: TreePre /> - + ) -} \ No newline at end of file +} diff --git a/packages/web/src/app/(app)/browse/components/emptyRepositoryPanels.test.tsx b/packages/web/src/app/(app)/browse/components/emptyRepositoryPanels.test.tsx new file mode 100644 index 000000000..b8873b2ea --- /dev/null +++ b/packages/web/src/app/(app)/browse/components/emptyRepositoryPanels.test.tsx @@ -0,0 +1,55 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { PureFileTreePanel } from './pureFileTreePanel'; +import { PureTreePreviewPanel } from '../[...path]/components/treePreviewPanel/pureTreePreviewPanel'; + +vi.mock('@/app/(app)/browse/hooks/useBrowseParams', () => ({ + useBrowseParams: () => ({ + repoName: 'github.com/sourcebot-dev/empty', + revisionName: 'HEAD', + path: '', + pathType: 'tree', + }), +})); + +vi.mock('@bprogress/next', () => ({ + useProgress: () => ({ + stop: vi.fn(), + }), +})); + +afterEach(() => { + cleanup(); +}); + +describe('empty repository browse panels', () => { + test('tree preview panel shows a clear empty repository state', () => { + render(); + + expect(screen.queryByText('This repository is empty')).toBeTruthy(); + }); + + test('tree preview panel does not infer repository emptiness from empty items', () => { + render(); + + expect(screen.queryByText('This repository is empty')).toBeNull(); + }); + + test('file tree panel shows a clear empty repository state', () => { + render( + + ); + + expect(screen.queryByText('This repository is empty')).toBeTruthy(); + }); +}); diff --git a/packages/web/src/app/(app)/browse/components/pureFileTreePanel.tsx b/packages/web/src/app/(app)/browse/components/pureFileTreePanel.tsx index dded766f5..08a07cd3f 100644 --- a/packages/web/src/app/(app)/browse/components/pureFileTreePanel.tsx +++ b/packages/web/src/app/(app)/browse/components/pureFileTreePanel.tsx @@ -84,6 +84,14 @@ export const PureFileTreePanel = ({ tree, openPaths, path, onTreeNodeClicked }: const renderedTree = useMemo(() => renderTree(tree), [tree, renderTree]); + if (tree.children.length === 0) { + return ( +
+ This repository is empty +
+ ); + } + return ( ) } - diff --git a/packages/web/src/features/git/emptyRepositoryApi.test.ts b/packages/web/src/features/git/emptyRepositoryApi.test.ts new file mode 100644 index 000000000..62d2981c6 --- /dev/null +++ b/packages/web/src/features/git/emptyRepositoryApi.test.ts @@ -0,0 +1,278 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const findFirst = vi.fn(); + return { + findFirst, + gitRaw: vi.fn(), + cwd: vi.fn(), + simpleGit: vi.fn(), + prisma: { + repo: { + findFirst, + }, + }, + }; +}); + +vi.mock('simple-git', () => ({ + default: mocks.simpleGit, + simpleGit: mocks.simpleGit, +})); + +vi.mock('@sourcebot/shared', () => ({ + getRepoPath: (repo: { id: number }) => ({ + path: `/mock/repos/${repo.id}`, + }), + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('@/middleware/sew', () => ({ + sew: async (fn: () => Promise | T): Promise => { + try { + return await fn(); + } catch (error) { + return { + errorCode: 'UNEXPECTED_ERROR', + message: error instanceof Error ? error.message : String(error), + statusCode: 500, + } as T; + } + }, +})); + +vi.mock('@/middleware/withAuth', () => ({ + withOptionalAuth: async ( + fn: (args: { org: { id: number; name: string }; prisma: unknown; user?: unknown }) => Promise + ): Promise => { + return await fn({ + org: { id: 1, name: 'test-org' }, + prisma: mocks.prisma, + }); + }, +})); + +vi.mock('@/ee/features/audit/audit', () => ({ + createAudit: vi.fn(), +})); + +vi.mock('next/headers', () => ({ + headers: vi.fn().mockResolvedValue(new Headers()), +})); + +import { getFolderContents } from './getFolderContentsApi'; +import { getTree } from './getTreeApi'; + +describe('empty repository git APIs', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mocks.cwd.mockReturnValue({ raw: mocks.gitRaw }); + mocks.simpleGit.mockReturnValue({ cwd: mocks.cwd }); + mocks.findFirst.mockResolvedValue({ + id: 123, + name: 'github.com/sourcebot-dev/empty', + orgId: 1, + }); + mocks.gitRaw.mockResolvedValue(''); + }); + + test('getFolderContents returns an empty root folder for a repository with no commits', async () => { + mocks.gitRaw.mockImplementation(async (args: string[]) => { + if (args.includes('ls-tree')) { + throw new Error('fatal: Not a valid object name HEAD'); + } + if (args[0] === 'rev-list') { + return '0\n'; + } + return ''; + }); + + const result = await getFolderContents({ + repoName: 'github.com/sourcebot-dev/empty', + revisionName: 'HEAD', + path: '', + }); + + expect(result).toEqual({ + items: [], + isRepositoryEmpty: true, + }); + expect(mocks.gitRaw).toHaveBeenCalledWith(['rev-list', '--count', '--all']); + }); + + test('getFolderContents keeps unresolved refs as errors for empty repositories', async () => { + mocks.gitRaw.mockImplementation(async (args: string[]) => { + if (args.includes('ls-tree')) { + throw new Error('fatal: Not a valid object name definitely-not-a-ref'); + } + if (args[0] === 'symbolic-ref') { + return 'main\n'; + } + if (args[0] === 'rev-list') { + return '0\n'; + } + return ''; + }); + + const result = await getFolderContents({ + repoName: 'github.com/sourcebot-dev/empty', + revisionName: 'definitely-not-a-ref', + path: '', + }); + + expect(result).toMatchObject({ + errorCode: 'UNEXPECTED_ERROR', + message: expect.stringContaining('git ls-tree command failed'), + }); + expect(mocks.gitRaw).not.toHaveBeenCalledWith(['rev-list', '--count', '--all']); + }); + + test('getTree returns an empty root tree for a repository with no commits', async () => { + mocks.gitRaw.mockImplementation(async (args: string[]) => { + if (args.includes('ls-tree')) { + throw new Error('fatal: Not a valid object name HEAD'); + } + if (args[0] === 'rev-list') { + return '0\n'; + } + return ''; + }); + + const result = await getTree({ + repoName: 'github.com/sourcebot-dev/empty', + revisionName: 'HEAD', + paths: [], + }); + + expect(result).toEqual({ + tree: { + name: 'root', + path: '', + type: 'tree', + children: [], + }, + }); + expect(mocks.gitRaw).toHaveBeenCalledWith(['rev-list', '--count', '--all']); + }); + + test('getTree treats empty path entries as the root for empty repositories', async () => { + mocks.gitRaw.mockImplementation(async (args: string[]) => { + if (args.includes('ls-tree')) { + throw new Error('fatal: Not a valid object name HEAD'); + } + if (args[0] === 'rev-list') { + return '0\n'; + } + return ''; + }); + + const result = await getTree({ + repoName: 'github.com/sourcebot-dev/empty', + revisionName: 'HEAD', + paths: [''], + }); + + const lsTreeCall = mocks.gitRaw.mock.calls.find(([args]) => args.includes('ls-tree'))?.[0]; + + expect(result).toEqual({ + tree: { + name: 'root', + path: '', + type: 'tree', + children: [], + }, + }); + expect(lsTreeCall).toEqual([ + '-c', 'core.quotePath=false', + 'ls-tree', + 'HEAD', + '--format=%(objecttype),%(path)', + '-t', + '--', + '.', + ]); + }); + + test('getTree returns an empty tree for stale paths in empty repositories', async () => { + mocks.gitRaw.mockImplementation(async (args: string[]) => { + if (args.includes('ls-tree')) { + throw new Error('fatal: Not a valid object name HEAD'); + } + if (args[0] === 'rev-list') { + return '0\n'; + } + return ''; + }); + + const result = await getTree({ + repoName: 'github.com/sourcebot-dev/empty', + revisionName: 'HEAD', + paths: ['stale/path'], + }); + + expect(result).toEqual({ + tree: { + name: 'root', + path: '', + type: 'tree', + children: [], + }, + }); + }); + + test('getTree keeps unresolved refs as errors for empty repositories', async () => { + mocks.gitRaw.mockImplementation(async (args: string[]) => { + if (args.includes('ls-tree')) { + throw new Error('fatal: Not a valid object name definitely-not-a-ref'); + } + if (args[0] === 'symbolic-ref') { + return 'main\n'; + } + if (args[0] === 'rev-list') { + return '0\n'; + } + return ''; + }); + + const result = await getTree({ + repoName: 'github.com/sourcebot-dev/empty', + revisionName: 'definitely-not-a-ref', + paths: [], + }); + + expect(result).toMatchObject({ + errorCode: 'UNEXPECTED_ERROR', + message: expect.stringContaining('git ls-tree command failed'), + }); + expect(mocks.gitRaw).not.toHaveBeenCalledWith(['rev-list', '--count', '--all']); + }); + + test('getTree keeps unresolved revisions as errors when the repository has commits', async () => { + mocks.gitRaw.mockImplementation(async (args: string[]) => { + if (args.includes('ls-tree')) { + throw new Error('fatal: Not a valid object name HEAD'); + } + if (args[0] === 'rev-list') { + return '42\n'; + } + return ''; + }); + + const result = await getTree({ + repoName: 'github.com/sourcebot-dev/not-empty', + revisionName: 'HEAD', + paths: [], + }); + + expect(result).toMatchObject({ + errorCode: 'UNEXPECTED_ERROR', + message: expect.stringContaining('git ls-tree command failed'), + }); + }); +}); diff --git a/packages/web/src/features/git/getFolderContentsApi.ts b/packages/web/src/features/git/getFolderContentsApi.ts index 71b08440b..e46eb4b12 100644 --- a/packages/web/src/features/git/getFolderContentsApi.ts +++ b/packages/web/src/features/git/getFolderContentsApi.ts @@ -1,11 +1,11 @@ import { sew } from "@/middleware/sew"; import { FileTreeItem } from "./types"; -import { notFound, unexpectedError } from '@/lib/serviceError'; +import { invalidGitRef, notFound, unexpectedError } from '@/lib/serviceError'; import { withOptionalAuth } from "@/middleware/withAuth"; import { getRepoPath } from '@sourcebot/shared'; import simpleGit from 'simple-git'; import z from 'zod'; -import { compareFileTreeItems, isPathValid, normalizePath } from './utils'; +import { compareFileTreeItems, isEmptyRepositoryRootRef, isGitRefValid, isPathValid, normalizePath } from './utils'; import { logger } from './logger'; @@ -15,6 +15,10 @@ export const getFolderContentsRequestSchema = z.object({ path: z.string(), }); export type GetFolderContentsRequest = z.infer; +export type GetFolderContentsResponse = { + items: FileTreeItem[]; + isRepositoryEmpty: boolean; +}; /** * Returns the contents of a folder at a given path in a given repository, @@ -36,6 +40,10 @@ export const getFolderContents = async ({ repoName, revisionName, path }: GetFol const { path: repoPath } = getRepoPath(repo); const git = simpleGit().cwd(repoPath); + if (!isGitRefValid(revisionName)) { + return invalidGitRef(revisionName); + } + if (!isPathValid(path)) { return notFound(); } @@ -47,12 +55,20 @@ export const getFolderContents = async ({ repoName, revisionName, path }: GetFol // Disable quoting of non-ASCII characters in paths '-c', 'core.quotePath=false', 'ls-tree', - revisionName, // format as output as {type},{path} '--format=%(objecttype),%(path)', + revisionName, + '--', ...(normalizedPath.length === 0 ? [] : [normalizedPath]), ]); } catch (error) { + if (await isEmptyRepositoryRootRef(git, revisionName)) { + return { + items: [], + isRepositoryEmpty: true, + }; + } + logger.error('git ls-tree failed.', { error }); return unexpectedError('git ls-tree command failed.'); } @@ -78,5 +94,8 @@ export const getFolderContents = async ({ repoName, revisionName, path }: GetFol // Sort the contents in place, first by type (trees before blobs), then by name. contents.sort(compareFileTreeItems); - return contents; + return { + items: contents, + isRepositoryEmpty: false, + } satisfies GetFolderContentsResponse; })); diff --git a/packages/web/src/features/git/getTreeApi.ts b/packages/web/src/features/git/getTreeApi.ts index fecbb958d..ea53f6eee 100644 --- a/packages/web/src/features/git/getTreeApi.ts +++ b/packages/web/src/features/git/getTreeApi.ts @@ -7,7 +7,7 @@ import { headers } from 'next/headers'; import simpleGit from 'simple-git'; import type z from 'zod'; import { getTreeRequestSchema, getTreeResponseSchema } from './schemas'; -import { buildFileTree, isGitRefValid, isPathValid, normalizePath } from './utils'; +import { buildFileTree, isEmptyRepositoryRootRef, isGitRefValid, isPathValid, normalizePath } from './utils'; import { logger } from './logger'; export { getTreeRequestSchema, getTreeResponseSchema } from './schemas'; @@ -54,7 +54,9 @@ export const getTree = async ({ repoName, revisionName, paths }: GetTreeRequest, return notFound(); } - const normalizedPaths = paths.map(path => normalizePath(path)); + const normalizedPaths = paths + .map(path => normalizePath(path)) + .filter(path => path.length > 0); let result: string = ''; try { @@ -75,6 +77,12 @@ export const getTree = async ({ repoName, revisionName, paths }: GetTreeRequest, result = await git.raw(command); } catch (error) { + if (await isEmptyRepositoryRootRef(git, revisionName)) { + return { + tree: buildFileTree([]), + }; + } + logger.error('git ls-tree failed.', { error }); return unexpectedError('git ls-tree command failed.'); } diff --git a/packages/web/src/features/git/utils.ts b/packages/web/src/features/git/utils.ts index f9b1a71e7..be833cb64 100644 --- a/packages/web/src/features/git/utils.ts +++ b/packages/web/src/features/git/utils.ts @@ -1,4 +1,5 @@ import { FileTreeItem, FileTreeNode } from "./types"; +import type { SimpleGit } from "simple-git"; // @note: reject refs starting with '-' to prevent git flag injection. export const isGitRefValid = (ref: string): boolean => { @@ -44,7 +45,31 @@ export const compareFileTreeItems = (a: FileTreeItem, b: FileTreeItem): number = return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); } +export const isGitRepositoryEmpty = async (git: Pick): Promise => { + try { + const count = await git.raw(['rev-list', '--count', '--all']); + return Number.parseInt(count.trim(), 10) === 0; + } catch { + return false; + } +} +export const isDefaultRepositoryRef = async (git: Pick, revisionName: string): Promise => { + if (revisionName === 'HEAD') { + return true; + } + + try { + const defaultBranch = (await git.raw(['symbolic-ref', '--short', 'HEAD'])).trim(); + return revisionName === defaultBranch || revisionName === `refs/heads/${defaultBranch}`; + } catch { + return false; + } +} + +export const isEmptyRepositoryRootRef = async (git: Pick, revisionName: string): Promise => { + return await isDefaultRepositoryRef(git, revisionName) && await isGitRepositoryEmpty(git); +} export const buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { const root: FileTreeNode = { @@ -93,4 +118,4 @@ export const buildFileTree = (flatList: { type: string, path: string }[]): FileT }; return sortTree(root); -} \ No newline at end of file +}