Skip to content
Merged
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
56 changes: 53 additions & 3 deletions apps/cli/src/git/checkout.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,71 @@
import { type GitError, GitNotRepositoryError } from "@cyrus/errors/git";
import { resolve } from "node:path";
import {
branchAlreadyCheckedOutError,
type GitError,
GitNotRepositoryError,
} from "@cyrus/errors/git";
import { Result } from "better-result";
import { openRepositoryFromWorktree, type Repository } from "es-git";
import { openGitRepository, operationFailedFromUnknown } from "./open";

function currentBranchRefName(repo: Repository): string | null {
return repo.findReference("HEAD")?.symbolicTarget() ?? null;
}

function findBranchCheckedOutElsewhere(
repo: Repository,
branch: string,
excludePath: string
): string | null {
const target = `refs/heads/${branch}`;
const exclude = resolve(excludePath);

const workdir = repo.workdir();
if (
workdir &&
resolve(workdir) !== exclude &&
currentBranchRefName(repo) === target
) {
return workdir;
}

for (const name of repo.worktrees()) {
const worktree = repo.findWorktree(name);
const worktreePath = worktree.path();
if (resolve(worktreePath) === exclude) continue;

const worktreeRepo = Result.try(() => openRepositoryFromWorktree(worktree));
if (worktreeRepo.isErr()) continue;

if (currentBranchRefName(worktreeRepo.value) === target)
return worktreePath;
}

return null;
}

export async function checkoutGitRef(
cwd: string,
refName: string
): Promise<Result<void, GitError>> {
const opened = await openGitRepository(cwd);
if (opened.isErr()) return Result.err(opened.error);

const conflictPath = findBranchCheckedOutElsewhere(
opened.value,
refName,
cwd
);
if (conflictPath)
return Result.err(branchAlreadyCheckedOutError(refName, conflictPath));

const checkout = Result.try(() => {
opened.value.setHead(`refs/heads/${refName}`);
opened.value.checkoutHead();
});
if (checkout.isErr()) {
if (checkout.isErr())
return Result.err(operationFailedFromUnknown(checkout.error));
}

return Result.ok(undefined);
}

Expand Down
72 changes: 71 additions & 1 deletion apps/cli/src/git/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { initRepository } from "es-git";
import { GitBranchCheckedOutError } from "@cyrus/errors/git";
import { initRepository, openRepository } from "es-git";
import { checkoutGitRef } from "@/git/checkout";
import { getGitPatch } from "@/git/patch";
import { defaultWorktreePath, sanitizeBranchDirName } from "@/git/paths";
import { getGitStatus } from "@/git/status";
import { createGitWorktree } from "@/git/worktree";

async function initRepo(dir: string) {
const repo = await initRepository(dir, { initialHead: "main" });
Expand Down Expand Up @@ -63,3 +66,70 @@ describe("git status", () => {
}
});
});

