Repository navigation
Description
Summary
On Windows, when a session is moved into a freshly git init-ed repository that has no remote and no commits, the session's project is persisted with worktree = C:/ (the drive root), so the UI shows the session under C:\ instead of the new repository directory.
Environment
- opencode version:
opencode v2.2.0 - OS:
Windows_NT 10.0.26300(win32 x64), Git BashC:\WINDOWS\system32\cmd.exeas COMSPEC - Terminal:
TERM=xterm-256color(TERM_PROGRAMandCOLORTERMunset) - Shell:
C:\WINDOWS\system32\cmd.exe, opencode configured with"shell": "pwsh" - Install/channel: npm global install of
@opencode/[email protected]via pnpm - Active plugins:
@wakatime/opencode-wakatime, local pluginC:\Users\tokisaki\.config\opencode\plugins\session-header.ts
Reproduction
- Start an opencode session in any directory.
- Ask opencode to create a new directory and
git initinside it, without any commit and without adding a remote. - Ask opencode to move the current session into that new directory.
- Observe the project/session list in the app and the
projecttable inopencode.db.
Expected Behavior
The session should be grouped under the new repository directory, and the project's canonical directory should be that new directory.
Actual Behavior
A project row is created with worktree = C:/, so the session is displayed under C:\. The record persists and keeps receiving time_active updates whenever the session runs.
Query against C:\Users\tokisaki\.local\share\opencode\opencode.db:
SELECT id, worktree, vcs, time_created, time_active FROM project
WHERE worktree = 'C:/';id worktree vcs time_created time_active
76d39595d0fb157155ea9307b2960e8717cec1df C:/ 1791382259755 1793186634214
time_active is refreshed on the current day, so the record is still in active use.
Related rows observed in the same database:
SELECT id, worktree, vcs, time_created, time_active FROM project
ORDER BY time_active DESC LIMIT 8;id worktree vcs c a
fd3a4e17272a0d16f241c086cc4fe98c2e9dde50 Z:/Code/GitHub/WORLD git 2026-09-28 22:33:59 2026-10-28 19:32:31
global / git 2026-05-27 17:56:09 2026-10-28 19:31:41
6f17c072fc37975a01656da9da882b8d9cd2b366 Z:/Code/GitHub/wows-battle-intel git 2026-10-14 22:07:01 2026-10-28 19:25:40
7a29fe9bf67457a45596ce104c9dfa0fdb19d8da C:/builtin 2026-10-28 19:25:25 2026-10-28 19:25:25
76d39595d0fb157155ea9307b2960e8717cec1df C:/ 2026-10-07 22:10:59 2026-10-28 19:23:54
Note that the global project row has worktree = /, and there is also a C:/builtin project row created the same day.
Additional Context
Root cause analysis
There are three contributing defects.
1. Project identity degrades on an empty repository
In packages/core/src/project.ts, gitDiscover derives the project ID in this order:
const id =
(yield* remote(repo).pipe(fallback(undefined))) ??
previous ??
(yield* rootCommit(repo).pipe(fallback(undefined)))
...
id: id ?? ID.global,A freshly initialized repository has neither a remote nor a root commit. Verified on the reproduction directory:
$ git remote get-url origin
error: No such remote 'origin'
$ git rev-list --max-parents=0 HEAD
fatal: ambiguous argument 'HEAD': unknown revision or path not in the working tree.
Both lookups fail, so id falls back to ID.global, which is the schema literal "global".
2. Directory probing upward has no boundary
In packages/util/src/fs-util.ts, up stops only at the filesystem root:
const parent = dirname(current)
if (parent === current) breakProject.resolve and Git.repo.discover both call fs.up without a stop argument, so probing walks all the way up to the drive root.
3. Windows path parsing turns a root into a drive letter
The Windows write normalizer in packages/core/src/database/path.ts accepts either a POSIX absolute path or an A:/-style path:
function isWindowsStoragePath(input: string) {
return /^[A-Za-z]:\//.test(input) || input.startsWith("//")
}
export function storedAbsolutePath(input: string) {
const result = storagePath(input)
if (nodePath.posix.isAbsolute(result) || isWindowsStoragePath(result)) return result
}The v1 migration in packages/core/src/database/v1-migration.bun.ts inserts the global project using the root of the data directory:
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, time_active, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ...)On Windows path.parse("C:\\Users\\tokisaki\\.local\\share\\opencode").root is C:\\, which then normalizes to C:/. On this machine the global row holds / (a legacy value), while newer writes produce separate C:/-rooted rows.
The same root problem shows up in a second place: skill metadata uses the base directory /builtin, and path.win32.resolve("/builtin") resolves to a drive-relative absolute path. That produces the C:/builtin project row created at 2026-10-28 19:25:25.
Why the UI displays C:\
In packages/app/src/runtime/server/global-sync/utils.ts, the display value comes straight from the project record:
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
const worktree = "canonical" in project ? project.canonical : project.worktree
return { ...project, worktree, ... }
}canonical maps to the worktree column, so a stored C:/ is rendered as C:\.
The self-healing branch never fires
persist in packages/core/src/project.ts only replaces a canonical directory when the previous one no longer exists:
const replaced =
previous &&
previous.canonical !== project.canonical &&
!(yield* fs.exists(previous.canonical).pipe(Effect.orElseSucceed(() => true)))
? ...
: undefinedOn Windows fs.exists('/') is true and path.dirname('/') is /, so a once-written C:/ canonical is never corrected and remains permanently.
Suggested fixes
- Do not fall back to
ID.globalfor directories that live under a VCS root. Fall back to a directory-derived identity, for exampleHash.fast("directory:" + directory), and upgrade the identity once a remote or root commit appears. - Pass a
stopboundary intofs.upfromProject.resolveandGit.repo.discoverso probing never crosses into the drive root. - Avoid persisting
path.parse(global.data).rootas a projectworktreeon Windows, and reject drive-root paths instoredAbsolutePath. - Make the canonical-replacement branch treat a filesystem root as an invalid canonical so existing bad rows self-correct.
Frequency
Consistently reproducible for the empty-repository case. The C:/ row on this machine has survived since 2026-10-07 and is still being activated daily.
Workarounds tried
Manually inspecting and deleting the project row with worktree = 'C:/' from opencode.db clears the display, but the row reappears whenever a session resolves into another empty repository or into a directory that resolves to the drive root.
Activity
[-]project: empty git repo resolves to drive root on Windows, sessions show under C:'\'[/-][+]project: empty git repo resolves to drive root C:/ on Windows, sessions show under C: root[/+]opencodejr commentedon Oct 28, 2026
Thanks for the detailed write-up! I think this is a duplicate of #50979.
Possible duplicates and related issues, ordered from most to least likely:
git initbut has no commits or remotes leaves it in theglobalproject instead of a project for that directory, and changes theglobalproject's worktree. This is the same fallback toID.globalingitDiscoverthat you found. It has been reproduced ondev, and fix(core): preserve global canonical on session moves #53874 partly addresses it.C:\gets registered as a project of its own. YourC:/row has a directory-hash ID, novcs, and was created on 2026-10-07, before this repo existed, so it looks more like this issue than the empty-repo path.Until this is fixed, making an initial commit in the new repo gives it its own project identity.
The
C:/builtinrow, from the skill base directory/builtinbeing resolved as a Windows path, looks new. It would be worth adding as a comment on #50979 or filing as its own issue.The bot will close this issue as a duplicate in 1 day unless you reply explaining how your problem is different.
Tokisaki-Galaxy commentedon Oct 29, 2026
Follow-up: deleting the session and re-adding the directory does not clear the bad display
Adding concrete evidence for a second symptom on the same machine. The project list still renders a
/entry after the affected session is deleted and the directory is re-added.The
/value is theglobalproject row itselfThe row has existed since the initial database was created. Its
worktreeis the POSIX root/, and it is still being activated every day.Worktree rows bind real directories to
globalThe same directory
Z:/Code/GitHub/lingguang-mcplater resolved to a proper project ID at2026-10-28 18:52:47:1b186e592d787c5907309aeb62e2f7db96e9bacaequalssha1("git-remote:github.com/Tokisaki-Galaxy/lingguang-mcp"), so the identity came from the git remote once it was configured. Before that, at15:56:53, the same directory producedglobal.Deleting a session never touches the project row
Session removal only deletes rows from
session_v2. Theprojectrow withworktree = /is untouched. When the directory is re-added,location.getrunsProject.resolveagain; if the result is stillglobal, the client keeps renderingglobal'sworktree, which is/.The write path cannot repair
worktreeupsertProjectinpackages/core/src/project/sql.tsonly updates the VCS column on conflict:So a stored
worktreevalue is never corrected by a later resolve.persistinpackages/core/src/project.tsdoes have a replacement branch, but it only fires when the previous canonical path no longer exists:Verified on Windows:
A root path always exists, so the self-healing branch is unreachable for this row.
Rendering path
The session list resolves the project by ID, then renders the project's canonical directory:
So a session with
projectID = globalrenders/.Client persistence keeps the bad ID
packages/app/src/runtime/server/registry.tsxpersistslastProjectIDandusedProjectIDsper server scope. Deleting a session does not removeglobalfrom that set, so the/entry stays in the list after restart even when the directory now resolves to a correct project.The comment in
packages/app/src/runtime/server/runtime.tsxalready acknowledges the multi-ID situation:Additional fix points
persist's replacement branch, soglobaland other root-valued rows self-correct.globalproject as a user-facing project, or give it no directory to display.usedProjectIDsentries when the corresponding project no longer matches the session's current project.upsertProject, allow updatingworktreewhen the resolved canonical is more specific than the stored one.Reproduction that produced this
git init, leave it with no commit and no remote./project in the list.git remote originand make the first commit./entry is still present because theprojectrow forglobalwas never modified.opencodejr commentedon Oct 29, 2026
We haven't heard back, so I'm closing this as a duplicate of #50979. That issue covers the same bug: a session moved into a repo that has no commits or remotes falls back to the
globalproject instead of getting a project for that directory. Please follow #50979 for progress. Related: #55133 (the same fallback, seen as sessions mixing together) and #49428 (C:\registered as a project on Windows).Until it's fixed, making an initial commit in the new repo gives it its own project identity. If your problem turns out to be different from #50979, for example the
C:/builtinrow, reply here or open a new issue and we'll take another look. Thanks again for the detailed report!ryanmeador commentedon Nov 8, 2026
Confirmed a similar reproduction on Fedora 44 Linux, OpenCode v2.2.6, so this is not limited to Windows path handling.
/instead of the selected directory././reload, then reopen the folder: it opens correctly.The repository is not bare. Git correctly reports its intended directory through
git rev-parse --show-toplevel.Inspecting release tag
v2.2.6suggests two contributing behaviors:gitDiscoverfalls back toID.globalwhen there is no remote, cached identity, or root commit (packages/core/src/project.ts:364–377).Location.Serviceresolves project identity when its services are constructed (packages/core/src/location.ts:29). Cached location services can therefore retain the old identity after the first commit; reloading rebuilds them.Thanks to everyone looking into this! Hopefully the cross-platform reproduction and confirmed workaround help narrow it down.