Skip to content

project: session move into an empty git repo re-points the global project's worktree #50979

Open

Description

@stevenshack

Summary

Moving a session (POST /api/session/{id}/move) into an empty git repo
(no commits, no remotes) does not create a project for that directory.
Instead the session stays in project global and the global project's
worktree is silently re-pointed at the target directory, making unrelated
directories cross-list each other's sessions.

Environment

  • opencode version: 2.0.15
  • OS: Linux 6.12.107+deb13-amd64 (Debian 13)
  • Terminal: xterm-256color, truecolor
  • Shell: fish
  • Install/channel: latest
  • Active plugins: none configured

Reproduction

  1. Two directories, A and B, both empty git repos (git init, no commits, no
    remotes). Sessions from both were previously filed under project global
    (empty repos resolve to global by design).
  2. opencode api post /api/session/<sesID>/move -d '{"directory": "<path-to-B>"}'
  3. Inspect the project table: the global row's worktree has been
    rewritten from A to B.

Expected Behavior

The move should either create a project row for the target directory or leave
the session in global — and never mutate the global project record.

Actual Behavior

  • The moved session remains in project global.
  • global's worktree is re-pointed at B. Every directory that resolves to
    global (all empty repos) then shows the same mixed session list: two
    unrelated projects' sessions appear in each other's /sessions.
  • Recoverable via PATCH /api/project/global -d '{"canonical": "/"}', which
    restores the catch-all semantics.

Additional Context

  • Underlying cause: empty repos have no derivable identity (no root commit,
    no origin remote), so resolution falls back to global, and the
    session-adoption step in the project bootstrap skips global by design.
  • Suggested fix directions: create a project for the move target (or reject
    the move with a clear error), and treat the global record as immutable.
  • Workaround that made the directories first-class: create an initial commit
    in each repo (root-commit SHA becomes the derived project identity).

