Skip to content

fix(core): rebuild a location after its directory becomes a git repo - #61314

Open
xzyangha wants to merge 1 commit into
anomalyco:devfrom
xzyang-commit:location-git-identity
Open

xzyangha wants to merge 1 commit into
anomalyco:devfrom
xzyang-commit:location-git-identity

Conversation

@xzyangha

@xzyangha xzyangha commented Nov 3, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #61009

Type of change

  • Bug fix

What does this PR do?

A location graph caches its Project, so opening a folder before git init pinned the markerless identity: opencode session list returned nothing because sessions were stored under the new VCS identity.

The map now records the resolved Project id. Graphs whose identity could not be pinned yet (no VCS marker, or no commit or remote, where the id is global) are re-resolved before the next borrow, and invalidated only when it changes. Sessions are listed again and snapshot capture resumes.

The check never disturbs a live Location: graphs without a VCS compare Project.root with async probes (so a repo above it is found too), borrowed or retained graphs are skipped, and the detach is a deferred invalidation instead of lifecycle.shutdown, so running steps hand off at a boundary.

How did you verify your code works?

Tests in packages/core/test/location-layer.test.ts cover the git init -> borrow -> first commit sequence (a global identity mid-way, then the commit id), a parent folder becoming a repo, a borrowed location keeping its identity until release, and snapshot capture recovering afterwards. Without the fix the first test fails with Received: undefined. bun test test/location-layer.test.ts: 28 pass, 0 fail. bun run typecheck:tsc passes.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #57985, #55133

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes the flow in #61009 (git init and the first commit with nothing in between). It misses a common variant, though, so the bug can still happen.

I ran the issue's script with each checkout's CLI from source. On the base, location.get keeps the directory-hash id and session list returns []. With this PR, the location reports the root-commit id and both sessions are listed. Then I changed the script to fetch /api/location once between git init and the commit. The location is rebuilt as a git location with project id global, and since it's no longer markerless it's never checked again. After the commit it still reports global, and session list returns []. This happens whenever an agent or a client opens the location between the two steps, for example an agent running git init and git commit as separate tool calls. Details inline.

Also inline: the rebuild runs the location's shutdown (pending permissions, forms and RPC are closed, and a shutdown event is published) whenever the markers change, and every borrow of a markerless location now walks up to the filesystem root.

Tests in location-layer and location-activity pass, and typecheck:tsc passes. The new test only covers init and commit with no borrow in between. A case that borrows between git init and the commit would catch the problem above.

Public API: unchanged.

Related: #57985, #55133

Comment thread packages/core/src/location-services.ts Outdated
build.close = lifecycle.shutdown
// Remember whether this graph resolved a VCS identity, and which marker it saw.
// A markerless graph is the only one that can become wrong later.
build.markerless = Context.get(exit.value, Location.Service).vcs === undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only markerless graphs are ever checked again. After git init and before the first commit, Project.resolve returns vcs git with id global (no root commit yet). If anything borrows the location in that window, it's rebuilt as a git graph with markerless = false, and after the commit it never updates. I reproduced this: an extra GET /api/location between git init and git commit leaves the location on global, and opencode session list returns [] again. Could the check compare the project identity instead of whether a marker exists? For example, record the resolved id and re-resolve when it was global or markerless, or react to Worktree.Event.Resolved/previous/adopted for this location's project.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The map now records the resolved Project id instead of markerless-ness. A graph is re-resolved before the next borrow when its id was global or it resolved no VCS, and it is invalidated only when that id actually changes. Your reproduction (init → borrow → first commit) now ends on the commit id, and packages/core/test/location-layer.test.ts covers that exact sequence: the borrow between init and commit resolves global, then the next borrow reports Project.ID.make(head). Graphs that pinned a VCS identity are still untouched.

