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
This commit is contained in:
Mohamed Boudra
2026-06-03 23:17:08 +08:00
committed by GitHub
parent bd4889e243
commit 369adced52
14 changed files with 262 additions and 75 deletions
+2 -2
View File
@@ -257,9 +257,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> { export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page); await openSettings(page);
await openSettingsHost(page, serverId); 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. // 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({ await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
timeout: 15_000, timeout: 15_000,
}); });
+6 -5
View File
@@ -15,7 +15,7 @@ const SECTION_LABELS = {
export type SettingsSection = keyof typeof SECTION_LABELS | "projects"; 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<void> { export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar"); const sidebar = page.getByTestId("settings-sidebar");
@@ -231,9 +231,9 @@ export async function openHostSection(
} }
export async function expectHostActionCards(page: Page, serverId: string): Promise<void> { export async function expectHostActionCards(page: Page, serverId: string): Promise<void> {
// 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). // 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-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible(); await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible(); await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
@@ -296,9 +296,10 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
// Host group rows are now flat top-level sections (no drill-in). // Host group rows are now flat top-level sections (no drill-in).
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible(); await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-orchestration")).toBeVisible(); await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible(); await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-daemon")).toBeVisible(); await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
// The old per-host entry rows are replaced by the host picker. // The old per-host entry rows are replaced by the host picker.
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0); await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0);
+10 -10
View File
@@ -33,15 +33,15 @@ test.describe("Settings host page", () => {
await expectHostConnectionsCard(page, port); 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(); const serverId = getServerId();
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await openSettingsHost(page, serverId); await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "orchestration"); await openHostSection(page, serverId, "agents");
await expectSettingsHeader(page, "Orchestration"); await expectSettingsHeader(page, "Agents");
await expectHostInjectMcpCard(page); await expectHostInjectMcpCard(page);
}); });
@@ -56,15 +56,15 @@ test.describe("Settings host page", () => {
await expectSettingsHeader(page, "Providers"); 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(); const serverId = getServerId();
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await openSettingsHost(page, serverId); await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon"); await openHostSection(page, serverId, "host");
await expectSettingsHeader(page, "Daemon"); await expectSettingsHeader(page, "Host");
await expectHostLabelDisplayed(page); await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId); await expectHostActionCards(page, serverId);
}); });
@@ -75,14 +75,14 @@ test.describe("Settings host page", () => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await openSettingsHost(page, serverId); await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon"); await openHostSection(page, serverId, "host");
await expectHostLabelDisplayed(page); await expectHostLabelDisplayed(page);
await clickEditHostLabel(page); await clickEditHostLabel(page);
await expectHostLabelEditMode(page, TEST_HOST_LABEL); 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, page,
}) => { }) => {
const serverId = getServerId(); const serverId = getServerId();
@@ -90,7 +90,7 @@ test.describe("Settings host page", () => {
await gotoAppShell(page); await gotoAppShell(page);
await openSettings(page); await openSettings(page);
await openSettingsHost(page, serverId); await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon"); await openHostSection(page, serverId, "host");
// TODO: add local-daemon fixture for positive Pair/Daemon coverage. // TODO: add local-daemon fixture for positive Pair/Daemon coverage.
await expectHostNoLocalOnlyRows(page); await expectHostNoLocalOnlyRows(page);
@@ -113,7 +113,7 @@ test.describe("Settings host page", () => {
await expectHostPageVisible(page, serverId); await expectHostPageVisible(page, serverId);
await expectSettingsHeader(page, "Connections"); await expectSettingsHeader(page, "Connections");
await openHostSection(page, serverId, "daemon"); await openHostSection(page, serverId, "host");
await expectHostLabelDisplayed(page); await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId); await expectHostActionCards(page, serverId);
}); });
@@ -2,13 +2,13 @@ import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react"; import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen"; import SettingsScreen from "@/screens/settings-screen";
import { type HostSectionSlug, isHostSectionSlug } from "@/utils/host-routes"; import { normalizeHostSectionSlug } from "@/utils/host-routes";
export default function SettingsHostSectionRoute() { export default function SettingsHostSectionRoute() {
const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>(); const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : ""; const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const rawSection = typeof params.hostSection === "string" ? params.hostSection : ""; 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]); const view = useMemo(() => ({ kind: "host" as const, serverId, section }), [serverId, section]);
return ( return (
+13 -9
View File
@@ -30,7 +30,7 @@ import {
Palette, Palette,
Server, Server,
Network, Network,
Workflow, Bot,
Boxes, Boxes,
Keyboard, Keyboard,
Stethoscope, Stethoscope,
@@ -93,9 +93,10 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { import {
HostConnectionsPage, HostConnectionsPage,
HostDaemonPage, HostAgentsPage,
HostOrchestrationPage, HostSettingsPage,
HostProvidersPage, HostProvidersPage,
HostWorkspacesPage,
} from "@/screens/settings/host-page"; } from "@/screens/settings/host-page";
import ProjectsScreen from "@/screens/projects-screen"; import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen"; import ProjectSettingsScreen from "@/screens/project-settings-screen";
@@ -148,9 +149,10 @@ interface HostSectionItem {
const HOST_SECTION_ITEMS: HostSectionItem[] = [ const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "connections", label: "Connections", icon: Network }, { 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: "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) { switch (view.section) {
case "connections": case "connections":
return <HostConnectionsPage serverId={view.serverId} />; return <HostConnectionsPage serverId={view.serverId} />;
case "orchestration": case "agents":
return <HostOrchestrationPage serverId={view.serverId} />; return <HostAgentsPage serverId={view.serverId} />;
case "workspaces":
return <HostWorkspacesPage serverId={view.serverId} />;
case "providers": case "providers":
return <HostProvidersPage serverId={view.serverId} />; return <HostProvidersPage serverId={view.serverId} />;
case "daemon": case "host":
return <HostDaemonPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />; return <HostSettingsPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
} }
} }
if (view.kind === "projects") { if (view.kind === "projects") {
@@ -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 host = useHostProfile(serverId);
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
@@ -199,13 +199,36 @@ export function HostOrchestrationPage({ serverId }: { serverId: string }) {
return ( return (
<View> <View>
{isConnected ? ( {isConnected ? (
<SettingsSection title="Orchestration"> <SettingsSection title="Agents">
<InjectPaseoToolsCard serverId={serverId} /> <InjectPaseoToolsCard serverId={serverId} />
<AppendSystemPromptCard serverId={serverId} /> <AppendSystemPromptCard serverId={serverId} />
</SettingsSection> </SettingsSection>
) : ( ) : (
<View style={EMPTY_CARD_STYLE}> <View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>Connect to this host to manage orchestration</Text> <Text style={styles.emptyText}>Connect to this host to manage agents</Text>
</View>
)}
</View>
);
}
export function HostWorkspacesPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
{isConnected ? (
<SettingsSection title="Workspaces">
<AutoArchiveMergedWorkspacesCard serverId={serverId} />
</SettingsSection>
) : (
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>Connect to this host to manage workspaces</Text>
</View> </View>
)} )}
</View> </View>
@@ -226,7 +249,7 @@ export function HostProvidersPage({ serverId }: { serverId: string }) {
); );
} }
export function HostDaemonPage({ export function HostSettingsPage({
serverId, serverId,
onHostRemoved, 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 (
<View style={settingsStyles.card} testID="host-page-auto-archive-merged-workspaces-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Archive merged PR workspaces</Text>
<Text style={settingsStyles.rowHint}>
Automatically archive clean Paseo workspaces after their pull request is merged
</Text>
</View>
<Switch
value={config?.autoArchiveAfterMerge === true}
onValueChange={handleValueChange}
accessibilityLabel="Archive merged PR workspaces"
testID="host-page-auto-archive-merged-workspaces-switch"
/>
</View>
</View>
);
}
function AppendSystemPromptCard({ serverId }: { serverId: string }) { function AppendSystemPromptCard({ serverId }: { serverId: string }) {
const isConnected = useHostRuntimeIsConnected(serverId); const isConnected = useHostRuntimeIsConnected(serverId);
const { config, patchConfig } = useDaemonConfig(serverId); const { config, patchConfig } = useDaemonConfig(serverId);
@@ -10,6 +10,7 @@ import {
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
encodeFilePathForPathSegment, encodeFilePathForPathSegment,
encodeWorkspaceIdForPathSegment, encodeWorkspaceIdForPathSegment,
normalizeHostSectionSlug,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceOpenIntentFromPathname,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
@@ -160,3 +161,18 @@ describe("projects settings routes", () => {
expect(decodeURIComponent(segment)).toBe(projectKey); 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");
});
});
+19 -1
View File
@@ -391,14 +391,32 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value); 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]; export type HostSectionSlug = (typeof HOST_SECTION_SLUGS)[number];
const LEGACY_HOST_SECTION_SLUGS: Record<string, HostSectionSlug> = {
orchestration: "agents",
daemon: "host",
};
export function isHostSectionSlug(value: string): value is HostSectionSlug { export function isHostSectionSlug(value: string): value is HostSectionSlug {
return (HOST_SECTION_SLUGS as readonly string[]).includes(value); 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() { export function buildSettingsRoute() {
return "/settings" as const; return "/settings" as const;
} }
@@ -233,6 +233,17 @@ describe("archiveIfSafe", () => {
expect(harness.deps.archivePaseoWorktree).not.toHaveBeenCalled(); 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 () => { test("does nothing when the cwd is not a Paseo-owned worktree", async () => {
const harness = createHarness({ const harness = createHarness({
isPaseoOwnedWorktreeCwd: async () => ({ allowed: false, worktreePath: CWD }), isPaseoOwnedWorktreeCwd: async () => ({ allowed: false, worktreePath: CWD }),
@@ -78,7 +78,10 @@ export async function archiveIfSafe(input: {
return; 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; return;
} }
@@ -79,7 +79,6 @@ function createCheckoutFacts(
comparisonBaseRef: null, comparisonBaseRef: null,
branchRemoteName: null, branchRemoteName: null,
branchMergeRef: null, branchMergeRef: null,
trackedOriginBranch: null,
pullRequestLookupTarget: { headRef: "main" }, pullRequestLookupTarget: { headRef: "main" },
...overrides, ...overrides,
}; };
@@ -890,7 +889,6 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
currentBranch: "fork-owner/open-button-targets-active-file", currentBranch: "fork-owner/open-button-targets-active-file",
branchRemoteName: "paseo-pr-1285", branchRemoteName: "paseo-pr-1285",
branchMergeRef: "refs/heads/open-button-targets-active-file", branchMergeRef: "refs/heads/open-button-targets-active-file",
trackedOriginBranch: "paseo-pr-1285/open-button-targets-active-file",
pullRequestLookupTarget: { pullRequestLookupTarget: {
headRef: "open-button-targets-active-file", headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner", headRepositoryOwner: "fork-owner",
@@ -121,7 +121,6 @@ function createCheckoutSnapshotFacts(cwd: string): CheckoutSnapshotFacts {
comparisonBaseRef: null, comparisonBaseRef: null,
branchRemoteName: "origin", branchRemoteName: "origin",
branchMergeRef: "refs/heads/main", branchMergeRef: "refs/heads/main",
trackedOriginBranch: "main",
pullRequestLookupTarget: { headRef: "main" }, pullRequestLookupTarget: { headRef: "main" },
}; };
} }
+89 -3
View File
@@ -522,6 +522,92 @@ const x = 1;
expect(divergedStatus.behindOfOrigin).toBe(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 () => { it("does not report the full branch history as ahead when the current branch remote is gone", async () => {
setupRemoteTrackingMain(repoDir, tempDir); setupRemoteTrackingMain(repoDir, tempDir);
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir });
@@ -557,11 +643,11 @@ const x = 1;
isPaseoOwnedWorktree: true, isPaseoOwnedWorktree: true,
baseRef: "main", baseRef: "main",
aheadBehind: { ahead: 0, behind: 0 }, 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); setupRemoteTrackingMain(repoDir, tempDir);
commitFile(repoDir, "second.txt", "second\n", "second commit"); commitFile(repoDir, "second.txt", "second\n", "second commit");
execFileSync("git", ["push"], { cwd: repoDir }); execFileSync("git", ["push"], { cwd: repoDir });
@@ -581,7 +667,7 @@ const x = 1;
isPaseoOwnedWorktree: true, isPaseoOwnedWorktree: true,
baseRef: "main", baseRef: "main",
aheadBehind: { ahead: 1, behind: 0 }, aheadBehind: { ahead: 1, behind: 0 },
aheadOfOrigin: 1, aheadOfOrigin: null,
}); });
}); });
+24 -35
View File
@@ -802,7 +802,6 @@ export type CheckoutSnapshotFacts =
comparisonBaseRef: string | null; comparisonBaseRef: string | null;
branchRemoteName: string | null; branchRemoteName: string | null;
branchMergeRef: string | null; branchMergeRef: string | null;
trackedOriginBranch: string | null;
pullRequestLookupTarget: PullRequestStatusLookupTarget | null; pullRequestLookupTarget: PullRequestStatusLookupTarget | null;
}; };
@@ -1407,57 +1406,46 @@ async function getAheadBehind(
async function getAheadOfOrigin( async function getAheadOfOrigin(
cwd: string, cwd: string,
currentBranch: string, currentBranch: string,
baseRef: string | null,
context?: CheckoutContext, context?: CheckoutContext,
): Promise<number | null> { ): Promise<number | null> {
if (!currentBranch) { if (!currentBranch) {
return null; return null;
} }
const trackedOriginBranch = await getTrackedOriginBranch(cwd, currentBranch, context); const upstreamRef = await getConfiguredUpstreamRef(cwd, currentBranch, context);
const originBranch = trackedOriginBranch ?? currentBranch; if (!upstreamRef) {
return null;
}
try { try {
const { stdout } = await runGitCommand( const { stdout } = await runGitCommand(
["rev-list", "--count", `origin/${originBranch}..${currentBranch}`], ["rev-list", "--count", `${upstreamRef}..${currentBranch}`],
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger }, { cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
); );
const count = Number.parseInt(stdout.trim(), 10); const count = Number.parseInt(stdout.trim(), 10);
return Number.isNaN(count) ? null : count; return Number.isNaN(count) ? null : count;
} catch { } catch {
if (trackedOriginBranch) { return null;
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;
}
} }
} }
async function getTrackedOriginBranch( async function getConfiguredUpstreamRef(
cwd: string, cwd: string,
currentBranch: string, currentBranch: string,
context?: CheckoutContext, context?: CheckoutContext,
): Promise<string | null> { ): Promise<string | null> {
if (context?.facts?.isGit && context.facts.currentBranch === currentBranch) { const remoteName =
return context.facts.trackedOriginBranch; context?.facts?.isGit && context.facts.currentBranch === currentBranch
} ? context.facts.branchRemoteName
const remoteName = await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context); : await getGitConfigValue(cwd, `branch.${currentBranch}.remote`, context);
if (remoteName !== "origin") { if (!remoteName) {
return null; return null;
} }
const mergeRef = await getGitConfigValue(cwd, `branch.${currentBranch}.merge`, context); const mergeRef =
return parseBranchMergeHeadRef(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( async function getBehindOfOrigin(
@@ -1468,9 +1456,13 @@ async function getBehindOfOrigin(
if (!currentBranch) { if (!currentBranch) {
return null; return null;
} }
const upstreamRef = await getConfiguredUpstreamRef(cwd, currentBranch, context);
if (!upstreamRef) {
return null;
}
try { try {
const { stdout } = await runGitCommand( const { stdout } = await runGitCommand(
["rev-list", "--count", `${currentBranch}..origin/${currentBranch}`], ["rev-list", "--count", `${currentBranch}..${upstreamRef}`],
{ cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger }, { cwd, envOverlay: READ_ONLY_GIT_ENV, logger: context?.logger },
); );
const count = Number.parseInt(stdout.trim(), 10); 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 const pullRequestLookupTarget = inspected.currentBranch
? buildPullRequestLookupTargetFromBranchConfig({ ? buildPullRequestLookupTargetFromBranchConfig({
currentBranch: inspected.currentBranch, currentBranch: inspected.currentBranch,
@@ -1627,7 +1617,6 @@ export async function getCheckoutSnapshotFacts(
comparisonBaseRef, comparisonBaseRef,
branchRemoteName, branchRemoteName,
branchMergeRef, branchMergeRef,
trackedOriginBranch,
pullRequestLookupTarget, pullRequestLookupTarget,
}; };
} }
@@ -1765,7 +1754,7 @@ export async function getCheckoutStatus(
? getAheadBehind(cwd, baseRef, currentBranch, factsContext) ? getAheadBehind(cwd, baseRef, currentBranch, factsContext)
: Promise.resolve(null), : Promise.resolve(null),
hasRemote && currentBranch hasRemote && currentBranch
? getAheadOfOrigin(cwd, currentBranch, baseRef, factsContext) ? getAheadOfOrigin(cwd, currentBranch, factsContext)
: Promise.resolve(null), : Promise.resolve(null),
hasRemote && currentBranch hasRemote && currentBranch
? getBehindOfOrigin(cwd, currentBranch, factsContext) ? getBehindOfOrigin(cwd, currentBranch, factsContext)