You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
project: session move into an empty git repo re-points the global project's worktree #50979
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 globaland 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
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).
opencode api post /api/session/<sesID>/move -d '{"directory": "<path-to-B>"}'
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).
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.
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.
Additional failure mode on v2.1.2 (no move involved): an already-attributed project gets demoted to global.
Plain non-git directory with a project row and existing sessions → sessions attributed to that project; GET /api/location returns its id.
git init in that directory (0 commits).
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.
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):
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.
A session in B also lands in global, which still points at A.
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.shset -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 remotescd"${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
Same symptom on macOS Desktop, through "Add project" rather than a session move.
mkdir demo && cd demo && git init
Add demo in Desktop.
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.
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:
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:
/ 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.
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:
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.
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 becomesglobal'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.
Activity
cncloudli commentedon Oct 8, 2026
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.
Dante-dan commentedon Oct 8, 2026
I reproduced a narrower canonical rewrite on current v2: if
global.canonicalpoints 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 toglobaltoo. This patch excludes the catch-all identity from that handling: Dante-dan@4798310The 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.
/sessionsdefaults 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.tamasys commentedon Oct 21, 2026
Additional failure mode on v2.1.2 (no move involved): an already-attributed project gets demoted to
global.GET /api/locationreturns its id.git initin that directory (0 commits).Result: the project row still exists and
GET /api/locationstill returns its id, but every session in that directory — including the pre-existing ones — now hasproject_id='global'. A control run without thegit initkept 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 wholeglobalbucket instead.Related: #55133 documents the unborn-HEAD fallback itself, #57629 the pre-row stranding.
opencodejr commentedon Oct 28, 2026
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):global, and theglobalproject immediately becomescanonical=A,vcs=gitinstead of staying/. This happens even before any move.global, which still points at A.POST /api/session/<id>/move) leaves it inglobaland rewritesglobal'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. Theglobalrecord seems to be updated like a normal project when an empty repo falls back to it, soPATCH /api/project/globalwithcanonical: "/"and making an initial commit remain the workarounds for now.The script below reproduces it through the server API.
Reproduction script (repro.sh)
Run it:
bash repro.shPavior0 commentedon Oct 29, 2026
Same symptom on macOS Desktop, through "Add project" rather than a session move.
mkdir demo && cd demo && git initdemoin Desktop./and opens/.The TUI opens
democorrectly.On my database the
globalrow isworktree="/"from an older build, andupsertProjectnever updatesworktreeon conflict.GET /api/location?location[directory]=.../demoreturnsid=global, canonical=.../demo, butGET /api/projectstill returnsglobal, canonical=/, so Desktop's add flow opensproject.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
globalrow at/.Is the intended fix to give empty repos a directory-scoped project id, or to keep
globaland make Desktop use the requested directory? I can test either.Tokisaki-Galaxy commentedon Oct 30, 2026
Related report from Windows, with two findings not covered here
I hit the same
ID.globalfallback 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.
upsertProjectnever updatesworktreeon conflictpackages/core/src/project/sql.tsonly refreshes the VCS column:A
worktreevalue is written once and never corrected by a later resolve. On my machineglobalstill holds the POSIX root/from the initial database creation on 2026-05-27, and it is reactivated daily: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
persistinpackages/core/src/project.tsdoes try to replace a stale canonical, but only when the previous path no longer exists:On Windows:
/always exists andpath.dirname('/') === '/'. A root canonical therefore never satisfies the replacement condition, so this repair path cannot clean upglobalor any other root-valued row.3. Client-side persistence survives session deletion
Deleting a session does not remove
globalfrom the client's stored project set.packages/app/src/runtime/server/registry.tsxpersistslastProjectIDandusedProjectIDsper 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:
4. Suggested additions to the fix list
persist, so root-valued rows self-correct instead of being permanently exempt.worktreeto be updated inupsertProjectwhen the resolved canonical is more specific than the stored value.usedProjectIDsentries 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 equalssha1("directory:C:\\"), so it came from the plain-directory branch with no VCS, not from the empty-repo fallback. TheC:/builtinrow from the skill base directory/builtinalso looks unrelated to this issue.argszero commentedon Oct 30, 2026
I traced the write paths on
devata8091a2ea033rather than reproducing live, because the four reports here look inconsistent and one condition reconciles them: whether aglobalrow 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.upsertProjectwritesworktreeonly on insert. Inpackages/core/src/project/sql.tsit setsworktree: project.canonicalinside.values(), and itsonConflictDoUpdatesetsvcsonly. So a resolve can createglobalwith a directory and can never change one afterwards:globalrow yet, as on a fresh database: the first empty repo to resolve becomesglobal's worktree immediately, before any move. That is the/to A change in @opencodejr's run.globalrow 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.
persistinpackages/core/src/project.tsreplaces 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}isproject.update, whose payload carries an optionalcanonical; it callsProject.update, which issuesUPDATE project SET worktree = ...directly.absoluteColumn().toDriverruns the value throughabsolute(), 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 andreplacedtherefore 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
devata8091a2ea033, 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 aftergit init, nor the storedlastProjectID/usedProjectIDsrendering 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 orglobalkept with clients using the requested directory; that is the maintainers' call and both would change what I measured above.