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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
11 changes: 9 additions & 2 deletions src/app/services/project-service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<boolean> {
Expand Down
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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),
Expand Down
98 changes: 97 additions & 1 deletion tests/app/services/project-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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(),
Expand All @@ -29,6 +38,7 @@ describe("project/manager", () => {
beforeEach(() => {
projectListMock.mockReset();
cachedSessionProjectsMock.mockReset();
configMock.bot.excludedProjectPaths = [];
});

afterEach(async () => {
Expand Down Expand Up @@ -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({
Expand Down
31 changes: 31 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Loading