Skip to content

Commit 96089ee

Browse files
committed
test: add tests for git worktree project ID differentiation
Tests cover: - Main worktree ID format (no separator) - Linked worktree ID format ({rootCommit}-{hash}) - Session migration from old to new project ID - Data isolation between multiple simultaneous worktrees
1 parent 8ed811e commit 96089ee

1 file changed

Lines changed: 216 additions & 0 deletions

File tree

packages/opencode/test/project/project.test.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@ import { describe, expect, test } from "bun:test"
22
import { Project } from "../../src/project/project"
33
import { Log } from "../../src/util/log"
44
import { Storage } from "../../src/storage/storage"
5+
import { Session } from "../../src/session"
56
import { $ } from "bun"
67
import path from "path"
8+
import fs from "fs/promises"
79
import { tmpdir } from "../fixture/fixture"
810

911
Log.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+
1119
describe("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

Comments
 (0)