Skip to content

Worktrees resolve to different project IDs due to per-worktree cache lookup #16995

Closed as not planned

Description

@afkent27

Summary

When OpenCode is opened from a git worktree, it looks for the .git/opencode cache file in the per-worktree git directory (.git/worktrees/<name>/opencode) rather than the common git directory (.git/opencode). This causes each worktree to compute a potentially different project ID, resulting in separate project records and empty session lists.

Steps to Reproduce

  1. Have a repo with a cached project ID in .git/opencode
  2. Create a git worktree: git worktree add ../my-worktree feature-branch
  3. Open OpenCode from the worktree directory
  4. Open the session list — it shows "No results found"

Root Cause

In src/project/project.ts, the fromDirectory() function resolves the .git path and looks for an opencode file there. For worktrees, .git is a file (not a directory) pointing to .git/worktrees/<name>/, so the cache lookup goes to .git/worktrees/<name>/opencode instead of the main .git/opencode.

Since the per-worktree cache doesn't exist, OpenCode falls back to git rev-list --max-parents=0 --all. This command can return different results depending on stash state (e.g., git stash -u creates orphan root commits), leading to a different project ID being computed and cached per-worktree.

Expected Behavior

All worktrees of the same repository should resolve to the same project ID and share sessions. The cache file should be read from and written to the common git directory (obtainable via git rev-parse --git-common-dir), not the per-worktree git directory.

Workaround

Manually create the cache file in each worktree's git directory:

echo "<project-id>" > .git/worktrees/<worktree-name>/opencode

This must be repeated for every new worktree.

Suggested Fix

