@@ -2,12 +2,20 @@ import { describe, expect, test } from "bun:test"
22import { Project } from "../../src/project/project"
33import { Log } from "../../src/util/log"
44import { Storage } from "../../src/storage/storage"
5+ import { Session } from "../../src/session"
56import { $ } from "bun"
67import path from "path"
8+ import fs from "fs/promises"
79import { tmpdir } from "../fixture/fixture"
810
911Log . init ( { print : false } )
1012
13+ // Helper: cleanup worktree properly (must remove worktree reference before deleting directory)
14+ async function cleanupWorktree ( mainRepoPath : string , worktreePath : string ) {
15+ await $ `git worktree remove --force ${ worktreePath } ` . cwd ( mainRepoPath ) . quiet ( ) . nothrow ( )
16+ await fs . rm ( worktreePath , { recursive : true , force : true } )
17+ }
18+
1119describe ( "Project.fromDirectory" , ( ) => {
1220 test ( "should handle git repository with no commits" , async ( ) => {
1321 await using tmp = await tmpdir ( )
@@ -38,6 +46,214 @@ describe("Project.fromDirectory", () => {
3846 const opencodeFile = path . join ( tmp . path , ".git" , "opencode" )
3947 const fileExists = await Bun . file ( opencodeFile ) . exists ( )
4048 expect ( fileExists ) . toBe ( true )
49+
50+ // Task 4.1: Main worktree ID should not contain separator
51+ expect ( project . id ) . not . toContain ( "-" )
52+
53+ // Task 4.1: opencode-worktree file should not exist for main worktree
54+ const worktreeHashFile = path . join ( tmp . path , ".git" , "opencode-worktree" )
55+ const worktreeHashExists = await Bun . file ( worktreeHashFile ) . exists ( )
56+ expect ( worktreeHashExists ) . toBe ( false )
57+ } )
58+
59+ // Task 4.2: Add linked worktree test
60+ test ( "should differentiate linked worktrees from main worktree" , async ( ) => {
61+ await using tmp = await tmpdir ( { git : true } )
62+ const mainRepoPath = tmp . path
63+
64+ // Create a linked worktree
65+ const worktreePath = path . join ( path . dirname ( mainRepoPath ) , "linked-worktree-" + Math . random ( ) . toString ( 36 ) . slice ( 2 ) )
66+ await $ `git worktree add ${ worktreePath } HEAD` . cwd ( mainRepoPath ) . quiet ( )
67+
68+ try {
69+ // Get project for main worktree
70+ const mainProject = await Project . fromDirectory ( mainRepoPath )
71+
72+ // Get project for linked worktree
73+ const linkedProject = await Project . fromDirectory ( worktreePath )
74+
75+ // Verify main worktree uses root commit only (no separator)
76+ expect ( mainProject . id ) . not . toContain ( "-" )
77+
78+ // Verify linked worktree uses {rootCommit}-{hash} format
79+ expect ( linkedProject . id ) . toContain ( "-" )
80+ expect ( linkedProject . id . split ( "-" ) . length ) . toBe ( 2 )
81+
82+ // Verify IDs are different
83+ expect ( mainProject . id ) . not . toBe ( linkedProject . id )
84+
85+ // Verify linked ID starts with the main ID (root commit)
86+ expect ( linkedProject . id . startsWith ( mainProject . id + "-" ) ) . toBe ( true )
87+
88+ // Verify opencode-worktree file exists for linked worktree
89+ const gitDirOutput = await $ `git rev-parse --git-dir` . cwd ( worktreePath ) . quiet ( ) . text ( )
90+ const gitDir = path . resolve ( worktreePath , gitDirOutput . trim ( ) )
91+ const worktreeHashFile = path . join ( gitDir , "opencode-worktree" )
92+ const worktreeHashExists = await Bun . file ( worktreeHashFile ) . exists ( )
93+ expect ( worktreeHashExists ) . toBe ( true )
94+ } finally {
95+ // Proper cleanup: remove worktree before deleting directory
96+ await cleanupWorktree ( mainRepoPath , worktreePath )
97+ }
98+ } )
99+
100+ // Task 4.3: Add migration test
101+ test ( "should migrate sessions from old project ID to new project ID for linked worktrees" , async ( ) => {
102+ await using tmp = await tmpdir ( { git : true } )
103+ const mainRepoPath = tmp . path
104+
105+ // Get main project to get the root commit (old project ID)
106+ const mainProject = await Project . fromDirectory ( mainRepoPath )
107+ const oldProjectID = mainProject . id
108+
109+ // Create a linked worktree
110+ const worktreePath = path . join (
111+ path . dirname ( mainRepoPath ) ,
112+ "linked-worktree-migration-" + Math . random ( ) . toString ( 36 ) . slice ( 2 ) ,
113+ )
114+ await $ `git worktree add ${ worktreePath } HEAD` . cwd ( mainRepoPath ) . quiet ( )
115+
116+ try {
117+ // Create legacy sessions under the old project ID with different directories
118+ const sessionForWorktree : Session . Info = {
119+ id : "session_for_worktree" ,
120+ projectID : oldProjectID ,
121+ directory : worktreePath ,
122+ title : "Test session for worktree" ,
123+ time : { created : Date . now ( ) , updated : Date . now ( ) } ,
124+ version : "1" ,
125+ }
126+ const sessionForMain : Session . Info = {
127+ id : "session_for_main" ,
128+ projectID : oldProjectID ,
129+ directory : mainRepoPath ,
130+ title : "Test session for main" ,
131+ time : { created : Date . now ( ) , updated : Date . now ( ) } ,
132+ version : "1" ,
133+ }
134+
135+ await Storage . write ( [ "session" , oldProjectID , sessionForWorktree . id ] , sessionForWorktree )
136+ await Storage . write ( [ "session" , oldProjectID , sessionForMain . id ] , sessionForMain )
137+
138+ // Open linked worktree to trigger migration
139+ const linkedProject = await Project . fromDirectory ( worktreePath )
140+ const newProjectID = linkedProject . id
141+
142+ // Verify the session matching worktree directory was migrated
143+ const migratedSession = await Storage . read < Session . Info > ( [ "session" , newProjectID , sessionForWorktree . id ] ) . catch (
144+ ( ) => undefined ,
145+ )
146+ expect ( migratedSession ) . toBeDefined ( )
147+ expect ( migratedSession ?. projectID ) . toBe ( newProjectID )
148+ expect ( migratedSession ?. directory ) . toBe ( worktreePath )
149+
150+ // Verify the old session was removed
151+ const oldSessionForWorktree = await Storage . read < Session . Info > ( [
152+ "session" ,
153+ oldProjectID ,
154+ sessionForWorktree . id ,
155+ ] ) . catch ( ( ) => undefined )
156+ expect ( oldSessionForWorktree ) . toBeUndefined ( )
157+
158+ // Verify session for main worktree remains under old project ID (not migrated)
159+ const remainingSession = await Storage . read < Session . Info > ( [ "session" , oldProjectID , sessionForMain . id ] ) . catch (
160+ ( ) => undefined ,
161+ )
162+ expect ( remainingSession ) . toBeDefined ( )
163+ expect ( remainingSession ?. directory ) . toBe ( mainRepoPath )
164+ } finally {
165+ // Cleanup: remove worktree
166+ await cleanupWorktree ( mainRepoPath , worktreePath )
167+
168+ // Cleanup storage
169+ await Storage . remove ( [ "session" , oldProjectID , "session_for_main" ] ) . catch ( ( ) => { } )
170+ }
171+ } )
172+
173+ // Task 4.4: Add integration test with real worktree scenario
174+ test ( "should support multiple worktrees simultaneously without data collision" , async ( ) => {
175+ await using tmp = await tmpdir ( { git : true } )
176+ const mainRepoPath = tmp . path
177+
178+ // Create two linked worktrees
179+ const worktree1Path = path . join ( path . dirname ( mainRepoPath ) , "worktree1-" + Math . random ( ) . toString ( 36 ) . slice ( 2 ) )
180+ const worktree2Path = path . join ( path . dirname ( mainRepoPath ) , "worktree2-" + Math . random ( ) . toString ( 36 ) . slice ( 2 ) )
181+
182+ await $ `git worktree add ${ worktree1Path } HEAD` . cwd ( mainRepoPath ) . quiet ( )
183+ await $ `git worktree add ${ worktree2Path } HEAD` . cwd ( mainRepoPath ) . quiet ( )
184+
185+ try {
186+ // Open all three (main + 2 linked) in opencode
187+ const mainProject = await Project . fromDirectory ( mainRepoPath )
188+ const worktree1Project = await Project . fromDirectory ( worktree1Path )
189+ const worktree2Project = await Project . fromDirectory ( worktree2Path )
190+
191+ // Verify all three have different project IDs
192+ const ids = new Set ( [ mainProject . id , worktree1Project . id , worktree2Project . id ] )
193+ expect ( ids . size ) . toBe ( 3 )
194+
195+ // Verify all three share the same root commit prefix
196+ const rootCommit = mainProject . id
197+ expect ( worktree1Project . id . startsWith ( rootCommit + "-" ) ) . toBe ( true )
198+ expect ( worktree2Project . id . startsWith ( rootCommit + "-" ) ) . toBe ( true )
199+
200+ // Create sessions in each project
201+ const session1 : Session . Info = {
202+ id : "session_main" ,
203+ projectID : mainProject . id ,
204+ directory : mainRepoPath ,
205+ title : "Main session" ,
206+ time : { created : Date . now ( ) , updated : Date . now ( ) } ,
207+ version : "1" ,
208+ }
209+ const session2 : Session . Info = {
210+ id : "session_wt1" ,
211+ projectID : worktree1Project . id ,
212+ directory : worktree1Path ,
213+ title : "Worktree 1 session" ,
214+ time : { created : Date . now ( ) , updated : Date . now ( ) } ,
215+ version : "1" ,
216+ }
217+ const session3 : Session . Info = {
218+ id : "session_wt2" ,
219+ projectID : worktree2Project . id ,
220+ directory : worktree2Path ,
221+ title : "Worktree 2 session" ,
222+ time : { created : Date . now ( ) , updated : Date . now ( ) } ,
223+ version : "1" ,
224+ }
225+
226+ await Storage . write ( [ "session" , mainProject . id , session1 . id ] , session1 )
227+ await Storage . write ( [ "session" , worktree1Project . id , session2 . id ] , session2 )
228+ await Storage . write ( [ "session" , worktree2Project . id , session3 . id ] , session3 )
229+
230+ // Verify complete isolation (each project only has its own session)
231+ const mainSessions = await Storage . list ( [ "session" , mainProject . id ] )
232+ const wt1Sessions = await Storage . list ( [ "session" , worktree1Project . id ] )
233+ const wt2Sessions = await Storage . list ( [ "session" , worktree2Project . id ] )
234+
235+ expect ( mainSessions . length ) . toBe ( 1 )
236+ expect ( wt1Sessions . length ) . toBe ( 1 )
237+ expect ( wt2Sessions . length ) . toBe ( 1 )
238+
239+ // Verify no data leakage
240+ const mainSession = await Storage . read < Session . Info > ( [ "session" , mainProject . id , "session_main" ] )
241+ const wt1Session = await Storage . read < Session . Info > ( [ "session" , worktree1Project . id , "session_wt1" ] )
242+ const wt2Session = await Storage . read < Session . Info > ( [ "session" , worktree2Project . id , "session_wt2" ] )
243+
244+ expect ( mainSession . directory ) . toBe ( mainRepoPath )
245+ expect ( wt1Session . directory ) . toBe ( worktree1Path )
246+ expect ( wt2Session . directory ) . toBe ( worktree2Path )
247+
248+ // Cleanup sessions
249+ await Storage . remove ( [ "session" , mainProject . id , session1 . id ] ) . catch ( ( ) => { } )
250+ await Storage . remove ( [ "session" , worktree1Project . id , session2 . id ] ) . catch ( ( ) => { } )
251+ await Storage . remove ( [ "session" , worktree2Project . id , session3 . id ] ) . catch ( ( ) => { } )
252+ } finally {
253+ // Proper cleanup order: remove worktrees before deleting directories
254+ await cleanupWorktree ( mainRepoPath , worktree1Path )
255+ await cleanupWorktree ( mainRepoPath , worktree2Path )
256+ }
41257 } )
42258} )
43259
0 commit comments