diff --git a/README.md b/README.md index 81f0f8b..746bde3 100644 --- a/README.md +++ b/README.md @@ -259,7 +259,7 @@ wt update --cleanup 3. Detects parent branches via merge-base 4. Rebases feature branches in correct order — parents before children -**Fork workflow** — when `upstream` is set in config (see `wt init --upstream`), the default branch is fast-forwarded from `/` instead of `origin/`. After a successful upstream sync, `post-update` hooks also run for the default branch (so you can, for example, push the synced default branch back to your fork). +**Fork workflow** — when `upstream` is set in config (see `wt init --upstream`), the default branch is synced from `/` instead of `origin/`, whether it is fast-forwarded in its own worktree or updated by ref because no worktree has it checked out. After a successful upstream sync, `post-update` hooks also run for the default branch (so you can, for example, push the synced default branch back to your fork); with no worktree for the default branch they run in the repository root. When `upstream` is **not** configured, an interactive `wt update` auto-detects candidate remotes (every remote except `origin`) the same way `wt init` does, and offers to save your choice: diff --git a/src/application/use-cases/update-worktrees.test.ts b/src/application/use-cases/update-worktrees.test.ts index 6aea72c..f94afed 100644 --- a/src/application/use-cases/update-worktrees.test.ts +++ b/src/application/use-cases/update-worktrees.test.ts @@ -1079,3 +1079,89 @@ describe("updateWorktrees — upstream sync", () => { expect(shell.calls.find((c) => c.options.cwd === "/repo")).toBeUndefined(); }); }); + +// Whether the default branch happens to be checked out decides how it is updated +// (fast-forward vs. ref fetch) — it must not decide which remote it is updated +// from, nor whether post-update hooks run for it. +describe("updateWorktrees — upstream sync without a default-branch worktree", () => { + test("upstream set — updates the default branch ref from the upstream remote", async () => { + const updateBranchRefCalls: { branch: string; remote: string }[] = []; + const git = createFakeGit({ + worktrees: [featureA], + updateBranchRefCalls, + ...flatBranchesConfig([featureA]), + }); + + const result = await updateWorktrees({ dryRun: false, upstream: "upstream" }, { git }); + + const output = expectOk(result); + expect(output.defaultBranchUpdate).toBe("ref-updated"); + expect(output.syncedFromUpstream).toBe("upstream"); + expect(updateBranchRefCalls).toEqual([{ branch: "main", remote: "upstream" }]); + }); + + test("upstream unset — updates the default branch ref from the primary remote", async () => { + const updateBranchRefCalls: { branch: string; remote: string }[] = []; + const git = createFakeGit({ + worktrees: [featureA], + updateBranchRefCalls, + ...flatBranchesConfig([featureA]), + }); + + const result = await updateWorktrees({ dryRun: false }, { git }); + + const output = expectOk(result); + expect(output.syncedFromUpstream).toBeUndefined(); + expect(updateBranchRefCalls).toEqual([{ branch: "main", remote: "origin" }]); + }); + + test("upstream set with post-update hook — runs the hook for the default branch in the repo root", async () => { + const git = createFakeGit({ worktrees: [featureA], ...flatBranchesConfig([featureA]) }); + const shell = createFakeShell(); + + const result = await updateWorktrees( + { dryRun: false, upstream: "upstream", postUpdateHooks: ["git push origin main"], repoRoot: "/repo" }, + { git, shell }, + ); + + const output = expectOk(result); + const mainReport = output.reports.find((r) => r.branch === "main"); + expect(mainReport?.result).toMatchObject({ status: "is-default-branch" }); + expect(mainReport?.hookNotifications).toHaveLength(1); + + // The default branch has no worktree, so the hook runs in the repo root. + const mainHookCall = shell.calls.find((c) => c.options.env?.WORKTREE_BRANCH === "main"); + expect(mainHookCall?.options.cwd).toBe("/repo"); + expect(mainHookCall?.options.env).toMatchObject({ + WORKTREE_PATH: "/repo", + BASE_BRANCH: "upstream/main", + }); + }); + + test("upstream unset — no hook runs for the default branch and it is not reported", async () => { + const git = createFakeGit({ worktrees: [featureA], ...flatBranchesConfig([featureA]) }); + const shell = createFakeShell(); + + const result = await updateWorktrees( + { dryRun: false, postUpdateHooks: ["git push"], repoRoot: "/repo" }, + { git, shell }, + ); + + const output = expectOk(result); + expect(output.reports.find((r) => r.branch === "main")).toBeUndefined(); + expect(shell.calls.find((c) => c.options.env?.WORKTREE_BRANCH === "main")).toBeUndefined(); + }); + + test("dry run with upstream — no hook runs for the default branch", async () => { + const git = createFakeGit({ worktrees: [featureA], ...flatBranchesConfig([featureA]) }); + const shell = createFakeShell(); + + const result = await updateWorktrees( + { dryRun: true, upstream: "upstream", postUpdateHooks: ["git push origin main"], repoRoot: "/repo" }, + { git, shell }, + ); + + expectOk(result); + expect(shell.calls).toEqual([]); + }); +}); diff --git a/src/application/use-cases/update-worktrees.ts b/src/application/use-cases/update-worktrees.ts index a283959..fd66e0b 100644 --- a/src/application/use-cases/update-worktrees.ts +++ b/src/application/use-cases/update-worktrees.ts @@ -132,7 +132,7 @@ function buildRebaseOrder(worktrees: Worktree[], parentMap: Record b !== defaultBranch) : []); const mainWorktree = worktrees.find((w) => w.branch === defaultBranch); + // Where default-branch work happens: its own worktree when it has one, else the + // repo root — the branch is then updated by ref and is checked out nowhere. + const defaultBranchPath = mainWorktree?.path ?? input.repoRoot ?? ""; let defaultBranchUpdate: "ff-updated" | "ref-updated"; let defaultBranchHookNotifications: Notification[] = []; let syncedFromUpstream: string | undefined; @@ -209,38 +212,33 @@ export async function updateWorktrees( return R.err(new Error(`Failed to fast-forward ${defaultBranch}: ${ffResult.error.message}`)); } defaultBranchUpdate = "ff-updated"; - - // When syncing the default branch from an upstream remote, run post-update hooks - // for the default branch too (mirrors the feature-branch path). - if (input.upstream) { - syncedFromUpstream = input.upstream; - if (!input.dryRun && input.postUpdateHooks?.length && deps.shell) { - const baseRef = `${input.upstream}/${defaultBranch}`; - const hookResult = await runHooks( - { - commands: input.postUpdateHooks, - context: { - worktreePath: mainWorktree.path, - branch: defaultBranch, - repoRoot: input.repoRoot ?? "", - baseBranch: baseRef, - }, - }, - { shell: deps.shell }, - ); - if (hookResult.success) { - defaultBranchHookNotifications = hookResult.data.notifications; - } - } - } } else { - const refResult = await git.updateBranchRef(defaultBranch); + // Same remote as the fast-forward path above: which remote is authoritative for + // the default branch must not depend on whether it happens to be checked out. + const refResult = input.upstream + ? await git.updateBranchRef(defaultBranch, input.upstream) + : await git.updateBranchRef(defaultBranch); if (!refResult.success) { return R.err(new Error(`Failed to update ${defaultBranch} ref: ${refResult.error.message}`)); } defaultBranchUpdate = "ref-updated"; } + // When syncing the default branch from an upstream remote, run post-update hooks + // for the default branch too (mirrors the feature-branch path). The branch was + // synced either way, so this does not depend on how it was updated. + if (input.upstream) { + syncedFromUpstream = input.upstream; + if (!input.dryRun && defaultBranchPath) { + defaultBranchHookNotifications = await runPostUpdateHooks( + { path: defaultBranchPath, branch: defaultBranch }, + `${input.upstream}/${defaultBranch}`, + input, + deps, + ); + } + } + const parentMap: Record = {}; const retargetMap: Record = {}; for (const wt of worktrees) { @@ -266,10 +264,12 @@ export async function updateWorktrees( const reports: WorktreeReport[] = []; const failedBranches = new Set(); - if (mainWorktree) { + // The default branch is reported when it has a worktree; without one there is + // nothing to rebase, but hook results still need somewhere to surface. + if (mainWorktree || defaultBranchHookNotifications.length > 0) { reports.push({ branch: defaultBranch, - path: mainWorktree.path, + path: defaultBranchPath, result: { status: "is-default-branch" }, hookNotifications: defaultBranchHookNotifications, }); diff --git a/src/domain/ports/git-port.ts b/src/domain/ports/git-port.ts index aa40bfd..85bc8b9 100644 --- a/src/domain/ports/git-port.ts +++ b/src/domain/ports/git-port.ts @@ -41,7 +41,8 @@ export interface GitPort { listRemotes(): Promise>; listGoneBranches(): Promise>; mergeFFOnly(worktreePath: string, branch: string, remote?: string): Promise>; - updateBranchRef(branch: string): Promise>; + /** Fast-forward a local branch ref that is not checked out anywhere, from `remote` (default: primary remote). */ + updateBranchRef(branch: string, remote?: string): Promise>; rebase( worktreePath: string, onto: string, diff --git a/src/index.ts b/src/index.ts index 76dda1b..126f27f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,7 +34,7 @@ const main = defineCommand({ }, }, async setup({ args }) { - container = createContainer({ + container = await createContainer({ verbose: args.verbose || process.env.WT_VERBOSE === "1", nonInteractive: args["non-interactive"] || process.env.WT_NON_INTERACTIVE === "1", }); diff --git a/src/infrastructure/adapters/bun-git-adapter.test.ts b/src/infrastructure/adapters/bun-git-adapter.test.ts index e96a6b1..6707a10 100644 --- a/src/infrastructure/adapters/bun-git-adapter.test.ts +++ b/src/infrastructure/adapters/bun-git-adapter.test.ts @@ -4,10 +4,10 @@ import { expectErr, expectOk } from "../../test-utils/assertions.ts"; import { createRemoteFixture, initTestRepo, initUnbornRepo } from "../../test-utils/git-fixtures.ts"; import { createNoopLogger } from "../../test-utils/noop-logger.ts"; import { createTempDir } from "../../test-utils/temp-dir.ts"; -import { createBunGitAdapter } from "./bun-git-adapter.ts"; +import { createBunGitAdapter, resolvePrimaryRemote } from "./bun-git-adapter.ts"; describe("BunGitAdapter", () => { - const git = createBunGitAdapter(createNoopLogger()); + const git = createBunGitAdapter(createNoopLogger(), "origin"); test("isGitRepository returns true when run inside a git repo", async () => { const isRepo = expectOk(await git.isGitRepository()); @@ -966,39 +966,37 @@ describe("BunGitAdapter", () => { }); }); - // === Non-origin remote resolution === + // === Injected remote name === // - // 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". + // The adapter never resolves the remote itself: the primary remote name is + // resolved once at composition time (see `resolvePrimaryRemote`) and injected, + // so a repository whose remote is not "origin" only needs a different injection. describe("non-origin remote name", () => { - test("listRemoteBranches strips the resolved remote prefix when remote is 'upstream'", async () => { + const upstreamGit = createBunGitAdapter(createNoopLogger(), "upstream"); + + test("listRemoteBranches strips the injected remote prefix", 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()); + const branches = expectOk(await upstreamGit.listRemoteBranches()); expect(branches.sort()).toEqual(["feat-a", "feat-b", "main"]); }); }); - test("getDefaultBranch resolves HEAD via the non-origin remote", async () => { + test("getDefaultBranch resolves HEAD via the injected 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"); + expect(expectOk(await upstreamGit.getDefaultBranch())).toBe("main"); }); }); - test("mergeFFOnly without an explicit remote uses the resolved non-origin remote", async () => { + test("mergeFFOnly without an explicit remote uses the injected remote", async () => { await using tmp = await createTempDir(); const fixture = await createRemoteFixture(tmp.path, { remoteName: "upstream" }); const clonePath = await fixture.cloneSecond(); @@ -1006,14 +1004,13 @@ describe("BunGitAdapter", () => { 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")); + expectOk(await upstreamGit.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 () => { + test("updateBranchRef without an explicit remote fetches from the injected remote", async () => { await using tmp = await createTempDir(); const fixture = await createRemoteFixture(tmp.path, { remoteName: "upstream" }); await fixture.addTrackedBranch("feat"); @@ -1022,27 +1019,42 @@ describe("BunGitAdapter", () => { const pushedSha = await pushCommit(clonePath, "feat.txt", "remote moved ahead"); await withCwd(fixture.repoPath, async () => { - const localGit = createBunGitAdapter(createNoopLogger()); - expectOk(await localGit.updateBranchRef("feat")); + expectOk(await upstreamGit.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 () => { + test("updateBranchRef with an explicit remote fetches from that remote, not the injected one", async () => { + await using tmp = await createTempDir(); + const fixture = await createRemoteFixture(tmp.path); + 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 () => { + // The injected remote is "upstream", which does not exist in this + // repo — the explicitly passed remote has to win over it. + expectOk(await upstreamGit.updateBranchRef("feat", "origin")); + 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 injected 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")); + expectOk(await upstreamGit.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 () => { + test("createWorktreeFromRemote without an explicit remote checks out via the injected remote", async () => { await using tmp = await createTempDir(); const fixture = await createRemoteFixture(tmp.path, { remoteName: "upstream" }); const clonePath = await fixture.cloneSecond(); @@ -1051,11 +1063,10 @@ describe("BunGitAdapter", () => { 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); + expectOk(await upstreamGit.fetchAll()); + expect(expectOk(await upstreamGit.branchExists("remote-only"))).toBe(false); - const worktree = expectOk(await localGit.createWorktreeFromRemote("remote-only", wtPath)); + const worktree = expectOk(await upstreamGit.createWorktreeFromRemote("remote-only", wtPath)); expect(worktree.branch).toBe("remote-only"); const upstream = await Bun.$`git -C ${wtPath} rev-parse --abbrev-ref ${"remote-only@{upstream}"}` @@ -1065,7 +1076,31 @@ describe("BunGitAdapter", () => { }); }); - test("multiple remotes: the one tracking the default branch wins over the disambiguation fallbacks", async () => { + test("repo with no remote configured: updateBranchRef fails with MERGE_FAILED, not silently", async () => { + await using tmp = await createTempDir(); + const repoPath = await initTestRepo(tmp.path); + + await withCwd(repoPath, async () => { + // No remote exists at all — the operation must surface a typed + // error rather than crashing or returning a misleading success. + const result = await git.updateBranchRef("main"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("MERGE_FAILED"); + } + }); + }); + }); + + // === Primary-remote resolution === + // + // Resolution runs once at composition time instead of inside the adapter, so + // it is exercised directly rather than through a port method. + + describe("resolvePrimaryRemote", () => { + const logger = createNoopLogger(); + + test("prefers the tracking remote of the default branch over other remotes", 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 @@ -1078,30 +1113,50 @@ describe("BunGitAdapter", () => { 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"); + expect(await resolvePrimaryRemote(logger)).toBe("upstream"); }); }); - test("repo with no remote configured: methods fall back to 'origin' and fail with MERGE_FAILED, not silently", async () => { + test("falls back to the current branch's tracking remote when the default branch has none", async () => { await using tmp = await createTempDir(); const repoPath = await initTestRepo(tmp.path); + await Bun.$`git -C ${repoPath} remote add aaa ${join(tmp.path, "aaa.git")}`.quiet(); + await Bun.$`git -C ${repoPath} remote add fork ${join(tmp.path, "fork.git")}`.quiet(); + await Bun.$`git -C ${repoPath} checkout -q -b feat`.quiet(); + await Bun.$`git -C ${repoPath} config branch.feat.remote fork`.quiet(); 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"); - } + expect(await resolvePrimaryRemote(logger)).toBe("fork"); + }); + }); + + test("a branch tracking the local repository ('.') does not count as a remote", async () => { + await using tmp = await createTempDir(); + const repoPath = await initTestRepo(tmp.path); + await Bun.$`git -C ${repoPath} remote add solo ${join(tmp.path, "solo.git")}`.quiet(); + await Bun.$`git -C ${repoPath} config branch.main.remote .`.quiet(); + + await withCwd(repoPath, async () => { + expect(await resolvePrimaryRemote(logger)).toBe("solo"); + }); + }); + + test("falls back to the sole configured remote when no branch tracks one", async () => { + await using tmp = await createTempDir(); + const repoPath = await initTestRepo(tmp.path); + await Bun.$`git -C ${repoPath} remote add solo ${join(tmp.path, "solo.git")}`.quiet(); + + await withCwd(repoPath, async () => { + expect(await resolvePrimaryRemote(logger)).toBe("solo"); + }); + }); + + test("falls back to 'origin' when the repository has no remote at all", async () => { + await using tmp = await createTempDir(); + const repoPath = await initTestRepo(tmp.path); + + await withCwd(repoPath, async () => { + expect(await resolvePrimaryRemote(logger)).toBe("origin"); }); }); }); diff --git a/src/infrastructure/adapters/bun-git-adapter.ts b/src/infrastructure/adapters/bun-git-adapter.ts index 6b0e266..b3a69f4 100644 --- a/src/infrastructure/adapters/bun-git-adapter.ts +++ b/src/infrastructure/adapters/bun-git-adapter.ts @@ -5,72 +5,70 @@ import type { GitError, GitPort } from "../../domain/ports/git-port.ts"; import type { LoggerPort } from "../../domain/ports/logger-port.ts"; import { Result } from "../../shared/result.ts"; -export function createBunGitAdapter(logger: LoggerPort): GitPort { - async function runGit(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> { - const command = `git ${args.join(" ")}`; - logger.debug("git", command); +async function execGit( + logger: LoggerPort, + args: string[], +): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const command = `git ${args.join(" ")}`; + logger.debug("git", command); - const proc = Bun.spawn(["git", ...args], { stdout: "pipe", stderr: "pipe" }); - const exitCode = await proc.exited; - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); + const proc = Bun.spawn(["git", ...args], { stdout: "pipe", stderr: "pipe" }); + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); - logger.debug("git", `-> exit ${exitCode}${stderr.trim() ? ` (${stderr.trim()})` : ""}`); + logger.debug("git", `-> exit ${exitCode}${stderr.trim() ? ` (${stderr.trim()})` : ""}`); - 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; + return { exitCode, stdout: stdout.trim(), stderr: stderr.trim() }; +} - async function getTrackingRemote(branch: string): Promise { - const { exitCode, stdout } = await runGit(["config", "--get", `branch.${branch}.remote`]); +/** + * Name of the repository's primary remote, read from git's own configuration so + * that a user's non-"origin" naming flows naturally without changing port + * signatures. Resolved once at composition time and injected into the adapter — + * keeping it out of the adapter means no per-instance cache to go stale. + */ +export async function resolvePrimaryRemote(logger: LoggerPort): Promise { + const getTrackingRemote = async (branch: string): Promise => { + const { exitCode, stdout } = await execGit(logger, ["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) return remote; + } - // 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 execGit(logger, ["symbolic-ref", "--quiet", "--short", "HEAD"]); + if (head.exitCode === 0 && head.stdout) { + const remote = await getTrackingRemote(head.stdout); + if (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 execGit(logger, ["remote"]); + if (remotes.exitCode === 0) { + const names = remotes.stdout.split("\n").filter(Boolean); + if (names.length === 1 && names[0]) return names[0]; + } - // 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. + return "origin"; +} - // 4. Final fallback to git's own historical default. - cachedRemoteName = "origin"; - return "origin"; - } +/** + * @param primaryRemote name of the repository's primary remote, used by every + * operation that does not receive an explicit remote (see `resolvePrimaryRemote`). + */ +export function createBunGitAdapter(logger: LoggerPort, primaryRemote: string): GitPort { + const runGit = (args: string[]) => execGit(logger, args); return { async isGitRepository(): Promise> { @@ -185,8 +183,7 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort { message: "Not inside a git repository", }); } - const remoteName = await resolveRemoteName(); - const prefix = `${remoteName}/`; + const prefix = `${primaryRemote}/`; const branches = stdout .split("\n") .filter(Boolean) @@ -215,8 +212,7 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort { async getDefaultBranch(): Promise> { try { - const remoteName = await resolveRemoteName(); - const remotePrefix = `refs/remotes/${remoteName}/`; + const remotePrefix = `refs/remotes/${primaryRemote}/`; const { exitCode, stdout } = await runGit(["symbolic-ref", `${remotePrefix}HEAD`]); if (exitCode === 0 && stdout) { const branch = stdout.replace(remotePrefix, ""); @@ -278,7 +274,7 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort { async createWorktreeFromRemote(branch: string, path: string, remote?: string): Promise> { try { - const remoteName = remote ?? (await resolveRemoteName()); + const remoteName = remote ?? primaryRemote; // git worktree add --track -b / const args = ["worktree", "add", "--track", "-b", branch, path, `${remoteName}/${branch}`]; @@ -537,7 +533,7 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort { async mergeFFOnly(worktreePath: string, branch: string, remote?: string): Promise> { try { - const remoteName = remote ?? (await resolveRemoteName()); + const remoteName = remote ?? primaryRemote; const { exitCode, stderr } = await runGit([ "-C", worktreePath, @@ -554,9 +550,9 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort { } }, - async updateBranchRef(branch: string): Promise> { + async updateBranchRef(branch: string, remote?: string): Promise> { try { - const remoteName = await resolveRemoteName(); + const remoteName = remote ?? primaryRemote; 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}` }); @@ -766,7 +762,7 @@ export function createBunGitAdapter(logger: LoggerPort): GitPort { async deleteRemoteBranch(branch: string, remote?: string): Promise> { try { - const remoteName = remote ?? (await resolveRemoteName()); + const remoteName = remote ?? primaryRemote; const { exitCode, stderr } = await runGit(["push", "--delete", remoteName, branch]); if (exitCode !== 0) { if (stderr?.includes("remote ref does not exist")) { diff --git a/src/infrastructure/container.ts b/src/infrastructure/container.ts index fd3c715..3416abc 100644 --- a/src/infrastructure/container.ts +++ b/src/infrastructure/container.ts @@ -4,7 +4,7 @@ import type { LoggerPort } from "../domain/ports/logger-port.ts"; import type { ShellPort } from "../domain/ports/shell-port.ts"; import type { UiPort } from "../domain/ports/ui-port.ts"; import { createBunFilesystemAdapter } from "./adapters/bun-filesystem-adapter.ts"; -import { createBunGitAdapter } from "./adapters/bun-git-adapter.ts"; +import { createBunGitAdapter, resolvePrimaryRemote } from "./adapters/bun-git-adapter.ts"; import { createBunShellAdapter } from "./adapters/bun-shell-adapter.ts"; import { createClackUiAdapter } from "./adapters/clack-ui-adapter.ts"; import { createConsoleLoggerAdapter } from "./adapters/console-logger-adapter.ts"; @@ -22,14 +22,18 @@ export interface ContainerOptions { nonInteractive?: boolean; } -export function createContainer(options: ContainerOptions = {}): Container { +export async function createContainer(options: ContainerOptions = {}): Promise { const verbose = options.verbose ?? false; const nonInteractive = options.nonInteractive ?? false; const logger = createConsoleLoggerAdapter(verbose); + // Resolved once, here, and injected: the git adapter stays free of mutable + // state, and every remote-aware operation agrees on the same primary remote. + const primaryRemote = await resolvePrimaryRemote(logger); + return { ui: createClackUiAdapter({ nonInteractive }), - git: createBunGitAdapter(logger), + git: createBunGitAdapter(logger, primaryRemote), fs: createBunFilesystemAdapter(logger), shell: createBunShellAdapter(logger), logger, diff --git a/src/test-utils/fake-git.ts b/src/test-utils/fake-git.ts index 5a2c4db..084d4cd 100644 --- a/src/test-utils/fake-git.ts +++ b/src/test-utils/fake-git.ts @@ -30,6 +30,7 @@ export interface FakeGitOptions { addRemoteCalls?: { name: string; url: string }[]; setRemoteUrlCalls?: { name: string; url: string }[]; mergeFFOnlyCalls?: { worktreePath: string; branch: string; remote: string }[]; + updateBranchRefCalls?: { branch: string; remote: string }[]; mergeBaseMap?: Map; commitCountMap?: Map; trackedPaths?: Set; @@ -261,7 +262,8 @@ export function createFakeGit(options: FakeGitOptions = {}): GitPort { return Result.ok(undefined); }, - async updateBranchRef(_branch: string): Promise> { + async updateBranchRef(branch: string, remote = "origin"): Promise> { + options.updateBranchRefCalls?.push({ branch, remote }); if (mergeFFOnlyFails) { return Result.err({ code: "MERGE_FAILED", message: "Cannot update ref" }); } diff --git a/src/test-utils/git-fixtures.test.ts b/src/test-utils/git-fixtures.test.ts index 618adb8..75a8481 100644 --- a/src/test-utils/git-fixtures.test.ts +++ b/src/test-utils/git-fixtures.test.ts @@ -8,7 +8,7 @@ import { createTempDir } from "./temp-dir.ts"; // Self-tests: prove the fixtures produce the git states the adapter // integration tests (and the fake-vs-adapter contract suite) depend on. describe("git fixtures", () => { - const git = createBunGitAdapter(createNoopLogger()); + const git = createBunGitAdapter(createNoopLogger(), "origin"); test("remote fixture supports the full gone-branch flow", async () => { await using tmp = await createTempDir(); diff --git a/src/test-utils/git-port-contract.test.ts b/src/test-utils/git-port-contract.test.ts index 6929c77..56df16d 100644 --- a/src/test-utils/git-port-contract.test.ts +++ b/src/test-utils/git-port-contract.test.ts @@ -49,7 +49,7 @@ async function openRealSession(): Promise { const originalCwd = process.cwd(); process.chdir(repo); return { - git: createBunGitAdapter(createNoopLogger()), + git: createBunGitAdapter(createNoopLogger(), "origin"), async [Symbol.asyncDispose]() { process.chdir(originalCwd); await tmp[Symbol.asyncDispose]();