Skip to content

Commit 4e80a16

Browse files
authored
Merge pull request #204 from pkchv/fix/opencode-sandbox-path-matching
opencode: index sandbox paths for project lookup
2 parents 46653dc + 081a6e9 commit 4e80a16

3 files changed

Lines changed: 190 additions & 3 deletions

File tree

internal/adapter/opencode/adapter.go

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,9 @@ func (a *Adapter) Watch(projectRoot string) (<-chan adapter.Event, io.Closer, er
334334
}
335335

336336
// findProjectID finds the OpenCode project ID for the given project root.
337+
// It tries an exact match first, then checks if projectRoot is a subdirectory
338+
// of any known project worktree. This handles bare-repo layouts where sidecar's
339+
// ProjectRoot may be .bare/ inside the OpenCode-registered worktree directory.
337340
func (a *Adapter) findProjectID(projectRoot string) (string, error) {
338341
// Normalize the project root path
339342
absRoot, err := filepath.Abs(projectRoot)
@@ -344,19 +347,25 @@ func (a *Adapter) findProjectID(projectRoot string) (string, error) {
344347
absRoot = resolved
345348
}
346349
absRoot = filepath.Clean(absRoot)
347-
348-
// Load and cache all projects once
349350
if !a.projectsLoaded {
350351
if err := a.loadProjects(); err != nil {
351352
return "", err
352353
}
353354
}
354355

355-
// Lookup in cache
356+
// Exact match (covers worktree path and sandbox paths)
356357
if proj, ok := a.projectIndex[absRoot]; ok {
357358
return proj.ID, nil
358359
}
359360

361+
// Subdirectory match: check if absRoot is inside a known project directory.
362+
// This handles bare-repo worktree layouts where git reports the main worktree
363+
// as .bare/ (e.g., /repo/.bare) but OpenCode registers the parent (/repo).
364+
for indexedPath, proj := range a.projectIndex {
365+
if strings.HasPrefix(absRoot, indexedPath+string(filepath.Separator)) {
366+
return proj.ID, nil
367+
}
368+
}
360369
return "", nil
361370
}
362371

@@ -402,6 +411,18 @@ func (a *Adapter) loadProjects() error {
402411

403412
projCopy := proj // Copy to avoid pointer aliasing
404413
a.projectIndex[worktree] = &projCopy
414+
415+
// Index sandbox paths as well
416+
for _, sandbox := range proj.Sandboxes {
417+
sbNorm := sandbox
418+
if resolved, err := filepath.EvalSymlinks(sbNorm); err == nil {
419+
sbNorm = resolved
420+
}
421+
sbNorm = filepath.Clean(sbNorm)
422+
if sbNorm != worktree {
423+
a.projectIndex[sbNorm] = &projCopy
424+
}
425+
}
405426
}
406427

407428
a.projectsLoaded = true

internal/adapter/opencode/adapter_test.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,171 @@ func TestDetect_SkipsGlobal(t *testing.T) {
120120
}
121121
}
122122

