Skip to content

Desktop project picker: prefix collision — opening "foo-ios" selects/opens "foo" #40094

Closed

Description

@Hyperion2220

Description

In OpenCode Desktop, I cannot open a repository/directory whose name is a string prefix of another directory already added to the project list. Example: I have a repo/dir vesper and another repo/dir vesper-ios. When I open the project picker and try to choose vesper-ios, the app selects vesper instead (or refuses to open vesper-ios entirely).

Expected behavior: Selecting a directory opens exactly that directory, regardless of whether another directory's name is a prefix of it.

Actual behavior: The shorter name matches as a prefix, so the wrong project is opened and I can't work in vesper-ios.

This is distinct from the existing issues about identical project names or directories that share the same git remote (e.g. #34285, #34067, #39676, #36344, #35714) — here the two directories have different names, but one is a string prefix of the other.

Plugins

None relevant.

OpenCode version

N/A — reproduced on the current Desktop beta.

Steps to reproduce

  1. Add a directory named vesper to OpenCode Desktop.
  2. Attempt to open a new project and choose a directory named vesper-ios.
  3. Observe that vesper-ios cannot be selected; the picker resolves to vesper instead.

Screenshot and/or share link

Not available.

Operating System

macOS (Apple Silicon)

Terminal

OpenCode Desktop app

Activity

  1. AliAltivate commented on Aug 2, 2026

    @AliAltivate

    I spent some time investigating this and wanted to share what I found in case it's useful.

    The project picker uses exact matching throughout the selection path — pathKey === / Map.get / findIndex(x => key(x) === active()) across dialog-select-directory[-v2], home.project.add, layout.projects.open, rootFor, server.projects.open, and use-filtered-list. I couldn't find a name-prefix / startsWith collision anywhere in the picker.

    The only collision I could reproduce is shared git root commit — i.e. when vesper-ios was created by copying/cloning vesper (so they share .git history). Project.resolve (packages/core/src/project.ts:115) falls back to root(repo) = the first root-commit hash for repos without a usable remote; two copies share that hash → same project ID → fromDirectory adds vesper-ios to vesper's sandboxes → rootFor(vesper-ios) resolves to vesper. The name prefix is incidental — any copied dir collides regardless of name.

    Two scenarios I tested locally (in packages/opencode/test/project/project.test.ts, modeled on the existing suite):

    • two sibling git init repos with different initial commits (different file content) → distinct project IDs, no collision ✅
    • cp -R vesper vesper-ios (shared root commit, no remote) → same project ID, vesper-ios folded into vesper.sandboxes

    The shared-ID case is already asserted as intended by project.test.ts:344 ("separate clones of the same repo should share project ID"), so a change that separates copies would conflict with that test and the clone-grouping design.

    @Hyperion2220 — could you confirm whether vesper-ios was created by copying or cloning vesper (e.g. cp -R, git clone of a local path)? If they're genuinely distinct repos with different root commits, the above wouldn't explain it and I'd like to reproduce your exact setup.

    Happy to implement whichever direction you all want here (e.g. an explicit "open as separate project" override for explicitly-chosen dirs), but that's a design call I'll defer to the maintainers.

  2. Hyperion2220 commented on Aug 2, 2026

    @Hyperion2220
    Author

    @AliAltivate Thanks for digging into this. To answer your question: vesper-ios was not copied or cloned from vesper. They're independent repos with different GitHub remotes and different initial commits, and they resolve to different project IDs. Both have usable remotes, so Project.resolve returns the git-remote: hash and never reaches the root-commit fallback — that path isn't involved here.

    You're also right that it isn't a name-prefix collision. But there is a real bug: my project table had two projects claiming the same worktree, with the real vesper-ios directory demoted to a sandbox of the iOS project. The trigger is a renamed repo, not a copied one.

    Cause

    fromDirectory pins worktree permanently for every non-global project:

    // packages/opencode/src/project/project.ts:237
    worktree: projectID === ProjectV2.ID.global ? worktree : existing.worktree,

    So when a repo is renamed or moved and reopened, the new path falls through to the sandbox branch instead of correcting the worktree:

    // packages/opencode/src/project/project.ts:243-246
    data.directory !== result.worktree &&
    !result.sandboxes.includes(data.directory)
      ? result.sandboxes.push(data.directory)

    sandboxes entries are pruned against the filesystem a few lines later, but worktree is never revalidated — so a stale path survives indefinitely, even once it belongs to a different repository.

    The desktop symptom follows from there. rootFor (packages/app/src/context/layout.tsx:473) builds a sandbox → worktree map, so opening the renamed directory resolves back to the old path. And the effect at layout.tsx:495-505 removes any open project whose rootFor differs from its own worktree, which is why the project wouldn't stay open at all. That accounts for both behaviors in my original report.

    Reproduction — no copying or cloning required

    git init foo && cd foo && git remote add origin <some-url> && git commit --allow-empty -m init
    # open foo in OpenCode Desktop
    cd .. && mv foo bar
    # open bar in OpenCode Desktop
    

    bar is folded into foo as a sandbox, and the picker resolves it back to foo. Any rename or move of a tracked repo reproduces it; the names don't need to share a prefix.

    I confirmed this against fromDirectory directly on 1882c33. After the rename the project ID is stable, worktree stays on the old path — which no longer exists on disk — and the renamed directory is appended to sandboxes:

    [repro] project id stable:   true
    [repro] worktree after mv:   /…/T/opencode-test-xzwcavrmesm
    [repro] renamed dir:         /…/T/opencode-test-xzwcavrmesm-renamed
    [repro] sandboxes after mv:  ["/…/T/opencode-test-xzwcavrmesm-renamed"]
    
    Test used (passes today, documenting current behavior)
    it.live("renaming a tracked repo folds the new path into the old worktree", () =>
      Effect.gen(function* () {
        const project = yield* Project.Service
        const tmp = yield* tmpdirScoped({ git: true })
        yield* Effect.promise(() => $`git remote add origin https://github.com/example/foo.git`.cwd(tmp).quiet())
    
        const first = yield* project.fromDirectory(tmp)
        expect(first.project.worktree).toBe(tmp)
        expect(first.project.sandboxes).toEqual([])
    
        const renamed = path.join(path.dirname(tmp), path.basename(tmp) + "-renamed")
        yield* Effect.promise(() => $`mv ${tmp} ${renamed}`.quiet())
    
        const second = yield* project.fromDirectory(renamed)
    
        expect(second.project.id).toBe(first.project.id)
        expect(second.project.worktree).toBe(tmp) // stale: this path is gone
        expect(second.project.sandboxes).toContain(renamed)
      }),
    )

    The existing project.test.ts suite (36 tests) is green alongside it.

    On the test conflict

    I don't think this collides with project.test.ts:344 ("separate clones of the same repo should share project ID"). Genuine clones still resolve to the same ID under any fix here — the question is only which directory owns worktree. A targeted change would be: when data.directory !== existing.worktree, re-resolve existing.worktree first; if that path no longer exists, or now resolves to a different project ID, promote data.directory to worktree rather than appending it to sandboxes. Two live clones of one repo both still resolve to the same ID, so the grouping behavior that test asserts is unchanged.

    Workaround

    Fixing the row directly resolves it, with OpenCode closed:

    UPDATE project
    SET worktree = '<real path>', sandboxes = '[]'
    WHERE id = '<project id>';

    Verified on 1882c33.

  3. twittwer commented on Aug 25, 2026

    @twittwer

    I have the same problem on Windows 11.

  4. neriousy commented on Oct 22, 2026

    @neriousy
    Member

    Fixed by #45043 and #45735. When a tracked repo is renamed or moved, the project's canonical directory now moves to the new path if the old one no longer exists, instead of folding the new path into the old project as a sandbox. Covered by the 'canonical directory is renamed' test in packages/core/test/project.test.ts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions