Prevent unrelated PR merges from archiving workspaces (#3425)
* fix(server): bind auto-archive to workspace PR state Managed worktrees previously derived PR status from whichever branch was currently checked out, so checkout drift could attach an unrelated merged PR to the sidebar and archive flow. Pin PR lookup identity at worktree creation, fail closed on drift, and fan the same fresh PR snapshot shown in the sidebar to every workspace sharing that cwd. * fix(server): serialize shared workspace auto-archive * refactor(server): archive from one fresh git observation
This commit is contained in:
@@ -2619,7 +2619,7 @@ const x = 1;
|
||||
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
|
||||
});
|
||||
|
||||
it("reconciles a PR worktree lookup from current branch tracking", async () => {
|
||||
it("does not retarget a PR worktree lookup from current branch tracking", async () => {
|
||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
@@ -2657,8 +2657,7 @@ const x = 1;
|
||||
const switchedTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
|
||||
const commands = stopGitCommandMetrics().commands.map((command) => command.args[0]);
|
||||
|
||||
expect(switchedTarget).toMatchObject({ headRef: "new-change" });
|
||||
expect(switchedTarget).not.toHaveProperty("headRepositoryOwner");
|
||||
expect(switchedTarget).toBeNull();
|
||||
expect(commands).not.toContain("fetch");
|
||||
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
@@ -2669,7 +2668,8 @@ const x = 1;
|
||||
},
|
||||
});
|
||||
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
||||
headRef: "new-change",
|
||||
headRef: "old-change",
|
||||
headRepositoryOwner: "contributor",
|
||||
});
|
||||
|
||||
execFileSync(
|
||||
@@ -2696,10 +2696,52 @@ const x = 1;
|
||||
cwd: repoDir,
|
||||
});
|
||||
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
||||
headRef: "local-upstream",
|
||||
headRef: "old-change",
|
||||
headRepositoryOwner: "contributor",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not replace a managed branch pin from shared push config", async () => {
|
||||
execFileSync("git", ["branch", "feature/pinned"], { cwd: repoDir });
|
||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["remote", "add", "fork", "https://github.com/other/repo.git"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "pinned-worktree");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/pinned"], { cwd: repoDir });
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "feature/pinned",
|
||||
localBranchName: "feature/pinned",
|
||||
},
|
||||
});
|
||||
execFileSync("git", ["config", "branch.feature/pinned.pushRemote", "fork"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
execFileSync("git", ["config", "remote.fork.push", "HEAD:refs/heads/unrelated"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
||||
headRef: "feature/pinned",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a managed worktree has no metadata", async () => {
|
||||
execFileSync("git", ["branch", "feature/unpinned"], { cwd: repoDir });
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "unpinned-worktree");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/unpinned"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not apply ambiguous legacy PR metadata to a suffixed branch", async () => {
|
||||
execFileSync("git", ["branch", "contributor/old-change-1"], { cwd: repoDir });
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-pr-worktree");
|
||||
@@ -2718,8 +2760,7 @@ const x = 1;
|
||||
|
||||
const lookupTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
|
||||
|
||||
expect(lookupTarget).toMatchObject({ headRef: "contributor/old-change-1" });
|
||||
expect(lookupTarget).not.toHaveProperty("headRepositoryOwner");
|
||||
expect(lookupTarget).toBeNull();
|
||||
});
|
||||
|
||||
it("does not apply a legacy fork hint to an ownerless branch with the same head", async () => {
|
||||
@@ -2762,8 +2803,7 @@ const x = 1;
|
||||
{ paseoHome, facts: ownerlessFacts },
|
||||
);
|
||||
|
||||
expect(requestedTargets.at(-1)).toMatchObject({ headRef: "old-change" });
|
||||
expect(requestedTargets.at(-1)).not.toHaveProperty("headRepositoryOwner");
|
||||
expect(requestedTargets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("recognizes a normalized GitHub owner branch from legacy Enterprise metadata", async () => {
|
||||
@@ -2830,7 +2870,7 @@ const x = 1;
|
||||
|
||||
expect(requestedTargets.at(-1)).toMatchObject({
|
||||
headRef: "old-change",
|
||||
headRepositoryOwner: "OtherOwner",
|
||||
headRepositoryOwner: "MixedOwner",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2878,7 +2918,34 @@ const x = 1;
|
||||
{ paseoHome, facts: switchedFacts },
|
||||
);
|
||||
|
||||
expect(requestedTargets.at(-1)).toMatchObject({ headRef: "other-branch" });
|
||||
expect(requestedTargets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("moves a managed branch identity pin when its branch is renamed", async () => {
|
||||
execFileSync("git", ["branch", "feature/placeholder"], { cwd: repoDir });
|
||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-worktree");
|
||||
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", workspaceDir, "feature/placeholder"], {
|
||||
cwd: repoDir,
|
||||
});
|
||||
writePaseoWorktreeMetadata(workspaceDir, {
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: {
|
||||
headRef: "feature/placeholder",
|
||||
localBranchName: "feature/placeholder",
|
||||
},
|
||||
});
|
||||
|
||||
await renameCurrentBranch(workspaceDir, "feature/generated");
|
||||
|
||||
expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toEqual({
|
||||
headRef: "feature/generated",
|
||||
localBranchName: "feature/generated",
|
||||
});
|
||||
const facts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
||||
expect(facts.isGit && facts.pullRequestLookupTarget).toMatchObject({
|
||||
headRef: "feature/generated",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps fork identity when the local and tracked branch names match", async () => {
|
||||
|
||||
@@ -1824,6 +1824,12 @@ function buildInitialPullRequestLookupTarget(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Paseo worktree metadata owns PR identity. A checkout drift must not fall
|
||||
// through to branch config and silently retarget the workspace.
|
||||
if (input.metadata) {
|
||||
return buildPullRequestLookupTargetFromMetadata(input.metadata, input.currentBranch);
|
||||
}
|
||||
|
||||
const hasConfiguredBranchTarget = Boolean(
|
||||
input.branchRemoteName && parseBranchMergeHeadRef(input.branchMergeRef),
|
||||
);
|
||||
@@ -1883,6 +1889,47 @@ async function resolvePullRequestLookupTargetFromPushConfig(
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveFactsPullRequestLookupTarget(input: {
|
||||
cwd: string;
|
||||
inspected: CheckoutInspectionContext;
|
||||
metadata: PaseoWorktreeMetadata | null;
|
||||
branchRemoteName: string | null;
|
||||
branchMergeRef: string | null;
|
||||
branchRemoteUrl: string | null;
|
||||
resolvedBaseRef: string | null;
|
||||
context?: CheckoutContext;
|
||||
}): Promise<PullRequestStatusLookupTarget | null> {
|
||||
const { cwd, inspected, metadata, context } = input;
|
||||
if (inspected.paseoWorktree.isPaseoOwnedWorktree) {
|
||||
return buildPullRequestLookupTargetFromMetadata(metadata, inspected.currentBranch ?? "");
|
||||
}
|
||||
|
||||
let target = buildInitialPullRequestLookupTarget({
|
||||
currentBranch: inspected.currentBranch,
|
||||
metadata,
|
||||
branchRemoteName: input.branchRemoteName,
|
||||
branchMergeRef: input.branchMergeRef,
|
||||
branchRemoteUrl: input.branchRemoteUrl,
|
||||
originRemoteUrl: inspected.remoteUrl,
|
||||
resolvedBaseRef: input.resolvedBaseRef,
|
||||
});
|
||||
if (
|
||||
inspected.currentBranch &&
|
||||
target?.headRef === inspected.currentBranch &&
|
||||
!target.headRepositoryOwner
|
||||
) {
|
||||
target =
|
||||
(await resolvePullRequestLookupTargetFromPushConfig(
|
||||
cwd,
|
||||
inspected.currentBranch,
|
||||
inspected.remoteUrl,
|
||||
input.resolvedBaseRef,
|
||||
context,
|
||||
)) ?? target;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export async function getCheckoutSnapshotFacts(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
@@ -1938,29 +1985,16 @@ export async function getCheckoutSnapshotFacts(
|
||||
]);
|
||||
}
|
||||
}
|
||||
let pullRequestLookupTarget = buildInitialPullRequestLookupTarget({
|
||||
currentBranch: inspected.currentBranch,
|
||||
let pullRequestLookupTarget = await resolveFactsPullRequestLookupTarget({
|
||||
cwd,
|
||||
inspected,
|
||||
metadata: paseoWorktreeMetadata,
|
||||
branchRemoteName,
|
||||
branchMergeRef,
|
||||
branchRemoteUrl,
|
||||
originRemoteUrl: inspected.remoteUrl,
|
||||
resolvedBaseRef,
|
||||
context,
|
||||
});
|
||||
if (
|
||||
inspected.currentBranch &&
|
||||
pullRequestLookupTarget?.headRef === inspected.currentBranch &&
|
||||
!pullRequestLookupTarget.headRepositoryOwner
|
||||
) {
|
||||
pullRequestLookupTarget =
|
||||
(await resolvePullRequestLookupTargetFromPushConfig(
|
||||
cwd,
|
||||
inspected.currentBranch,
|
||||
inspected.remoteUrl,
|
||||
resolvedBaseRef,
|
||||
context,
|
||||
)) ?? pullRequestLookupTarget;
|
||||
}
|
||||
pullRequestLookupTarget = await addHeadShaToPullRequestLookupTarget(
|
||||
cwd,
|
||||
pullRequestLookupTarget,
|
||||
@@ -3952,9 +3986,8 @@ async function getPullRequestStatusUncached(
|
||||
context?: CheckoutContext,
|
||||
headSha?: string | null,
|
||||
): Promise<PullRequestStatusResult> {
|
||||
if (context?.facts?.isGit === false) {
|
||||
return buildPullRequestStatusResult(null, "no_remote");
|
||||
}
|
||||
const unavailable = getUnavailablePullRequestStatus(context?.facts);
|
||||
if (unavailable) return unavailable;
|
||||
if (!context?.facts?.isGit) {
|
||||
await requireGitRepo(cwd);
|
||||
}
|
||||
@@ -3995,3 +4028,19 @@ async function getPullRequestStatusUncached(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getUnavailablePullRequestStatus(
|
||||
facts: CheckoutSnapshotFacts | null | undefined,
|
||||
): PullRequestStatusResult | null {
|
||||
if (facts?.isGit === false) {
|
||||
return buildPullRequestStatusResult(null, "no_remote");
|
||||
}
|
||||
if (
|
||||
facts?.isGit === true &&
|
||||
facts.paseoWorktree.isPaseoOwnedWorktree &&
|
||||
facts.pullRequestLookupTarget === null
|
||||
) {
|
||||
return buildPullRequestStatusResult(null, "authenticated");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -105,12 +105,36 @@ export function rebindPaseoWorktreeChangeRequestHint(
|
||||
...metadata,
|
||||
changeRequestLookupTarget: {
|
||||
...target,
|
||||
...(target.headRef === previousBranch &&
|
||||
!target.headRepositoryOwner &&
|
||||
target.changeRequestNumber === undefined
|
||||
? { headRef: currentBranch }
|
||||
: {}),
|
||||
localBranchName: currentBranch,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pinPaseoWorktreeBranchIdentityIfMissing(
|
||||
worktreeRoot: string,
|
||||
branch: string,
|
||||
): boolean {
|
||||
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||
if (!metadata || metadata.changeRequestLookupTarget) {
|
||||
return false;
|
||||
}
|
||||
const target = createPaseoWorktreeChangeRequestHint({
|
||||
headRef: branch,
|
||||
localBranchName: branch,
|
||||
});
|
||||
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||
...metadata,
|
||||
changeRequestLookupTarget: target,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
||||
const gitPath = join(worktreeRoot, ".git");
|
||||
if (!existsSync(gitPath)) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type WorktreeConfig,
|
||||
} from "./worktree";
|
||||
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
import { getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutStatus,
|
||||
@@ -128,7 +128,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
expect(existsSync(metadataPath)).toBe(true);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
||||
expect(metadata).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: { headRef: "hello-world", localBranchName: "hello-world" },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates and owns worktrees under a configured root", async () => {
|
||||
@@ -254,7 +258,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
||||
expect(metadata).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "main",
|
||||
changeRequestLookupTarget: { headRef: "feature/x", localBranchName: "feature/x" },
|
||||
});
|
||||
});
|
||||
|
||||
it("checks out an existing local branch that is not checked out elsewhere", async () => {
|
||||
@@ -278,7 +286,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
|
||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "dev" });
|
||||
expect(metadata).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "dev",
|
||||
changeRequestLookupTarget: { headRef: "dev", localBranchName: "dev" },
|
||||
});
|
||||
});
|
||||
|
||||
it("checks out an existing local branch whose name contains uppercase letters and dots", async () => {
|
||||
@@ -312,6 +324,10 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
|
||||
expect(result.branchName).toBe("main-1");
|
||||
expect(existsSync(result.worktreePath)).toBe(true);
|
||||
expect(readPaseoWorktreeMetadata(result.worktreePath)?.changeRequestLookupTarget).toEqual({
|
||||
headRef: "main-1",
|
||||
localBranchName: "main-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => {
|
||||
|
||||
@@ -1322,6 +1322,10 @@ async function resolveWorktreeSourcePlan({
|
||||
branchName: newBranchName,
|
||||
metadataBaseRefName: normalizedBaseBranch,
|
||||
metadataBaseRef: resolvedBaseBranch,
|
||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||
headRef: newBranchName,
|
||||
localBranchName: newBranchName,
|
||||
}),
|
||||
addArguments: ["-b", newBranchName, "--no-track", base],
|
||||
};
|
||||
}
|
||||
@@ -1342,6 +1346,10 @@ async function resolveWorktreeSourcePlan({
|
||||
return {
|
||||
branchName,
|
||||
metadataBaseRefName: source.branchName,
|
||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||
headRef: branchName,
|
||||
localBranchName: branchName,
|
||||
}),
|
||||
addArguments: ["-b", branchName, "--no-track", source.branchName],
|
||||
};
|
||||
}
|
||||
@@ -1349,6 +1357,10 @@ async function resolveWorktreeSourcePlan({
|
||||
return {
|
||||
branchName: source.branchName,
|
||||
metadataBaseRefName: source.branchName,
|
||||
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||
headRef: source.branchName,
|
||||
localBranchName: source.branchName,
|
||||
}),
|
||||
addArguments: [source.branchName],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user