Skip to content
Merged
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
1 change: 1 addition & 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,7 @@ export class WorkspaceService extends TypedEventEmitter<WorkspaceServiceEvents>
worktree = await worktreeManager.createWorktree({
baseBranch: defaultBranch,
onOutput,
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
33 changes: 30 additions & 3 deletions packages/git/src/sagas/worktree.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
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;
/** Base the worktree on `origin/<baseBranch>` after fetching; falls back to the local ref if the fetch fails. */
fetchBeforeCreate?: boolean;
}

export interface CreateWorktreeOutput {
Expand All @@ -26,13 +34,32 @@ 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 });
});

// Use `this.git` directly to avoid re-entering the 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 +71,7 @@ export class CreateWorktreeSaga extends GitSaga<
"-b",
branchName,
worktreePath,
base,
baseRef,
]),
rollback: async () => {
try {
Expand Down
132 changes: 132 additions & 0 deletions packages/git/src/worktree.test.ts
Original file line number Diff line number Diff line change
@@ -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<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.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);
});
});
50 changes: 48 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,8 @@ export class WorktreeManager {
async createWorktree(options?: {
baseBranch?: string;
onOutput?: (data: string) => void;
/** Base the worktree on `origin/<baseBranch>` after fetching; falls back to the local ref if the fetch fails. */
fetchBeforeCreate?: boolean;
}): Promise<WorktreeInfo> {
const manager = getGitOperationManager();

Expand Down Expand Up @@ -155,9 +159,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 +323,44 @@ export class WorktreeManager {
};
}

/**
* Returns `origin/<baseBranch>` after fetching, or the local branch name as a fallback.
* Bases off `origin/<branch>` rather than fast-forwarding so local refs stay untouched.
*/
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