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
17 changes: 13 additions & 4 deletions packages/core/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,15 @@ 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", branch]),
)

const checkout = Effect.fn("Git.checkout")((directory: string, branch: string) =>
execute(directory, proc)(["checkout", "-B", branch, `origin/${branch}`]),
)
const checkout = Effect.fn("Git.checkout")((directory: string, branch: string) => {
const local = localBranchName(branch)
return local
? execute(directory, proc)(["checkout", "-B", local, "--force", "FETCH_HEAD"])
: execute(directory, proc)(["checkout", "--detach", "--force", "FETCH_HEAD"])
})

const reset = Effect.fn("Git.reset")((directory: string, target: string) =>
execute(directory, proc)(["reset", "--hard", target]),
Expand Down Expand Up @@ -436,6 +439,12 @@ function execute(cwd: string, proc: AppProcess.Interface) {
)
}

function localBranchName(ref: string) {
if (ref.startsWith("refs/heads/")) return ref.slice("refs/heads/".length)
if (!ref.startsWith("refs/")) return ref
return undefined
}

function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
Expand Down
66 changes: 34 additions & 32 deletions packages/core/src/repository-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ 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 })
.pipe(
Effect.mapError((error) => new CloneFailedError({ repository, message: errorMessage(error) })),
)
Expand All @@ -181,42 +181,44 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | E
return yield* new FetchFailedError({
repository,
message: resultMessage(fetch, `Failed to refresh ${repository}`),
})
}

if (input.branch) {
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 (input.branch && status !== "cached") {
const requestedBranch = input.branch
const fetchBranch = yield* git
.fetchBranch(localPath, requestedBranch)
.pipe(
Effect.mapError((error) => new FetchFailedError({ repository, message: errorMessage(error) })),
)
if (checkout.exitCode !== 0) {
return yield* new CheckoutFailedError({
repository,
branch: requestedBranch,
message: resultMessage(checkout, `Failed to checkout ${requestedBranch}`),
})
}
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 !== "cached") {
const reset = yield* git
.reset(localPath, yield* resetTarget(git, localPath, input.branch))
.pipe(
Expand Down Expand Up @@ -276,7 +278,7 @@ 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 "HEAD"
const remoteHead = yield* git.remoteHead(cwd)
if (remoteHead) return remoteHead
const currentBranch = yield* git.branch(cwd)
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,20 @@ 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 (
/^[A-Za-z0-9/@_.-]+$/.test(branch) &&
!branch.startsWith("-") &&
!branch.startsWith("/") &&
!branch.endsWith("/") &&
!branch.includes("..") &&
!branch.includes("@{") &&
!branch.split("/").some((segment) => !segment || segment.endsWith(".lock"))
)
return
throw new InvalidBranchError({
branch,
message: "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..",
message:
"Branch or ref must contain only alphanumeric characters, /, @, _, ., and -, and must be a safe git ref name",
})
}

Expand Down
11 changes: 10 additions & 1 deletion packages/core/test/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,18 @@ describe("Git", () => {
yield* Effect.promise(() => branch(fixture.source, "feature/docs", "feature\n"))
expect((yield* git.fetchBranch(target, "feature/docs")).exitCode).toBe(0)
expect((yield* git.checkout(target, "feature/docs")).exitCode).toBe(0)
expect((yield* git.reset(target, "origin/feature/docs")).exitCode).toBe(0)
expect((yield* git.reset(target, "HEAD")).exitCode).toBe(0)
expect(yield* git.branch(target)).toBe("feature/docs")
expect(yield* read(path.join(target, "README.md"))).toBe("feature\n")

yield* Effect.promise(async () => {
await $`git tag release@1`.cwd(fixture.source).quiet()
await $`git push origin refs/tags/release@1`.cwd(fixture.source).quiet()
})
expect((yield* git.fetchBranch(target, "refs/tags/release@1")).exitCode).toBe(0)
expect((yield* git.checkout(target, "refs/tags/release@1")).exitCode).toBe(0)
expect(yield* git.branch(target)).toBeUndefined()
expect(yield* read(path.join(target, "README.md"))).toBe("feature\n")
}),
),
)
Expand Down
20 changes: 20 additions & 0 deletions packages/core/test/repository-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,26 @@ describe("RepositoryCache", () => {
),
)

it.live("materializes a full git tag ref", () =>
withRemote((fixture) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await git(fixture.source, "tag", "release@1")
await git(fixture.source, "push", "origin", "refs/tags/release@1")
})

const result = yield* (yield* RepositoryCache.Service).ensure({
reference: fixture.reference,
branch: "refs/tags/release@1",
})

expect(result.status).toBe("cloned")
expect(result.branch).toBeUndefined()
expect(yield* read(path.join(result.localPath, "README.md"))).toBe("one\n")
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)

it.live("replaces an existing checkout whose origin does not match", () =>
withRemote((fixture) =>
Effect.gen(function* () {
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ 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/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)
Expand Down
Loading