Activity

  1. cncloudli commented on Oct 8, 2026

    @cncloudli

    In this case, move a session with empty git repo from its original project to another would make it shows both in the original project and the new one. DO NOT delete the session in old project because it would also be deleted from new project.

  2. Dante-dan commented on Oct 8, 2026

    @Dante-dan
    Contributor

    I reproduced a narrower canonical rewrite on current v2: if global.canonical points to a source directory that has disappeared, moving a session into an empty repository changes that global canonical to the destination. The checkout-rename handling applies to global too. This patch excludes the catch-all identity from that handling: Dante-dan@4798310

    The real Session.move regression fails before the change and passes afterward. The existing Project tests also pass, including legitimate checkout renames.

    With both empty directories still present, my boundary test preserved the global canonical and listed the moved session only for the destination directory. /sessions defaults to the all-project view; Ctrl+A switches to the current-directory view. Seeing the same Session ID in the all-project view from either directory is not a second stored copy. This patch addresses the missing-source canonical rewrite; I have not reproduced the reported rewrite while both directories exist.

  3. tamasys commented on Oct 21, 2026

    @tamasys

    Additional failure mode on v2.1.2 (no move involved): an already-attributed project gets demoted to global.

    1. Plain non-git directory with a project row and existing sessions → sessions attributed to that project; GET /api/location returns its id.
    2. git init in that directory (0 commits).
    3. Create one new session there.

    Result: the project row still exists and GET /api/location still returns its id, but every session in that directory — including the pre-existing ones — now has project_id='global'. A control run without the git init kept both sessions attributed, so the unborn-HEAD state is what triggers it.

    So the empty-repo fallback isn't only a failure to attribute new sessions; it can strip attribution from sessions that already had it. Nothing is lost (GET /api/session?project=global&directory=<dir> still returns them), but project-scoped listing becomes impossible and the directory's view shows the whole global bucket instead.

    Related: #55133 documents the unborn-HEAD fallback itself, #57629 the pre-row stranding.

  4. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

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

    With two empty repos A and B (git init, no commits, no remotes):

    1. Creating a session in A files it under global, and the global project immediately becomes canonical=A, vcs=git instead of staying /. This happens even before any move.
    2. A session in B also lands in global, which still points at A.
    3. After A's directory is gone, moving the A session into B (POST /api/session/<id>/move) leaves it in global and rewrites global's worktree to B. Listing sessions from B then returns both repos' sessions.

    One difference from your description: in my run the A to B rewrite happened only once A's directory no longer existed. The first change, from / to the first empty repo, happens every time. The global record seems to be updated like a normal project when an empty repo falls back to it, so PATCH /api/project/global with canonical: "/" and making an initial commit remain the workarounds for now.

    The script below reproduces it through the server API.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #50979: resolving/moving into empty git repos mutates the `global` project row.
    # Usage: bash repro.sh   (uses `opencode` from PATH; stop any running opencode service first)
    # From source: RUNDIR=/path/to/repo/packages/cli OC="bun --conditions=browser --preload=.../preload.js /path/to/repo/packages/cli/src/index.ts" bash repro.sh
    set -u
    OC=${OC:-opencode}
    R=$(mktemp -d /tmp/opencode/oc50979.XXXX)
    export HOME=$R/home XDG_DATA_HOME=$R/data XDG_CONFIG_HOME=$R/cfg XDG_STATE_HOME=$R/state XDG_CACHE_HOME=$R/cache
    mkdir -p "$HOME" "$R/A" "$R/B"
    git -C "$R/A" init -q; git -C "$R/B" init -q   # empty repos: no commits, no remotes
    cd "${RUNDIR:-$R}"
    api() { $OC api "$@" 2>&1; }
    dump() { echo "--- projects:"; api get /api/project | python3 -c 'import sys,json; [print(" ",p["id"],"canonical=",p["canonical"],"vcs=",p.get("vcs")) for p in json.load(sys.stdin)]'; }
    
    echo "== create session in A ($R/A)"
    SA=$(api post /api/session -d "{\"location\":{\"directory\":\"$R/A\"}}" | python3 -c 'import sys,json;d=json.load(sys.stdin)["data"];print(d["id"]);print("  projectID="+d["projectID"],file=sys.stderr)')
    dump
    echo "== create session in B ($R/B)"
    SB=$(api post /api/session -d "{\"location\":{\"directory\":\"$R/B\"}}" | python3 -c 'import sys,json;d=json.load(sys.stdin)["data"];print(d["id"]);print("  projectID="+d["projectID"],file=sys.stderr)')
    dump
    echo "== remove A, then move session $SA into B"
    rm -rf "$R/A"
    api post /api/session/$SA/move -d "{\"directory\":\"$R/B\"}" | head -c 400; echo
    sleep 2
    dump
    echo "== sessions listed from B"
    api get /api/session -H "x-opencode-directory:$R/B" | head -c 800; echo
    timeout 15 $OC service stop >/dev/null 2>&1 || true

    Run it: bash repro.sh

  5. Pavior0 commented on Oct 29, 2026

    @Pavior0

    Same symptom on macOS Desktop, through "Add project" rather than a session move.

    1. mkdir demo && cd demo && git init
    2. Add demo in Desktop.
    3. The project card shows / and opens /.

    The TUI opens demo correctly.

    On my database the global row is worktree="/" from an older build, and upsertProject never updates worktree on conflict. GET /api/location?location[directory]=.../demo returns id=global, canonical=.../demo, but GET /api/project still returns global, canonical=/, so Desktop's add flow opens project.worktree.

    I also see the same collapse with receipt-back, another empty git repo. Making an initial commit gives it a separate project id and fixes the display.

    The closed #59889 was marked duplicate of this and its follow-up shows the same global row at /.

    Is the intended fix to give empty repos a directory-scoped project id, or to keep global and make Desktop use the requested directory? I can test either.

  6. Tokisaki-Galaxy commented on Oct 30, 2026

    @Tokisaki-Galaxy

    Related report from Windows, with two findings not covered here

    I hit the same ID.global fallback on Windows and was pointed here from #59889. Two details from that investigation are not in the original write-up and may affect how this gets fixed.

    1. upsertProject never updates worktree on conflict

    packages/core/src/project/sql.ts only refreshes the VCS column:

    .insert(ProjectTable)
    .values({ id: project.id, worktree: project.canonical, vcs, sandboxes: [] })
    .onConflictDoUpdate({
      target: ProjectTable.id,
      set: { vcs: vcs ?? null },
      setWhere: vcs ? or(isNull(ProjectTable.vcs), ne(ProjectTable.vcs, vcs)) : isNotNull(ProjectTable.vcs),
    })

    A worktree value is written once and never corrected by a later resolve. On my machine global still holds the POSIX root / from the initial database creation on 2026-05-27, and it is reactivated daily:

    SELECT id, worktree, vcs, time_created, time_active FROM project WHERE id = 'global';
    -- global | / | git | 1779875769149 | 1793268822138

    So there are two distinct outcomes of the same fallback. This issue describes global's worktree being re-pointed at the target. On my machine it was never re-pointed at all, and it also cannot be corrected later, because nothing writes the column.

    2. The existing self-healing branch is unreachable for a root canonical

    persist in packages/core/src/project.ts does try to replace a stale canonical, but only when the previous path no longer exists:

    const replaced =
      previous &&
      previous.canonical !== project.canonical &&
      !(yield* fs.exists(previous.canonical).pipe(Effect.orElseSucceed(() => true)))
        ? ...
        : undefined

    On Windows:

    node -e "const fs=require('fs'); const path=require('path'); console.log(fs.existsSync('/'), path.resolve('/'), path.dirname('/'))"
    true Z:\ /
    

    / always exists and path.dirname('/') === '/'. A root canonical therefore never satisfies the replacement condition, so this repair path cannot clean up global or any other root-valued row.

    3. Client-side persistence survives session deletion

    Deleting a session does not remove global from the client's stored project set. packages/app/src/runtime/server/registry.tsx persists lastProjectID and usedProjectIDs per server scope, so the / entry keeps rendering after a restart even when the directory now resolves to a correct project.

    The rendering path is ID-first, which is why the stale ID wins:

    // packages/app/src/shell/layout/helpers.ts
    const matching = projects.filter((project) => project.id === session.projectID)
    if (matching.length === 1) return matching[0]
    // packages/app/src/runtime/server/global-sync/utils.ts
    const worktree = "canonical" in project ? project.canonical : project.worktree

    4. Suggested additions to the fix list

    • Treat a filesystem root as an invalid canonical in persist, so root-valued rows self-correct instead of being permanently exempt.
    • Allow worktree to be updated in upsertProject when the resolved canonical is more specific than the stored value.
    • Prune stale usedProjectIDs entries when the stored project no longer matches the session's current project.

    Scope note

    The C:/ row in #59889 is a different bug and belongs to #49428, not this issue. Its ID equals sha1("directory:C:\\"), so it came from the plain-directory branch with no VCS, not from the empty-repo fallback. The C:/builtin row from the skill base directory /builtin also looks unrelated to this issue.

  7. argszero commented on Oct 30, 2026

    @argszero
    Contributor

    I traced the write paths on dev at a8091a2ea033 rather than reproducing live, because the four reports here look inconsistent and one condition reconciles them: whether a global row already exists. The recovery recipe in the description also holds, and the reason it holds is the same line one of the comments reads as a bug.

    upsertProject writes worktree only on insert. In packages/core/src/project/sql.ts it sets worktree: project.canonical inside .values(), and its onConflictDoUpdate sets vcs only. So a resolve can create global with a directory and can never change one afterwards:

    • No global row yet, as on a fresh database: the first empty repo to resolve becomes global's worktree immediately, before any move. That is the / to A change in @opencodejr's run.
    • global row already present holding /: resolution leaves it alone. That is @Pavior0's and @Tokisaki-Galaxy's machine, and it is why the desktop card opens / there.

    The A to B rewrite during a move goes through a different branch. persist in packages/core/src/project.ts replaces the stored canonical only when the previous one is gone, !(yield* fs.exists(previous.canonical)). That is why @Dante-dan could not reproduce the rewrite with both directories still present, and why @opencodejr saw it only after deleting A. The three observations are one code path, not three bugs.

    The documented recovery writes the column, because it does not go through upsertProject. PATCH /api/project/{projectID} is project.update, whose payload carries an optional canonical; it calls Project.update, which issues UPDATE project SET worktree = ... directly. absoluteColumn().toDriver runs the value through absolute(), so / normalizes to / rather than being rejected. So @Tokisaki-Galaxy's "nothing writes the column" is accurate for the resolve path and not for this one: the column is writable through the update route.

    "/" also happens to be the one value the self-healing branch can never move again, since a root canonical always exists and replaced therefore can never fire for it. That is the same fact as the "unreachable for a root canonical" observation, read the other way: the branch cannot clean up a root row, and it equally cannot undo one. A repair to / is stable for that reason, not by convention.

    What this does not cover, so nobody reads more into it than was measured: it is a code read of dev at a8091a2ea033, not a live run, so it says what the code does and not what a given build does end to end. I did not trace the session-adoption path behind @tamasys's attribution stripping after git init, nor the stored lastProjectID / usedProjectIDs rendering behind @Pavior0's Desktop symptom, so neither is explained here. I am also not proposing which design is right, a directory-scoped project for empty repos or global kept with clients using the requested directory; that is the maintainers' call and both would change what I measured above.

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