diff --git a/apps/code/src/main/services/workspace/service.ts b/apps/code/src/main/services/workspace/service.ts index 851b627d4..439f8aabb 100644 --- a/apps/code/src/main/services/workspace/service.ts +++ b/apps/code/src/main/services/workspace/service.ts @@ -590,6 +590,10 @@ export class WorkspaceService extends TypedEventEmitter worktree = await worktreeManager.createWorktree({ baseBranch: defaultBranch, onOutput, + // Fetch the remote tip first so the worktree isn't started from a + // stale local trunk — common when the harness provisions a clone + // whose local default branch hasn't been refreshed. + fetchBeforeCreate: true, }); log.info( `Created detached worktree from trunk: ${worktree.worktreeName} at ${worktree.worktreePath}`, diff --git a/packages/git/src/queries.ts b/packages/git/src/queries.ts index 3e067024f..bc3a60488 100644 --- a/packages/git/src/queries.ts +++ b/packages/git/src/queries.ts @@ -1023,6 +1023,28 @@ export async function fetch( ); } +export async function hasRef(git: GitLike, ref: string): Promise { + try { + await git.revparse(["--verify", "--quiet", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} + +export async function fetchRef( + git: GitLike, + remote: string, + ref: string, +): Promise { + try { + await git.raw(["fetch", "--quiet", "--no-tags", remote, ref]); + return true; + } catch { + return false; + } +} + export async function listFiles( baseDir: string, options?: CreateGitClientOptions, diff --git a/packages/git/src/sagas/worktree.ts b/packages/git/src/sagas/worktree.ts index 9f9e1fea8..50d77d025 100644 --- a/packages/git/src/sagas/worktree.ts +++ b/packages/git/src/sagas/worktree.ts @@ -1,7 +1,13 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { GitSaga, type GitSagaInput } from "../git-saga"; -import { addToLocalExclude, branchExists, getDefaultBranch } from "../queries"; +import { + addToLocalExclude, + branchExists, + fetchRef, + getDefaultBranch, + hasRef, +} from "../queries"; import { forceRemove, safeSymlink } from "../utils"; import { processWorktreeInclude, runPostCheckoutHook } from "../worktree"; @@ -9,6 +15,12 @@ export interface CreateWorktreeInput extends GitSagaInput { worktreePath: string; branchName: string; baseBranch?: string; + /** + * When true, fetch `origin/` before creating the worktree and + * base the new branch on the remote tip when reachable. Falls back to the + * local base ref if the fetch fails. + */ + fetchBeforeCreate?: boolean; } export interface CreateWorktreeOutput { @@ -26,13 +38,35 @@ export class CreateWorktreeSaga extends GitSaga< protected async executeGitOperations( input: CreateWorktreeInput, ): Promise { - const { baseDir, worktreePath, branchName, baseBranch, signal } = input; + const { + baseDir, + worktreePath, + branchName, + baseBranch, + fetchBeforeCreate, + signal, + } = input; const base = await this.readOnlyStep("get-base-branch", async () => { if (baseBranch) return baseBranch; return getDefaultBranch(baseDir, { abortSignal: signal }); }); + // Best-effort fetch the remote tip before creating the worktree so the + // new branch starts from `origin/` rather than a stale local ref. + // Uses `this.git` directly (rather than the `fetch` query helper) to + // avoid re-entering the per-repo write lock the saga already holds. + const baseRef = fetchBeforeCreate + ? await this.readOnlyStep("resolve-fresh-base-ref", async () => { + const remote = "origin"; + const remoteRef = `${remote}/${base}`; + const fetched = await fetchRef(this.git, remote, base); + if (!fetched) return base; + const exists = await hasRef(this.git, remoteRef); + return exists ? remoteRef : base; + }) + : base; + await this.step({ name: "create-worktree", execute: () => @@ -44,7 +78,7 @@ export class CreateWorktreeSaga extends GitSaga< "-b", branchName, worktreePath, - base, + baseRef, ]), rollback: async () => { try { diff --git a/packages/git/src/worktree.test.ts b/packages/git/src/worktree.test.ts new file mode 100644 index 000000000..49bd88a44 --- /dev/null +++ b/packages/git/src/worktree.test.ts @@ -0,0 +1,132 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createGitClient } from "./client"; +import { WorktreeManager } from "./worktree"; + +async function initBareRemote(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "posthog-code-remote-")); + const git = createGitClient(dir); + await git.init(["--bare", "--initial-branch", "main"]); + return dir; +} + +async function initLocalClone(remoteDir: string): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "posthog-code-local-")); + const git = createGitClient(dir); + await git.clone(remoteDir, dir); + await git.addConfig("user.name", "Test"); + await git.addConfig("user.email", "test@example.com"); + await git.addConfig("commit.gpgsign", "false"); + return dir; +} + +async function commit(repoDir: string, file: string, content: string) { + await writeFile(path.join(repoDir, file), content); + const git = createGitClient(repoDir); + await git.add([file]); + await git.commit(`add ${file}`); +} + +async function shaOfBranch(repoDir: string, ref: string): Promise { + const git = createGitClient(repoDir); + return (await git.revparse([ref])).trim(); +} + +describe("WorktreeManager.createWorktree fetchBeforeCreate", () => { + let remoteDir: string; + let localDir: string; + let worktreeBaseDir: string; + + beforeEach(async () => { + remoteDir = await initBareRemote(); + + // Seed the remote with an initial commit on `main` so other clones can + // fetch a real tip. + const seedDir = await mkdtemp(path.join(tmpdir(), "posthog-code-seed-")); + const seedGit = createGitClient(seedDir); + await seedGit.init(["--initial-branch", "main"]); + await seedGit.addConfig("user.name", "Test"); + await seedGit.addConfig("user.email", "test@example.com"); + await seedGit.addConfig("commit.gpgsign", "false"); + await commit(seedDir, "initial.txt", "initial\n"); + await seedGit.addRemote("origin", remoteDir); + await seedGit.push(["origin", "main"]); + await rm(seedDir, { recursive: true, force: true }); + + localDir = await initLocalClone(remoteDir); + worktreeBaseDir = await mkdtemp(path.join(tmpdir(), "posthog-code-wts-")); + }); + + afterEach(async () => { + for (const d of [remoteDir, localDir, worktreeBaseDir]) { + await rm(d, { recursive: true, force: true }); + } + }); + + it.each([ + { + name: "without fetchBeforeCreate, worktree is based on the stale local ref", + fetchBeforeCreate: false, + expectRemoteTip: false, + }, + { + name: "with fetchBeforeCreate, worktree starts at the remote tip", + fetchBeforeCreate: true, + expectRemoteTip: true, + }, + ])("$name", async ({ fetchBeforeCreate, expectRemoteTip }) => { + // Advance the remote: push a new commit from a separate clone. + const otherDir = await initLocalClone(remoteDir); + await commit(otherDir, "remote-new.txt", "remote-new\n"); + const otherGit = createGitClient(otherDir); + await otherGit.push(["origin", "main"]); + const remoteTip = await shaOfBranch(otherDir, "main"); + await rm(otherDir, { recursive: true, force: true }); + + const localTipBefore = await shaOfBranch(localDir, "main"); + expect(localTipBefore).not.toBe(remoteTip); + + const manager = new WorktreeManager({ + mainRepoPath: localDir, + worktreeBasePath: worktreeBaseDir, + }); + const info = await manager.createWorktree({ + baseBranch: "main", + fetchBeforeCreate, + }); + + const worktreeHead = await shaOfBranch(info.worktreePath, "HEAD"); + if (expectRemoteTip) { + expect(worktreeHead).toBe(remoteTip); + } else { + expect(worktreeHead).toBe(localTipBefore); + expect(worktreeHead).not.toBe(remoteTip); + } + + // Local `main` should never be mutated — only `origin/main` advances on fetch. + const localMainAfter = await shaOfBranch(localDir, "main"); + expect(localMainAfter).toBe(localTipBefore); + }); + + it("with fetchBeforeCreate and an unreachable remote, falls back to local base", async () => { + // Point origin at a directory that doesn't exist so the fetch fails. + const git = createGitClient(localDir); + await git.remote(["set-url", "origin", "/nonexistent/path/to/remote"]); + + const localTipBefore = await shaOfBranch(localDir, "main"); + + const manager = new WorktreeManager({ + mainRepoPath: localDir, + worktreeBasePath: worktreeBaseDir, + }); + const info = await manager.createWorktree({ + baseBranch: "main", + fetchBeforeCreate: true, + }); + + const worktreeHead = await shaOfBranch(info.worktreePath, "HEAD"); + expect(worktreeHead).toBe(localTipBefore); + }); +}); diff --git a/packages/git/src/worktree.ts b/packages/git/src/worktree.ts index 5fe1e7cd9..bd249d6bb 100644 --- a/packages/git/src/worktree.ts +++ b/packages/git/src/worktree.ts @@ -6,8 +6,10 @@ import { getCleanEnv, getGitOperationManager } from "./operation-manager"; import { addToLocalExclude, branchExists, + fetchRef, getDefaultBranch, getHeadSha, + hasRef, listWorktrees as listWorktreesRaw, } from "./queries"; import { clonePath, forceRemove, safeSymlink } from "./utils"; @@ -125,6 +127,16 @@ export class WorktreeManager { async createWorktree(options?: { baseBranch?: string; onOutput?: (data: string) => void; + /** + * When true, fetch `origin/` before creating the worktree and + * base the new worktree on the remote tip when reachable. This avoids + * spawning a worktree off a stale local ref (e.g. when the cloud harness + * provisions a clone whose local trunk has fallen behind `origin`). + * + * Falls back to the local base ref if the fetch fails (offline / no remote + * configured / network blip), so existing local-only setups still work. + */ + fetchBeforeCreate?: boolean; }): Promise { const manager = getGitOperationManager(); @@ -155,9 +167,13 @@ export class WorktreeManager { ? worktreePath : `./${WORKTREE_FOLDER_NAME}/${worktreeName}/${this.repoName}`; - options?.onOutput?.(`Creating worktree from ${baseBranch}...\n`); + const baseRef = options?.fetchBeforeCreate + ? await this.resolveFreshBaseRef(baseBranch, options?.onOutput) + : baseBranch; + + options?.onOutput?.(`Creating worktree from ${baseRef}...\n`); const output = await manager.executeWrite(this.mainRepoPath, async () => { - return this.spawnWorktreeAdd(["--detach", targetPath, baseBranch], { + return this.spawnWorktreeAdd(["--detach", targetPath, baseRef], { onOutput: options?.onOutput, }); }); @@ -315,6 +331,52 @@ export class WorktreeManager { }; } + /** + * Best-effort resolve a fresh base ref for a new worktree. + * + * Tries to `git fetch origin ` and return `origin/` + * if the remote tip is reachable. Falls back to the local branch name when + * the fetch fails or the remote ref isn't present afterwards (offline, + * no `origin` remote, ref doesn't exist on the remote, etc.). + * + * We base off `origin/` rather than fast-forwarding the local + * branch so the user's local checkout is untouched — the worktree gets + * the current remote tip without mutating any local refs. + */ + private async resolveFreshBaseRef( + baseBranch: string, + onOutput?: (data: string) => void, + ): Promise { + const manager = getGitOperationManager(); + const remote = "origin"; + const remoteRef = `${remote}/${baseBranch}`; + + onOutput?.(`Fetching ${remoteRef}...\n`); + const fetched = await manager.executeWrite(this.mainRepoPath, (git) => + fetchRef(git, remote, baseBranch), + ); + + if (!fetched) { + onOutput?.( + `Fetch failed for ${remoteRef}, falling back to local ${baseBranch}.\n`, + ); + return baseBranch; + } + + const remoteRefExists = await manager.executeRead( + this.mainRepoPath, + (git) => hasRef(git, remoteRef), + ); + if (!remoteRefExists) { + onOutput?.( + `Remote ref ${remoteRef} not found after fetch, falling back to local ${baseBranch}.\n`, + ); + return baseBranch; + } + + return remoteRef; + } + private spawnWorktreeAdd( args: string[], options?: { onOutput?: (data: string) => void },