Show committed changes for existing-branch worktrees (#6462)

* fix: resolve real base branch for worktrees checked out from an existing branch

When a worktree is created by checking out an already-existing branch,
the stored baseRefName was set to the branch's own name instead of the
repository's default branch. The diff panel then diffs the branch
against itself, always showing no changes.

Fixes #1873

* fix: fall back to the current branch when no default branch is found

Addresses Greptile review feedback on #1874: resolveRepositoryDefaultBranch
returns null for local-only repos with no origin/HEAD and no local
main/master (e.g. a repo whose trunk is "develop"), which previously
fell back to storing the checked-out branch as its own base — the
exact self-diff bug this PR fixes. Fall back to whichever branch is
currently checked out in the main repo instead.

* fix(server): preserve existing-branch diffs through workspace restore

Carry the resolved comparison base from the worktree creator into the workspace record. Resolve it for occupied branches too, so a fresh suffixed checkout still displays the feature's committed changes.

Co-authored-by: 2takumax-chura <takuma.okayama@churadata.okinawa>

* fix(server): skip deleted default refs when creating worktrees

---------

Co-authored-by: 2takumax-chura <takuma.okayama@churadata.okinawa>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-10 15:18:05 +02:00
committed by GitHub
co-authored by 2takumax-chura
parent e81d5d02b1
commit bd78e5efa4
6 changed files with 156 additions and 13 deletions
@@ -2486,7 +2486,7 @@ describe("create_agent MCP tool", () => {
});
expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({
version: 1,
baseRefName: "existing-feature",
baseRefName: "main",
});
expect(generateCalls).toBe(1);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
@@ -791,13 +791,14 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
createDeps(),
);
// baseRefName must be the repo's default branch, not the checked-out branch
// itself — otherwise the diff panel compares the branch against itself and
// always shows no changes.
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 1,
baseRefName: "dev",
baseRefName: "main",
});
// A checkout-branch worktree has no distinct base, so the workspace records a
// null baseBranch even though worktree.json's baseRefName is the branch itself.
expect(created.workspace.baseBranch).toBe(null);
expect(created.workspace.baseBranch).toBe("main");
await expect(
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
@@ -64,6 +64,48 @@ test("workspace.create worktree source forwards action=checkout + refName into t
}
}, 180000);
test("existing branch changes remain visible after archive and restore", async () => {
const daemon = await createTestPaseoDaemon();
const { repoDir, tempRoot } = createGitRepoWithBranch();
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.11.2",
});
onTestFinished(async () => {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(tempRoot, { recursive: true, force: true });
});
runGit(repoDir, "switch", "feature/existing-branch");
commitFile(repoDir, "feature.txt", "feature change\n");
runGit(repoDir, "switch", "main");
await client.connect();
const created = await client.createWorkspace({
source: {
kind: "worktree",
cwd: repoDir,
action: "checkout",
refName: "feature/existing-branch",
},
});
expect(created.error).toBeNull();
const workspace = created.workspace!;
const cwd = workspace.workspaceDirectory;
const head = runGit(cwd, "rev-parse", "HEAD");
const before = await client.getCheckoutDiff(cwd, { mode: "base" });
expect(before.error).toBeNull();
expect(before.files.map((file) => file.path)).toEqual(["feature.txt"]);
expect((await client.getCheckoutStatus(cwd)).baseRef).toBe("main");
expect((await client.archiveWorkspace(workspace.id)).error).toBeNull();
expect(existsSync(cwd)).toBe(false);
await client.restoreWorkspace(workspace.id);
expect(runGit(cwd, "rev-parse", "HEAD")).toBe(head);
expect((await client.getCheckoutStatus(cwd)).baseRef).toBe("main");
const after = await client.getCheckoutDiff(cwd, { mode: "base" });
expect(after.error).toBeNull();
expect(after.files.map((file) => file.path)).toEqual(["feature.txt"]);
}, 180000);
test("workspace.create keeps a branch-off name separate from its worktree slug", async () => {
const daemon = await createTestPaseoDaemon();
const { repoDir, tempRoot } = createGitRepoWithBranch();
+5 -1
View File
@@ -1560,11 +1560,15 @@ export async function resolveRepositoryDefaultBranch(
);
return localName;
} catch {
await getRunGitCommand(context)(["show-ref", "--verify", "--quiet", ref], {
cwd: repoRoot,
envOverlay: READ_ONLY_GIT_ENV,
});
return remoteShort;
}
}
} catch {
// ignore
// A stale origin/HEAD must fall through to the available local branches.
}
const { stdout } = await getRunGitCommand(context)(["branch", "--format=%(refname:short)"], {
@@ -265,6 +265,38 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
});
});
it.each([false, true])(
"shows committed changes for an existing branch (already checked out: %s)",
async (alreadyCheckedOut) => {
execFileSync("git", ["checkout", "-b", "feature/existing"], { cwd: repoDir });
writeFileSync(join(repoDir, "file.txt"), "feature change\n");
execFileSync("git", ["add", "file.txt"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], {
cwd: repoDir,
});
if (!alreadyCheckedOut) {
execFileSync("git", ["checkout", "main"], { cwd: repoDir });
}
const result = await createWorktreePrimitive({
cwd: repoDir,
worktreeSlug: "existing-feature",
source: { kind: "checkout-branch", branchName: "feature/existing" },
runSetup: false,
paseoHome,
});
const status = await getCheckoutStatus(result.worktreePath, { paseoHome });
const diff = await getCheckoutDiff(result.worktreePath, { mode: "base" }, { paseoHome });
expect(diff.diff).toContain("+feature change");
expect(status).toMatchObject({
isGit: true,
baseRef: "main",
aheadBehind: { ahead: 1, behind: 0 },
});
expect(result.comparisonBaseRef).toBe("main");
},
);
it("checks out an existing local branch that is not checked out elsewhere", async () => {
execFileSync("git", ["branch", "dev"], { cwd: repoDir });
@@ -284,15 +316,65 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
.trim();
expect(currentBranch).toBe("dev");
// baseRefName must be the repo's default branch, not the checked-out branch
// itself — otherwise the diff panel compares the branch against itself and
// always shows no changes.
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({
version: 1,
baseRefName: "dev",
baseRefName: "main",
changeRequestLookupTarget: { headRef: "dev", localBranchName: "dev" },
});
});
it("shows committed changes when origin HEAD points to a deleted default branch", async () => {
execFileSync("git", ["branch", "-m", "main", "master"], { cwd: repoDir });
execFileSync(
"git",
["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"],
{ cwd: repoDir },
);
execFileSync("git", ["checkout", "-b", "feature/stale-default"], { cwd: repoDir });
writeFileSync(join(repoDir, "file.txt"), "feature change\n");
execFileSync("git", ["add", "file.txt"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], {
cwd: repoDir,
});
execFileSync("git", ["checkout", "master"], { cwd: repoDir });
const created = await createWorktreePrimitive({
cwd: repoDir,
worktreeSlug: "stale-default",
source: { kind: "checkout-branch", branchName: "feature/stale-default" },
runSetup: false,
paseoHome,
});
const diff = await getCheckoutDiff(created.worktreePath, { mode: "base" }, { paseoHome });
expect(diff.diff).toContain("+feature change");
expect(created.comparisonBaseRef).toBe("master");
});
it("falls back to the currently checked out branch when no default branch can be resolved", async () => {
// No origin remote and no local main/master — resolveRepositoryDefaultBranch
// can't find a default branch, so the fix must fall back to whatever is
// currently checked out in the main repo (here: "develop").
execFileSync("git", ["branch", "-m", "main", "develop"], { cwd: repoDir });
execFileSync("git", ["branch", "feature-x"], { cwd: repoDir });
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "feature-x-worktree",
source: { kind: "checkout-branch", branchName: "feature-x" },
runSetup: true,
paseoHome,
});
expect(existsSync(result.worktreePath)).toBe(true);
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" });
});
it("checks out an existing local branch whose name contains uppercase letters and dots", async () => {
execFileSync("git", ["branch", "release/1.1.15"], { cwd: repoDir });
+20 -6
View File
@@ -39,6 +39,7 @@ import {
writePaseoWorktreeRuntimeMetadata,
} from "./worktree-metadata.js";
import { runGitCommand } from "./run-git-command.js";
import { getCurrentBranch, resolveRepositoryDefaultBranch } from "./checkout-git.js";
import { spawnProcess } from "./spawn.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
import { createExternalProcessEnv } from "../server/paseo-env.js";
@@ -1279,10 +1280,7 @@ export const createWorktree = async ({
return {
branchName: sourcePlan.branchName,
worktreePath,
comparisonBaseRef:
source.kind === "checkout-branch"
? null
: (sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName),
comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName,
};
};
@@ -1399,11 +1397,12 @@ async function resolveWorktreeSourcePlan({
case "checkout-branch": {
await validateGitBranchName(cwd, source.branchName);
await ensureLocalBranch(cwd, source.branchName);
const baseRef = await resolveCheckoutBranchBaseRef(cwd, source.branchName);
if (await isBranchCheckedOut(cwd, source.branchName)) {
const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName);
return {
branchName,
metadataBaseRefName: source.branchName,
metadataBaseRefName: baseRef,
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
headRef: branchName,
localBranchName: branchName,
@@ -1414,7 +1413,7 @@ async function resolveWorktreeSourcePlan({
return {
branchName: source.branchName,
metadataBaseRefName: source.branchName,
metadataBaseRefName: baseRef,
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
headRef: source.branchName,
localBranchName: source.branchName,
@@ -1669,6 +1668,21 @@ function normalizeRequiredBaseBranch(baseBranch: string): string {
return normalizedBaseBranch;
}
// The checked-out branch itself isn't a base to diff against — resolve the
// repository's default branch so the diff panel has a real comparison target.
// If the default branch can't be determined (no origin/HEAD, no local
// main/master — e.g. a local-only repo based on "develop"), fall back to
// whichever branch is currently checked out in the main repo, since that's
// almost always the branch this one was intended to be compared against.
async function resolveCheckoutBranchBaseRef(cwd: string, branchName: string): Promise<string> {
const defaultBranch = await resolveRepositoryDefaultBranch(cwd);
if (defaultBranch) {
return defaultBranch;
}
const currentBranch = await getCurrentBranch(cwd);
return currentBranch && currentBranch !== branchName ? currentBranch : branchName;
}
async function resolveBaseBranchForWorktree(
cwd: string,
requestedBaseBranch: string,