describe("createGitWorktree", () => {
test("forks a new branch instead of the checked-out source branch", async () => {
const dir = await mkdtemp(join(tmpdir(), "cyrus-git-"));
try {
await initRepo(dir);
const result = await createGitWorktree(dir, "main", "wt-main");
expect(result.isOk()).toBe(true);
if (!result.isOk()) return;

const worktreeRepo = await openRepository(result.value);
expect(worktreeRepo.head().shorthand()).not.toBe("main");
} finally {
await rm(dir, { recursive: true, force: true });
}
});

test("succeeds even when the source branch is checked out in the main repo", async () => {
const dir = await mkdtemp(join(tmpdir(), "cyrus-git-"));
try {
await initRepo(dir);
const first = await createGitWorktree(dir, "main", "wt-a");
const second = await createGitWorktree(dir, "main", "wt-b");
expect(first.isOk()).toBe(true);
expect(second.isOk()).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

describe("checkoutGitRef", () => {
test("succeeds as a no-op when already on the requested branch", async () => {
const dir = await mkdtemp(join(tmpdir(), "cyrus-git-"));
try {
await initRepo(dir);
const result = await checkoutGitRef(dir, "main");
expect(result.isOk()).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

test("returns branchAlreadyCheckedOutError when the branch is checked out in another worktree", async () => {
const dir = await mkdtemp(join(tmpdir(), "cyrus-git-"));
try {
await initRepo(dir);
const repo = await openRepository(dir);
const head = repo.head();
const oid = head.target();
if (!oid) throw new Error("expected HEAD to resolve to a commit");
const commit = repo.getCommit(oid);
repo.createBranch("feature", commit);
repo.worktree("feature", join(dir, "wt-feature"), {
refName: "refs/heads/feature",
checkoutExisting: true,
});

const result = await checkoutGitRef(dir, "feature");
expect(result.isErr()).toBe(true);
if (!result.isErr()) return;
expect(GitBranchCheckedOutError.is(result.error)).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
50 changes: 44 additions & 6 deletions apps/cli/src/git/worktree.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { mkdir } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { type GitError, operationFailedError } from "@cyrus/errors/git";
import { generateName } from "@cyrus/utils/identity";
import { Result } from "better-result";
import type { Repository, Worktree } from "es-git";
import type { Commit, Repository, Worktree } from "es-git";
import {
openGitRepository,
operationFailedFromUnknown,
Expand All @@ -14,6 +15,8 @@ import {
worktreeNameForBranch,
} from "./paths";

const MAX_BRANCH_NAME_ATTEMPTS = 5;

function findWorktreeByPath(
repo: Repository,
worktreePath: string
Expand All @@ -26,6 +29,26 @@ function findWorktreeByPath(
return null;
}

function resolveBranchCommit(
repo: Repository,
refName: string
): Result<Commit, GitError> {
return Result.try(() => {
const branch = repo.getBranch(refName, "Local");
const oid = branch.referenceTarget();
if (!oid) throw new Error(`Branch '${refName}' has no commits yet`);
return repo.getCommit(oid);
}).mapError(operationFailedFromUnknown);
}

function generateUniqueBranchName(repo: Repository): string {
for (let attempt = 0; attempt < MAX_BRANCH_NAME_ATTEMPTS; attempt++) {
const candidate = generateName();
if (!repo.findBranch(candidate, "Local")) return candidate;
}
return `${generateName()}-${crypto.randomUUID().slice(0, 8)}`;
}

export async function createGitWorktree(
projectCwd: string,
refName: string,
Expand All @@ -39,23 +62,38 @@ export async function createGitWorktree(
: Result.ok(undefined);
if (resolvedPath.isErr()) return Result.err(resolvedPath.error);

const commit = resolveBranchCommit(opened.value, refName);
if (commit.isErr()) return Result.err(commit.error);

const branchName = generateUniqueBranchName(opened.value);
const branch = Result.try(() =>
opened.value.createBranch(branchName, commit.value)
);
if (branch.isErr())
return Result.err(operationFailedFromUnknown(branch.error));

const worktreePath =
resolvedPath.value ?? defaultWorktreePath(projectCwd, refName);
resolvedPath.value ?? defaultWorktreePath(projectCwd, branchName);
const prepared = await runGitOperationAsync(() =>
mkdir(dirname(worktreePath), { recursive: true })
);
if (prepared.isErr()) return Result.err(prepared.error);
if (prepared.isErr()) {
Result.try(() => branch.value.delete());
return Result.err(prepared.error);
}

const created = Result.try(() => {
const name = worktreeNameForBranch(refName);
const name = worktreeNameForBranch(branchName);
opened.value.worktree(name, worktreePath, {
refName: `refs/heads/${refName}`,
refName: `refs/heads/${branchName}`,
checkoutExisting: true,
});
return worktreePath;
});
if (created.isErr())
if (created.isErr()) {
Result.try(() => branch.value.delete());
return Result.err(operationFailedFromUnknown(created.error));
}

return Result.ok(created.value);
}
Expand Down
28 changes: 27 additions & 1 deletion shared/errors/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { EmptyPayload } from "./orpc";
const tags = {
notRepository: errorTag(errorModules.git, "not_repository"),
operationFailed: errorTag(errorModules.git, "operation_failed"),
branchCheckedOut: errorTag(errorModules.git, "branch_checked_out"),
} as const;

export class GitNotRepositoryError extends TaggedError(
Expand All @@ -28,7 +29,25 @@ export class GitOperationFailedError extends TaggedError(tags.operationFailed)<{
}
}

export type GitError = GitNotRepositoryError | GitOperationFailedError;
export class GitBranchCheckedOutError extends TaggedError(
tags.branchCheckedOut
)<{
branch: string;
path: string;
}>() {
get message() {
return `Branch '${this.branch}' is already checked out at ${this.path}`;
}

get orpcCode() {
return "BAD_REQUEST" as const;
}
}

export type GitError =
| GitNotRepositoryError
| GitOperationFailedError
| GitBranchCheckedOutError;

export function notRepositoryError(): GitError {
return new GitNotRepositoryError({});
Expand All @@ -40,3 +59,10 @@ export function operationFailedError(
): GitError {
return new GitOperationFailedError({ message, detail });
}

export function branchAlreadyCheckedOutError(
branch: string,
path: string
): GitError {
return new GitBranchCheckedOutError({ branch, path });
}