From f5a356f5ea766cee7322f13e73b2b20a9d87c5b6 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:22:35 -0700 Subject: [PATCH 1/6] fix(shared): decode local repository paths from file:// clone URLs Local generic git repositories are stored with a `file://` clone URL. getRepoPath returned URL.pathname, which is percent-encoded, so a repository under `/Users/me/Code Projects/repo` resolved to `/Users/me/Code%20Projects/repo`. Indexing and the file viewer then looked for a directory that does not exist. Use fileURLToPath to get the decoded on-disk path. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/shared/src/utils.test.ts | 32 ++++++++++++++++++++++++++++++- packages/shared/src/utils.ts | 5 ++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/utils.test.ts b/packages/shared/src/utils.test.ts index 14e1de4e8..114f7e01b 100644 --- a/packages/shared/src/utils.test.ts +++ b/packages/shared/src/utils.test.ts @@ -1,7 +1,8 @@ import { readFile } from 'fs/promises'; 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. @@ -110,3 +111,32 @@ describe('resolveConfigSettings', () => { ); }); }); + +describe('getRepoPath', () => { + const localRepo = (cloneUrl: string) => ({ + id: 1, + external_codeHostType: 'genericGitHost', + cloneUrl, + }) as unknown as Repo; + + test('returns the on-disk path of a local repository', () => { + expect(getRepoPath(localRepo('file:///repos/project'))).toEqual({ + path: '/repos/project', + isReadOnly: true, + }); + }); + + test('returns the on-disk path of a local repository whose path contains spaces', () => { + expect(getRepoPath(localRepo('file:///Users/me/Code Projects/my repo'))).toEqual({ + path: '/Users/me/Code Projects/my repo', + isReadOnly: true, + }); + }); + + test('returns the on-disk path of a local repository whose path contains percent-encodable characters', () => { + expect(getRepoPath(localRepo('file:///repos/caf\u00e9/[legacy]'))).toEqual({ + path: '/repos/caf\u00e9/[legacy]', + isReadOnly: true, + }); + }); +}); diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 2a1867fc4..995312db9 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -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 @@ -109,7 +110,9 @@ export const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } = const cloneUrl = new URL(repo.cloneUrl); if (repo.external_codeHostType === 'genericGitHost' && cloneUrl.protocol === 'file:') { return { - path: cloneUrl.pathname, + // @note: URL.pathname is percent-encoded (e.g., spaces become %20), + // so decode it back into the on-disk path. + path: fileURLToPath(cloneUrl), isReadOnly: true, } } From 35ca4554e5b56f6e2cce3682f2d745ccdb8dde53 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:58:17 -0700 Subject: [PATCH 2/6] docs: add changelog entry for #1690 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f4d08c7..538eab24e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 01eac558b0308d9fe784e77c0d064ebac36d2f09 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:44:27 -0700 Subject: [PATCH 3/6] fix(shared): encode local repository file URLs --- packages/backend/src/repoCompileUtils.ts | 3 ++- packages/shared/src/utils.test.ts | 29 +++++++++++++++++++----- packages/shared/src/utils.ts | 13 ++++++++--- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index cdc78d8e0..f5198c6b9 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -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'; @@ -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, diff --git a/packages/shared/src/utils.test.ts b/packages/shared/src/utils.test.ts index 114f7e01b..b79fb17df 100644 --- a/packages/shared/src/utils.test.ts +++ b/packages/shared/src/utils.test.ts @@ -1,4 +1,5 @@ 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 type { Repo } from '@sourcebot/db'; @@ -119,24 +120,40 @@ describe('getRepoPath', () => { 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', () => { - expect(getRepoPath(localRepo('file:///repos/project'))).toEqual({ - path: '/repos/project', + 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', () => { - expect(getRepoPath(localRepo('file:///Users/me/Code Projects/my repo'))).toEqual({ - path: '/Users/me/Code Projects/my repo', + 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', () => { - expect(getRepoPath(localRepo('file:///repos/caf\u00e9/[legacy]'))).toEqual({ - path: '/repos/caf\u00e9/[legacy]', + 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, + }); + }, + ); }); diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 995312db9..d813769c5 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -109,10 +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); + } 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 { - // @note: URL.pathname is percent-encoded (e.g., spaces become %20), - // so decode it back into the on-disk path. - path: fileURLToPath(cloneUrl), + // New clone URLs are filesystem-encoded; decode them to the path. + path: localPath, isReadOnly: true, } } From 3288fc83b90d7123422b6bd2911c356c78cba24a Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:20:40 -0700 Subject: [PATCH 4/6] fix(shared): preserve legacy percent paths --- packages/shared/src/utils.test.ts | 20 ++++++++++++++++++++ packages/shared/src/utils.ts | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/shared/src/utils.test.ts b/packages/shared/src/utils.test.ts index b79fb17df..1fb9d1371 100644 --- a/packages/shared/src/utils.test.ts +++ b/packages/shared/src/utils.test.ts @@ -1,4 +1,7 @@ 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'; @@ -156,4 +159,21 @@ describe('getRepoPath', () => { }); }, ); + + 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({ + path: repoPath, + isReadOnly: true, + }); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); }); diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index d813769c5..74d344ecb 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -1,4 +1,5 @@ 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"; @@ -112,6 +113,13 @@ export const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } = let localPath: string; try { localPath = fileURLToPath(cloneUrl); + // 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). + if (!existsSync(localPath) && existsSync(cloneUrl.pathname)) { + localPath = cloneUrl.pathname; + } } catch { // Older records may contain raw paths with invalid escapes such as // `%2F`; retain the previous pathname behavior for those records. From a91b67adc0c03f469308003ac82475823ac8bfaf Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:26:54 -0700 Subject: [PATCH 5/6] fix(shared): normalize legacy Windows file URLs --- packages/shared/src/utils.test.ts | 19 ++++++++++++++++++- packages/shared/src/utils.ts | 17 ++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/utils.test.ts b/packages/shared/src/utils.test.ts index 1fb9d1371..fb6506d13 100644 --- a/packages/shared/src/utils.test.ts +++ b/packages/shared/src/utils.test.ts @@ -6,7 +6,12 @@ import { fileURLToPath, pathToFileURL } from 'url'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { DEFAULT_CONFIG_SETTINGS } from './constants.js'; import type { Repo } from '@sourcebot/db'; -import { getConfigSettings, getRepoPath, resolveConfigSettings } from './utils.js'; +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. @@ -126,6 +131,18 @@ describe('getRepoPath', () => { const localPath = (...parts: string[]) => `${process.platform === 'win32' ? 'C:\\' : '/'}${parts.join('/')}`; + test( + 'normalizes legacy Windows file URL pathnames before checking the filesystem', + () => { + expect( + normalizeLegacyFileURLPathname('/C:/Users/me/100%20Free', 'win32'), + ).toBe('C:\\Users\\me\\100%20Free'); + 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({ diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 74d344ecb..5984d5967 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -105,6 +105,16 @@ 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. @@ -117,13 +127,14 @@ export const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } = // 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). - if (!existsSync(localPath) && existsSync(cloneUrl.pathname)) { - localPath = cloneUrl.pathname; + 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 = cloneUrl.pathname; + localPath = normalizeLegacyFileURLPathname(cloneUrl.pathname); } return { // New clone URLs are filesystem-encoded; decode them to the path. From 0dc961916e2c336d0078378deb582448defc3770 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:32:13 -0700 Subject: [PATCH 6/6] test(shared): verify legacy Windows path fallback --- packages/shared/src/utils.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/utils.test.ts b/packages/shared/src/utils.test.ts index fb6506d13..22503adc6 100644 --- a/packages/shared/src/utils.test.ts +++ b/packages/shared/src/utils.test.ts @@ -134,9 +134,15 @@ describe('getRepoPath', () => { 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('/C:/Users/me/100%20Free', 'win32'), - ).toBe('C:\\Users\\me\\100%20Free'); + normalizeLegacyFileURLPathname(legacyPathname, 'win32'), + ).toBe(nativePath); expect( normalizeLegacyFileURLPathname('/repos/100%20Free', 'win32'), ).toBe('/repos/100%20Free');