In fromDirectory(), use git rev-parse --git-common-dir (which is already being called later in the function) to determine where to read/write the opencode cache file, instead of using the dotgit path which varies per worktree.

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Mar 11, 2026
  2. github-actions commented on Mar 11, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  3. afkent27 commented on Mar 11, 2026

    @afkent27
    Author

    Additional Findings

    After further investigation, the initial analysis was incomplete. The cache file approach (<gitdir>/opencode) cannot work in worktrees at all, regardless of where the file is placed.

    Root Cause (Corrected)

    In fromDirectory(), the code does:

    const dotgit = await matches.next().then((x) => x.value);  // finds .git entry
    let id = await Filesystem.readText(path.join(dotgit, "opencode"))  // reads cache

    Filesystem.up finds the .git entry by checking exists(). In a worktree, .git is a file (not a directory), containing gitdir: /path/to/.git/worktrees/<name>.

    path.join("/path/to/worktree/.git", "opencode") produces /path/to/worktree/.git/opencode — but .git is a file, so this path can never exist. The read always fails silently, and OpenCode falls back to git rev-list --max-parents=0 --all.

    Similarly, the write-back also fails silently:

    await Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => { return; });

    So the cache is never read or written in worktrees. Every time OpenCode launches from a worktree, it recomputes the project ID from scratch.

    Why it produces wrong IDs

    git stash -u (stash with untracked files) creates orphan commits (zero parents) to store untracked file trees. git rev-list --max-parents=0 --all picks these up. Since the results are sorted lexicographically and the first entry is used as the project ID, any stash orphan that sorts before the real initial commit produces a different project ID.

    Suggested Fix

    Replace:

    const dotgit = await matches.next().then((x) => x.value);
    // ...
    let id = await Filesystem.readText(path.join(dotgit, "opencode"))

    With logic that resolves to the common git directory (which is always a real directory):

    const dotgit = await matches.next().then((x) => x.value);
    let sandbox = path.dirname(dotgit);
    // Resolve the actual git directory (handles worktrees where .git is a file)
    const commonDir = await git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
        .then(async (result) => gitpath(sandbox, await result.text()))
        .catch(() => dotgit);
    let id = await Filesystem.readText(path.join(commonDir, "opencode"))

    And similarly for the write-back.

    Workaround

    A shell function wrapper that detects worktrees and runs opencode from the main repo root:

    opencode() {
        local git_common_dir
        git_common_dir=$(git rev-parse --git-common-dir 2>/dev/null)
        if [ $? -ne 0 ]; then
            command opencode "$@"
            return
        fi
        local git_dir
        git_dir=$(git rev-parse --git-dir 2>/dev/null)
        if [ "$git_common_dir" != "$git_dir" ]; then
            local main_repo
            main_repo=$(dirname "$git_common_dir")
            command opencode "$main_repo" "$@"
        else
            command opencode "$@"
        fi
    }

    This passes the main repo root as the [project] positional argument so OpenCode finds the real .git directory (not the worktree .git file).

  4. natewill commented on Mar 11, 2026

    @natewill
    Contributor

    #16684 fixes this

  5. afkent27 commented on Mar 11, 2026

    @afkent27
    Author

    Better Workaround: Symlink Approach

    The previous workaround (redirecting to main repo root) has a critical flaw: OpenCode operates in the main repo directory instead of the worktree, so file edits and git operations happen in the wrong place.

    Better solution: Convert the worktree's .git file to a symlink pointing to the git worktree metadata directory (e.g., .git/worktrees/<name>/). This makes path.join(".git", "opencode") resolve through the symlink, while git operations continue to work correctly.

    # One-time conversion for existing worktrees:
    WORKTREE="/path/to/worktree"
    GITDIR=$(sed 's/gitdir: //' "$WORKTREE/.git")
    rm "$WORKTREE/.git"
    ln -s "$GITDIR" "$WORKTREE/.git"

    Shell wrapper that auto-converts on opencode launch:

    opencode() {
        local dotgit
        dotgit=$(git rev-parse --git-dir 2>/dev/null)
    
        if [ $? -eq 0 ]; then
            local toplevel
            toplevel=$(git rev-parse --show-toplevel 2>/dev/null)
            local gitfile="$toplevel/.git"
    
            # If .git is a regular file (worktree), convert to symlink
            if [ -f "$gitfile" ] && [ ! -L "$gitfile" ]; then
                local gitdir
                gitdir=$(sed 's/gitdir: //' "$gitfile")
                if [ -d "$gitdir" ]; then
                    rm "$gitfile"
                    ln -s "$gitdir" "$gitfile"
    
                    # Ensure the opencode cache exists in the target dir
                    local main_cache
                    main_cache=$(git rev-parse --git-common-dir 2>/dev/null)/opencode
                    if [ -f "$main_cache" ] && [ ! -f "$gitdir/opencode" ]; then
                        cp "$main_cache" "$gitdir/opencode"
                    fi
                fi
            fi
        fi
    
        command opencode "$@"
    }

    Tested: git status, branch, rev-parse, commit, push all work correctly through the symlink.

  6. natewill commented on Mar 11, 2026

    @natewill
    Contributor

    All you have to do is call project.current with the directory name which calls fromDirectiory and resolves the project correctly

  7. removed
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  8. bashrusakh commented on Jun 24, 2026

    @bashrusakh

    Confirmed the same root cause in a worktree sandbox scenario. After a daemon restart, the project ID changed because the git remote URL was modified (fork). The new project ID caused fromDirectory() to create a separate project row and snapshot, splitting sessions across two projects. The original session was migrated via migrateProjectId, but existing sessions with the same title were forked (fork #1) under the new project.

    The current git.find() already uses --git-common-dir correctly for cache lookup — the cache itself is sound. The remaining gap is that fromDirectory() has no sandbox‑based project fallback: when the resolved ID differs from an existing project whose sandboxes already contain the directory, it still creates a new project.

    Opened a draft PR with a fix: if the directory is already a sandbox of another project, reuse its ID instead of creating a new one. This prevents the duplicate project and the spurious fork session.

  9. github-actions commented on Aug 24, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

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