Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Made the default home page configurable with `DEFAULT_HOME_VIEW_PAGE`, defaulting to Code Search and supporting Ask. [#1677](https://github.com/sourcebot-dev/sourcebot/pull/1677)
- Require authentication for the streaming and blocking Ask APIs in Public SaaS deployments. [#1679](https://github.com/sourcebot-dev/sourcebot/pull/1679)
- Fixed local repositories whose path contains spaces or non-ASCII characters resolving to a percent-encoded path that could not be indexed. [#1690](https://github.com/sourcebot-dev/sourcebot/pull/1690)

## [5.1.14] - 2026-09-17

Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/repoCompileUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createLogger } from '@sourcebot/shared';
import { BitbucketConnectionConfig, GerritConnectionConfig, GiteaConnectionConfig, GitlabConnectionConfig, GenericGitHostConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
import { ProjectVisibility } from "azure-devops-node-api/interfaces/CoreInterfaces.js";
import path from 'path';
import { pathToFileURL } from 'url';
import fs from 'fs/promises';
import { glob } from 'glob';
import { getLocalDefaultBranch, getOriginUrl, isPathAValidGitRepoRoot, isUrlAValidGitRepo } from './git.js';
Expand Down Expand Up @@ -658,7 +659,7 @@ export const compileGenericGitHostConfig_file = async (
external_codeHostType: 'genericGitHost',
external_codeHostUrl: remoteUrl.resource,
external_id: remoteUrl.toString(),
cloneUrl: `file://${repoPath}`,
cloneUrl: pathToFileURL(repoPath).href,
name: repoName,
displayName: repoName,
defaultBranch,
Expand Down
49 changes: 48 additions & 1 deletion packages/shared/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { readFile } from 'fs/promises';
import { fileURLToPath, pathToFileURL } from 'url';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { DEFAULT_CONFIG_SETTINGS } from './constants.js';
import { getConfigSettings, resolveConfigSettings } from './utils.js';
import type { Repo } from '@sourcebot/db';
import { getConfigSettings, getRepoPath, resolveConfigSettings } from './utils.js';

// Mock fs/promises so loadConfig doesn't hit the filesystem.
// The config schema has no required fields, so '{}' is valid.
Expand Down Expand Up @@ -110,3 +112,48 @@ describe('resolveConfigSettings', () => {
);
});
});

describe('getRepoPath', () => {
const localRepo = (cloneUrl: string) => ({
id: 1,
external_codeHostType: 'genericGitHost',
cloneUrl,
}) as unknown as Repo;

const localPath = (...parts: string[]) =>
`${process.platform === 'win32' ? 'C:\\' : '/'}${parts.join('/')}`;

test('returns the on-disk path of a local repository', () => {
const repoPath = localPath('repos', 'project');
expect(getRepoPath(localRepo(pathToFileURL(repoPath).href))).toEqual({
path: fileURLToPath(pathToFileURL(repoPath)),
isReadOnly: true,
});
});

test('returns the on-disk path of a local repository whose path contains spaces', () => {
const repoPath = localPath('Users', 'me', 'Code Projects', 'my repo');
expect(getRepoPath(localRepo(pathToFileURL(repoPath).href))).toEqual({
path: fileURLToPath(pathToFileURL(repoPath)),
isReadOnly: true,
});
});

test('returns the on-disk path of a local repository whose path contains percent-encodable characters', () => {
const repoPath = localPath('repos', 'caf\u00e9', '[legacy]');
expect(getRepoPath(localRepo(pathToFileURL(repoPath).href))).toEqual({
path: fileURLToPath(pathToFileURL(repoPath)),
isReadOnly: true,
});
});

test.each(['file:///repos/100%Free', 'file:///repos/report%2F2024'])(
'preserves a legacy raw file URL pathname containing %s', (cloneUrl) => {
const url = new URL(cloneUrl);
expect(getRepoPath(localRepo(cloneUrl))).toEqual({
path: url.pathname,
isReadOnly: true,
});
},
);
});
12 changes: 11 additions & 1 deletion packages/shared/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ConfigSettings } from "./types.js";
import { Org, Repo } from "@sourcebot/db";
import type { SourcebotConfig } from "@sourcebot/schemas/v3/index.type";
import path from "path";
import { fileURLToPath } from "url";
import { env, isRemotePath, loadConfig } from "./env.server.js";

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
Expand Down Expand Up @@ -108,8 +109,17 @@ export const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } =
// Mark as read-only since we aren't guaranteed to have write access to the local filesystem.
const cloneUrl = new URL(repo.cloneUrl);
if (repo.external_codeHostType === 'genericGitHost' && cloneUrl.protocol === 'file:') {
let localPath: string;
try {
localPath = fileURLToPath(cloneUrl);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch {
// Older records may contain raw paths with invalid escapes such as
// `%2F`; retain the previous pathname behavior for those records.
localPath = cloneUrl.pathname;
}
return {
path: cloneUrl.pathname,
// New clone URLs are filesystem-encoded; decode them to the path.
path: localPath,
isReadOnly: true,
}
}
Expand Down