Skip to content

project: empty git repo resolves to drive root C:/ on Windows, sessions show under C: root #59889

Open

Description

@Tokisaki-Galaxy

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 Bash C:\WINDOWS\system32\cmd.exe as COMSPEC
  • Terminal: TERM=xterm-256color (TERM_PROGRAM and COLORTERM unset)
  • 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 plugin C:\Users\tokisaki\.config\opencode\plugins\session-header.ts

Reproduction

  1. Start an opencode session in any directory.
  2. Ask opencode to create a new directory and git init inside it, without any commit and without adding a remote.
  3. Ask opencode to move the current session into that new directory.
  4. Observe the project/session list in the app and the project table in opencode.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) break

Project.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)))
    ? ...
    : undefined

On 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.global for directories that live under a VCS root. Fall back to a directory-derived identity, for example Hash.fast("directory:" + directory), and upgrade the identity once a remote or root commit appears.
  • Pass a stop boundary into fs.up from Project.resolve and Git.repo.discover so probing never crosses into the drive root.
  • Avoid persisting path.parse(global.data).root as a project worktree on Windows, and reject drive-root paths in storedAbsolutePath.
  • 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

  1. changed the title [-]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[/+] on Oct 28, 2026
  2. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    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:

    Until this is fixed, making an initial commit in the new repo gives it its own project identity.

    The C:/builtin row, from the skill base directory /builtin being 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.

  3. Tokisaki-Galaxy commented on Oct 29, 2026

    @Tokisaki-Galaxy
    Author

    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 the global project row itself

    SELECT id, worktree, vcs, time_created, time_active FROM project WHERE id = 'global';
    id      worktree  vcs  time_created   time_active
    global  /         git  1779875769149  1793268822138
    
    created:     2026-05-27 17:56:09
    time_active: 2026-10-29 09:33:42
    

    The row has existed since the initial database was created. Its worktree is the POSIX root /, and it is still being activated every day.

    Worktree rows bind real directories to global

    SELECT project_id, directory, time_created FROM worktree WHERE project_id = 'global';
    project_id  directory                          time_created
    global      Z:/Code/GitHub/lingguang-mcp         1793174213641
    global      Z:/Code/GitHub/wows-battle-intel     1791959158945
    
    Z:/Code/GitHub/lingguang-mcp      -> 2026-10-28 15:56:53
    Z:/Code/GitHub/wows-battle-intel  -> 2026-10-14 14:25:58
    

    The same directory Z:/Code/GitHub/lingguang-mcp later resolved to a proper project ID at 2026-10-28 18:52:47:

    SELECT id, worktree, vcs FROM project
    WHERE worktree = 'Z:/Code/GitHub/lingguang-mcp';
    id                                        worktree                        vcs
    1b186e592d787c5907309aeb62e2f7db96e9baca  Z:/Code/GitHub/lingguang-mcp    git
    

    1b186e592d787c5907309aeb62e2f7db96e9baca equals sha1("git-remote:github.com/Tokisaki-Galaxy/lingguang-mcp"), so the identity came from the git remote once it was configured. Before that, at 15:56:53, the same directory produced global.

    Deleting a session never touches the project row

    Session removal only deletes rows from session_v2. The project row with worktree = / is untouched. When the directory is re-added, location.get runs Project.resolve again; if the result is still global, the client keeps rendering global's worktree, which is /.

    The write path cannot repair worktree

    upsertProject in packages/core/src/project/sql.ts only updates the VCS column on conflict:

    .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),
    })

    So a stored worktree value is never corrected by a later resolve.

    persist in packages/core/src/project.ts does have a replacement branch, but it only fires when the previous canonical path no longer exists:

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

    Verified on Windows:

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

    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:

    // packages/app/src/shell/layout/helpers.ts
    export function projectForSession(session, projects) {
      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
    export function normalizeProjectInfo(project) {
      const worktree = "canonical" in project ? project.canonical : project.worktree
      return { ...project, worktree, ... }
    }

    So a session with projectID = global renders /.

    Client persistence keeps the bad ID

    packages/app/src/runtime/server/registry.tsx persists lastProjectID and usedProjectIDs per server scope. Deleting a session does not remove global from 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.tsx already acknowledges the multi-ID situation:

    // One directory can belong to several project IDs, for example a folder resolved before `.git` existed and again
    // after a remote was added. The list is most recent first, so the first project per path is the current one.

    Additional fix points

    • Treat a filesystem root as an invalid canonical in persist's replacement branch, so global and other root-valued rows self-correct.
    • Stop rendering the global project as a user-facing project, or give it no directory to display.
    • Prune stale usedProjectIDs entries when the corresponding project no longer matches the session's current project.
    • On upsertProject, allow updating worktree when the resolved canonical is more specific than the stored one.

    Reproduction that produced this

    1. Create a directory, run git init, leave it with no commit and no remote.
    2. Open a session in it from the desktop app.
    3. Observe a / project in the list.
    4. Configure git remote origin and make the first commit.
    5. Delete the session and re-add the same directory.
    6. The / entry is still present because the project row for global was never modified.
  4. opencodejr commented on Oct 29, 2026

    @opencodejr
    Contributor

    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 global project 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:/builtin row, reply here or open a new issue and we'll take another look. Thanks again for the detailed report!

  5. ryanmeador commented on Nov 8, 2026

    @ryanmeador

    Confirmed a similar reproduction on Fedora 44 Linux, OpenCode v2.2.6, so this is not limited to Windows path handling.

    1. Open a newly initialized Git repository with no commits and no remotes.
    2. OpenCode opens / instead of the selected directory.
    3. Create the first commit and reopen the folder: it still opens /.
    4. Run /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.6 suggests two contributing behaviors:

    • gitDiscover falls back to ID.global when there is no remote, cached identity, or root commit (packages/core/src/project.ts:364–377).
    • Location.Service resolves 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.

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