Show the current pull request after switching branches (#2699)
* fix(git): reconcile pull requests after branch changes Treat persisted change-request metadata as a branch-bound hint and prefer current tracking configuration when it identifies the checked-out branch. * fix(git): preserve enterprise fork identity Use branch-bound metadata to supplement the owner when explicit tracking points at the same head on a self-hosted remote. * fix(git): honor local branch upstreams Treat Git remote dot with a merge ref as explicit current tracking so stale PR metadata cannot override a local upstream. * fix(git): own change request branch bindings Centralize persisted hint validation and rebinding so renames preserve change request identity while switches invalidate it, including normalized legacy Enterprise owners. * fix(git): preserve PR identity through workspace renames Resolve the backing worktree root during rename without adding a Git call, and retain fork owner identity from explicit same-name tracking configuration. * fix(git): trust current enterprise fork identity Explicit tracking now derives repository identity from the current remote path, so stale checkout metadata cannot override a repointed fork. * fix(git): recognize enterprise repository aliases GitHub repository identity is case-insensitive. Normalize equality checks so differently cased aliases cannot turn base tracking into a fork lookup. * fix(git): reject ownerless legacy fork matches Owner-qualified legacy hints only belong to their historical owner-prefixed branch names. A later ownerless branch reusing the same head must resolve from current Git state instead. * fix(git): keep fork feature lookups local A feature branch may track the upstream base branch for ancestry even when origin is a fork. Only treat that cross-repository base ref as PR identity when the local branch name identifies the tracked head.
This commit is contained in:
@@ -3,6 +3,7 @@ import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, readFile, stat as statFile } from "fs/promises";
|
||||
import { TTLCache } from "@isaacs/ttlcache";
|
||||
import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages";
|
||||
import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import { maxBase64EncryptedPlaintextByteLength } from "@getpaseo/relay";
|
||||
import type { Logger } from "pino";
|
||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||
@@ -28,7 +29,12 @@ import {
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
|
||||
import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||
import {
|
||||
getPaseoWorktreeChangeRequestHintForBranch,
|
||||
type PaseoWorktreeMetadata,
|
||||
readPaseoWorktreeMetadata,
|
||||
rebindPaseoWorktreeChangeRequestHint,
|
||||
} from "./worktree-metadata.js";
|
||||
const READ_ONLY_GIT_ENV = {
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
LC_ALL: "C",
|
||||
@@ -827,6 +833,22 @@ async function requireGitRepo(cwd: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function requireGitWorktreeRoot(cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const worktreeRoot = parseGitRevParsePath(stdout);
|
||||
if (!worktreeRoot) {
|
||||
throw new Error("Git returned no worktree root");
|
||||
}
|
||||
return worktreeRoot;
|
||||
} catch {
|
||||
throw new NotGitRepoError(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
@@ -1037,7 +1059,7 @@ export async function renameCurrentBranch(
|
||||
cwd: string,
|
||||
newName: string,
|
||||
): Promise<{ previousBranch: string | null; currentBranch: string | null }> {
|
||||
await requireGitRepo(cwd);
|
||||
const worktreeRoot = await requireGitWorktreeRoot(cwd);
|
||||
|
||||
const previousBranch = await getCurrentBranch(cwd);
|
||||
if (!previousBranch || previousBranch === "HEAD") {
|
||||
@@ -1050,6 +1072,9 @@ export async function renameCurrentBranch(
|
||||
});
|
||||
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
if (currentBranch) {
|
||||
rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch);
|
||||
}
|
||||
return { previousBranch, currentBranch };
|
||||
}
|
||||
|
||||
@@ -1600,22 +1625,25 @@ function buildPullRequestLookupTargetFromBranchConfig(
|
||||
input: PullRequestLookupTargetBranchConfig,
|
||||
): PullRequestStatusLookupTarget {
|
||||
const trackedHeadRef = parseBranchMergeHeadRef(input.branchMergeRef);
|
||||
if (!input.branchRemoteName || !trackedHeadRef || trackedHeadRef === input.currentBranch) {
|
||||
if (!input.branchRemoteName || !trackedHeadRef) {
|
||||
return { headRef: input.currentBranch };
|
||||
}
|
||||
|
||||
const remoteRepo = input.branchRemoteUrl
|
||||
? parseGitHubRepoFromRemote(input.branchRemoteUrl)
|
||||
: null;
|
||||
const originRepo = input.originRemoteUrl
|
||||
? parseGitHubRepoFromRemote(input.originRemoteUrl)
|
||||
: null;
|
||||
const isSameRepo = Boolean(remoteRepo && originRepo && remoteRepo === originRepo);
|
||||
const remoteRepo = parseRepositoryIdentityFromRemote(input.branchRemoteUrl);
|
||||
const originRepo = parseRepositoryIdentityFromRemote(input.originRemoteUrl);
|
||||
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
|
||||
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
||||
const normalizedBaseRef = input.resolvedBaseRef
|
||||
? normalizeLocalBranchRefName(input.resolvedBaseRef)
|
||||
: null;
|
||||
if (trackedHeadRef === normalizedBaseRef && !headRepositoryOwner) {
|
||||
if (
|
||||
trackedHeadRef === normalizedBaseRef &&
|
||||
!doesLocalBranchNameIdentifyTrackedHead(
|
||||
input.currentBranch,
|
||||
trackedHeadRef,
|
||||
headRepositoryOwner,
|
||||
)
|
||||
) {
|
||||
return { headRef: input.currentBranch };
|
||||
}
|
||||
|
||||
@@ -1629,6 +1657,34 @@ function buildPullRequestLookupTargetFromBranchConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function parseRepositoryIdentityFromRemote(remoteUrl: string | null): string | null {
|
||||
if (!remoteUrl) {
|
||||
return null;
|
||||
}
|
||||
const location = parseGitRemoteLocation(remoteUrl);
|
||||
return location ? (parseGitHubRemoteIdentity(location.path)?.repo ?? null) : null;
|
||||
}
|
||||
|
||||
function areSameGitHubRepository(left: string | null, right: string | null): boolean {
|
||||
return Boolean(left && right && left.toLowerCase() === right.toLowerCase());
|
||||
}
|
||||
|
||||
function doesLocalBranchNameIdentifyTrackedHead(
|
||||
currentBranch: string,
|
||||
trackedHeadRef: string,
|
||||
headRepositoryOwner: string | null,
|
||||
): boolean {
|
||||
if (currentBranch === trackedHeadRef) {
|
||||
return true;
|
||||
}
|
||||
if (!headRepositoryOwner) {
|
||||
return false;
|
||||
}
|
||||
return [headRepositoryOwner, headRepositoryOwner.toLowerCase()].some(
|
||||
(owner) => currentBranch === `${owner}/${trackedHeadRef}`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildPullRequestLookupTargetFromPushConfig(
|
||||
input: PullRequestLookupTargetPushConfig,
|
||||
): PullRequestStatusLookupTarget | null {
|
||||
@@ -1641,7 +1697,7 @@ function buildPullRequestLookupTargetFromPushConfig(
|
||||
const originRepo = input.originRemoteUrl
|
||||
? parseGitHubRepoFromRemote(input.originRemoteUrl)
|
||||
: null;
|
||||
const isSameRepo = Boolean(remoteRepo && originRepo && remoteRepo === originRepo);
|
||||
const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo);
|
||||
const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null;
|
||||
const normalizedBaseRef = input.resolvedBaseRef
|
||||
? normalizeLocalBranchRefName(input.resolvedBaseRef)
|
||||
@@ -1658,8 +1714,9 @@ function buildPullRequestLookupTargetFromPushConfig(
|
||||
|
||||
function buildPullRequestLookupTargetFromMetadata(
|
||||
metadata: PaseoWorktreeMetadata | null,
|
||||
currentBranch: string,
|
||||
): PullRequestStatusLookupTarget | null {
|
||||
const target = metadata?.changeRequestLookupTarget;
|
||||
const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, currentBranch);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
@@ -1682,8 +1739,22 @@ function buildInitialPullRequestLookupTarget(input: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasConfiguredBranchTarget = Boolean(
|
||||
input.branchRemoteName && parseBranchMergeHeadRef(input.branchMergeRef),
|
||||
);
|
||||
if (hasConfiguredBranchTarget) {
|
||||
return buildPullRequestLookupTargetFromBranchConfig({
|
||||
currentBranch: input.currentBranch,
|
||||
branchRemoteName: input.branchRemoteName,
|
||||
branchMergeRef: input.branchMergeRef,
|
||||
branchRemoteUrl: input.branchRemoteUrl,
|
||||
originRemoteUrl: input.originRemoteUrl,
|
||||
resolvedBaseRef: input.resolvedBaseRef,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
buildPullRequestLookupTargetFromMetadata(input.metadata) ??
|
||||
buildPullRequestLookupTargetFromMetadata(input.metadata, input.currentBranch) ??
|
||||
buildPullRequestLookupTargetFromBranchConfig({
|
||||
currentBranch: input.currentBranch,
|
||||
branchRemoteName: input.branchRemoteName,
|
||||
|
||||
Reference in New Issue
Block a user