Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/code/src/main/services/workspace/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,10 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
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}`,
Expand Down
22 changes: 22 additions & 0 deletions packages/git/src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,28 @@ export async function fetch(
);
}

export async function hasRef(git: GitLike, ref: string): Promise<boolean> {
try {
await git.revparse(["--verify", "--quiet", `${ref}^{commit}`]);
return true;
} catch {
return false;
}
}

export async function fetchRef(
git: GitLike,
remote: string,
ref: string,
): Promise<boolean> {
try {
await git.raw(["fetch", "--quiet", "--no-tags", remote, ref]);
return true;
} catch {
return false;
}
}

export async function listFiles(
baseDir: string,
options?: CreateGitClientOptions,
Expand Down
40 changes: 37 additions & 3 deletions packages/git/src/sagas/worktree.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
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";

export interface CreateWorktreeInput extends GitSagaInput {
worktreePath: string;
branchName: string;
baseBranch?: string;
/**
* When true, fetch `origin/<baseBranch>` 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 {
Expand All @@ -26,13 +38,35 @@ export class CreateWorktreeSaga extends GitSaga<
protected async executeGitOperations(
input: CreateWorktreeInput,
): Promise<CreateWorktreeOutput> {
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/<base>` 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: () =>
Expand All @@ -44,7 +78,7 @@ export class CreateWorktreeSaga extends GitSaga<
"-b",
branchName,
worktreePath,
base,
baseRef,
]),
rollback: async () => {
try {
Expand Down
138 changes: 138 additions & 0 deletions packages/git/src/worktree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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<string> {
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<string> {
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<string> {
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("without fetchBeforeCreate, worktree is based on the stale local ref", async () => {
// 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" });

const worktreeHead = await shaOfBranch(info.worktreePath, "HEAD");
expect(worktreeHead).toBe(localTipBefore);
expect(worktreeHead).not.toBe(remoteTip);
});

it("with fetchBeforeCreate, worktree starts at the remote tip", async () => {
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: true,
});

const worktreeHead = await shaOfBranch(info.worktreePath, "HEAD");
expect(worktreeHead).toBe(remoteTip);

// Local `main` should NOT be mutated — only `origin/main` advances.
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);
});
});
66 changes: 64 additions & 2 deletions packages/git/src/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -125,6 +127,16 @@ export class WorktreeManager {
async createWorktree(options?: {
baseBranch?: string;
onOutput?: (data: string) => void;
/**
* When true, fetch `origin/<baseBranch>` 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<WorktreeInfo> {
const manager = getGitOperationManager();

Expand Down Expand Up @@ -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,
});
});
Expand Down Expand Up @@ -315,6 +331,52 @@ export class WorktreeManager {
};
}

/**
* Best-effort resolve a fresh base ref for a new worktree.
*
* Tries to `git fetch origin <baseBranch>` and return `origin/<baseBranch>`
* 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/<branch>` 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<string> {
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 },
Expand Down
Loading