mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 18:04:55 +00:00
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:
co-authored by
Tianyu Yang
parent
e9f897a66a
commit
3ed57e9dd7
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user