From 369adced529d61376dbe573a653dec1d89c47433 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Jun 2026 23:17:08 +0800 Subject: [PATCH] Archive merged PR workspaces from settings (#1313) * Add workspace settings for merged PR cleanup * Fix worktree branch ahead status * Handle PR worktree upstream status * Remove stale checkout tracking field * Update settings E2E host section slugs * Treat unknown upstream as unsafe for auto-archive --- packages/app/e2e/helpers/desktop-updates.ts | 4 +- packages/app/e2e/helpers/settings.ts | 11 ++- packages/app/e2e/settings-host-page.spec.ts | 20 ++-- .../hosts/[serverId]/[hostSection].tsx | 4 +- packages/app/src/screens/settings-screen.tsx | 22 +++-- .../app/src/screens/settings/host-page.tsx | 70 +++++++++++++- packages/app/src/utils/host-routes.test.ts | 16 ++++ packages/app/src/utils/host-routes.ts | 20 +++- .../archive-if-safe.test.ts | 11 +++ .../auto-archive-on-merge/archive-if-safe.ts | 5 +- .../workspace-git-service.primitive.test.ts | 2 - .../src/server/workspace-git-service.test.ts | 1 - .../server/src/utils/checkout-git.test.ts | 92 ++++++++++++++++++- packages/server/src/utils/checkout-git.ts | 59 +++++------- 14 files changed, 262 insertions(+), 75 deletions(-) diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/app/e2e/helpers/desktop-updates.ts index c6c728fca..4023f4f54 100644 --- a/packages/app/e2e/helpers/desktop-updates.ts +++ b/packages/app/e2e/helpers/desktop-updates.ts @@ -257,9 +257,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi export async function openDesktopSettings(page: Page, serverId: string): Promise { await openSettings(page); await openSettingsHost(page, serverId); - // The daemon-lifecycle card moved to the Daemon section in the flat-settings + // The daemon-lifecycle card moved to the Host section in the flat-settings // layout; navigate there before asserting it. - await openSettingsHostSection(page, serverId, "daemon"); + await openSettingsHostSection(page, serverId, "host"); await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({ timeout: 15_000, }); diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index 6164ee673..e7648e0ea 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -15,7 +15,7 @@ const SECTION_LABELS = { export type SettingsSection = keyof typeof SECTION_LABELS | "projects"; -type HostSection = "connections" | "orchestration" | "providers" | "daemon"; +type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host"; export async function openSettingsSection(page: Page, section: SettingsSection): Promise { const sidebar = page.getByTestId("settings-sidebar"); @@ -231,9 +231,9 @@ export async function openHostSection( } export async function expectHostActionCards(page: Page, serverId: string): Promise { - // Restart + remove cards live on the Daemon section; providers moved to its + // Restart + remove cards live on the Host section; providers moved to its // own Providers section (asserted via expectHostProvidersCard). - await openSettingsHostSection(page, serverId, "daemon"); + await openSettingsHostSection(page, serverId, "host"); await expect(page.getByTestId("host-page-restart-card")).toBeVisible(); await expect(page.getByTestId("host-page-restart-button")).toBeVisible(); await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible(); @@ -296,9 +296,10 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise { await expectHostConnectionsCard(page, port); }); - test("orchestration section shows the inject MCP toggle", async ({ page }) => { + test("agents section shows the inject MCP toggle", async ({ page }) => { const serverId = getServerId(); await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, serverId); - await openHostSection(page, serverId, "orchestration"); - await expectSettingsHeader(page, "Orchestration"); + await openHostSection(page, serverId, "agents"); + await expectSettingsHeader(page, "Agents"); await expectHostInjectMcpCard(page); }); @@ -56,15 +56,15 @@ test.describe("Settings host page", () => { await expectSettingsHeader(page, "Providers"); }); - test("daemon section shows the host label and restart/remove action cards", async ({ page }) => { + test("host section shows the host label and restart/remove action cards", async ({ page }) => { const serverId = getServerId(); await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, serverId); - await openHostSection(page, serverId, "daemon"); - await expectSettingsHeader(page, "Daemon"); + await openHostSection(page, serverId, "host"); + await expectSettingsHeader(page, "Host"); await expectHostLabelDisplayed(page); await expectHostActionCards(page, serverId); }); @@ -75,14 +75,14 @@ test.describe("Settings host page", () => { await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, serverId); - await openHostSection(page, serverId, "daemon"); + await openHostSection(page, serverId, "host"); await expectHostLabelDisplayed(page); await clickEditHostLabel(page); await expectHostLabelEditMode(page, TEST_HOST_LABEL); }); - test("daemon section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({ + test("host section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({ page, }) => { const serverId = getServerId(); @@ -90,7 +90,7 @@ test.describe("Settings host page", () => { await gotoAppShell(page); await openSettings(page); await openSettingsHost(page, serverId); - await openHostSection(page, serverId, "daemon"); + await openHostSection(page, serverId, "host"); // TODO: add local-daemon fixture for positive Pair/Daemon coverage. await expectHostNoLocalOnlyRows(page); @@ -113,7 +113,7 @@ test.describe("Settings host page", () => { await expectHostPageVisible(page, serverId); await expectSettingsHeader(page, "Connections"); - await openHostSection(page, serverId, "daemon"); + await openHostSection(page, serverId, "host"); await expectHostLabelDisplayed(page); await expectHostActionCards(page, serverId); }); diff --git a/packages/app/src/app/settings/hosts/[serverId]/[hostSection].tsx b/packages/app/src/app/settings/hosts/[serverId]/[hostSection].tsx index 7ca4283ce..dd870e681 100644 --- a/packages/app/src/app/settings/hosts/[serverId]/[hostSection].tsx +++ b/packages/app/src/app/settings/hosts/[serverId]/[hostSection].tsx @@ -2,13 +2,13 @@ import { useLocalSearchParams } from "expo-router"; import { useMemo } from "react"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import SettingsScreen from "@/screens/settings-screen"; -import { type HostSectionSlug, isHostSectionSlug } from "@/utils/host-routes"; +import { normalizeHostSectionSlug } from "@/utils/host-routes"; export default function SettingsHostSectionRoute() { const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>(); const serverId = typeof params.serverId === "string" ? params.serverId.trim() : ""; const rawSection = typeof params.hostSection === "string" ? params.hostSection : ""; - const section: HostSectionSlug = isHostSectionSlug(rawSection) ? rawSection : "connections"; + const section = normalizeHostSectionSlug(rawSection) ?? "connections"; const view = useMemo(() => ({ kind: "host" as const, serverId, section }), [serverId, section]); return ( diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index a1acac1e8..82d82ae17 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -30,7 +30,7 @@ import { Palette, Server, Network, - Workflow, + Bot, Boxes, Keyboard, Stethoscope, @@ -93,9 +93,10 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { HostConnectionsPage, - HostDaemonPage, - HostOrchestrationPage, + HostAgentsPage, + HostSettingsPage, HostProvidersPage, + HostWorkspacesPage, } from "@/screens/settings/host-page"; import ProjectsScreen from "@/screens/projects-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen"; @@ -148,9 +149,10 @@ interface HostSectionItem { const HOST_SECTION_ITEMS: HostSectionItem[] = [ { id: "connections", label: "Connections", icon: Network }, - { id: "orchestration", label: "Orchestration", icon: Workflow }, + { id: "agents", label: "Agents", icon: Bot }, + { id: "workspaces", label: "Workspaces", icon: FolderGit2 }, { id: "providers", label: "Providers", icon: Boxes }, - { id: "daemon", label: "Daemon", icon: Server }, + { id: "host", label: "Host", icon: Server }, ]; // --------------------------------------------------------------------------- @@ -1304,12 +1306,14 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { switch (view.section) { case "connections": return ; - case "orchestration": - return ; + case "agents": + return ; + case "workspaces": + return ; case "providers": return ; - case "daemon": - return ; + case "host": + return ; } } if (view.kind === "projects") { diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index dc561c28a..78835e378 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -188,7 +188,7 @@ export function HostConnectionsPage({ serverId }: { serverId: string }) { ); } -export function HostOrchestrationPage({ serverId }: { serverId: string }) { +export function HostAgentsPage({ serverId }: { serverId: string }) { const host = useHostProfile(serverId); const isConnected = useHostRuntimeIsConnected(serverId); @@ -199,13 +199,36 @@ export function HostOrchestrationPage({ serverId }: { serverId: string }) { return ( {isConnected ? ( - + ) : ( - Connect to this host to manage orchestration + Connect to this host to manage agents + + )} + + ); +} + +export function HostWorkspacesPage({ serverId }: { serverId: string }) { + const host = useHostProfile(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + + if (!host) { + return ; + } + + return ( + + {isConnected ? ( + + + + ) : ( + + Connect to this host to manage workspaces )} @@ -226,7 +249,7 @@ export function HostProvidersPage({ serverId }: { serverId: string }) { ); } -export function HostDaemonPage({ +export function HostSettingsPage({ serverId, onHostRemoved, }: { @@ -626,6 +649,45 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) { ); } +function AutoArchiveMergedWorkspacesCard({ serverId }: { serverId: string }) { + const isConnected = useHostRuntimeIsConnected(serverId); + const { config, patchConfig } = useDaemonConfig(serverId); + + const handleValueChange = useCallback( + (next: boolean) => { + void patchConfig({ autoArchiveAfterMerge: next }).catch((error) => { + console.error("[HostPage] Failed to update auto-archive after merge", error); + Alert.alert( + "Unable to update workspaces", + error instanceof Error ? error.message : String(error), + ); + }); + }, + [patchConfig], + ); + + if (!isConnected) return null; + + return ( + + + + Archive merged PR workspaces + + Automatically archive clean Paseo workspaces after their pull request is merged + + + + + + ); +} + function AppendSystemPromptCard({ serverId }: { serverId: string }) { const isConnected = useHostRuntimeIsConnected(serverId); const { config, patchConfig } = useDaemonConfig(serverId); diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 02712d2f4..f9c23ec47 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -10,6 +10,7 @@ import { decodeWorkspaceIdFromPathSegment, encodeFilePathForPathSegment, encodeWorkspaceIdForPathSegment, + normalizeHostSectionSlug, parseHostAgentRouteFromPathname, parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceRouteFromPathname, @@ -160,3 +161,18 @@ describe("projects settings routes", () => { expect(decodeURIComponent(segment)).toBe(projectKey); }); }); + +describe("host settings section slugs", () => { + it("keeps current host settings sections", () => { + expect(normalizeHostSectionSlug("connections")).toBe("connections"); + expect(normalizeHostSectionSlug("agents")).toBe("agents"); + expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces"); + expect(normalizeHostSectionSlug("providers")).toBe("providers"); + expect(normalizeHostSectionSlug("host")).toBe("host"); + }); + + it("maps old host settings sections to their new names", () => { + expect(normalizeHostSectionSlug("orchestration")).toBe("agents"); + expect(normalizeHostSectionSlug("daemon")).toBe("host"); + }); +}); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 7d210c194..9d4f2edd5 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -391,14 +391,32 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value); } -export const HOST_SECTION_SLUGS = ["connections", "orchestration", "providers", "daemon"] as const; +export const HOST_SECTION_SLUGS = [ + "connections", + "agents", + "workspaces", + "providers", + "host", +] as const; export type HostSectionSlug = (typeof HOST_SECTION_SLUGS)[number]; +const LEGACY_HOST_SECTION_SLUGS: Record = { + orchestration: "agents", + daemon: "host", +}; + export function isHostSectionSlug(value: string): value is HostSectionSlug { return (HOST_SECTION_SLUGS as readonly string[]).includes(value); } +export function normalizeHostSectionSlug(value: string): HostSectionSlug | null { + if (isHostSectionSlug(value)) { + return value; + } + return LEGACY_HOST_SECTION_SLUGS[value] ?? null; +} + export function buildSettingsRoute() { return "/settings" as const; } diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index 3b531c428..789326195 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -233,6 +233,17 @@ describe("archiveIfSafe", () => { expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); }); + test("does nothing when the upstream status is unknown", async () => { + const harness = createHarness({ + getSnapshot: async () => createSnapshot({ git: { aheadOfOrigin: null } }), + }); + + await runArchiveIfSafe(harness); + + expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled(); + expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); + }); + test("does nothing when the cwd is not a Paseo-owned worktree", async () => { const harness = createHarness({ isPaseoOwnedWorktreeCwd: async () => ({ allowed: false, worktreePath: CWD }), diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index 40fd706e8..d98ceaa86 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -78,7 +78,10 @@ export async function archiveIfSafe(input: { return; } - if (snapshot.git.isDirty === true || (snapshot.git.aheadOfOrigin ?? 0) > 0) { + if (snapshot.git.isDirty === true || snapshot.git.aheadOfOrigin === null) { + return; + } + if (snapshot.git.aheadOfOrigin > 0) { return; } diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 967467292..10131ee60 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -79,7 +79,6 @@ function createCheckoutFacts( comparisonBaseRef: null, branchRemoteName: null, branchMergeRef: null, - trackedOriginBranch: null, pullRequestLookupTarget: { headRef: "main" }, ...overrides, }; @@ -890,7 +889,6 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { currentBranch: "fork-owner/open-button-targets-active-file", branchRemoteName: "paseo-pr-1285", branchMergeRef: "refs/heads/open-button-targets-active-file", - trackedOriginBranch: "paseo-pr-1285/open-button-targets-active-file", pullRequestLookupTarget: { headRef: "open-button-targets-active-file", headRepositoryOwner: "fork-owner", diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index f99a39e2b..eb07fc11f 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -121,7 +121,6 @@ function createCheckoutSnapshotFacts(cwd: string): CheckoutSnapshotFacts { comparisonBaseRef: null, branchRemoteName: "origin", branchMergeRef: "refs/heads/main", - trackedOriginBranch: "main", pullRequestLookupTarget: { headRef: "main" }, }; } diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index fcccea51f..1c45e8869 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -522,6 +522,92 @@ const x = 1; expect(divergedStatus.behindOfOrigin).toBe(1); }); + it("reports a PR worktree as not ahead when its branch is pushed to the configured PR remote", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + const prRemoteDir = join(tempDir, "pr-remote.git"); + execFileSync("git", ["init", "--bare", "-b", "main", prRemoteDir]); + execFileSync("git", ["checkout", "-b", "aaronzhongg/open-button-targets-active-file"], { + cwd: repoDir, + }); + commitFile(repoDir, "feature.txt", "feature\n", "feature commit"); + execFileSync("git", ["remote", "add", "paseo-pr-1285", prRemoteDir], { cwd: repoDir }); + execFileSync( + "git", + ["push", "paseo-pr-1285", "HEAD:refs/heads/open-button-targets-active-file"], + { cwd: repoDir }, + ); + execFileSync( + "git", + ["config", "branch.aaronzhongg/open-button-targets-active-file.remote", "paseo-pr-1285"], + { + cwd: repoDir, + }, + ); + execFileSync( + "git", + [ + "config", + "branch.aaronzhongg/open-button-targets-active-file.merge", + "refs/heads/open-button-targets-active-file", + ], + { cwd: repoDir }, + ); + + const status = await getCheckoutStatus(repoDir); + + expect(status).toMatchObject({ + isGit: true, + currentBranch: "aaronzhongg/open-button-targets-active-file", + aheadOfOrigin: 0, + }); + }); + + it("reports a PR worktree as behind when its configured PR remote has newer commits", async () => { + setupRemoteTrackingMain(repoDir, tempDir); + const prRemoteDir = join(tempDir, "pr-remote.git"); + const prCloneDir = join(tempDir, "pr-clone"); + execFileSync("git", ["init", "--bare", "-b", "main", prRemoteDir]); + execFileSync("git", ["checkout", "-b", "aaronzhongg/open-button-targets-active-file"], { + cwd: repoDir, + }); + commitFile(repoDir, "feature.txt", "feature\n", "feature commit"); + execFileSync("git", ["remote", "add", "paseo-pr-1285", prRemoteDir], { cwd: repoDir }); + execFileSync( + "git", + ["push", "paseo-pr-1285", "HEAD:refs/heads/open-button-targets-active-file"], + { cwd: repoDir }, + ); + execFileSync( + "git", + ["config", "branch.aaronzhongg/open-button-targets-active-file.remote", "paseo-pr-1285"], + { cwd: repoDir }, + ); + execFileSync( + "git", + [ + "config", + "branch.aaronzhongg/open-button-targets-active-file.merge", + "refs/heads/open-button-targets-active-file", + ], + { cwd: repoDir }, + ); + execFileSync("git", ["clone", prRemoteDir, prCloneDir]); + execFileSync("git", ["checkout", "open-button-targets-active-file"], { cwd: prCloneDir }); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: prCloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: prCloneDir }); + commitFile(prCloneDir, "remote.txt", "remote\n", "remote update"); + execFileSync("git", ["push"], { cwd: prCloneDir }); + execFileSync("git", ["fetch", "paseo-pr-1285"], { cwd: repoDir }); + + const status = await getCheckoutStatus(repoDir); + + expect(status).toMatchObject({ + isGit: true, + currentBranch: "aaronzhongg/open-button-targets-active-file", + behindOfOrigin: 1, + }); + }); + it("does not report the full branch history as ahead when the current branch remote is gone", async () => { setupRemoteTrackingMain(repoDir, tempDir); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); @@ -557,11 +643,11 @@ const x = 1; isPaseoOwnedWorktree: true, baseRef: "main", aheadBehind: { ahead: 0, behind: 0 }, - aheadOfOrigin: 0, + aheadOfOrigin: null, }); }); - it("reports local-only worktree commits as unpushed relative to base", async () => { + it("does not report local-only no-track worktree commits as ahead of origin", async () => { setupRemoteTrackingMain(repoDir, tempDir); commitFile(repoDir, "second.txt", "second\n", "second commit"); execFileSync("git", ["push"], { cwd: repoDir }); @@ -581,7 +667,7 @@ const x = 1; isPaseoOwnedWorktree: true, baseRef: "main", aheadBehind: { ahead: 1, behind: 0 }, - aheadOfOrigin: 1, + aheadOfOrigin: null, }); }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index b3d8bca9b..f6bbc6e00 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -802,7 +802,6 @@ export type CheckoutSnapshotFacts = comparisonBaseRef: string | null; branchRemoteName: string | null; branchMergeRef: string | null; - trackedOriginBranch: string | null; pullRequestLookupTarget: PullRequestStatusLookupTarget | null; }; @@ -1407,57 +1406,46 @@ async function getAheadBehind( async function getAheadOfOrigin( cwd: string, currentBranch: string, - baseRef: string | null, context?: CheckoutContext, ): Promise { if (!currentBranch) { return null; } - const trackedOriginBranch = await getTrackedOriginBranch(cwd, currentBranch, context); - const originBranch = trackedOriginBranch ?? currentBranch; + const upstreamRef = await getConfiguredUpstreamRef(cwd, currentBranch, context); + if (!upstreamRef) { + return null; + } try { const { stdout } = await runGitCommand( - ["rev-list", "--count", `origin/${originBranch}..${currentBranch}`], + ["rev-list", "--count", `${upstreamRef}..${currentBranch}`], { cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger }, ); const count = Number.parseInt(stdout.trim(), 10); return Number.isNaN(count) ? null : count; } catch { - if (trackedOriginBranch) { - return null; - } - if (!baseRef || normalizeLocalBranchRefName(baseRef) === currentBranch) { - return null; - } - try { - const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef, context); - const { stdout } = await runGitCommand( - ["rev-list", "--count", `${comparisonBaseRef}..${currentBranch}`], - { cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger }, - ); - const count = Number.parseInt(stdout.trim(), 10); - return Number.isNaN(count) ? null : count; - } catch { - return null; - } + return null; } } -async function getTrackedOriginBranch( +async function getConfiguredUpstreamRef( cwd: string, currentBranch: string, context?: CheckoutContext, ): Promise { - if (context?.facts?.isGit && context.facts.currentBranch === currentBranch) { - return context.facts.trackedOriginBranch; - } - const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context); - if (remoteName !== "origin") { + const remoteName = + context?.facts?.isGit && context.facts.currentBranch === currentBranch + ? context.facts.branchRemoteName + : await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context); + if (!remoteName) { return null; } - const mergeRef = await getGitConfigValue(cwd, `branch.${currentBranch}.merge`, context); - return parseBranchMergeHeadRef(mergeRef); + const mergeRef = + context?.facts?.isGit && context.facts.currentBranch === currentBranch + ? context.facts.branchMergeRef + : await getGitConfigValue(cwd, `branch.${currentBranch}.merge`, context); + const upstreamBranch = parseBranchMergeHeadRef(mergeRef); + return upstreamBranch ? `${remoteName}/${upstreamBranch}` : null; } async function getBehindOfOrigin( @@ -1468,9 +1456,13 @@ async function getBehindOfOrigin( if (!currentBranch) { return null; } + const upstreamRef = await getConfiguredUpstreamRef(cwd, currentBranch, context); + if (!upstreamRef) { + return null; + } try { const { stdout } = await runGitCommand( - ["rev-list", "--count", `${currentBranch}..origin/${currentBranch}`], + ["rev-list", "--count", `${currentBranch}..${upstreamRef}`], { cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger }, ); const count = Number.parseInt(stdout.trim(), 10); @@ -1602,8 +1594,6 @@ export async function getCheckoutSnapshotFacts( } } } - const trackedOriginBranch = - branchRemoteName === "origin" ? parseBranchMergeHeadRef(branchMergeRef) : null; const pullRequestLookupTarget = inspected.currentBranch ? buildPullRequestLookupTargetFromBranchConfig({ currentBranch: inspected.currentBranch, @@ -1627,7 +1617,6 @@ export async function getCheckoutSnapshotFacts( comparisonBaseRef, branchRemoteName, branchMergeRef, - trackedOriginBranch, pullRequestLookupTarget, }; } @@ -1765,7 +1754,7 @@ export async function getCheckoutStatus( ? getAheadBehind(cwd, baseRef, currentBranch, factsContext) : Promise.resolve(null), hasRemote && currentBranch - ? getAheadOfOrigin(cwd, currentBranch, baseRef, factsContext) + ? getAheadOfOrigin(cwd, currentBranch, factsContext) : Promise.resolve(null), hasRemote && currentBranch ? getBehindOfOrigin(cwd, currentBranch, factsContext)