fix: reopen worktrees under the right project
This commit is contained in:
@@ -854,6 +854,18 @@ const x = 1;
|
||||
expect(status.mainRepoRoot).toBe(mainCheckoutDir);
|
||||
});
|
||||
|
||||
it("detects plain git worktrees from git alone", async () => {
|
||||
const worktreeDir = join(tempDir, "plain-git-worktree");
|
||||
execSync(`git worktree add -b feature/plain ${worktreeDir} main`, { cwd: repoDir });
|
||||
|
||||
const status = await getCheckoutStatus(worktreeDir, { paseoHome });
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.repoRoot).toBe(worktreeDir);
|
||||
expect(status.isPaseoOwnedWorktree).toBe(false);
|
||||
expect(status.mainRepoRoot).toBe(repoDir);
|
||||
expect(status.currentBranch).toBe("feature/plain");
|
||||
});
|
||||
|
||||
it("merges the current branch into base from a worktree checkout", async () => {
|
||||
const worktree = await createLegacyWorktreeForTest({
|
||||
branchName: "main",
|
||||
@@ -1770,23 +1782,50 @@ const x = 1;
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("throws if Paseo worktree base metadata is missing", async () => {
|
||||
it("falls back to the repository default branch for base-dependent operations when metadata is missing", async () => {
|
||||
const worktree = await createLegacyWorktreeForTest({
|
||||
branchName: "main",
|
||||
branchName: "feature-default-base",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "missing-metadata",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
writeFileSync(join(worktree.worktreePath, "feature.txt"), "feature\n");
|
||||
execSync("git add feature.txt", { cwd: worktree.worktreePath });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'feature commit'", {
|
||||
cwd: worktree.worktreePath,
|
||||
});
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
rmSync(metadataPath, { force: true });
|
||||
|
||||
await expect(getCheckoutStatus(worktree.worktreePath, { paseoHome })).rejects.toThrow(/base/i);
|
||||
await expect(
|
||||
getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome }),
|
||||
).rejects.toThrow(/base/i);
|
||||
await expect(mergeToBase(worktree.worktreePath, {}, { paseoHome })).rejects.toThrow(/base/i);
|
||||
const baseDiff = await getCheckoutDiff(worktree.worktreePath, { mode: "base" }, { paseoHome });
|
||||
expect(baseDiff.diff).toContain("feature.txt");
|
||||
|
||||
const shortstat = await getCheckoutShortstat(worktree.worktreePath, { paseoHome });
|
||||
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||
});
|
||||
|
||||
it("falls back to plain git checkout status when Paseo worktree metadata is missing", async () => {
|
||||
const worktree = await createLegacyWorktreeForTest({
|
||||
branchName: "feature",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "missing-metadata-status-fallback",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(worktree.worktreePath);
|
||||
rmSync(metadataPath, { force: true });
|
||||
|
||||
const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome });
|
||||
expect(status.isGit).toBe(true);
|
||||
expect(status.currentBranch).toBe("feature");
|
||||
expect(status.repoRoot).toBe(worktree.worktreePath);
|
||||
expect(status.isPaseoOwnedWorktree).toBe(true);
|
||||
expect(status.mainRepoRoot).toBe(repoDir);
|
||||
expect(status.baseRef).toBe("main");
|
||||
});
|
||||
|
||||
describe("parseWorktreeList", () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
||||
import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js";
|
||||
import { readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
@@ -643,6 +643,7 @@ export interface CheckoutStatus {
|
||||
export interface CheckoutStatusGitNonPaseo {
|
||||
isGit: true;
|
||||
repoRoot: string;
|
||||
mainRepoRoot: string | null;
|
||||
currentBranch: string | null;
|
||||
isDirty: boolean;
|
||||
baseRef: string | null;
|
||||
@@ -878,28 +879,67 @@ export async function renameCurrentBranch(
|
||||
return { previousBranch, currentBranch };
|
||||
}
|
||||
|
||||
type ConfiguredBaseRefForCwd =
|
||||
| { baseRef: null; isPaseoOwnedWorktree: false }
|
||||
| { baseRef: string; isPaseoOwnedWorktree: true };
|
||||
type PaseoWorktreeForCwd =
|
||||
| { isPaseoOwnedWorktree: false }
|
||||
| { isPaseoOwnedWorktree: true; worktreeRoot: string };
|
||||
|
||||
async function getConfiguredBaseRefForCwd(
|
||||
async function getPaseoWorktreeForCwd(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<ConfiguredBaseRefForCwd> {
|
||||
): Promise<PaseoWorktreeForCwd> {
|
||||
// Fast-path reject: non-worktree paths do not need expensive ownership checks.
|
||||
if (!/[\\/]worktrees[\\/]/.test(cwd)) {
|
||||
return { baseRef: null, isPaseoOwnedWorktree: false };
|
||||
return { isPaseoOwnedWorktree: false };
|
||||
}
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome });
|
||||
if (!ownership.allowed) {
|
||||
return { baseRef: null, isPaseoOwnedWorktree: false };
|
||||
return { isPaseoOwnedWorktree: false };
|
||||
}
|
||||
|
||||
const worktreeRoot = (await getWorktreeRoot(cwd)) ?? cwd;
|
||||
return {
|
||||
baseRef: requirePaseoWorktreeBaseRefName(worktreeRoot),
|
||||
isPaseoOwnedWorktree: true,
|
||||
worktreeRoot: (await getWorktreeRoot(cwd)) ?? cwd,
|
||||
};
|
||||
}
|
||||
|
||||
function readPaseoWorktreeBaseRef(worktreeRoot: string): string | null {
|
||||
return readPaseoWorktreeMetadata(worktreeRoot)?.baseRefName ?? null;
|
||||
}
|
||||
|
||||
async function getStoredBaseRefForCwd(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
const paseoWorktree = await getPaseoWorktreeForCwd(cwd, context);
|
||||
if (!paseoWorktree.isPaseoOwnedWorktree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readPaseoWorktreeBaseRef(paseoWorktree.worktreeRoot);
|
||||
}
|
||||
|
||||
async function getResolvedBaseRefForCwd(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<string | null> {
|
||||
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||
return resolvedBaseRef;
|
||||
}
|
||||
|
||||
interface BaseRefResolution {
|
||||
storedBaseRef: string | null;
|
||||
resolvedBaseRef: string | null;
|
||||
}
|
||||
|
||||
async function resolveBaseRefForCwd(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<BaseRefResolution> {
|
||||
const storedBaseRef = await getStoredBaseRefForCwd(cwd, context);
|
||||
return {
|
||||
storedBaseRef,
|
||||
resolvedBaseRef: storedBaseRef ?? (await resolveBaseRef(cwd)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1225,7 +1265,7 @@ interface CheckoutInspectionContext {
|
||||
worktreeRoot: string;
|
||||
currentBranch: string | null;
|
||||
remoteUrl: string | null;
|
||||
configured: ConfiguredBaseRefForCwd;
|
||||
paseoWorktree: PaseoWorktreeForCwd;
|
||||
}
|
||||
|
||||
async function inspectCheckoutContext(
|
||||
@@ -1238,17 +1278,17 @@ async function inspectCheckoutContext(
|
||||
return null;
|
||||
}
|
||||
|
||||
const [currentBranch, remoteUrl, configured] = await Promise.all([
|
||||
const [currentBranch, remoteUrl, paseoWorktree] = await Promise.all([
|
||||
getCurrentBranch(cwd),
|
||||
getOriginRemoteUrl(cwd),
|
||||
getConfiguredBaseRefForCwd(cwd, context),
|
||||
getPaseoWorktreeForCwd(cwd, context),
|
||||
]);
|
||||
|
||||
return {
|
||||
worktreeRoot: root,
|
||||
currentBranch,
|
||||
remoteUrl,
|
||||
configured,
|
||||
paseoWorktree,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isGitError(error)) {
|
||||
@@ -1380,25 +1420,25 @@ export async function getCheckoutStatus(
|
||||
const worktreeRoot = inspected.worktreeRoot;
|
||||
const currentBranch = inspected.currentBranch;
|
||||
const remoteUrl = inspected.remoteUrl;
|
||||
const configured = inspected.configured;
|
||||
const paseoWorktree = inspected.paseoWorktree;
|
||||
const isDirty = await isWorkingTreeDirty(cwd);
|
||||
const hasRemote = remoteUrl !== null;
|
||||
const baseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
||||
const { resolvedBaseRef: baseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||
const mainRepoRoot = await getMainRepoRoot(cwd).catch(() => null);
|
||||
const [aheadBehind, aheadOfOrigin, behindOfOrigin] = await Promise.all([
|
||||
baseRef && currentBranch ? getAheadBehind(cwd, baseRef, currentBranch) : Promise.resolve(null),
|
||||
hasRemote && currentBranch ? getAheadOfOrigin(cwd, currentBranch) : Promise.resolve(null),
|
||||
hasRemote && currentBranch ? getBehindOfOrigin(cwd, currentBranch) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (configured.isPaseoOwnedWorktree) {
|
||||
const mainRepoRoot = await getMainRepoRoot(cwd);
|
||||
if (paseoWorktree.isPaseoOwnedWorktree && baseRef) {
|
||||
return {
|
||||
isGit: true,
|
||||
repoRoot: worktreeRoot,
|
||||
mainRepoRoot,
|
||||
mainRepoRoot: mainRepoRoot ?? worktreeRoot,
|
||||
currentBranch,
|
||||
isDirty,
|
||||
baseRef: configured.baseRef,
|
||||
baseRef,
|
||||
aheadBehind,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
@@ -1411,6 +1451,8 @@ export async function getCheckoutStatus(
|
||||
return {
|
||||
isGit: true,
|
||||
repoRoot: worktreeRoot,
|
||||
mainRepoRoot:
|
||||
mainRepoRoot && resolve(mainRepoRoot) !== resolve(worktreeRoot) ? mainRepoRoot : null,
|
||||
currentBranch,
|
||||
isDirty,
|
||||
baseRef,
|
||||
@@ -1462,8 +1504,7 @@ async function getCheckoutShortstatUncached(
|
||||
return null;
|
||||
}
|
||||
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||
const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
||||
const localBaseRef = await getResolvedBaseRefForCwd(cwd, context);
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
|
||||
let comparisonRef: string;
|
||||
@@ -1739,12 +1780,12 @@ async function resolveCheckoutDiffRefs(
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { baseRef: "HEAD", includeUntracked: true };
|
||||
}
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||
const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd));
|
||||
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||
const baseRef = compare.baseRef ?? resolvedBaseRef;
|
||||
if (!baseRef) {
|
||||
return null;
|
||||
}
|
||||
if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) {
|
||||
if (storedBaseRef && compare.baseRef && compare.baseRef !== storedBaseRef) {
|
||||
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`);
|
||||
}
|
||||
const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef);
|
||||
@@ -1983,12 +2024,12 @@ export async function mergeToBase(
|
||||
): Promise<string> {
|
||||
await requireGitRepo(cwd);
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||
const baseRef = configured.baseRef ?? options.baseRef ?? (await resolveBaseRef(cwd));
|
||||
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||
const baseRef = options.baseRef ?? resolvedBaseRef;
|
||||
if (!baseRef) {
|
||||
throw new Error("Unable to determine base branch for merge");
|
||||
}
|
||||
if (configured.isPaseoOwnedWorktree && options.baseRef && options.baseRef !== baseRef) {
|
||||
if (storedBaseRef && options.baseRef && options.baseRef !== storedBaseRef) {
|
||||
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${options.baseRef}`);
|
||||
}
|
||||
if (!currentBranch) {
|
||||
@@ -2059,12 +2100,12 @@ export async function mergeFromBase(
|
||||
throw new Error("Unable to determine current branch for merge");
|
||||
}
|
||||
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd, context);
|
||||
const baseRef = configured.baseRef ?? options.baseRef ?? (await resolveBaseRef(cwd));
|
||||
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||
const baseRef = options.baseRef ?? resolvedBaseRef;
|
||||
if (!baseRef) {
|
||||
throw new Error("Unable to determine base branch for merge");
|
||||
}
|
||||
if (configured.isPaseoOwnedWorktree && options.baseRef && options.baseRef !== baseRef) {
|
||||
if (storedBaseRef && options.baseRef && options.baseRef !== storedBaseRef) {
|
||||
throw new Error(`Base ref mismatch: expected ${baseRef}, got ${options.baseRef}`);
|
||||
}
|
||||
|
||||
@@ -2238,6 +2279,7 @@ export async function createPullRequest(
|
||||
options: CreatePullRequestOptions,
|
||||
github: GitHubService = createGitHubService(),
|
||||
workspaceGitService: GitHubRepoRemoteUrlResolver,
|
||||
context?: CheckoutContext,
|
||||
): Promise<{ url: string; number: number }> {
|
||||
await requireGitRepo(cwd);
|
||||
const repo = await resolveGitHubRepo(cwd, { workspaceGitService });
|
||||
@@ -2246,8 +2288,8 @@ export async function createPullRequest(
|
||||
}
|
||||
|
||||
const head = options.head ?? (await getCurrentBranch(cwd));
|
||||
const configured = await getConfiguredBaseRefForCwd(cwd);
|
||||
const base = configured.baseRef ?? options.base ?? (await resolveBaseRef(cwd));
|
||||
const { storedBaseRef, resolvedBaseRef } = await resolveBaseRefForCwd(cwd, context);
|
||||
const base = options.base ?? resolvedBaseRef;
|
||||
if (!head) {
|
||||
throw new Error("Unable to determine head branch for PR");
|
||||
}
|
||||
@@ -2255,7 +2297,7 @@ export async function createPullRequest(
|
||||
throw new Error("Unable to determine base branch for PR");
|
||||
}
|
||||
const normalizedBase = normalizeLocalBranchRefName(base);
|
||||
if (configured.isPaseoOwnedWorktree && options.base && options.base !== base) {
|
||||
if (storedBaseRef && options.base && options.base !== storedBaseRef) {
|
||||
throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user