From efb7e0ea90cde4f172cf0616193ab1f055bffde6 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Sun, 28 Jun 2026 05:47:39 +0530 Subject: [PATCH 1/3] Clarify browse behavior for empty repositories Treat zero-commit repositories as an empty root in browse tree APIs after git ls-tree fails, and render a dedicated empty-state message instead of generic tree errors. Constraint: Empty repositories have no resolvable HEAD, so git ls-tree fails before the existing empty-list UI path can run. Rejected: Adding a new service error code | the browse surfaces already accept empty tree/list payloads and this keeps the API change narrow. Confidence: high Scope-risk: narrow Directive: Keep non-empty unresolved refs on the error path; do not hide git failures unless rev-list confirms the repository has zero commits. Tested: node .yarn/releases/yarn-4.7.0.cjs workspace @sourcebot/web test src/features/git/utils.test.ts src/features/git/emptyRepositoryApi.test.ts 'src/app/(app)/browse/components/emptyRepositoryPanels.test.tsx' Tested: node .yarn/releases/yarn-4.7.0.cjs workspace @sourcebot/web lint Not-tested: Manual browser reproduction against a live empty remote repository. --- .../treePreviewPanel/pureTreePreviewPanel.tsx | 10 +- .../components/emptyRepositoryPanels.test.tsx | 49 ++++++ .../browse/components/pureFileTreePanel.tsx | 9 +- .../features/git/emptyRepositoryApi.test.ts | 156 ++++++++++++++++++ .../src/features/git/getFolderContentsApi.ts | 6 +- packages/web/src/features/git/getTreeApi.ts | 8 +- packages/web/src/features/git/utils.ts | 12 +- 7 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 packages/web/src/app/(app)/browse/components/emptyRepositoryPanels.test.tsx create mode 100644 packages/web/src/features/git/emptyRepositoryApi.test.ts 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..5f1526f0f 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 @@ -14,6 +14,14 @@ interface PureTreePreviewPanelProps { export const PureTreePreviewPanel = ({ items }: PureTreePreviewPanelProps) => { const { repoName, revisionName } = useBrowseParams(); const scrollAreaRef = useRef(null); + + if (items.length === 0) { + return ( +
+ This repository is empty +
+ ); + } return ( { ))} ) -} \ 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..6a602b8b1 --- /dev/null +++ b/packages/web/src/app/(app)/browse/components/emptyRepositoryPanels.test.tsx @@ -0,0 +1,49 @@ +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('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..4817e42d8 --- /dev/null +++ b/packages/web/src/features/git/emptyRepositoryApi.test.ts @@ -0,0 +1,156 @@ +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([]); + expect(mocks.gitRaw).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 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..3b42ba814 100644 --- a/packages/web/src/features/git/getFolderContentsApi.ts +++ b/packages/web/src/features/git/getFolderContentsApi.ts @@ -5,7 +5,7 @@ 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, isGitRepositoryEmpty, isPathValid, normalizePath } from './utils'; import { logger } from './logger'; @@ -53,6 +53,10 @@ export const getFolderContents = async ({ repoName, revisionName, path }: GetFol ...(normalizedPath.length === 0 ? [] : [normalizedPath]), ]); } catch (error) { + if (normalizedPath.length === 0 && await isGitRepositoryEmpty(git)) { + return []; + } + logger.error('git ls-tree failed.', { error }); return unexpectedError('git ls-tree command failed.'); } diff --git a/packages/web/src/features/git/getTreeApi.ts b/packages/web/src/features/git/getTreeApi.ts index fecbb958d..76f5e779e 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, isGitRefValid, isGitRepositoryEmpty, isPathValid, normalizePath } from './utils'; import { logger } from './logger'; export { getTreeRequestSchema, getTreeResponseSchema } from './schemas'; @@ -75,6 +75,12 @@ export const getTree = async ({ repoName, revisionName, paths }: GetTreeRequest, result = await git.raw(command); } catch (error) { + if (normalizedPaths.length === 0 && await isGitRepositoryEmpty(git)) { + 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..a9170be2f 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,14 @@ 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 buildFileTree = (flatList: { type: string, path: string }[]): FileTreeNode => { const root: FileTreeNode = { @@ -93,4 +101,4 @@ export const buildFileTree = (flatList: { type: string, path: string }[]): FileT }; return sortTree(root); -} \ No newline at end of file +} From 7dc30611dd4708b81017761a04b86703247b8552 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Sun, 28 Jun 2026 05:48:18 +0530 Subject: [PATCH 2/3] Record empty repository browse fix in changelog Constraint: Sourcebot requires a follow-up changelog entry for every PR under the matching Unreleased section. Confidence: high Scope-risk: narrow Directive: Keep this commit scoped to the changelog entry for PR #1380. Tested: Not run; changelog-only follow-up. Not-tested: Runtime tests unchanged from the implementation commit. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0725af08..d38a5e4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - 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) +- 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 From 636a93898cdccf6896a762b6d76e61eff130963a Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Sun, 28 Jun 2026 07:00:55 +0530 Subject: [PATCH 3/3] Keep empty repository fallback precise Preserve the empty browse state while preventing missing paths or arbitrary unresolved refs from being reported as an empty repository. The API now carries explicit empty-repository state to the tree preview and the file-tree path handling treats root-equivalent path entries consistently. Constraint: Empty repositories have no commit object, so HEAD ls-tree fails even for the legitimate default browse state. Rejected: Inferring repository emptiness from an empty tree listing | non-empty repos can return empty ls-tree output for missing pathspecs. Confidence: high Scope-risk: narrow Directive: Only downgrade ls-tree failures to empty results after confirming the repo has zero commits and the ref is HEAD or the symbolic default branch. Tested: node .yarn/releases/yarn-4.7.0.cjs workspace @sourcebot/web test src/features/git/utils.test.ts src/features/git/emptyRepositoryApi.test.ts 'src/app/(app)/browse/components/emptyRepositoryPanels.test.tsx'; node .yarn/releases/yarn-4.7.0.cjs workspace @sourcebot/web lint; git diff --check. Not-tested: Manual browser reproduction against a live empty remote repository. --- .../treePreviewPanel/pureTreePreviewPanel.tsx | 5 +- .../treePreviewPanel/treePreviewPanel.tsx | 7 +- .../components/emptyRepositoryPanels.test.tsx | 8 +- .../features/git/emptyRepositoryApi.test.ts | 124 +++++++++++++++++- .../src/features/git/getFolderContentsApi.ts | 27 +++- packages/web/src/features/git/getTreeApi.ts | 8 +- packages/web/src/features/git/utils.ts | 17 +++ 7 files changed, 181 insertions(+), 15 deletions(-) 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 5f1526f0f..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,13 +9,14 @@ 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 (items.length === 0) { + if (isRepositoryEmpty) { return (
This repository is empty 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 index 6a602b8b1..b8873b2ea 100644 --- a/packages/web/src/app/(app)/browse/components/emptyRepositoryPanels.test.tsx +++ b/packages/web/src/app/(app)/browse/components/emptyRepositoryPanels.test.tsx @@ -24,11 +24,17 @@ afterEach(() => { describe('empty repository browse panels', () => { test('tree preview panel shows a clear empty repository state', () => { - render(); + 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( { path: '', }); - expect(result).toEqual([]); + 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')) { @@ -131,6 +161,98 @@ describe('empty repository git APIs', () => { 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')) { diff --git a/packages/web/src/features/git/getFolderContentsApi.ts b/packages/web/src/features/git/getFolderContentsApi.ts index 3b42ba814..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, isGitRepositoryEmpty, 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,14 +55,18 @@ 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 (normalizedPath.length === 0 && await isGitRepositoryEmpty(git)) { - return []; + if (await isEmptyRepositoryRootRef(git, revisionName)) { + return { + items: [], + isRepositoryEmpty: true, + }; } logger.error('git ls-tree failed.', { error }); @@ -82,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 76f5e779e..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, isGitRepositoryEmpty, 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,7 +77,7 @@ export const getTree = async ({ repoName, revisionName, paths }: GetTreeRequest, result = await git.raw(command); } catch (error) { - if (normalizedPaths.length === 0 && await isGitRepositoryEmpty(git)) { + if (await isEmptyRepositoryRootRef(git, revisionName)) { return { tree: buildFileTree([]), }; diff --git a/packages/web/src/features/git/utils.ts b/packages/web/src/features/git/utils.ts index a9170be2f..be833cb64 100644 --- a/packages/web/src/features/git/utils.ts +++ b/packages/web/src/features/git/utils.ts @@ -54,6 +54,23 @@ export const isGitRepositoryEmpty = async (git: Pick): Promise } } +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 = { name: 'root',