// opencode already opened). Nothing re-resolves a cached graph, so a markerless one is
// dropped as soon as the markers above it change and the next borrow rebuilds it with the
// identity its sessions are stored under.
const refresh = (key: Location.Ref) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two concerns with doing this on every borrow:

  • drop runs lifecycle.shutdown, which closes pending permissions, forms and RPC and publishes LocationEvent.Shutdown. That's the same as an explicit reload. When the agent itself runs git init in a markerless folder, the next Instance.provide borrow during that turn would shut down the live location under the running session. Please check that case, or move the rebuild to a point where the location is not busy.
  • refresh walks up to the filesystem root with synchronous existsSync calls on every get/contextEffect/contextEffectOption of a markerless location, including each Instance.provide. That blocks on slow or network filesystems (see the Windows work in fix(core): speed up location boot and VCS routes on Windows #60569). A trigger based on events (project resolution changing the id) would avoid both problems.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both addressed.

  • Not on a busy location: refresh returns early while the RcMap entry has borrowers (refCount > 0) or is retained by an open socket, and the detach is a plain inner.invalidate — no drop/lifecycle.shutdown. The old graph keeps its permissions/forms/RPC and is released when its last borrower lets go, so the step that runs git init hands off at its boundary instead of being shut down mid-turn. Added a test that a borrowed location keeps its identity until the borrow is released.
  • No walk, no sync block: the probe is two async fs.access calls for .git/.hg in the location directory itself, and only for graphs whose identity was provisional. A marker above the directory was already visible when the graph resolved (otherwise it would not have been markerless), so there is no upward walk. The real resolution only runs once a marker appears, or for global, where Project.resolve hits the git fingerprint cache.

To re-resolve without widening the map layer dependencies, the location graph exposes resolveProject through LocationLifecycle, which already owns Project.Service. bun test test/location-layer.test.ts: 26 pass; the other location tests and bun run typecheck:tsc pass.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier points are addressed:

  • A location that had no VCS, or that resolved as global (repo with no commit yet), is now re-resolved before the next borrow. I re-ran the #61009 script on the base and with this PR, plus a variant that reads /api/location between git init and the first commit. With the PR, the location ends on the root-commit id and opencode session list shows both sessions. On the base it returns [].
  • Borrowed or retained locations are left alone, the rebuild invalidates without running lifecycle.shutdown, and the check is two async file probes in the folder itself.

The location-layer, location-activity and location-retain tests pass, and typecheck:tsc passes.

Public API: unchanged. The only addition is resolveProject on the internal LocationLifecycle service.

One small gap remains (inline): a parent folder becoming a repo after a subfolder was opened is not picked up.

Comment thread packages/core/src/location-services.ts Outdated
if (held && held.refCount > 0) return Effect.void
return Effect.gen(function* () {
// Without a VCS the identity only changes once a marker appears in this directory. A
// marker above it was already visible when the graph resolved, so there is no walk here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A marker above the folder is only seen if it existed when the graph was built. If someone opens proj/src and then runs git init in proj, this check never finds a marker, so the location keeps its directory-hash id, the same #61009 symptom for a subfolder. Not blocking, but cheap to handle: compare against Project.root with async fs.access. That is one walk, and only for graphs with no VCS.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handled in c20ce8d. For a graph with no VCS, refresh now calls Project.root with the async fs.access probe instead of looking only at the folder, so a marker that appears at or above the location invalidates it. Graphs that resolved a VCS still skip the walk entirely.

Added a regression test for your case: open proj/src (no repo anywhere), run git init in proj, and the next borrow resolves the git identity — Project.ID.global until the first commit pins the root commit, same as the location opened directly in the repo.

bun test test/location-layer.test.ts: 27 pass, 0 fail. The other location tests and bun run typecheck:tsc pass.

@xzyangha
xzyangha force-pushed the location-git-identity branch from 7c08ce6 to c20ce8d Compare November 3, 2026 05:57

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thread 3 is addressed: a location with no VCS now uses Project.root with async file checks, so a repo created in a parent folder after a subfolder was opened is picked up, and the new test covers it. The changes for threads 1 and 2 are still in place.

I re-ran the #61009 script on the base and with this PR, plus the variant that reads /api/location between git init and the first commit. With the PR, the location ends on the root-commit id and opencode session list shows both sessions. On the base it returns []. The location-layer, location-activity and location-retain tests pass, and typecheck:tsc passes.

Public API: unchanged.

No new concerns.

A location graph is cached for its whole life and resolves its project once, so
opening a folder before `git init` pinned the markerless identity and its
sessions kept being reported under it.

Record the resolved project id and re-resolve before a borrow only when the
identity could not be pinned yet: a directory without a VCS marker, or a
repository without a commit or remote (`global`). The graph is invalidated only
when that id changes.

Without a VCS, Project.root decides whether a marker appeared at or above the
location with async probes, which also covers opening a subfolder before its
parent becomes a repo. The check never runs while the location is borrowed or
retained, and the detach is a deferred invalidation instead of
`lifecycle.shutdown`, so a running step keeps its graph until it hands off.

Snapshot capture is gated on the location's VCS, so re-resolving also restores
it after the first commit; undo/redo no longer rolls back only the conversation.
@xzyangha
xzyangha force-pushed the location-git-identity branch from c20ce8d to f7c56fa Compare November 5, 2026 01:26
@xzyangha

xzyangha commented Nov 5, 2026

Copy link
Copy Markdown
Author

Added a regression test for the second symptom reported in #61009 today: the stale location also disables snapshot capture, because snapshot.ts gates on location.vcs?.type === "git". That is why undo/redo can roll back the conversation while the files keep the changes.

Local check on the dev location code: after git init + first commit, the retained location still reports vcs === undefined and capture() returns undefined. With this PR the next borrow resolves the git identity, capture() returns a tree, and files() between two captures lists the edited file. Covered by the new test in location-layer.test.ts.

Also noting #61249 (fix(core): give unborn git repos their own project id), which references this issue from the project side: it keeps unrelated unborn repos from sharing global, while this PR rebuilds a cached location when the resolved identity changes. They touch different files and should compose; this PR is the one that closes #61009.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit only adds a test, which checks that snapshot capture works again after the folder becomes a git repo. The fix itself is unchanged since my last review, where the #61009 script passed with this PR and failed on the base. location-layer tests pass, including the new one, typecheck:tsc passes, and the PR still merges cleanly into the current base.

Public API: unchanged.

No new concerns.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

project: session list returns empty in a directory that became a git repo after first open

1 participant