Skip to content

Commit 79c0241

Browse files
Johell1NSJohell1NS
andauthored
feat: add PROJECTS_EXCLUDED_PATHS to hide unwanted projects from /projects (#197)
Co-authored-by: Johell1NS <alessio.perilli@me.com>
1 parent 81b2100 commit 79c0241

6 files changed

Lines changed: 239 additions & 3 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ OPENCODE_MODEL_ID=big-pickle
7575
# Maximum number of projects shown in /projects (default: 10)
7676
# PROJECTS_LIST_LIMIT=10
7777

78+
# Comma-separated absolute paths to hide from /projects (project worktrees are matched exactly)
79+
# PROJECTS_EXCLUDED_PATHS=/home/user/repo-a,/home/user/repo-b
80+
7881
# Maximum number of commands shown in /commands (default: 10)
7982
# COMMANDS_LIST_LIMIT=10
8083

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ Configuration can be provided through process environment variables or an `.env`
227227
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
228228
| `MESSAGES_LIST_LIMIT` | User messages per page in `/messages` | No | `10` |
229229
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
230+
| `PROJECTS_EXCLUDED_PATHS` | Comma-separated absolute paths hidden from `/projects` (exact worktree match) | No | *(none)* |
230231
| `OPEN_BROWSER_ROOTS` | Comma-separated paths `/open` is allowed to browse (supports `~`) | No | `~` (home directory) |
231232
| `COMMANDS_LIST_LIMIT` | Items per page in `/commands` and `/skills` | No | `10` |
232233
| `MODELS_LIST_LIMIT` | Providers and provider models per page in the model picker | No | `10` |

src/app/services/project-service.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readFile, stat } from "node:fs/promises";
22
import path from "node:path";
33
import { opencodeClient } from "../../opencode/client.js";
4+
import { config } from "../../config.js";
45
import { getCachedSessionProjects } from "./session-cache-service.js";
56
import { logger } from "../../utils/logger.js";
67
import type { ProjectInfo } from "../types/project.js";
@@ -67,11 +68,18 @@ async function getResolvedProjects(options?: {
6768
const visibleProjects = projectList.filter((_, index) => !linkedWorktreeFlags[index]);
6869
const hiddenLinkedWorktrees = projectList.length - visibleProjects.length;
6970

71+
const excludedPaths = config.bot.excludedProjectPaths;
72+
const excludedKeys = new Set(excludedPaths.map((excluded) => worktreeKey(excluded)));
73+
const filteredProjects = excludedKeys.size > 0
74+
? visibleProjects.filter((p) => !excludedKeys.has(worktreeKey(p.worktree)))
75+
: visibleProjects;
76+
const hiddenExcluded = visibleProjects.length - filteredProjects.length;
77+
7078
logger.debug(
71-
`[ProjectManager] Projects resolved: api=${projects.length}, cached=${cachedProjects.length}, hiddenLinkedWorktrees=${hiddenLinkedWorktrees}, total=${visibleProjects.length}`,
79+
`[ProjectManager] Projects resolved: api=${projects.length}, cached=${cachedProjects.length}, hiddenLinkedWorktrees=${hiddenLinkedWorktrees}, hiddenExcluded=${hiddenExcluded}, total=${filteredProjects.length}`,
7280
);
7381

74-
return visibleProjects;
82+
return filteredProjects;
7583
}
7684

7785
async function isLinkedGitWorktree(worktree: string): Promise<boolean> {

src/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ function getEnvVar(key: string, required: boolean = true): string {
1919
return value || "";
2020
}
2121

22+
function getOptionalPathListEnvVar(key: string, delimiter: string = ","): string[] {
23+
const value = getEnvVar(key, false);
24+
if (!value || value.trim() === "") {
25+
return [];
26+
}
27+
return value
28+
.split(delimiter)
29+
.map((s) => s.trim())
30+
.filter((s) => s.length > 0);
31+
}
32+
2233
function getOptionalPositiveIntEnvVar(key: string, defaultValue: number): number {
2334
const value = getEnvVar(key, false);
2435

@@ -229,6 +240,7 @@ export const config = {
229240
// Short messages are processed immediately; 0 disables merging entirely.
230241
messageMergeWindowMs: getOptionalNonNegativeIntEnvVar("MESSAGE_MERGE_WINDOW_MS", 1500),
231242
initialSettingsPreset: parseInitialSettingsPreset(),
243+
excludedProjectPaths: getOptionalPathListEnvVar("PROJECTS_EXCLUDED_PATHS"),
232244
},
233245
files: {
234246
maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10),

tests/app/services/project-service.test.ts

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ import os from "node:os";
33
import path from "node:path";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55

6-
const { projectListMock, cachedSessionProjectsMock } = vi.hoisted(() => ({
6+
const { projectListMock, cachedSessionProjectsMock, configMock } = vi.hoisted(() => ({
77
projectListMock: vi.fn(),
88
cachedSessionProjectsMock: vi.fn(),
9+
configMock: {
10+
bot: {
11+
excludedProjectPaths: [] as string[],
12+
},
13+
},
914
}));
1015

1116
vi.mock("../../../src/opencode/client.js", () => ({
@@ -16,6 +21,10 @@ vi.mock("../../../src/opencode/client.js", () => ({
1621
},
1722
}));
1823

24+
vi.mock("../../../src/config.js", () => ({
25+
config: configMock,
26+
}));
27+
1928
vi.mock("../../../src/app/services/session-cache-service.js", () => ({
2029
getCachedSessionProjects: cachedSessionProjectsMock,
2130
__resetSessionDirectoryCacheForTests: vi.fn(),
@@ -29,6 +38,7 @@ describe("project/manager", () => {
2938
beforeEach(() => {
3039
projectListMock.mockReset();
3140
cachedSessionProjectsMock.mockReset();
41+
configMock.bot.excludedProjectPaths = [];
3242
});
3343

3444
afterEach(async () => {
@@ -99,6 +109,177 @@ describe("project/manager", () => {
99109
expect(projects).toEqual([{ id: "main", worktree: mainWorktree, name: "Main" }]);
100110
});
101111

112+
it("keeps all projects when no excluded paths are configured", async () => {
113+
projectListMock.mockResolvedValueOnce({
114+
data: [
115+
{ id: "p1", worktree: "/home/user/repo-a", name: "Repo A" },
116+
{ id: "p2", worktree: "/home/user/repo-b", name: "Repo B" },
117+
],
118+
error: null,
119+
});
120+
cachedSessionProjectsMock.mockResolvedValueOnce([]);
121+
122+
const projects = await getProjects();
123+
124+
expect(projects).toEqual([
125+
{ id: "p1", worktree: "/home/user/repo-a", name: "Repo A" },
126+
{ id: "p2", worktree: "/home/user/repo-b", name: "Repo B" },
127+
]);
128+
});
129+
130+
it("filters out projects whose worktree matches an excluded path", async () => {
131+
configMock.bot.excludedProjectPaths = ["/home/user/repo-b"];
132+
133+
projectListMock.mockResolvedValueOnce({
134+
data: [
135+
{ id: "p1", worktree: "/home/user/repo-a", name: "Repo A" },
136+
{ id: "p2", worktree: "/home/user/repo-b", name: "Repo B" },
137+
],
138+
error: null,
139+
});
140+
cachedSessionProjectsMock.mockResolvedValueOnce([]);
141+
142+
const projects = await getProjects();
143+
144+
expect(projects).toEqual([{ id: "p1", worktree: "/home/user/repo-a", name: "Repo A" }]);
145+
});
146+
147+
it("filters out projects matching any of multiple excluded paths", async () => {
148+
configMock.bot.excludedProjectPaths = ["/home/user/repo-a", "/home/user/repo-b"];
149+
150+
projectListMock.mockResolvedValueOnce({
151+
data: [
152+
{ id: "p1", worktree: "/home/user/repo-a", name: "Repo A" },
153+
{ id: "p2", worktree: "/home/user/repo-b", name: "Repo B" },
154+
{ id: "p3", worktree: "/home/user/repo-c", name: "Repo C" },
155+
],
156+
error: null,
157+
});
158+
cachedSessionProjectsMock.mockResolvedValueOnce([]);
159+
160+
const projects = await getProjects();
161+
162+
expect(projects).toEqual([{ id: "p3", worktree: "/home/user/repo-c", name: "Repo C" }]);
163+
});
164+
165+
it("applies exclusion after hiding linked git worktrees", async () => {
166+
tempRoot = await mkdtemp(path.join(os.tmpdir(), "opencode-excluded-worktrees-"));
167+
168+
const mainWorktree = path.join(tempRoot, "repo-main");
169+
const linkedWorktree = path.join(tempRoot, "repo-feature");
170+
const excludedWorktree = path.join(tempRoot, "repo-excluded");
171+
172+
await mkdir(path.join(mainWorktree, ".git"), { recursive: true });
173+
await mkdir(linkedWorktree, { recursive: true });
174+
await mkdir(excludedWorktree, { recursive: true });
175+
await writeFile(
176+
path.join(linkedWorktree, ".git"),
177+
`gitdir: ${path.join(mainWorktree, ".git", "worktrees", "feature")}`,
178+
"utf-8",
179+
);
180+
181+
configMock.bot.excludedProjectPaths = [excludedWorktree];
182+
183+
projectListMock.mockResolvedValueOnce({
184+
data: [
185+
{ id: "main", worktree: mainWorktree, name: "Main" },
186+
{ id: "feature", worktree: linkedWorktree, name: "Feature" },
187+
{ id: "excluded", worktree: excludedWorktree, name: "Excluded" },
188+
],
189+
error: null,
190+
});
191+
cachedSessionProjectsMock.mockResolvedValueOnce([]);
192+
193+
const projects = await getProjects();
194+
195+
expect(projects).toEqual([{ id: "main", worktree: mainWorktree, name: "Main" }]);
196+
});
197+
198+
it("filters out projects when excluded path has trailing separator", async () => {
199+
configMock.bot.excludedProjectPaths = ["/home/user/repo-b/"];
200+
201+
projectListMock.mockResolvedValueOnce({
202+
data: [
203+
{ id: "p1", worktree: "/home/user/repo-a", name: "Repo A" },
204+
{ id: "p2", worktree: "/home/user/repo-b", name: "Repo B" },
205+
],
206+
error: null,
207+
});
208+
cachedSessionProjectsMock.mockResolvedValueOnce([]);
209+
210+
const projects = await getProjects();
211+
212+
expect(projects).toEqual([{ id: "p1", worktree: "/home/user/repo-a", name: "Repo A" }]);
213+
});
214+
215+
it("filters out projects when worktree has trailing separator but excluded does not", async () => {
216+
configMock.bot.excludedProjectPaths = ["/home/user/repo-b"];
217+
218+
projectListMock.mockResolvedValueOnce({
219+
data: [
220+
{ id: "p1", worktree: "/home/user/repo-a/", name: "Repo A" },
221+
{ id: "p2", worktree: "/home/user/repo-b/", name: "Repo B" },
222+
],
223+
error: null,
224+
});
225+
cachedSessionProjectsMock.mockResolvedValueOnce([]);
226+
227+
const projects = await getProjects();
228+
229+
expect(projects).toEqual([{ id: "p1", worktree: "/home/user/repo-a/", name: "Repo A" }]);
230+
});
231+
232+
it("filters out projects with Windows casing differences", async () => {
233+
configMock.bot.excludedProjectPaths = ["c:\\users\\dev\\repo"];
234+
235+
projectListMock.mockResolvedValueOnce({
236+
data: [
237+
{ id: "p1", worktree: "C:\\Users\\Dev\\Repo", name: "Repo A" },
238+
{ id: "p2", worktree: "C:\\Users\\Dev\\Other", name: "Other" },
239+
],
240+
error: null,
241+
});
242+
cachedSessionProjectsMock.mockResolvedValueOnce([]);
243+
244+
const projects = await getProjects();
245+
246+
expect(projects).toEqual([{ id: "p2", worktree: "C:\\Users\\Dev\\Other", name: "Other" }]);
247+
});
248+
249+
it("filters out projects with Windows mixed separators", async () => {
250+
configMock.bot.excludedProjectPaths = ["C:/Users/Dev/Repo"];
251+
252+
projectListMock.mockResolvedValueOnce({
253+
data: [
254+
{ id: "p1", worktree: "C:\\Users\\Dev\\Repo", name: "Repo A" },
255+
{ id: "p2", worktree: "C:\\Users\\Dev\\Other", name: "Other" },
256+
],
257+
error: null,
258+
});
259+
cachedSessionProjectsMock.mockResolvedValueOnce([]);
260+
261+
const projects = await getProjects();
262+
263+
expect(projects).toEqual([{ id: "p2", worktree: "C:\\Users\\Dev\\Other", name: "Other" }]);
264+
});
265+
266+
it("filters out projects with Windows trailing separator and casing", async () => {
267+
configMock.bot.excludedProjectPaths = ["C:\\Users\\Dev\\Repo\\"];
268+
269+
projectListMock.mockResolvedValueOnce({
270+
data: [
271+
{ id: "p1", worktree: "c:/users/dev/repo", name: "Repo A" },
272+
{ id: "p2", worktree: "C:/Users/Dev/Other/", name: "Other" },
273+
],
274+
error: null,
275+
});
276+
cachedSessionProjectsMock.mockResolvedValueOnce([]);
277+
278+
const projects = await getProjects();
279+
280+
expect(projects).toEqual([{ id: "p2", worktree: "C:/Users/Dev/Other/", name: "Other" }]);
281+
});
282+
102283
describe("getProjectByWorktree", () => {
103284
it("should find project by exact worktree path", async () => {
104285
projectListMock.mockResolvedValueOnce({

tests/config.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,37 @@ describe("config boolean env parsing", () => {
5252
expect(config.bot.messageFormatMode).toBe("markdown");
5353
});
5454

55+
it("returns an empty list when PROJECTS_EXCLUDED_PATHS is not set", async () => {
56+
vi.stubEnv("PROJECTS_EXCLUDED_PATHS", "");
57+
58+
const config = await loadConfig();
59+
60+
expect(config.bot.excludedProjectPaths).toEqual([]);
61+
});
62+
63+
it("parses PROJECTS_EXCLUDED_PATHS as a comma-separated path list", async () => {
64+
vi.stubEnv(
65+
"PROJECTS_EXCLUDED_PATHS",
66+
"/home/user/repo-a,/home/user/repo-b,/home/user/repo-c",
67+
);
68+
69+
const config = await loadConfig();
70+
71+
expect(config.bot.excludedProjectPaths).toEqual([
72+
"/home/user/repo-a",
73+
"/home/user/repo-b",
74+
"/home/user/repo-c",
75+
]);
76+
});
77+
78+
it("trims whitespace and drops empty entries from PROJECTS_EXCLUDED_PATHS", async () => {
79+
vi.stubEnv("PROJECTS_EXCLUDED_PATHS", " /home/user/repo-a , ,/home/user/repo-b ");
80+
81+
const config = await loadConfig();
82+
83+
expect(config.bot.excludedProjectPaths).toEqual(["/home/user/repo-a", "/home/user/repo-b"]);
84+
});
85+
5586
it("parses markdown message format mode", async () => {
5687
vi.stubEnv("MESSAGE_FORMAT_MODE", "MARKDOWN");
5788

0 commit comments

Comments
 (0)