Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
92 changes: 91 additions & 1 deletion packages/shared/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { readFile } from 'fs/promises';
import { existsSync, mkdtempSync, mkdirSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
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,
normalizeLegacyFileURLPathname,
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 +120,83 @@ 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(
'normalizes legacy Windows file URL pathnames before checking the filesystem',
() => {
const cloneUrl = new URL('file://C:\\Users\\me\\100%20Free');
const nativePath = 'C:\\Users\\me\\100%20Free';
const legacyPathname = cloneUrl.pathname;

expect(legacyPathname).toBe('/C:/Users/me/100%20Free');
expect(path.win32.normalize(legacyPathname)).not.toBe(nativePath);
expect(
normalizeLegacyFileURLPathname(legacyPathname, 'win32'),
).toBe(nativePath);
expect(
normalizeLegacyFileURLPathname('/repos/100%20Free', 'win32'),
).toBe('/repos/100%20Free');
},
);

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,
});
},
);

test('preserves a legacy local path containing a valid percent escape when that path exists', () => {
const tempRoot = mkdtempSync(path.join(tmpdir(), 'sourcebot-local-repo-'));
const repoPath = path.join(tempRoot, '100%20Free');
mkdirSync(repoPath);

try {
// Older records stored `file://` plus the raw path, so `%20` here
// is a literal part of the directory name rather than an escape.
expect(getRepoPath(localRepo(`file://${repoPath}`))).toEqual({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
breken-ai marked this conversation as resolved.
path: repoPath,
isReadOnly: true,
});
} finally {
rmSync(tempRoot, { recursive: true, force: true });
}
});
});
31 changes: 30 additions & 1 deletion packages/shared/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { readFile } from 'fs/promises';
import { existsSync } from 'fs';
import stripJsonComments from 'strip-json-comments';
import { z } from "zod";
import { DEFAULT_CONFIG_SETTINGS } from "./constants.js";
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 @@ -103,13 +105,40 @@ export const getRepoIdFromPath = (repoPath: string): number | undefined => {
return isNaN(id) ? undefined : id;
}

export const normalizeLegacyFileURLPathname = (
pathname: string,
platform = process.platform,
): string => {
if (platform === 'win32' && /^\/[A-Za-z]:\//.test(pathname)) {
return path.win32.normalize(pathname.slice(1));
}
return pathname;
}

export const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } => {
// If we are dealing with a local repository, then use that as the path.
// 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.
// Older records were written as `file://` plus the raw filesystem
// path. If a literal-percent path exists but its decoded spelling
// does not, keep the original path instead of treating `%20` as a
// space (or another percent escape as URL syntax).
const legacyPath = normalizeLegacyFileURLPathname(cloneUrl.pathname);
if (!existsSync(localPath) && existsSync(legacyPath)) {
localPath = legacyPath;
}
} catch {
// Older records may contain raw paths with invalid escapes such as
// `%2F`; retain the previous pathname behavior for those records.
localPath = normalizeLegacyFileURLPathname(cloneUrl.pathname);
}
return {
path: cloneUrl.pathname,
// New clone URLs are filesystem-encoded; decode them to the path.
path: localPath,
isReadOnly: true,
}
}
Expand Down