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
5 changes: 3 additions & 2 deletions src/application/use-cases/create-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export type { FileToCopy, SymlinkToCreate };
export interface CreateWorktreeInput {
branch: string;
baseBranch?: string;
fromRemote?: string;
/** When true, check the branch out from the adapter's resolved remote with tracking. */
fromRemote?: boolean;
dryRun?: boolean;
}

Expand Down Expand Up @@ -102,7 +103,7 @@ export async function createWorktree(
worktree = { path: worktreePath, branch: input.branch, head: "", isMain: false, isPrunable: false };
} else {
const createResult = input.fromRemote
? await git.createWorktreeFromRemote(input.branch, worktreePath, input.fromRemote)
? await git.createWorktreeFromRemote(input.branch, worktreePath)
: await git.createWorktree(input.branch, worktreePath, input.baseBranch);
if (!createResult.success) {
return R.err(new Error(createResult.error.message));
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export function createCommand(container: Container) {
spinner.start("Creating worktree...");

const createResult = await createWorktree(
{ branch, baseBranch, fromRemote: isRemoteBranch ? "origin" : undefined, dryRun },
{ branch, baseBranch, fromRemote: isRemoteBranch, dryRun },
{ git, fs },
);

Expand Down
2 changes: 1 addition & 1 deletion src/domain/ports/git-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface GitPort {
branchExists(branch: string): Promise<Result<boolean, GitError>>;
getDefaultBranch(): Promise<Result<string, GitError>>;
createWorktree(branch: string, path: string, baseBranch?: string): Promise<Result<Worktree, GitError>>;
createWorktreeFromRemote(branch: string, path: string, remote: string): Promise<Result<Worktree, GitError>>;
createWorktreeFromRemote(branch: string, path: string, remote?: string): Promise<Result<Worktree, GitError>>;
removeWorktree(path: string, options?: { force?: boolean }): Promise<Result<void, GitError>>;
moveWorktree(from: string, to: string): Promise<Result<void, GitError>>;
pruneWorktree(path: string): Promise<Result<void, GitError>>;
Expand Down
140 changes: 140 additions & 0 deletions src/infrastructure/adapters/bun-git-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,146 @@ describe("BunGitAdapter", () => {
});
});

// === Non-origin remote resolution ===
//
// The adapter resolves the primary remote name lazily from git's own
// configuration (tracking branch of HEAD / main / master, or a sole
// remote), with "origin" only as the final fallback. These tests use
// fresh adapter instances because the per-instance remote-name cache
// would otherwise be locked by an earlier test running against "origin".

describe("non-origin remote name", () => {
test("listRemoteBranches strips the resolved remote prefix when remote is 'upstream'", async () => {
await using tmp = await createTempDir();
const fixture = await createRemoteFixture(tmp.path, { remoteName: "upstream" });
await fixture.addTrackedBranch("feat-a");
await fixture.addTrackedBranch("feat-b", { withCommit: true });

await withCwd(fixture.repoPath, async () => {
const localGit = createBunGitAdapter(createNoopLogger());
const branches = expectOk(await localGit.listRemoteBranches());
expect(branches.sort()).toEqual(["feat-a", "feat-b", "main"]);
});
});

test("getDefaultBranch resolves HEAD via the non-origin remote", async () => {
await using tmp = await createTempDir();
const fixture = await createRemoteFixture(tmp.path, { remoteName: "upstream" });

await withCwd(fixture.repoPath, async () => {
const localGit = createBunGitAdapter(createNoopLogger());
expect(expectOk(await localGit.getDefaultBranch())).toBe("main");
});
});

test("mergeFFOnly without an explicit remote uses the resolved non-origin remote", async () => {
await using tmp = await createTempDir();
const fixture = await createRemoteFixture(tmp.path, { remoteName: "upstream" });
const clonePath = await fixture.cloneSecond();
const pushedSha = await pushCommit(clonePath, "ff.txt", "remote ahead");

await Bun.$`git -C ${fixture.repoPath} fetch upstream`.quiet();
await withCwd(fixture.repoPath, async () => {
const localGit = createBunGitAdapter(createNoopLogger());
expectOk(await localGit.mergeFFOnly(fixture.repoPath, "main"));
const localSha = (await Bun.$`git -C ${fixture.repoPath} rev-parse main`.quiet().text()).trim();
expect(localSha).toBe(pushedSha);
});
});

test("updateBranchRef fetches from the resolved non-origin remote", async () => {
await using tmp = await createTempDir();
const fixture = await createRemoteFixture(tmp.path, { remoteName: "upstream" });
await fixture.addTrackedBranch("feat");
const clonePath = await fixture.cloneSecond();
await Bun.$`git -C ${clonePath} checkout -q feat`.quiet();
const pushedSha = await pushCommit(clonePath, "feat.txt", "remote moved ahead");

await withCwd(fixture.repoPath, async () => {
const localGit = createBunGitAdapter(createNoopLogger());
expectOk(await localGit.updateBranchRef("feat"));
const localSha = (await Bun.$`git -C ${fixture.repoPath} rev-parse feat`.quiet().text()).trim();
expect(localSha).toBe(pushedSha);
});
});

test("deleteRemoteBranch without an explicit remote pushes the delete to the resolved remote", async () => {
await using tmp = await createTempDir();
const fixture = await createRemoteFixture(tmp.path, { remoteName: "upstream" });
await fixture.addTrackedBranch("doomed");

await withCwd(fixture.repoPath, async () => {
const localGit = createBunGitAdapter(createNoopLogger());
expectOk(await localGit.deleteRemoteBranch("doomed"));
const remoteRefs = await Bun.$`git -C ${fixture.remotePath} branch --list doomed`.quiet().text();
expect(remoteRefs.trim()).toBe("");
});
});

test("createWorktreeFromRemote without an explicit remote checks out via the resolved non-origin remote", async () => {
await using tmp = await createTempDir();
const fixture = await createRemoteFixture(tmp.path, { remoteName: "upstream" });
const clonePath = await fixture.cloneSecond();
await Bun.$`git -C ${clonePath} checkout -q -b remote-only`.quiet();
await Bun.$`git -C ${clonePath} push -u upstream remote-only`.quiet();
const wtPath = join(tmp.path, "wt-remote");

await withCwd(fixture.repoPath, async () => {
const localGit = createBunGitAdapter(createNoopLogger());
expectOk(await localGit.fetchAll());
expect(expectOk(await localGit.branchExists("remote-only"))).toBe(false);

const worktree = expectOk(await localGit.createWorktreeFromRemote("remote-only", wtPath));
expect(worktree.branch).toBe("remote-only");

const upstream = await Bun.$`git -C ${wtPath} rev-parse --abbrev-ref ${"remote-only@{upstream}"}`
.quiet()
.text();
expect(upstream.trim()).toBe("upstream/remote-only");
});
});

test("multiple remotes: the one tracking the default branch wins over the disambiguation fallbacks", async () => {
await using tmp = await createTempDir();
const fixture = await createRemoteFixture(tmp.path, { remoteName: "upstream" });
// Second remote, pushed to and fetched so it produces remote-tracking
// refs. A naive "single remote or alphabetical" picker would prefer
// "aaa" — but branch.main.remote points to "upstream".
const otherBare = join(tmp.path, "other.git");
await Bun.$`git init --bare -b main ${otherBare}`.quiet();
await Bun.$`git -C ${fixture.repoPath} remote add aaa ${otherBare}`.quiet();
await Bun.$`git -C ${fixture.repoPath} push aaa main`.quiet();
await Bun.$`git -C ${fixture.repoPath} fetch aaa`.quiet();

await withCwd(fixture.repoPath, async () => {
const localGit = createBunGitAdapter(createNoopLogger());
// Both remotes now have a main ref. listRemoteBranches strips
// only "upstream/", so the "aaa/main" entry survives — proving
// resolution followed branch.main.remote rather than picking aaa.
const branches = expectOk(await localGit.listRemoteBranches());
expect(branches).toContain("main");
expect(branches).toContain("aaa/main");
expect(branches).not.toContain("upstream/main");
});
});

test("repo with no remote configured: methods fall back to 'origin' and fail with MERGE_FAILED, not silently", async () => {
await using tmp = await createTempDir();
const repoPath = await initTestRepo(tmp.path);

await withCwd(repoPath, async () => {
const localGit = createBunGitAdapter(createNoopLogger());
// No remote exists at all — the operation must surface a typed
// error rather than crashing or returning a misleading success.
const result = await localGit.updateBranchRef("main");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("MERGE_FAILED");
}
});
});
});

// === Edge cases ===

describe("edge cases", () => {
Expand Down
86 changes: 76 additions & 10 deletions src/infrastructure/adapters/bun-git-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,58 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort {
return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() };
}

// Cached primary-remote resolution. Looked up lazily from git's own
// configuration so that the user's `upstream` (or any non-"origin" name)
// flows naturally without changing port signatures.
let cachedRemoteName: string | null = null;

async function getTrackingRemote(branch: string): Promise<string | null> {
const { exitCode, stdout } = await runGit(["config", "--get", `branch.${branch}.remote`]);
// A literal "." is git's sentinel for "this repository" (a branch tracking
// another local branch); treat it as "no remote" so resolution falls through.
return exitCode === 0 && stdout && stdout !== "." ? stdout : null;
}

async function resolveRemoteName(): Promise<string> {
if (cachedRemoteName !== null) return cachedRemoteName;

// 1. Tracking remote of the conventional default branches — the most
// reliable signal for the repository's canonical remote.
for (const branch of ["main", "master"]) {
const remote = await getTrackingRemote(branch);
if (remote) {
cachedRemoteName = remote;
return remote;
}
}

// 2. Tracking remote of the currently checked-out branch. Consulted after
// the default branches because the current branch may track a side remote
// (e.g. a fork), which is the wrong answer for these repo-level queries.
const head = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"]);
if (head.exitCode === 0 && head.stdout) {
const remote = await getTrackingRemote(head.stdout);
if (remote) {
cachedRemoteName = remote;
return remote;
}
}

// 3. Single remote present → it has to be the one.
const remotes = await runGit(["remote"]);
if (remotes.exitCode === 0) {
const names = remotes.stdout.split("\n").filter(Boolean);
if (names.length === 1 && names[0]) {
cachedRemoteName = names[0];
return names[0];
}
}

// 4. Final fallback to git's own historical default.
cachedRemoteName = "origin";
return "origin";
}

return {
async isGitRepository(): Promise<Result<boolean, GitError>> {
try {
Expand Down Expand Up @@ -133,11 +185,13 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort {
message: "Not inside a git repository",
});
}
const remoteName = await resolveRemoteName();
const prefix = `${remoteName}/`;
const branches = stdout
.split("\n")
.filter(Boolean)
.filter((b) => !b.includes("HEAD"))
.map((b) => b.replace(/^origin\//, ""));
.map((b) => (b.startsWith(prefix) ? b.slice(prefix.length) : b));
return Result.ok(branches);
} catch {
return Result.err({
Expand All @@ -161,9 +215,11 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort {

async getDefaultBranch(): Promise<Result<string, GitError>> {
try {
const { exitCode, stdout } = await runGit(["symbolic-ref", "refs/remotes/origin/HEAD"]);
const remoteName = await resolveRemoteName();
const remotePrefix = `refs/remotes/${remoteName}/`;
const { exitCode, stdout } = await runGit(["symbolic-ref", `${remotePrefix}HEAD`]);
if (exitCode === 0 && stdout) {
const branch = stdout.replace("refs/remotes/origin/", "");
const branch = stdout.replace(remotePrefix, "");
return Result.ok(branch);
}

Expand Down Expand Up @@ -220,10 +276,11 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort {
}
},

async createWorktreeFromRemote(branch: string, path: string, remote: string): Promise<Result<Worktree, GitError>> {
async createWorktreeFromRemote(branch: string, path: string, remote?: string): Promise<Result<Worktree, GitError>> {
try {
const remoteName = remote ?? (await resolveRemoteName());
// git worktree add --track -b <branch> <path> <remote>/<branch>
const args = ["worktree", "add", "--track", "-b", branch, path, `${remote}/${branch}`];
const args = ["worktree", "add", "--track", "-b", branch, path, `${remoteName}/${branch}`];

const { exitCode, stderr } = await runGit(args);
if (exitCode !== 0) {
Expand Down Expand Up @@ -478,9 +535,16 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort {
}
},

async mergeFFOnly(worktreePath: string, branch: string, remote = "origin"): Promise<Result<void, GitError>> {
async mergeFFOnly(worktreePath: string, branch: string, remote?: string): Promise<Result<void, GitError>> {
try {
const { exitCode, stderr } = await runGit(["-C", worktreePath, "merge", "--ff-only", `${remote}/${branch}`]);
const remoteName = remote ?? (await resolveRemoteName());
const { exitCode, stderr } = await runGit([
"-C",
worktreePath,
"merge",
"--ff-only",
`${remoteName}/${branch}`,
]);
if (exitCode !== 0) {
return Result.err({ code: "MERGE_FAILED", message: stderr || `Failed to fast-forward ${branch}` });
}
Expand All @@ -492,7 +556,8 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort {

async updateBranchRef(branch: string): Promise<Result<void, GitError>> {
try {
const { exitCode, stderr } = await runGit(["fetch", "origin", `${branch}:${branch}`]);
const remoteName = await resolveRemoteName();
const { exitCode, stderr } = await runGit(["fetch", remoteName, `${branch}:${branch}`]);
if (exitCode !== 0) {
return Result.err({ code: "MERGE_FAILED", message: stderr || `Failed to update ref for ${branch}` });
}
Expand Down Expand Up @@ -699,9 +764,10 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort {
}
},

async deleteRemoteBranch(branch: string, remote = "origin"): Promise<Result<void, GitError>> {
async deleteRemoteBranch(branch: string, remote?: string): Promise<Result<void, GitError>> {
try {
const { exitCode, stderr } = await runGit(["push", "--delete", remote, branch]);
const remoteName = remote ?? (await resolveRemoteName());
const { exitCode, stderr } = await runGit(["push", "--delete", remoteName, branch]);
if (exitCode !== 0) {
if (stderr?.includes("remote ref does not exist")) {
return Result.err({
Expand Down
6 changes: 5 additions & 1 deletion src/test-utils/fake-git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,11 @@ export function createFakeGit(options: FakeGitOptions = {}): GitPort {
return Result.ok(wt);
},

async createWorktreeFromRemote(branch: string, path: string, _remote: string): Promise<Result<Worktree, GitError>> {
async createWorktreeFromRemote(
branch: string,
path: string,
_remote?: string,
): Promise<Result<Worktree, GitError>> {
if (store.some((w) => w.branch === branch)) {
return Result.err({ code: "BRANCH_EXISTS", message: `Branch ${branch} already exists` });
}
Expand Down
18 changes: 12 additions & 6 deletions src/test-utils/git-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,19 @@ export interface RemoteFixture {
}

/** Bare remote + tracking clone: the minimal setup for fetch/prune/gone-branch scenarios. */
export async function createRemoteFixture(parentDir: string): Promise<RemoteFixture> {
export async function createRemoteFixture(
parentDir: string,
opts: { remoteName?: string } = {},
): Promise<RemoteFixture> {
const remoteName = opts.remoteName ?? "origin";
const remoteDir = join(parentDir, "remote.git");
await Bun.$`git init --bare -b main ${remoteDir}`.quiet();
const remotePath = await realpath(remoteDir);

const repoDir = join(parentDir, "repo");
await Bun.$`git clone ${remotePath} ${repoDir}`.quiet();
// `git clone -o <name>` names the remote during clone — produces a working
// tracking setup with a non-default remote name in one step.
await Bun.$`git clone -o ${remoteName} ${remotePath} ${repoDir}`.quiet();
const repoPath = await realpath(repoDir);
await configureUser(repoPath);
// A clone of an empty remote starts on an unborn HEAD named after the
Expand All @@ -54,7 +60,7 @@ export async function createRemoteFixture(parentDir: string): Promise<RemoteFixt
await Bun.write(join(repoPath, "README.md"), "test");
await Bun.$`git -C ${repoPath} add .`.quiet();
await Bun.$`git -C ${repoPath} commit -m "Initial commit"`.quiet();
await Bun.$`git -C ${repoPath} push -u origin main`.quiet();
await Bun.$`git -C ${repoPath} push -u ${remoteName} main`.quiet();

return {
remotePath,
Expand All @@ -68,14 +74,14 @@ export async function createRemoteFixture(parentDir: string): Promise<RemoteFixt
await Bun.$`git -C ${repoPath} commit -m ${`commit on ${name}`}`.quiet();
await Bun.$`git -C ${repoPath} checkout main`.quiet();
}
await Bun.$`git -C ${repoPath} push -u origin ${name}`.quiet();
await Bun.$`git -C ${repoPath} push -u ${remoteName} ${name}`.quiet();
},
async deleteRemoteBranch(name: string): Promise<void> {
await Bun.$`git -C ${repoPath} push origin --delete ${name}`.quiet();
await Bun.$`git -C ${repoPath} push ${remoteName} --delete ${name}`.quiet();
},
async cloneSecond(): Promise<string> {
const cloneDir = join(parentDir, "clone2");
await Bun.$`git clone ${remotePath} ${cloneDir}`.quiet();
await Bun.$`git clone -o ${remoteName} ${remotePath} ${cloneDir}`.quiet();
const clonePath = await realpath(cloneDir);
await configureUser(clonePath);
return clonePath;
Expand Down