@@ -3,9 +3,14 @@ import os from "node:os";
33import path from "node:path" ;
44import { 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
1116vi . 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+
1928vi . 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 ( {
0 commit comments