mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-05 16:04:43 +00:00
Start new workspaces off origin/main at the remote tip (#5788)
* fix(server): start new worktrees at the remote tip of an origin/* base Branching off a remote-tracking base such as origin/main used whatever the repository had cached. Nothing on the branch-off path fetched, and the background fetch only runs while a workspace of that repository is observed, so a new workspace could start hours or weeks behind the remote. Fetch just the base branch from its remote before git worktree add. The refspec is passed on the command line so remote.<name>.fetch is untouched, and a failed fetch falls back to the cached ref so creation still works offline. Co-authored-by: odoo-mvds <mv@droggol.be> * fix(server): fall back to the cached base before the create request times out A remote that accepts the connection and never answers held the base-ref fetch for its 120s cap, past the 60s client request timeout, so paseo worktree create failed and the worktree appeared later. Cap the fetch at 15s so creation falls back to the cached ref in time. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: odoo-mvds <mv@droggol.be>
This commit is contained in:
co-authored by
odoo-mvds
parent
1f5f5d384e
commit
a7c9c05bef
@@ -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<void> }> {
|
||||
const connections = new Set<Socket>();
|
||||
const server = createServer((socket) => connections.add(socket));
|
||||
await new Promise<void>((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<void>((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 <remote>/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", () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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.<name>.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<void> {
|
||||
if (await localBranchExists(cwd, branchName)) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user