diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 4769bed7d0..479d4cfeab 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -20,6 +20,7 @@ import { writeFileSync, } from "fs"; import { join } from "path"; +import { createServer, type AddressInfo, type Socket } from "net"; import { tmpdir } from "os"; import { createRealpathAwarePathMatcher } from "./path"; @@ -52,6 +53,21 @@ function createLegacyWorktreeForTest( }); } +// A remote that accepts connections and never answers, like a VPN-only host while off the VPN. +async function startSilentRemote(): Promise<{ url: string; close: () => Promise }> { + const connections = new Set(); + const server = createServer((socket) => connections.add(socket)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/repo.git`, + close: () => { + for (const socket of connections) socket.destroy(); + return new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + describe("paseo worktree manager", () => { let tempDir: string; let repoDir: string; @@ -263,6 +279,103 @@ describe("paseo worktree manager", () => { expect(existsSync(created.worktreePath)).toBe(false); }); + + describe("branch-off from a remote-tracking base", () => { + function git(args: string[], cwd: string): string { + return execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd, + encoding: "utf8", + }).trim(); + } + + // The repo caches /main at "initial" while the remote itself moves one commit ahead, + // as when nothing has fetched since someone else pushed. + function pushPastCachedRemoteRef(remoteName: string): string { + const remoteDir = join(tempDir, "remote.git"); + git(["clone", "--bare", "-q", repoDir, remoteDir], tempDir); + git(["remote", "add", remoteName, remoteDir], repoDir); + git(["fetch", "-q", remoteName], repoDir); + + const pusherDir = join(tempDir, "pusher"); + git(["clone", "-q", remoteDir, pusherDir], tempDir); + git(["config", "user.email", "test@test.com"], pusherDir); + git(["config", "user.name", "Test"], pusherDir); + writeFileSync(join(pusherDir, "file.txt"), "pushed after the last fetch\n"); + git(["commit", "-q", "-am", "remote ahead"], pusherDir); + git(["push", "-q", "origin", "HEAD:main"], pusherDir); + return git(["rev-parse", "HEAD"], pusherDir); + } + + it("starts the new branch at the remote tip, not the cached origin/main", async () => { + const remoteTip = pushPastCachedRemoteRef("origin"); + const fetchRefspecs = git(["config", "--get-all", "remote.origin.fetch"], repoDir); + + const created = await createLegacyWorktreeForTest({ + branchName: "from-remote-tip", + cwd: repoDir, + baseBranch: "origin/main", + worktreeSlug: "from-remote-tip", + paseoHome, + }); + + expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(remoteTip); + expect(git(["config", "--get-all", "remote.origin.fetch"], repoDir)).toBe(fetchRefspecs); + }); + + it("refreshes a base on a remote other than origin", async () => { + const remoteTip = pushPastCachedRemoteRef("team/upstream"); + + const created = await createLegacyWorktreeForTest({ + branchName: "from-upstream-tip", + cwd: repoDir, + baseBranch: "refs/remotes/team/upstream/main", + worktreeSlug: "from-upstream-tip", + paseoHome, + }); + + expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(remoteTip); + }); + + it("branches from the cached ref when the remote is unreachable", async () => { + pushPastCachedRemoteRef("origin"); + const cachedTip = git(["rev-parse", "refs/remotes/origin/main"], repoDir); + git(["remote", "set-url", "origin", join(tempDir, "missing.git")], repoDir); + + const created = await createLegacyWorktreeForTest({ + branchName: "from-cached-ref", + cwd: repoDir, + baseBranch: "origin/main", + worktreeSlug: "from-cached-ref", + paseoHome, + }); + + expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(cachedTip); + }); + + it("branches from the cached ref when the remote accepts but never answers", async () => { + pushPastCachedRemoteRef("origin"); + const cachedTip = git(["rev-parse", "refs/remotes/origin/main"], repoDir); + const silentRemote = await startSilentRemote(); + git(["remote", "set-url", "origin", silentRemote.url], repoDir); + + try { + const startedAt = Date.now(); + const created = await createLegacyWorktreeForTest({ + branchName: "from-silent-remote", + cwd: repoDir, + baseBranch: "origin/main", + worktreeSlug: "from-silent-remote", + paseoHome, + }); + + // Clients give up on a create request after 60s, so the fallback has to land well before. + expect(Date.now() - startedAt).toBeLessThan(30_000); + expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(cachedTip); + } finally { + await silentRemote.close(); + } + }, 45_000); + }); }); describe("slugify", () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 0fd83e8dc7..f7f28205e0 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1368,6 +1368,7 @@ async function resolveBranchOffWorktreeSourcePlan( const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, source.baseBranch); const branchExists = await localBranchExists(cwd, branchName); const base = branchExists ? branchName : resolvedBaseBranch; + await refreshRemoteTrackingBaseRef(cwd, base); const candidateBranch = branchExists ? desiredSlug : branchName; const newBranchName = await resolveUniqueLocalBranchName(cwd, candidateBranch); @@ -1702,6 +1703,41 @@ async function resolveBaseBranchForWorktree( throw new Error(`Base branch not found: ${normalized}`); } +// Clients give up on a create request after 60s. A remote that accepts the connection and never +// answers (a VPN-only host while off the VPN) must fall back to the cached ref well before that. +const REMOTE_BASE_REFRESH_TIMEOUT_MS = 15_000; + +// A remote-tracking base is only as fresh as the last fetch, and the background fetch only runs +// while a workspace of the repository is observed. Refresh the one base branch so the new branch +// starts at the remote tip; when the fetch fails (offline, auth) the cached ref is still usable. +async function refreshRemoteTrackingBaseRef(cwd: string, baseRef: string): Promise { + if (!baseRef.startsWith("refs/remotes/")) { + return; + } + try { + // Remote names may contain slashes, so the owning remote is looked up rather than parsed. + const { stdout } = await runGitCommand(["remote"], { cwd }); + const remoteName = stdout + .split("\n") + .map((name) => name.trim()) + .find((name) => name.length > 0 && baseRef.startsWith(`refs/remotes/${name}/`)); + if (!remoteName) { + return; + } + const remoteBranch = baseRef.slice(`refs/remotes/${remoteName}/`.length); + // No refspec is added to remote..fetch: one left behind breaks later fetches once the + // branch is deleted on the remote. + await runGitCommand(["fetch", remoteName, `+refs/heads/${remoteBranch}:${baseRef}`], { + cwd, + envOverlay: { GIT_TERMINAL_PROMPT: "0" }, + timeout: REMOTE_BASE_REFRESH_TIMEOUT_MS, + acceptExitCodes: [0, 1, 128], + }); + } catch { + // The fetch timed out; branch from the cached ref. + } +} + async function ensureLocalBranch(cwd: string, branchName: string): Promise { if (await localBranchExists(cwd, branchName)) { return;