diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 0041c3353f43..e15a9e07edc9 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -165,11 +165,13 @@ export const layer = Layer.effect( const fetch = Effect.fn("Git.fetch")((directory: string) => execute(directory, proc)(["fetch", "--all", "--prune"])) const fetchBranch = Effect.fn("Git.fetchBranch")((directory: string, branch: string) => - execute(directory, proc)(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]), + execute(directory, proc)(["fetch", "origin", fetchRefSpec(branch)]), ) const checkout = Effect.fn("Git.checkout")((directory: string, branch: string) => - execute(directory, proc)(["checkout", "-B", branch, `origin/${branch}`]), + branch.startsWith("refs/") + ? execute(directory, proc)(["checkout", "--detach", trackingRef(branch)]) + : execute(directory, proc)(["checkout", "-B", branch, trackingRef(branch)]), ) const reset = Effect.fn("Git.reset")((directory: string, target: string) => @@ -443,3 +445,15 @@ function resolvePath(cwd: string, value: string) { if (path.isAbsolute(normalized)) return path.normalize(normalized) return path.resolve(cwd, normalized) } + +function fetchRefSpec(ref: string) { + if (ref.startsWith("refs/")) return `+${ref}:${trackingRef(ref)}` + return `+refs/heads/${ref}:${trackingRef(ref)}` +} + +function trackingRef(ref: string) { + if (ref.startsWith("refs/heads/")) return `refs/remotes/origin/${ref.slice("refs/heads/".length)}` + if (ref.startsWith("refs/tags/")) return `refs/remotes/origin/tags/${ref.slice("refs/tags/".length)}` + if (ref.startsWith("refs/")) return `refs/remotes/origin/${ref.slice("refs/".length)}` + return `refs/remotes/origin/${ref}` +} diff --git a/packages/core/src/repository-cache.ts b/packages/core/src/repository-cache.ts index 894dc38faa62..d80c6d05e0bd 100644 --- a/packages/core/src/repository-cache.ts +++ b/packages/core/src/repository-cache.ts @@ -159,7 +159,11 @@ export const layer: Layer.Layer new CloneFailedError({ repository, message: errorMessage(error) })), ) @@ -169,6 +173,39 @@ export const layer: Layer.Layer new FetchFailedError({ repository, message: errorMessage(error) })), + ) + if (fetchBranch.exitCode !== 0) { + return yield* new FetchFailedError({ + repository, + message: resultMessage(fetchBranch, `Failed to fetch ${requestedBranch}`), + }) + } + + const checkout = yield* git.checkout(localPath, requestedBranch).pipe( + Effect.mapError( + (error) => + new CheckoutFailedError({ + repository, + branch: requestedBranch, + message: errorMessage(error), + }), + ), + ) + if (checkout.exitCode !== 0) { + return yield* new CheckoutFailedError({ + repository, + branch: requestedBranch, + message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`), + }) + } + } } if (status === "refreshed") { @@ -276,7 +313,7 @@ function cacheOperation(effect: Effect.Effect, operation: stri } const resetTarget = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, requestedBranch?: string) { - if (requestedBranch) return `origin/${requestedBranch}` + if (requestedBranch) return trackingRef(requestedBranch) const remoteHead = yield* git.remoteHead(cwd) if (remoteHead) return remoteHead const currentBranch = yield* git.branch(cwd) @@ -284,6 +321,13 @@ const resetTarget = Effect.fnUntraced(function* (git: Git.Interface, cwd: string return "HEAD" }) +function trackingRef(ref: string) { + if (ref.startsWith("refs/heads/")) return `refs/remotes/origin/${ref.slice("refs/heads/".length)}` + if (ref.startsWith("refs/tags/")) return `refs/remotes/origin/tags/${ref.slice("refs/tags/".length)}` + if (ref.startsWith("refs/")) return `refs/remotes/origin/${ref.slice("refs/".length)}` + return `refs/remotes/origin/${ref}` +} + function resultMessage(result: Git.Result, fallback: string) { return result.stderr.trim() || result.text.trim() || fallback } diff --git a/packages/core/src/repository.ts b/packages/core/src/repository.ts index dbc6a8fbcae6..9942574d7829 100644 --- a/packages/core/src/repository.ts +++ b/packages/core/src/repository.ts @@ -103,10 +103,19 @@ export function parseRemote(input: string): RemoteReference { } export function validateBranch(branch: string): void { - if (/^[A-Za-z0-9/_.-]+$/.test(branch) && !branch.startsWith("-") && !branch.includes("..")) return + if ( + branch && + !branch.startsWith("-") && + !branch.includes("..") && + !branch.includes("@{") && + !branch.endsWith(".") && + !branch.endsWith(".lock") && + !/[\s~^:?*[\\\x00-\x1f\x7f]/.test(branch) + ) + return throw new InvalidBranchError({ branch, - message: "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", + message: "Branch must be a valid git ref name and cannot start with -, contain .., or contain @{", }) } diff --git a/packages/core/test/fixture/git.ts b/packages/core/test/fixture/git.ts index f02da400af6a..ae019926821b 100644 --- a/packages/core/test/fixture/git.ts +++ b/packages/core/test/fixture/git.ts @@ -44,6 +44,14 @@ export async function branch(source: string, name: string, content: string) { await git(source, "push", "-u", "origin", name) } +export async function tag(source: string, name: string, content: string) { + await fs.writeFile(path.join(source, "README.md"), content) + await git(source, "add", "README.md") + await git(source, "commit", "-m", name) + await git(source, "tag", name) + await git(source, "push", "origin", name) +} + export async function git(cwd: string, ...args: string[]) { await exec("git", args, { cwd }) } diff --git a/packages/core/test/git.test.ts b/packages/core/test/git.test.ts index e09e3e1a5d4e..ba9dddaf5fae 100644 --- a/packages/core/test/git.test.ts +++ b/packages/core/test/git.test.ts @@ -5,7 +5,7 @@ import path from "path" import { Effect } from "effect" import { Git } from "@opencode-ai/core/git" import { AbsolutePath } from "@opencode-ai/core/schema" -import { branch, commit, gitRemote } from "./fixture/git" +import { branch, commit, gitRemote, tag } from "./fixture/git" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -50,6 +50,22 @@ describe("Git", () => { }), ), ) + + it.live("fetches and checks out full tag refs", () => + withRemote((fixture) => + Effect.gen(function* () { + const git = yield* Git.Service + const target = path.join(fixture.root, "checkout") + yield* git.clone({ remote: fixture.remote, target }) + + yield* Effect.promise(() => tag(fixture.source, "effect@4.0.0-beta.65", "tagged\n")) + expect((yield* git.fetchBranch(target, "refs/tags/effect@4.0.0-beta.65")).exitCode).toBe(0) + expect((yield* git.checkout(target, "refs/tags/effect@4.0.0-beta.65")).exitCode).toBe(0) + expect(yield* git.branch(target)).toBeUndefined() + expect(yield* read(path.join(target, "README.md"))).toBe("tagged\n") + }), + ), + ) }) function withRemote(body: (fixture: Awaited>) => Effect.Effect) { diff --git a/packages/core/test/repository-cache.test.ts b/packages/core/test/repository-cache.test.ts index a99daea8e290..9b2010ee8fcf 100644 --- a/packages/core/test/repository-cache.test.ts +++ b/packages/core/test/repository-cache.test.ts @@ -9,7 +9,7 @@ import { Global } from "@opencode-ai/core/global" import { Repository } from "@opencode-ai/core/repository" import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" -import { git, gitRemote } from "./fixture/git" +import { git, gitRemote, tag } from "./fixture/git" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -67,6 +67,24 @@ describe("RepositoryCache", () => { ), ) + it.live("materializes full tag refs", () => + withRemote((fixture) => + Effect.gen(function* () { + const cache = yield* RepositoryCache.Service + yield* Effect.promise(() => tag(fixture.source, "effect@4.0.0-beta.65", "tagged\n")) + + const result = yield* cache.ensure({ + reference: fixture.reference, + branch: "refs/tags/effect@4.0.0-beta.65", + }) + + expect(result.status).toBe("cloned") + expect(result.branch).toBeUndefined() + expect(yield* read(path.join(result.localPath, "README.md"))).toBe("tagged\n") + }).pipe(Effect.provide(cacheLayer(fixture.root))), + ), + ) + it.live("returns typed validation and clone failures", () => withRemote((fixture) => Effect.gen(function* () { diff --git a/packages/core/test/repository.test.ts b/packages/core/test/repository.test.ts index 5b18f8b1d693..22fdf7c8ddcd 100644 --- a/packages/core/test/repository.test.ts +++ b/packages/core/test/repository.test.ts @@ -51,9 +51,13 @@ describe("Repository", () => { expect(() => Repository.parseRemote("not-a-repo")).toThrow(Repository.InvalidReferenceError) expect(() => Repository.parseRemote("git@github.com:../../../etc/passwd")).toThrow(Repository.InvalidReferenceError) expect(() => Repository.validateBranch("feature/docs.v1")).not.toThrow() + expect(() => Repository.validateBranch("refs/heads/feature/docs.v1")).not.toThrow() + expect(() => Repository.validateBranch("refs/tags/effect@4.0.0-beta.65")).not.toThrow() expect(() => Repository.validateBranch("-bad")).toThrow(Repository.InvalidBranchError) expect(() => Repository.validateBranch("bad..branch")).toThrow(Repository.InvalidBranchError) expect(() => Repository.validateBranch("bad branch")).toThrow(Repository.InvalidBranchError) + expect(() => Repository.validateBranch("bad@{branch")).toThrow(Repository.InvalidBranchError) + expect(() => Repository.validateBranch("bad:branch")).toThrow(Repository.InvalidBranchError) }) test("compares cache identity independent of input spelling", () => {