mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -257,9 +257,9 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
@@ -231,9 +231,9 @@ export async function openHostSection(
|
||||
}
|
||||
|
||||
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).
|
||||
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<vo
|
||||
|
||||
// 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-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-daemon")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
|
||||
|
||||
// The old per-host entry rows are replaced by the host picker.
|
||||
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0);
|
||||
|
||||
@@ -33,15 +33,15 @@ test.describe("Settings host page", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 <HostConnectionsPage serverId={view.serverId} />;
|
||||
case "orchestration":
|
||||
return <HostOrchestrationPage serverId={view.serverId} />;
|
||||
case "agents":
|
||||
return <HostAgentsPage serverId={view.serverId} />;
|
||||
case "workspaces":
|
||||
return <HostWorkspacesPage serverId={view.serverId} />;
|
||||
case "providers":
|
||||
return <HostProvidersPage serverId={view.serverId} />;
|
||||
case "daemon":
|
||||
return <HostDaemonPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
||||
case "host":
|
||||
return <HostSettingsPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
||||
}
|
||||
}
|
||||
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 isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
@@ -199,13 +199,36 @@ export function HostOrchestrationPage({ serverId }: { serverId: string }) {
|
||||
return (
|
||||
<View>
|
||||
{isConnected ? (
|
||||
<SettingsSection title="Orchestration">
|
||||
<SettingsSection title="Agents">
|
||||
<InjectPaseoToolsCard serverId={serverId} />
|
||||
<AppendSystemPromptCard serverId={serverId} />
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<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>
|
||||
@@ -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 (
|
||||
<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 }) {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, HostSectionSlug> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -121,7 +121,6 @@ function createCheckoutSnapshotFacts(cwd: string): CheckoutSnapshotFacts {
|
||||
comparisonBaseRef: null,
|
||||
branchRemoteName: "origin",
|
||||
branchMergeRef: "refs/heads/main",
|
||||
trackedOriginBranch: "main",
|
||||
pullRequestLookupTarget: { headRef: "main" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<number | null> {
|
||||
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<string | null> {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user