mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
co-authored by
2takumax-chura
parent
e81d5d02b1
commit
bd78e5efa4
@@ -2486,7 +2486,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
});
|
});
|
||||||
expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({
|
expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({
|
||||||
version: 1,
|
version: 1,
|
||||||
baseRefName: "existing-feature",
|
baseRefName: "main",
|
||||||
});
|
});
|
||||||
expect(generateCalls).toBe(1);
|
expect(generateCalls).toBe(1);
|
||||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -791,13 +791,14 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
|
|||||||
createDeps(),
|
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({
|
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||||
version: 1,
|
version: 1,
|
||||||
baseRefName: "dev",
|
baseRefName: "main",
|
||||||
});
|
});
|
||||||
// A checkout-branch worktree has no distinct base, so the workspace records a
|
expect(created.workspace.baseBranch).toBe("main");
|
||||||
// null baseBranch even though worktree.json's baseRefName is the branch itself.
|
|
||||||
expect(created.workspace.baseBranch).toBe(null);
|
|
||||||
await expect(
|
await expect(
|
||||||
attemptFirstAgentBranchAutoName({
|
attemptFirstAgentBranchAutoName({
|
||||||
cwd: created.worktree.worktreePath,
|
cwd: created.worktree.worktreePath,
|
||||||
|
|||||||
@@ -64,6 +64,48 @@ test("workspace.create worktree source forwards action=checkout + refName into t
|
|||||||
}
|
}
|
||||||
}, 180000);
|
}, 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 () => {
|
test("workspace.create keeps a branch-off name separate from its worktree slug", async () => {
|
||||||
const daemon = await createTestPaseoDaemon();
|
const daemon = await createTestPaseoDaemon();
|
||||||
const { repoDir, tempRoot } = createGitRepoWithBranch();
|
const { repoDir, tempRoot } = createGitRepoWithBranch();
|
||||||
|
|||||||
@@ -1560,11 +1560,15 @@ export async function resolveRepositoryDefaultBranch(
|
|||||||
);
|
);
|
||||||
return localName;
|
return localName;
|
||||||
} catch {
|
} catch {
|
||||||
|
await getRunGitCommand(context)(["show-ref", "--verify", "--quiet", ref], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
envOverlay: READ_ONLY_GIT_ENV,
|
||||||
|
});
|
||||||
return remoteShort;
|
return remoteShort;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// A stale origin/HEAD must fall through to the available local branches.
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stdout } = await getRunGitCommand(context)(["branch", "--format=%(refname:short)"], {
|
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 () => {
|
it("checks out an existing local branch that is not checked out elsewhere", async () => {
|
||||||
execFileSync("git", ["branch", "dev"], { cwd: repoDir });
|
execFileSync("git", ["branch", "dev"], { cwd: repoDir });
|
||||||
|
|
||||||
@@ -284,15 +316,65 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
.trim();
|
.trim();
|
||||||
expect(currentBranch).toBe("dev");
|
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 metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||||
expect(metadata).toMatchObject({
|
expect(metadata).toMatchObject({
|
||||||
version: 1,
|
version: 1,
|
||||||
baseRefName: "dev",
|
baseRefName: "main",
|
||||||
changeRequestLookupTarget: { headRef: "dev", localBranchName: "dev" },
|
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 () => {
|
it("checks out an existing local branch whose name contains uppercase letters and dots", async () => {
|
||||||
execFileSync("git", ["branch", "release/1.1.15"], { cwd: repoDir });
|
execFileSync("git", ["branch", "release/1.1.15"], { cwd: repoDir });
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
writePaseoWorktreeRuntimeMetadata,
|
writePaseoWorktreeRuntimeMetadata,
|
||||||
} from "./worktree-metadata.js";
|
} from "./worktree-metadata.js";
|
||||||
import { runGitCommand } from "./run-git-command.js";
|
import { runGitCommand } from "./run-git-command.js";
|
||||||
|
import { getCurrentBranch, resolveRepositoryDefaultBranch } from "./checkout-git.js";
|
||||||
import { spawnProcess } from "./spawn.js";
|
import { spawnProcess } from "./spawn.js";
|
||||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||||
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||||
@@ -1279,10 +1280,7 @@ export const createWorktree = async ({
|
|||||||
return {
|
return {
|
||||||
branchName: sourcePlan.branchName,
|
branchName: sourcePlan.branchName,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
comparisonBaseRef:
|
comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName,
|
||||||
source.kind === "checkout-branch"
|
|
||||||
? null
|
|
||||||
: (sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1399,11 +1397,12 @@ async function resolveWorktreeSourcePlan({
|
|||||||
case "checkout-branch": {
|
case "checkout-branch": {
|
||||||
await validateGitBranchName(cwd, source.branchName);
|
await validateGitBranchName(cwd, source.branchName);
|
||||||
await ensureLocalBranch(cwd, source.branchName);
|
await ensureLocalBranch(cwd, source.branchName);
|
||||||
|
const baseRef = await resolveCheckoutBranchBaseRef(cwd, source.branchName);
|
||||||
if (await isBranchCheckedOut(cwd, source.branchName)) {
|
if (await isBranchCheckedOut(cwd, source.branchName)) {
|
||||||
const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName);
|
const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName);
|
||||||
return {
|
return {
|
||||||
branchName,
|
branchName,
|
||||||
metadataBaseRefName: source.branchName,
|
metadataBaseRefName: baseRef,
|
||||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||||
headRef: branchName,
|
headRef: branchName,
|
||||||
localBranchName: branchName,
|
localBranchName: branchName,
|
||||||
@@ -1414,7 +1413,7 @@ async function resolveWorktreeSourcePlan({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
branchName: source.branchName,
|
branchName: source.branchName,
|
||||||
metadataBaseRefName: source.branchName,
|
metadataBaseRefName: baseRef,
|
||||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||||
headRef: source.branchName,
|
headRef: source.branchName,
|
||||||
localBranchName: source.branchName,
|
localBranchName: source.branchName,
|
||||||
@@ -1669,6 +1668,21 @@ function normalizeRequiredBaseBranch(baseBranch: string): string {
|
|||||||
return normalizedBaseBranch;
|
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(
|
async function resolveBaseBranchForWorktree(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
requestedBaseBranch: string,
|
requestedBaseBranch: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user