Skip to content

worktree: worktree_create targets the project's canonical directory, not the session directory (project ID collision after history import) #58148

Open

Description

@3marcin

Summary

After one git repository's history is imported into another (monorepo migration with preserved ancestry), OpenCode V2 associates the new repo's directory with the old repo's project, and the native worktree tool then creates worktrees inside the old repository — even though the session is running in the new one.

Environment

Reproduction

  1. Create git repo A (at least one commit) and open it once in OpenCode V2. A project is created; its ID equals a commit hash of A (in my case A's root commit — 40-hex exact match in the project table).
  2. Create git repo B in a different directory. Import A's history into B while preserving ancestry (e.g. git fetch /path/to/A <commit> + merge). B now has multiple root commits, one of which is A's root commit — i.e. it matches A's project ID.
  3. Open B in OpenCode. The new directory gets linked to A's existing project: a project_directory row for B's path appears under A's project_id, and new sessions created in B are assigned A's project.
  4. With worktree: { "directory": ".worktrees" } in the project config, invoke the native worktree-creation tool (worktree_create) from a session whose directory is B.
  5. The worktree is created at <A>/.worktrees/<name> — inside repo A. The server boots a location in A's directory for the tool call.

Expected Behavior

The worktree is created under the session's directory: <B>/.worktrees/<name>.

Actual Behavior

The worktree is created under the session's project's canonical directory (the worktree column of the project row), which is <A> — a different repository. Log evidence from ~/.local/share/opencode/log/opencode.log:

message="location services booted" directory=<A>
message="spawning process" command=git args=["worktree","add","--detach","--","<A>/.worktrees/<name>","HEAD"] cwd=<A>

while every shell command of the same session ran with cwd=<B>.

Additional Context

  • Observed in ~/.local/share/opencode/opencode.db: project stores a canonical directory; project_directory mapped B's path to A's project; essentially all new sessions in B (247/252 in my case) were assigned A's project after the association formed.
  • The project ID appears to be derived from a git commit (exact match with the repo's root/initial commit for two separate repos). After a history import, the new repo contains the old project's ID among its root commits, so directory→project resolution matches the old project. The exact tie-break when multiple roots match existing projects is unknown.
  • Consistently reproducible once the directory→project association exists (re-verified live: a probe worktree_create from a session in B landed in <A>/.worktrees/, then was removed).
  • Workaround: create worktrees manually via shell (git worktree add .worktrees/<name>), which is CWD-anchored and lands correctly.
  • Suggested directions: (1) native worktree placement should follow the session's directory rather than the project's canonical directory, and/or (2) directory→project resolution should prefer the project whose canonical directory equals the resolved directory over a project matched only via an imported-history commit.

Activity

  1. opencode-agent commented on Oct 22, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I can confirm this on 2.1.3, and it still happens on the latest dev (074f2fe).

    What I did:

    1. Opened repo A, which creates A's project.
    2. Made repo B import A's history and merge in its own (older) root commit, so A's root comes first among B's roots. B then gets A's project ID.
    3. Moved the session into B (the footer shows ~/repo-b:main).
    4. Called worktree_create with worktree.directory: ".worktrees".

    The worktree was created at <A>/.worktrees/probe and shows up in git worktree list for A; B got none. The recording and script below show the whole run.

    The cause looks like this: the tool passes only the session's project ID, and worktree creation then uses that project's canonical directory as both the git source and the base for worktree.directory. The session's own directory isn't used. Your manual git worktree add workaround avoids this for now.

    Related: #12103 (v1, the same root-commit project ID collision after a subtree merge, there affecting shared session history) and #51992 (canonical directory used instead of the selected worktree when creating sessions in the desktop app).

    recording.mp4

    Screenshot

    Reproduction script (drive.ts)
    // Repro for anomalyco/opencode#58148: worktree_create uses the project's canonical
    // directory (repo A) instead of the session's directory (repo B) when B imported A's history.
    import { execSync } from "node:child_process"
    import { existsSync, mkdirSync, readdirSync } from "node:fs"
    import { Effect, Stream } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    const sh = (cmd: string, cwd: string) =>
      execSync(cmd, { cwd, encoding: "utf8", env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } })
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        config: { worktree: { directory: ".worktrees" } } as any,
        project: { git: true, files: { "a.txt": "repo A\n" } },
      },
      ({ ui, llm, artifacts }) =>
        Effect.gen(function* () {
          const A = sh("git rev-parse --show-toplevel", `${artifacts}/files`).trim()
          const B = `${artifacts}/repo-b`
          // Repo B imports A's history; its own root is older, so A's root is listed first by `git rev-list --max-parents=0 HEAD`.
          yield* Effect.sync(() => {
            // B: start from A's imported history, then add B's own unrelated root and merge it in.
            mkdirSync(B, { recursive: true }); sh(`git init -q -b main`, B)
            sh(`git fetch -q ${A} HEAD && git reset -q --hard FETCH_HEAD`, B)
            sh(`git checkout -q --orphan own && git rm -rqf . && echo "repo B" > b.txt && git add b.txt && GIT_AUTHOR_DATE=2000-01-01T00:00:00 GIT_COMMITTER_DATE=2000-01-01T00:00:00 git commit -q -m "B root"`, B)
            sh(`git checkout -q main && git merge -q --allow-unrelated-histories -m "monorepo" own`, B)
            console.log("A =", A, "\nB =", B, "\nA roots:", sh("git rev-list --max-parents=0 HEAD", A), "B roots:", sh("git rev-list --max-parents=0 HEAD", B))
          })
    
          let turn = 0
          yield* llm.serve((request: any) => {
            const body = JSON.stringify(request)
            if (body.includes("Reply with only the title")) return Stream.make(Llm.text("Repro"), Llm.finish())
            const index = turn++
            const steps = [
              [Llm.toolCall({ index: 0, id: "call_move", name: "execute", input: { code: `return await tools.opencode.session_move({ directory: ${JSON.stringify(B)} })` } }), Llm.finish("tool-calls")],
              [Llm.text("Moved to repo B."), Llm.finish()],
              [Llm.toolCall({ index: 0, id: "call_wt", name: "execute", input: { code: `return await tools.opencode.worktree_create({ name: "probe" })` } }), Llm.finish("tool-calls")],
              [Llm.text("Worktree done."), Llm.finish()],
            ]
            return Stream.make(...(steps[index] ?? [Llm.text("?")]))
          })
          // Turn 1 (session starts in A, creating A's project): move the session to B.
          yield* ui.submit("Move this session to repo B")
          yield* Effect.sleep("4 seconds")
          yield* ui.screenshot("after-move")
    
          // Turn 2 (session now in B): create a worktree.
          yield* ui.submit("Create a worktree named probe")
          yield* Effect.sleep("5 seconds")
          yield* ui.screenshot("worktree-created")
    
          yield* Effect.sync(() => {
            console.log("worktree under A:", existsSync(`${A}/.worktrees/probe`), existsSync(`${A}/.worktrees`) ? readdirSync(`${A}/.worktrees`) : [])
            console.log("worktree under B:", existsSync(`${B}/.worktrees/probe`))
            console.log("git worktree list (A):\n" + sh("git worktree list", A))
            console.log("git worktree list (B):\n" + sh("git worktree list", B))
          })
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  2. Dante-dan commented on Oct 22, 2026

    @Dante-dan
    Contributor

    I've prepared a focused patch: worktree_create passes the current repository as its source and uses the calling location's registered worktree strategy and destination configuration. Calls without an explicit source retain their project-level defaults.

    The core worktree and native tool suites pass (37 tests on Bun 1.4.4), both adjusted regression cases pass 10 repetitions each on Bun 1.4.2, and bun run check passes. The clone regression checks the new worktree's destination and source HEAD while the stored project canonical directory stays unchanged. I haven't rerun the interactive history-import recording.

    This addresses native worktree creation only; project identity and session grouping are unchanged. I can adjust the patch based on feedback.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions