Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions packages/core/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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}`
}
48 changes: 46 additions & 2 deletions packages/core/src/repository-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,11 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E

if (status === "cloned") {
const result = yield* git
.clone({ remote: input.reference.remote, target: localPath, branch: input.branch })
.clone({
remote: input.reference.remote,
target: localPath,
branch: input.branch?.startsWith("refs/") ? undefined : input.branch,
})
.pipe(
Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })),
)
Expand All @@ -169,6 +173,39 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E
message: resultMessage(result, `Failed to clone ${repository}`),
})
}

if (input.branch?.startsWith("refs/")) {
const requestedBranch = input.branch
const fetchBranch = yield* git
.fetchBranch(localPath, requestedBranch)
.pipe(
Effect.mapError((error) => 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") {
Expand Down Expand Up @@ -276,14 +313,21 @@ function cacheOperation<A, E, R>(effect: Effect.Effect<A, E, R>, 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)
if (currentBranch) return `origin/${currentBranch}`
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
}
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 @{",
})
}

Expand Down
8 changes: 8 additions & 0 deletions packages/core/test/fixture/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
18 changes: 17 additions & 1 deletion packages/core/test/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
Expand Down
20 changes: 19 additions & 1 deletion packages/core/test/repository-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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* () {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/test/repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading