Change a worktree workspace base from Changes (#8642)

Change a worktree workspace's base from the Changes pane. The choice persists and diffs, counts, commits and Git actions follow it.

Supersedes #6872.

Co-authored-by: Tianyu Yang <yangtianyu.cl@bytedance.com>
This commit is contained in:
Mohamed Boudra
2026-11-05 22:30:14 +07:00
committed by GitHub
co-authored by Tianyu Yang
parent e9f897a66a
commit 3ed57e9dd7
41 changed files with 1192 additions and 46 deletions
@@ -32,6 +32,7 @@ import {
getCheckoutStatus,
checkoutResolvedBranch,
listBranchSuggestions,
setCheckoutBaseBranch,
mergeToBase,
mergeFromBase,
MergeConflictError,
@@ -263,6 +264,56 @@ describe("checkout git utilities", () => {
rmSync(tempDir, { recursive: true, force: true });
});
it("rejects changing the base of a local checkout without creating metadata", async () => {
await expect(setCheckoutBaseBranch({ cwd: repoDir, baseRef: "main" })).rejects.toThrow();
expect(existsSync(await getPaseoWorktreeMetadataPath(repoDir))).toBe(false);
});
it("changing a managed worktree base preserves its runtime and PR identity", async () => {
const worktree = await createLegacyWorktreeForTest({
cwd: repoDir,
branchName: "feature/base",
baseBranch: "main",
worktreeSlug: "base",
paseoHome,
runSetup: false,
});
const original = await readPaseoWorktreeMetadata(worktree.worktreePath);
execFileSync("git", ["branch", "release"], { cwd: repoDir });
await setCheckoutBaseBranch({
cwd: worktree.worktreePath,
baseRef: "release",
context: { paseoHome },
});
expect(await readPaseoWorktreeMetadata(worktree.worktreePath)).toEqual({
...original,
baseRefName: "release",
baseRef: "refs/heads/release",
});
expect(await getCheckoutSnapshotFacts(worktree.worktreePath, { paseoHome })).toMatchObject({
storedBaseRef: "refs/heads/release",
});
});
it("lists exact local and all remote refs separately for the base picker", async () => {
execFileSync("git", ["branch", "release"], { cwd: repoDir });
execFileSync("git", ["update-ref", "refs/remotes/origin/release", "HEAD"], { cwd: repoDir });
execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "HEAD"], { cwd: repoDir });
const suggestions = await listBranchSuggestions(repoDir, {
refMode: "exact",
query: "release",
});
expect(suggestions.map((entry) => entry.name).sort()).toEqual([
"refs/heads/release",
"refs/remotes/origin/release",
"refs/remotes/upstream/release",
]);
expect(suggestions.find((entry) => entry.name === "refs/heads/release")).toMatchObject({
hasLocal: true,
hasRemote: false,
});
});
it("throws NotGitRepoError for non-git directories", async () => {
const nonGitDir = join(tempDir, "not-git");
mkdirSync(nonGitDir, { recursive: true });
@@ -1920,6 +1971,13 @@ const x = 1;
expect(status.currentBranch).toBe("feature/plain");
});
it("rejects merging a branch into its own same-named remote base", async () => {
execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: repoDir });
await expect(mergeToBase(repoDir, { baseRef: "refs/remotes/origin/main" })).rejects.toThrow(
"Cannot merge a branch into itself",
);
});
it("merges the current branch into base from a worktree checkout", async () => {
const worktree = await createLegacyWorktreeForTest({
branchName: "main",
+64 -12
View File
@@ -51,6 +51,7 @@ import {
type PaseoWorktreeMetadata,
readPaseoWorktreeMetadata,
rebindPaseoWorktreeChangeRequestHint,
setPaseoWorktreeBaseRef,
} from "./worktree-metadata.js";
const READ_ONLY_GIT_ENV = {
GIT_OPTIONAL_LOCKS: "0",
@@ -323,15 +324,30 @@ function sortBranchSuggestions(
export async function listBranchSuggestions(
cwd: string,
options?: { query?: string; limit?: number },
options: { query?: string; limit?: number; refMode?: "exact" } = {},
): Promise<BranchSuggestion[]> {
await requireGitRepo(cwd);
const requestedLimit = options?.limit ?? 50;
const requestedLimit = options.limit ?? 50;
const limit = Math.max(1, Math.min(200, requestedLimit));
const rawQuery = options?.query?.trim().toLowerCase() ?? "";
const rawQuery = options.query?.trim().toLowerCase() ?? "";
const query = normalizeBranchSuggestionName(rawQuery) ?? rawQuery;
if (options.refMode === "exact") {
const refs = await listGitRefs(cwd, "refs");
const branches = refs.filter((ref) => {
const isBranch = ref.name.startsWith("refs/heads/") || ref.name.startsWith("refs/remotes/");
return isBranch && !ref.name.endsWith("/HEAD") && ref.name.toLowerCase().includes(rawQuery);
});
branches.sort((a, b) => b.committerDate - a.committerDate || a.name.localeCompare(b.name));
return branches.slice(0, limit).map((ref) => ({
name: ref.name,
committerDate: ref.committerDate,
hasLocal: ref.name.startsWith("refs/heads/"),
hasRemote: ref.name.startsWith("refs/remotes/"),
}));
}
const [localRefs, remoteRefs] = await Promise.all([
listGitRefs(cwd, "refs/heads"),
listGitRefs(cwd, "refs/remotes/origin"),
@@ -889,6 +905,7 @@ export interface CheckoutStatusGitNonPaseo {
currentBranch: string | null;
isDirty: boolean;
baseRef: string | null;
baseRefExact?: string | null;
aheadBehind: AheadBehind | null;
// Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the
// branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are
@@ -908,6 +925,7 @@ export interface CheckoutStatusGitPaseo {
currentBranch: string | null;
isDirty: boolean;
baseRef: string;
baseRefExact?: string | null;
aheadBehind: AheadBehind | null;
upstreamRef: string | null;
aheadOfOrigin: number | null;
@@ -1308,12 +1326,36 @@ async function getStoredBaseRefForCwd(
if (context?.facts?.isGit) {
return context.facts.storedBaseRef;
}
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, { context });
if (!paseoWorktree.isPaseoOwnedWorktree) {
return null;
}
const ownership = await getPaseoWorktreeForCwd(cwd, { context });
return ownership.isPaseoOwnedWorktree ? readPaseoWorktreeBaseRef(ownership.worktreeRoot) : null;
}
return readPaseoWorktreeBaseRef(paseoWorktree.worktreeRoot);
export async function setCheckoutBaseBranch(input: {
cwd: string;
baseRef: string;
context?: CheckoutContext;
}): Promise<string> {
await requireGitRepo(input.cwd);
const ownership = await getPaseoWorktreeForCwd(input.cwd, { context: input.context });
if (!ownership.isPaseoOwnedWorktree) throw new Error("Workspace is not a Paseo worktree");
const requested = input.baseRef.trim();
const qualified = requested.startsWith("refs/");
const candidates = qualified
? [requested]
: [`refs/heads/${requested}`, `refs/remotes/${requested}`, `refs/remotes/origin/${requested}`];
for (const candidate of candidates) {
if (!isQualifiedBranchRef(candidate) || candidate.endsWith("/HEAD")) continue;
const valid = await runGitCommand(["check-ref-format", candidate], {
cwd: input.cwd,
acceptExitCodes: [0, 1],
});
if (valid.exitCode !== 0 || !(await doesGitRefExist(input.cwd, candidate))) continue;
const worktreeRoot = await getWorktreeRoot(input.cwd);
if (!worktreeRoot) throw new NotGitRepoError(input.cwd);
await setPaseoWorktreeBaseRef({ worktreeRoot, baseRef: candidate });
return candidate;
}
throw new Error(`Base branch not found: ${requested}`);
}
async function getResolvedBaseRefForCwd(
@@ -1786,7 +1828,11 @@ async function getAheadBehind(
context?: CheckoutContext,
): Promise<AheadBehind | null> {
const normalizedBaseRef = branchNameFromRef(baseRef);
if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) {
if (
!normalizedBaseRef ||
!currentBranch ||
(!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch)
) {
return null;
}
const comparisonBaseRef =
@@ -2128,7 +2174,8 @@ export async function getCheckoutSnapshotFacts(
if (
resolvedBaseRef &&
inspected.currentBranch &&
branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch
(isQualifiedBranchRef(resolvedBaseRef) ||
branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch)
) {
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef, context).catch(
() => null,
@@ -2373,6 +2420,7 @@ export async function getCheckoutStatus(
currentBranch,
isDirty,
baseRef: displayBaseRef ?? baseRef,
baseRefExact: baseRef,
aheadBehind,
upstreamRef,
aheadOfOrigin,
@@ -2391,6 +2439,7 @@ export async function getCheckoutStatus(
currentBranch,
isDirty,
baseRef: displayBaseRef,
baseRefExact: baseRef,
aheadBehind,
upstreamRef,
aheadOfOrigin,
@@ -2605,7 +2654,10 @@ async function tryResolveCheckoutCommitsBaseRef(
return null;
}
const normalizedBaseRef = branchNameFromRef(baseRef);
if (!normalizedBaseRef || normalizedBaseRef === currentBranch) {
if (
!normalizedBaseRef ||
(!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch)
) {
return null;
}
try {
@@ -3890,7 +3942,7 @@ export async function mergeToBase(
const normalizedBaseRef = resolveMergeTargetBranch(baseRef);
const currentWorktreeRoot = (await getWorktreeRoot(cwd, context)) ?? cwd;
if (normalizedBaseRef === currentBranch) {
return currentWorktreeRoot;
throw new Error("Cannot merge a branch into itself");
}
const baseWorktree = await getWorktreePathForBranch(cwd, normalizedBaseRef);
+16 -1
View File
@@ -14,7 +14,7 @@ const ChangeRequestLookupTargetSchema = z.object({
localBranchName: z.string().min(1).optional(),
});
// baseRefName is the display name; baseRef is the exact ref the worktree was cut from
// baseRefName is the display name; baseRef is the checkout's current exact base
// ("refs/remotes/upstream/main"). baseRef is optional because worktrees written before it
// existed only have the name — there are no migrations, so readers fall back.
const PaseoWorktreeMetadataV1Schema = z.object({
@@ -249,6 +249,21 @@ export async function writePaseoWorktreeMetadata(
});
}
export async function setPaseoWorktreeBaseRef(input: {
worktreeRoot: string;
baseRef: string;
}): Promise<void> {
return mutateMetadata(input.worktreeRoot, async (root) => {
const current = await readMetadataFile(root);
if (!current) throw new Error("Cannot change base: missing worktree metadata");
await writePaseoWorktreeMetadataFile(root, {
...current,
baseRefName: branchNameFromRef(input.baseRef),
baseRef: input.baseRef,
});
});
}
export async function writePaseoWorktreeRuntimeMetadata(
worktreeRoot: string,
options: { worktreePort: number },