diff --git a/src/application/use-cases/create-worktree.ts b/src/application/use-cases/create-worktree.ts index 1816451..6045aab 100644 --- a/src/application/use-cases/create-worktree.ts +++ b/src/application/use-cases/create-worktree.ts @@ -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; } @@ -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)); diff --git a/src/cli/commands/create.ts b/src/cli/commands/create.ts index 596882e..5e84715 100644 --- a/src/cli/commands/create.ts +++ b/src/cli/commands/create.ts @@ -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 }, ); diff --git a/src/domain/ports/git-port.ts b/src/domain/ports/git-port.ts index 26d7912..aa40bfd 100644 --- a/src/domain/ports/git-port.ts +++ b/src/domain/ports/git-port.ts @@ -26,7 +26,7 @@ export interface GitPort { branchExists(branch: string): Promise>; getDefaultBranch(): Promise>; createWorktree(branch: string, path: string, baseBranch?: string): Promise>; - createWorktreeFromRemote(branch: string, path: string, remote: string): Promise>; + createWorktreeFromRemote(branch: string, path: string, remote?: string): Promise>; removeWorktree(path: string, options?: { force?: boolean }): Promise>; moveWorktree(from: string, to: string): Promise>; pruneWorktree(path: string): Promise>; diff --git a/src/infrastructure/adapters/bun-git-adapter.test.ts b/src/infrastructure/adapters/bun-git-adapter.test.ts index 2494e03..e96a6b1 100644 --- a/src/infrastructure/adapters/bun-git-adapter.test.ts +++ b/src/infrastructure/adapters/bun-git-adapter.test.ts @@ -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", () => { diff --git a/src/infrastructure/adapters/bun-git-adapter.ts b/src/infrastructure/adapters/bun-git-adapter.ts index 8d7e0b4..6b0e266 100644 --- a/src/infrastructure/adapters/bun-git-adapter.ts +++ b/src/infrastructure/adapters/bun-git-adapter.ts @@ -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 { + 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 { + 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> { try { @@ -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({ @@ -161,9 +215,11 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort { async getDefaultBranch(): Promise> { 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); } @@ -220,10 +276,11 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort { } }, - async createWorktreeFromRemote(branch: string, path: string, remote: string): Promise> { + async createWorktreeFromRemote(branch: string, path: string, remote?: string): Promise> { try { + const remoteName = remote ?? (await resolveRemoteName()); // git worktree add --track -b / - 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) { @@ -478,9 +535,16 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort { } }, - async mergeFFOnly(worktreePath: string, branch: string, remote = "origin"): Promise> { + async mergeFFOnly(worktreePath: string, branch: string, remote?: string): Promise> { 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}` }); } @@ -492,7 +556,8 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort { async updateBranchRef(branch: string): Promise> { 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}` }); } @@ -699,9 +764,10 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort { } }, - async deleteRemoteBranch(branch: string, remote = "origin"): Promise> { + async deleteRemoteBranch(branch: string, remote?: string): Promise> { 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({ diff --git a/src/test-utils/fake-git.ts b/src/test-utils/fake-git.ts index 1113071..5a2c4db 100644 --- a/src/test-utils/fake-git.ts +++ b/src/test-utils/fake-git.ts @@ -133,7 +133,11 @@ export function createFakeGit(options: FakeGitOptions = {}): GitPort { return Result.ok(wt); }, - async createWorktreeFromRemote(branch: string, path: string, _remote: string): Promise> { + async createWorktreeFromRemote( + branch: string, + path: string, + _remote?: string, + ): Promise> { if (store.some((w) => w.branch === branch)) { return Result.err({ code: "BRANCH_EXISTS", message: `Branch ${branch} already exists` }); } diff --git a/src/test-utils/git-fixtures.ts b/src/test-utils/git-fixtures.ts index a4c5186..ab40165 100644 --- a/src/test-utils/git-fixtures.ts +++ b/src/test-utils/git-fixtures.ts @@ -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 { +export async function createRemoteFixture( + parentDir: string, + opts: { remoteName?: string } = {}, +): Promise { + 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 ` 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 @@ -54,7 +60,7 @@ export async function createRemoteFixture(parentDir: string): Promise { - await Bun.$`git -C ${repoPath} push origin --delete ${name}`.quiet(); + await Bun.$`git -C ${repoPath} push ${remoteName} --delete ${name}`.quiet(); }, async cloneSecond(): Promise { 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;