123+
func TestFindProjectID_WithSandboxPaths(t *testing.T) {
124+
tmpDir := t.TempDir()
125+
projectDir := filepath.Join(tmpDir, "project")
126+
if err := os.MkdirAll(projectDir, 0755); err != nil {
127+
t.Fatalf("failed to create project dir: %v", err)
128+
}
129+
130+
// Create a project with sandboxes
131+
worktreePath := filepath.Join(tmpDir, "repos", "core.zdaj.app")
132+
sandboxPath := filepath.Join(worktreePath, "main")
133+
134+
projectJSON := fmt.Sprintf(`{
135+
"id": "test_project_sandbox",
136+
"worktree": "%s",
137+
"vcs": "git",
138+
"sandboxes": ["%s"],
139+
"time": { "created": 1767000000000, "updated": 1767100000000 }
140+
}`, worktreePath, sandboxPath)
141+
142+
if err := os.WriteFile(filepath.Join(projectDir, "test_sandbox.json"), []byte(projectJSON), 0644); err != nil {
143+
t.Fatalf("failed to write project file: %v", err)
144+
}
145+
146+
a := &Adapter{
147+
storageDir: tmpDir,
148+
projectIndex: make(map[string]*Project),
149+
sessionIndex: make(map[string]string),
150+
metaCache: make(map[string]sessionMetaCacheEntry),
151+
}
152+
153+
// Test 1: Find project by worktree path
154+
projectID, err := a.findProjectID(worktreePath)
155+
if err != nil {
156+
t.Fatalf("findProjectID error: %v", err)
157+
}
158+
if projectID != "test_project_sandbox" {
159+
t.Errorf("expected project ID %q, got %q", "test_project_sandbox", projectID)
160+
}
161+
162+
// Test 2: Find project by sandbox path
163+
projectID, err = a.findProjectID(sandboxPath)
164+
if err != nil {
165+
t.Fatalf("findProjectID error for sandbox: %v", err)
166+
}
167+
if projectID != "test_project_sandbox" {
168+
t.Errorf("expected project ID %q for sandbox path, got %q", "test_project_sandbox", projectID)
169+
}
170+
171+
// Test 3: Path not in sandboxes should not be found
172+
unrelatedPath := filepath.Join(tmpDir, "repos", "other-repo")
173+
projectID, err = a.findProjectID(unrelatedPath)
174+
if err != nil {
175+
t.Fatalf("findProjectID error: %v", err)
176+
}
177+
if projectID != "" {
178+
t.Errorf("expected empty project ID for unrelated path, got %q", projectID)
179+
}
180+
}
181+
182+
183+
func TestFindProjectID_SubdirectoryMatch(t *testing.T) {
184+
tmpDir := t.TempDir()
185+
projectDir := filepath.Join(tmpDir, "project")
186+
if err := os.MkdirAll(projectDir, 0755); err != nil {
187+
t.Fatalf("failed to create project dir: %v", err)
188+
}
189+
190+
// Simulate bare-repo layout: OpenCode registers /repo as worktree,
191+
// but sidecar's ProjectRoot resolves to /repo/.bare (the main worktree).
192+
worktreePath := filepath.Join(tmpDir, "repos", "core.zdaj.app")
193+
barePath := filepath.Join(worktreePath, ".bare")
194+
sandboxPath := filepath.Join(worktreePath, "main")
195+
196+
projectJSON := fmt.Sprintf(`{
197+
"id": "test_project_subdir",
198+
"worktree": "%s",
199+
"vcs": "git",
200+
"sandboxes": ["%s"],
201+
"time": { "created": 1767000000000, "updated": 1767100000000 }
202+
}`, worktreePath, sandboxPath)
203+
204+
if err := os.WriteFile(filepath.Join(projectDir, "test_subdir.json"), []byte(projectJSON), 0644); err != nil {
205+
t.Fatalf("failed to write project file: %v", err)
206+
}
207+
208+
a := &Adapter{
209+
storageDir: tmpDir,
210+
projectIndex: make(map[string]*Project),
211+
sessionIndex: make(map[string]string),
212+
metaCache: make(map[string]sessionMetaCacheEntry),
213+
}
214+
215+
// Test 1: .bare subdirectory should match via subdirectory fallback
216+
projectID, err := a.findProjectID(barePath)
217+
if err != nil {
218+
t.Fatalf("findProjectID error for .bare path: %v", err)
219+
}
220+
if projectID != "test_project_subdir" {
221+
t.Errorf("expected project ID %q for .bare subdirectory, got %q", "test_project_subdir", projectID)
222+
}
223+
224+
// Test 2: Nested subdirectory should also match
225+
nestedPath := filepath.Join(worktreePath, ".bare", "worktrees", "main")
226+
projectID, err = a.findProjectID(nestedPath)
227+
if err != nil {
228+
t.Fatalf("findProjectID error for nested path: %v", err)
229+
}
230+
if projectID != "test_project_subdir" {
231+
t.Errorf("expected project ID %q for nested subdirectory, got %q", "test_project_subdir", projectID)
232+
}
233+
234+
// Test 3: Sibling directory should NOT match (prefix attack prevention)
235+
siblingPath := filepath.Join(tmpDir, "repos", "core.zdaj.app-other")
236+
projectID, err = a.findProjectID(siblingPath)
237+
if err != nil {
238+
t.Fatalf("findProjectID error for sibling path: %v", err)
239+
}
240+
if projectID != "" {
241+
t.Errorf("expected empty project ID for sibling path, got %q", projectID)
242+
}
243+
}
244+
func TestFindProjectID_SandboxNotDuplicated(t *testing.T) {
245+
tmpDir := t.TempDir()
246+
projectDir := filepath.Join(tmpDir, "project")
247+
if err := os.MkdirAll(projectDir, 0755); err != nil {
248+
t.Fatalf("failed to create project dir: %v", err)
249+
}
250+
251+
// Create a project where sandbox path equals worktree path (should not duplicate)
252+
worktreePath := filepath.Join(tmpDir, "repos", "myrepo")
253+
254+
projectJSON := fmt.Sprintf(`{
255+
"id": "test_project_no_dup",
256+
"worktree": "%s",
257+
"vcs": "git",
258+
"sandboxes": ["%s"],
259+
"time": { "created": 1767000000000, "updated": 1767100000000 }
260+
}`, worktreePath, worktreePath)
261+
262+
if err := os.WriteFile(filepath.Join(projectDir, "test_no_dup.json"), []byte(projectJSON), 0644); err != nil {
263+
t.Fatalf("failed to write project file: %v", err)
264+
}
265+
266+
a := &Adapter{
267+
storageDir: tmpDir,
268+
projectIndex: make(map[string]*Project),
269+
sessionIndex: make(map[string]string),
270+
metaCache: make(map[string]sessionMetaCacheEntry),
271+
}
272+
273+
// Should find project by worktree path
274+
projectID, err := a.findProjectID(worktreePath)
275+
if err != nil {
276+
t.Fatalf("findProjectID error: %v", err)
277+
}
278+
if projectID != "test_project_no_dup" {
279+
t.Errorf("expected project ID %q, got %q", "test_project_no_dup", projectID)
280+
}
281+
282+
// Verify projectIndex has only one entry for this path (no duplication)
283+
if len(a.projectIndex) != 1 {
284+
t.Errorf("expected 1 entry in projectIndex, got %d", len(a.projectIndex))
285+
}
286+
}
287+
123288
func TestSessions_WithTestdata(t *testing.T) {
124289
a := newTestAdapter(t)
125290
testdataDir := getTestdataDir(t)

internal/adapter/opencode/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type Project struct {
1010
ID string `json:"id"`
1111
Worktree string `json:"worktree"`
1212
VCS string `json:"vcs,omitempty"`
13+
Sandboxes []string `json:"sandboxes,omitempty"`
1314
Time TimeInfo `json:"time"`
1415
Icon *Icon `json:"icon,omitempty"`
1516
}

0 commit comments

Comments
 (0)