diff --git a/.env.example b/.env.example index ef5767bf..4467c121 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,9 @@ OPENCODE_MODEL_ID=big-pickle # Maximum number of projects shown in /projects (default: 10) # PROJECTS_LIST_LIMIT=10 +# Comma-separated absolute paths to hide from /projects (project worktrees are matched exactly) +# PROJECTS_EXCLUDED_PATHS=/home/user/repo-a,/home/user/repo-b + # Maximum number of commands shown in /commands (default: 10) # COMMANDS_LIST_LIMIT=10 diff --git a/README.md b/README.md index 274e9803..81e54bb7 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ Configuration can be provided through process environment variables or an `.env` | `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` | | `MESSAGES_LIST_LIMIT` | User messages per page in `/messages` | No | `10` | | `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` | +| `PROJECTS_EXCLUDED_PATHS` | Comma-separated absolute paths hidden from `/projects` (exact worktree match) | No | *(none)* | | `OPEN_BROWSER_ROOTS` | Comma-separated paths `/open` is allowed to browse (supports `~`) | No | `~` (home directory) | | `COMMANDS_LIST_LIMIT` | Items per page in `/commands` and `/skills` | No | `10` | | `MODELS_LIST_LIMIT` | Providers and provider models per page in the model picker | No | `10` | diff --git a/src/app/services/project-service.ts b/src/app/services/project-service.ts index 81e267e6..a8269058 100644 --- a/src/app/services/project-service.ts +++ b/src/app/services/project-service.ts @@ -1,6 +1,7 @@ import { readFile, stat } from "node:fs/promises"; import path from "node:path"; import { opencodeClient } from "../../opencode/client.js"; +import { config } from "../../config.js"; import { getCachedSessionProjects } from "./session-cache-service.js"; import { logger } from "../../utils/logger.js"; import type { ProjectInfo } from "../types/project.js"; @@ -67,11 +68,17 @@ async function getResolvedProjects(options?: { const visibleProjects = projectList.filter((_, index) => !linkedWorktreeFlags[index]); const hiddenLinkedWorktrees = projectList.length - visibleProjects.length; + const excludedPaths = config.bot.excludedProjectPaths; + const filteredProjects = excludedPaths.length > 0 + ? visibleProjects.filter((p) => !excludedPaths.some((excluded) => p.worktree === excluded)) + : visibleProjects; + const hiddenExcluded = visibleProjects.length - filteredProjects.length; + logger.debug( - `[ProjectManager] Projects resolved: api=${projects.length}, cached=${cachedProjects.length}, hiddenLinkedWorktrees=${hiddenLinkedWorktrees}, total=${visibleProjects.length}`, + `[ProjectManager] Projects resolved: api=${projects.length}, cached=${cachedProjects.length}, hiddenLinkedWorktrees=${hiddenLinkedWorktrees}, hiddenExcluded=${hiddenExcluded}, total=${filteredProjects.length}`, ); - return visibleProjects; + return filteredProjects; } async function isLinkedGitWorktree(worktree: string): Promise { diff --git a/src/config.ts b/src/config.ts index c5d37ae4..427f9f5c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,6 +19,17 @@ function getEnvVar(key: string, required: boolean = true): string { return value || ""; } +function getOptionalPathListEnvVar(key: string, delimiter: string = ","): string[] { + const value = getEnvVar(key, false); + if (!value || value.trim() === "") { + return []; + } + return value + .split(delimiter) + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + function getOptionalPositiveIntEnvVar(key: string, defaultValue: number): number { const value = getEnvVar(key, false); @@ -230,6 +241,7 @@ export const config = { // Short messages are processed immediately; 0 disables merging entirely. messageMergeWindowMs: getOptionalNonNegativeIntEnvVar("MESSAGE_MERGE_WINDOW_MS", 1500), initialSettingsPreset: parseInitialSettingsPreset(), + excludedProjectPaths: getOptionalPathListEnvVar("PROJECTS_EXCLUDED_PATHS"), }, files: { maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10), diff --git a/tests/app/services/project-service.test.ts b/tests/app/services/project-service.test.ts index 98c2b22f..552c928c 100644 --- a/tests/app/services/project-service.test.ts +++ b/tests/app/services/project-service.test.ts @@ -3,9 +3,14 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { projectListMock, cachedSessionProjectsMock } = vi.hoisted(() => ({ +const { projectListMock, cachedSessionProjectsMock, configMock } = vi.hoisted(() => ({ projectListMock: vi.fn(), cachedSessionProjectsMock: vi.fn(), + configMock: { + bot: { + excludedProjectPaths: [] as string[], + }, + }, })); vi.mock("../../../src/opencode/client.js", () => ({ @@ -16,6 +21,10 @@ vi.mock("../../../src/opencode/client.js", () => ({ }, })); +vi.mock("../../../src/config.js", () => ({ + config: configMock, +})); + vi.mock("../../../src/app/services/session-cache-service.js", () => ({ getCachedSessionProjects: cachedSessionProjectsMock, __resetSessionDirectoryCacheForTests: vi.fn(), @@ -29,6 +38,7 @@ describe("project/manager", () => { beforeEach(() => { projectListMock.mockReset(); cachedSessionProjectsMock.mockReset(); + configMock.bot.excludedProjectPaths = []; }); afterEach(async () => { @@ -99,6 +109,92 @@ describe("project/manager", () => { expect(projects).toEqual([{ id: "main", worktree: mainWorktree, name: "Main" }]); }); + it("keeps all projects when no excluded paths are configured", async () => { + projectListMock.mockResolvedValueOnce({ + data: [ + { id: "p1", worktree: "/home/user/repo-a", name: "Repo A" }, + { id: "p2", worktree: "/home/user/repo-b", name: "Repo B" }, + ], + error: null, + }); + cachedSessionProjectsMock.mockResolvedValueOnce([]); + + const projects = await getProjects(); + + expect(projects).toEqual([ + { id: "p1", worktree: "/home/user/repo-a", name: "Repo A" }, + { id: "p2", worktree: "/home/user/repo-b", name: "Repo B" }, + ]); + }); + + it("filters out projects whose worktree matches an excluded path", async () => { + configMock.bot.excludedProjectPaths = ["/home/user/repo-b"]; + + projectListMock.mockResolvedValueOnce({ + data: [ + { id: "p1", worktree: "/home/user/repo-a", name: "Repo A" }, + { id: "p2", worktree: "/home/user/repo-b", name: "Repo B" }, + ], + error: null, + }); + cachedSessionProjectsMock.mockResolvedValueOnce([]); + + const projects = await getProjects(); + + expect(projects).toEqual([{ id: "p1", worktree: "/home/user/repo-a", name: "Repo A" }]); + }); + + it("filters out projects matching any of multiple excluded paths", async () => { + configMock.bot.excludedProjectPaths = ["/home/user/repo-a", "/home/user/repo-b"]; + + projectListMock.mockResolvedValueOnce({ + data: [ + { id: "p1", worktree: "/home/user/repo-a", name: "Repo A" }, + { id: "p2", worktree: "/home/user/repo-b", name: "Repo B" }, + { id: "p3", worktree: "/home/user/repo-c", name: "Repo C" }, + ], + error: null, + }); + cachedSessionProjectsMock.mockResolvedValueOnce([]); + + const projects = await getProjects(); + + expect(projects).toEqual([{ id: "p3", worktree: "/home/user/repo-c", name: "Repo C" }]); + }); + + it("applies exclusion after hiding linked git worktrees", async () => { + tempRoot = await mkdtemp(path.join(os.tmpdir(), "opencode-excluded-worktrees-")); + + const mainWorktree = path.join(tempRoot, "repo-main"); + const linkedWorktree = path.join(tempRoot, "repo-feature"); + const excludedWorktree = path.join(tempRoot, "repo-excluded"); + + await mkdir(path.join(mainWorktree, ".git"), { recursive: true }); + await mkdir(linkedWorktree, { recursive: true }); + await mkdir(excludedWorktree, { recursive: true }); + await writeFile( + path.join(linkedWorktree, ".git"), + `gitdir: ${path.join(mainWorktree, ".git", "worktrees", "feature")}`, + "utf-8", + ); + + configMock.bot.excludedProjectPaths = [excludedWorktree]; + + projectListMock.mockResolvedValueOnce({ + data: [ + { id: "main", worktree: mainWorktree, name: "Main" }, + { id: "feature", worktree: linkedWorktree, name: "Feature" }, + { id: "excluded", worktree: excludedWorktree, name: "Excluded" }, + ], + error: null, + }); + cachedSessionProjectsMock.mockResolvedValueOnce([]); + + const projects = await getProjects(); + + expect(projects).toEqual([{ id: "main", worktree: mainWorktree, name: "Main" }]); + }); + describe("getProjectByWorktree", () => { it("should find project by exact worktree path", async () => { projectListMock.mockResolvedValueOnce({ diff --git a/tests/config.test.ts b/tests/config.test.ts index c5033ce4..e988b150 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -52,6 +52,37 @@ describe("config boolean env parsing", () => { expect(config.bot.messageFormatMode).toBe("markdown"); }); + it("returns an empty list when PROJECTS_EXCLUDED_PATHS is not set", async () => { + vi.stubEnv("PROJECTS_EXCLUDED_PATHS", ""); + + const config = await loadConfig(); + + expect(config.bot.excludedProjectPaths).toEqual([]); + }); + + it("parses PROJECTS_EXCLUDED_PATHS as a comma-separated path list", async () => { + vi.stubEnv( + "PROJECTS_EXCLUDED_PATHS", + "/home/user/repo-a,/home/user/repo-b,/home/user/repo-c", + ); + + const config = await loadConfig(); + + expect(config.bot.excludedProjectPaths).toEqual([ + "/home/user/repo-a", + "/home/user/repo-b", + "/home/user/repo-c", + ]); + }); + + it("trims whitespace and drops empty entries from PROJECTS_EXCLUDED_PATHS", async () => { + vi.stubEnv("PROJECTS_EXCLUDED_PATHS", " /home/user/repo-a , ,/home/user/repo-b "); + + const config = await loadConfig(); + + expect(config.bot.excludedProjectPaths).toEqual(["/home/user/repo-a", "/home/user/repo-b"]); + }); + it("parses markdown message format mode", async () => { vi.stubEnv("MESSAGE_FORMAT_MODE", "MARKDOWN");