Skip to content

Commit 065dc27

Browse files
authored
fix(core): branch-keyed repository cache with gated reference readiness (anomalyco#38759)
1 parent a85d8d2 commit 065dc27

7 files changed

Lines changed: 89 additions & 52 deletions

File tree

packages/core/src/reference.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ const layer = Layer.effect(
5858
finalize: (draft) =>
5959
Effect.gen(function* () {
6060
materialized.clear()
61-
const seen = new Map<string, string | undefined>()
6261
for (const [name, source] of draft.list()) {
6362
if (source.type === "local") {
6463
materialized.set(
@@ -82,14 +81,11 @@ const layer = Layer.effect(
8281
continue
8382
}
8483
}
85-
const target = Repository.cachePath(global.repos, repository)
86-
if (seen.has(target) && seen.get(target) !== source.branch) continue
87-
seen.set(target, source.branch)
8884
materialized.set(
8985
name,
9086
new Info({
9187
name,
92-
path: AbsolutePath.make(target),
88+
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
9389
...(source.description === undefined ? {} : { description: source.description }),
9490
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
9591
source,

packages/core/src/repository-cache.ts

Lines changed: 36 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
/**
2+
* Local tracking checkouts for remote Git references, one per remote and
3+
* branch. Each checkout permanently tracks a single ref: the requested branch
4+
* when the cache key has one, otherwise the remote default branch. Content
5+
* follows "newest wins": refresh fetches and hard-resets, so readers may
6+
* observe the checkout move underneath them.
7+
*/
18
import path from "path"
29
import { Context, Effect, Layer, Schema } from "effect"
310
import { FSUtil } from "./fs-util"
@@ -135,29 +142,32 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | EffectFl
135142
if (input.branch) yield* validateBranch(input.branch)
136143

137144
const repository = input.reference.label
138-
const localPath = Repository.cachePath(global.repos, input.reference)
145+
const localPath = Repository.cachePath(global.repos, input.reference, input.branch)
139146
const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
140147

141148
return yield* flock
142149
.withLock(
143150
Effect.gen(function* () {
144151
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
145152

146-
const exists = yield* fs.existsSafe(localPath)
147153
const existing = yield* git.repo.discover(AbsolutePath.make(localPath))
148154
const origin = existing ? yield* git.remote.get(existing) : undefined
149155
const originReference = origin ? Repository.parse(origin) : undefined
150-
const reuse = Boolean(existing && originReference && Repository.same(originReference, cloneTarget))
151-
if (exists && !reuse) {
156+
// Discovery walks upward, so an enclosing repository with a
157+
// matching origin could masquerade as the cache entry; reuse
158+
// requires the checkout to live exactly at the cache path.
159+
const worktree = existing ? yield* fs.resolve(localPath) : undefined
160+
const reuse = Boolean(
161+
existing &&
162+
existing.worktree === worktree &&
163+
originReference &&
164+
Repository.same(originReference, cloneTarget),
165+
)
166+
if (!reuse && (yield* fs.existsSafe(localPath))) {
152167
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
153168
}
154169

155-
const currentBranch = reuse && existing ? yield* git.history.branch(existing) : undefined
156-
const status = statusForRepository({
157-
reuse,
158-
refresh: input.refresh,
159-
branchMatches: input.branch ? currentBranch === input.branch : undefined,
160-
})
170+
const status = !reuse ? ("cloned" as const) : input.refresh ? ("refreshed" as const) : ("cached" as const)
161171

162172
if (status === "cloned") {
163173
yield* git.repo
@@ -177,25 +187,28 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | EffectFl
177187
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
178188

179189
if (input.branch) {
180-
const requestedBranch = input.branch
181190
yield* git.sync
182-
.fetchBranch(existing, { branch: requestedBranch })
191+
.fetchBranch(existing, { branch: input.branch })
183192
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
193+
}
184194

185-
yield* git.sync.checkoutRemoteBranch(existing, { branch: requestedBranch }).pipe(
186-
Effect.mapError(
187-
(error) =>
188-
new CheckoutFailedError({
189-
repository,
190-
branch: requestedBranch,
191-
message: error.message,
192-
}),
193-
),
194-
)
195+
// Checking out the tracked ref before resetting keeps the
196+
// checkout self-healing even if it was left on another
197+
// branch.
198+
const branch = input.branch ?? (yield* git.history.defaultRemoteBranch(existing))
199+
if (branch) {
200+
yield* git.sync
201+
.checkoutRemoteBranch(existing, { branch })
202+
.pipe(
203+
Effect.mapError(
204+
(error) => new CheckoutFailedError({ repository, branch, message: error.message }),
205+
),
206+
)
195207
}
196208

209+
const target = branch ?? (yield* git.history.branch(existing))
197210
yield* git.sync
198-
.resetHard(existing, yield* resetTarget(git, existing, input.branch))
211+
.resetHard(existing, target ? `origin/${target}` : "HEAD")
199212
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
200213
}
201214

@@ -229,12 +242,6 @@ export const node = makeGlobalNode({
229242
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node],
230243
})
231244

232-
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
233-
if (!input.reuse) return "cloned" as const
234-
if (input.branchMatches === false || input.refresh) return "refreshed" as const
235-
return "cached" as const
236-
}
237-
238245
function errorMessage(error: unknown) {
239246
return error instanceof globalThis.Error ? error.message : String(error)
240247
}
@@ -245,17 +252,4 @@ function cacheOperation<A, E, R>(effect: Effect.Effect<A, E, R>, operation: stri
245252
)
246253
}
247254

248-
const resetTarget = Effect.fnUntraced(function* (
249-
git: Git.Interface,
250-
repository: Git.Repository,
251-
requestedBranch?: string,
252-
) {
253-
if (requestedBranch) return `origin/${requestedBranch}`
254-
const remoteHead = yield* git.history.defaultRemoteBranch(repository)
255-
if (remoteHead) return `origin/${remoteHead}`
256-
const currentBranch = yield* git.history.branch(repository)
257-
if (currentBranch) return `origin/${currentBranch}`
258-
return "HEAD"
259-
})
260-
261255
export * as RepositoryCache from "./repository-cache"

packages/core/src/repository.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,14 @@ export function isRemote(reference: Reference): reference is RemoteReference {
118118
return !isFile(reference)
119119
}
120120

121-
export function cachePath(root: string, reference: Reference): string {
122-
return path.join(root, ...reference.host.split(":"), ...reference.segments)
121+
/**
122+
* Checkouts are keyed by remote and branch: a branch-specific reference gets
123+
* its own directory so branchless refreshes can never move it. The branch is
124+
* percent-encoded because valid branch names may contain `/`.
125+
*/
126+
export function cachePath(root: string, reference: Reference, branch?: string): string {
127+
const base = path.join(root, ...reference.host.split(":"), ...reference.segments)
128+
return branch ? `${base}@${encodeURIComponent(branch)}` : base
123129
}
124130

125131
export function cacheIdentity(reference: Reference): string {

packages/core/test/reference.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe("Reference", () => {
4747
expect(yield* references.list()).toEqual([
4848
new Reference.Info({
4949
name: "sdk",
50-
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)),
50+
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository, "main")),
5151
source,
5252
}),
5353
])

packages/core/test/repository-cache.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
88
import { Global } from "@opencode-ai/core/global"
99
import { Repository } from "@opencode-ai/core/repository"
1010
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
11-
import { git, gitRemote } from "./fixture/git"
11+
import { branch, git, gitRemote } from "./fixture/git"
1212
import { tmpdir } from "./fixture/tmpdir"
1313
import { testEffect } from "./lib/effect"
1414

@@ -66,6 +66,41 @@ describe("RepositoryCache", () => {
6666
),
6767
)
6868

69+
it.live("keeps branch checkouts isolated from branchless refreshes", () =>
70+
withRemote((fixture) =>
71+
Effect.gen(function* () {
72+
yield* Effect.promise(() => branch(fixture.source, "feature", "two\n"))
73+
const cache = yield* RepositoryCache.Service
74+
75+
const featured = yield* cache.ensure({ reference: fixture.reference, branch: "feature" })
76+
expect(featured.branch).toBe("feature")
77+
expect(featured.localPath.endsWith("repo@feature")).toBe(true)
78+
expect(yield* read(path.join(featured.localPath, "README.md"))).toBe("two\n")
79+
80+
const refreshed = yield* cache.ensure({ reference: fixture.reference, refresh: true })
81+
expect(refreshed.localPath).not.toBe(featured.localPath)
82+
expect(yield* read(path.join(refreshed.localPath, "README.md"))).toBe("one\n")
83+
84+
const cached = yield* cache.ensure({ reference: fixture.reference, branch: "feature" })
85+
expect(cached.status).toBe("cached")
86+
expect(yield* read(path.join(cached.localPath, "README.md"))).toBe("two\n")
87+
}).pipe(Effect.provide(cacheLayer(fixture.root))),
88+
),
89+
)
90+
91+
it.live("does not mistake an enclosing repository for the cache checkout", () =>
92+
withRemote((fixture) =>
93+
Effect.gen(function* () {
94+
yield* Effect.promise(() => git(fixture.root, "clone", fixture.remote, path.join(fixture.root, "repos")))
95+
96+
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
97+
98+
expect(result.status).toBe("cloned")
99+
expect(yield* read(path.join(result.localPath, "README.md"))).toBe("one\n")
100+
}).pipe(Effect.provide(cacheLayer(fixture.root))),
101+
),
102+
)
103+
69104
it.live("returns typed validation and clone failures", () =>
70105
withRemote((fixture) =>
71106
Effect.gen(function* () {

packages/core/test/repository.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ describe("Repository", () => {
1717
label: "owner/repo",
1818
})
1919
expect(Repository.cachePath("/cache", reference)).toBe(path.join("/cache", "github.com", "owner", "repo"))
20+
expect(Repository.cachePath("/cache", reference, "main")).toBe(
21+
path.join("/cache", "github.com", "owner", "repo@main"),
22+
)
23+
expect(Repository.cachePath("/cache", reference, "feature/x")).toBe(
24+
path.join("/cache", "github.com", "owner", "repo@feature%2Fx"),
25+
)
2026
expect(Repository.cacheIdentity(reference)).toBe("github.com/owner/repo")
2127
})
2228

packages/opencode/test/server/httpapi-reference.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ describe("reference HttpApi", () => {
5151
},
5252
{
5353
name: "effect",
54-
path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"),
54+
path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect@main"),
5555
source: {
5656
type: "git",
5757
repository: "Effect-TS/effect",

0 commit comments

Comments
 (0)