Remove a worktree when its last workspace is archived (#1562)

* Remove a worktree when its last workspace is archived

Archiving is now workspace-centric: the UI always archives a workspace,
and a worktree's directory is removed only when its last referencing
workspace is archived (derived from ownership + a single reference
count). The "remove the worktree from disk?" prompt is gone; archiving
warns only when the workspace is dirty or has unpushed commits.

MCP archive_worktree and the CLI keep the explicit lower-level behavior:
archive every workspace backing the directory, then remove it.
Auto-archive-after-merge and create-agent auto-archive respect the
reference count like any other workspace archival.

The policy now lives in two modules - a server archive service and a
client archive hook - with the reference-count check in exactly one
place. The deleteWorktreeFromDisk request field is retained but ignored
for wire back-compat.

* Tidy archive-policy internals and close test gaps

Follow-up polish on the archive-policy consolidation; no behavior change.

Structure: the server archive service is renamed to reflect that it now
owns all archive policy (the file and its types no longer say "worktree"
where they mean "archive"); the client archive input derives the
workspace kind from the canonical type instead of re-listing it; the
not-found target is typed string|null instead of an empty-string
sentinel; a redundant workspace-update emit and the ambiguous
worktrees-root naming are cleaned up. Wire, SDK, and keybinding names are
deliberately left unchanged for back-compat.

Tests: re-home the archiving-state lifecycle and per-workspace snapshot
assertions; pin worktree-kind acceptance through archive_workspace, the
ignored deleteWorktreeFromDisk field, and three-workspace archive-all;
add a Playwright spec for the dirty/unpushed worktree archive flow (the
warning gates it, confirming removes the directory) and the bulk
remove-project confirm branch.

* Fix the hanging worktree-archive risk-warning e2e spec

The spec added in the previous commit hung deterministically, locally and
in CI. Two test-only bugs, no product change.

The archive click opens a synchronous window.confirm(); the spec
registered a passive dialog waiter but only dismissed the dialog after
awaiting the click, so the click never resolved (Playwright won't settle
a click while a dialog is open) - a circular wait. Answer the dialog
inline via page.once("dialog", ...) before the click resolves, matching
the proven worktree-archive spec.

The seed also committed on a never-pushed branch, so aheadOfOrigin was
null and the "1 unpushed commit" warning never appeared. Push the branch
to set its upstream before the local commit, so it reports exactly one
unpushed commit.

Verified: the spec now passes in ~6s (was a 45s timeout); both the
dismiss-gates and accept-removes paths run.
This commit is contained in:
Mohamed Boudra
2026-06-17 00:38:56 +08:00
committed by GitHub
parent 0fef66283a
commit e202ca5036
45 changed files with 2627 additions and 1610 deletions
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import { selectProjectWorkspacesToArchive } from "@/workspace/project-workspace-archive";
describe("selectProjectWorkspacesToArchive", () => {
it("skips archiving a dirty and unpushed worktree when the risky archive confirmation is canceled", async () => {
const confirmWorktreeArchive = vi.fn(async () => false);
const targets = await selectProjectWorkspacesToArchive(
[
{
serverId: "server-1",
workspaceId: "workspace-worktree",
workspaceKind: "worktree",
name: "feature/risky",
archiveHasUncommittedChanges: true,
archiveUnpushedCommitCount: 2,
diffStat: { additions: 5, deletions: 1 },
},
{
serverId: "server-1",
workspaceId: "workspace-checkout",
workspaceKind: "local_checkout",
name: "main",
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
diffStat: null,
},
],
confirmWorktreeArchive,
);
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
worktreeName: "feature/risky",
isDirty: true,
aheadOfOrigin: 2,
diffStat: { additions: 5, deletions: 1 },
});
expect(targets).toEqual([
{
serverId: "server-1",
workspaceId: "workspace-checkout",
},
]);
});
it("includes a dirty and unpushed worktree when the risky archive confirmation is accepted", async () => {
const confirmWorktreeArchive = vi.fn(async () => true);
const targets = await selectProjectWorkspacesToArchive(
[
{
serverId: "server-1",
workspaceId: "workspace-worktree",
workspaceKind: "worktree",
name: "feature/risky",
archiveHasUncommittedChanges: true,
archiveUnpushedCommitCount: 2,
diffStat: { additions: 5, deletions: 1 },
},
{
serverId: "server-1",
workspaceId: "workspace-checkout",
workspaceKind: "local_checkout",
name: "main",
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
diffStat: null,
},
],
confirmWorktreeArchive,
);
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
worktreeName: "feature/risky",
isDirty: true,
aheadOfOrigin: 2,
diffStat: { additions: 5, deletions: 1 },
});
expect(targets).toEqual([
{
serverId: "server-1",
workspaceId: "workspace-worktree",
},
{
serverId: "server-1",
workspaceId: "workspace-checkout",
},
]);
});
});
@@ -0,0 +1,42 @@
import { confirmRiskyWorktreeArchive, toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { WorkspaceArchiveTarget } from "@/workspace/workspace-archive";
export interface ProjectWorkspaceArchiveEntry extends Pick<
SidebarWorkspaceEntry,
| "serverId"
| "workspaceId"
| "workspaceKind"
| "name"
| "archiveHasUncommittedChanges"
| "archiveUnpushedCommitCount"
| "diffStat"
> {}
type ConfirmWorktreeArchive = typeof confirmRiskyWorktreeArchive;
export async function selectProjectWorkspacesToArchive(
workspaces: ProjectWorkspaceArchiveEntry[],
confirmWorktreeArchive: ConfirmWorktreeArchive = confirmRiskyWorktreeArchive,
): Promise<WorkspaceArchiveTarget[]> {
const confirmed: WorkspaceArchiveTarget[] = [];
for (const workspace of workspaces) {
if (workspace.workspaceKind === "worktree") {
const shouldArchive = await confirmWorktreeArchive({
worktreeName: workspace.name,
...toWorktreeArchiveRisk(workspace),
});
if (!shouldArchive) {
continue;
}
}
confirmed.push({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
}
return confirmed;
}
@@ -1,105 +1,83 @@
import { useCallback, useState } from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
import {
confirmRiskyWorktreeArchive,
DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
// A workspace is the last reference to its backing worktree when no other active
// workspace on the same host points at the same directory. A directory can back
// multiple workspaces (Model B), so a sibling reference must keep the worktree on
// disk even when this workspace is archived. Local checkouts are never worktrees,
// so they never reach the disk-deletion prompt.
export function useIsLastWorktreeReference(workspace: SidebarWorkspaceEntry): boolean {
return useSessionStore((state) => {
if (workspace.workspaceKind !== "worktree") {
return false;
}
const directory = normalizeWorkspacePath(workspace.workspaceDirectory);
if (!directory) {
return false;
}
const workspaces = state.sessions[workspace.serverId]?.workspaces;
if (!workspaces) {
return true;
}
for (const candidate of workspaces.values()) {
if (candidate.id === workspace.workspaceId) {
continue;
}
// Git-fact: comparing directories to detect a sibling worktree reference on
// disk, not attributing ownership. The disk-deletion decision is about the
// backing directory, which same-cwd siblings genuinely share.
if (normalizeWorkspacePath(candidate.workspaceDirectory) === directory) {
return false;
}
}
return true;
});
export interface ArchiveWorkspaceInput {
serverId: string;
workspaceId: string;
workspaceDirectory: string | null | undefined;
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
isDirty?: boolean | null;
aheadOfOrigin?: number | null;
diffStat?: { additions: number; deletions: number } | null;
warningLabels?: WorktreeArchiveWarningLabels;
onArchiveStarted: () => void;
onSetHiding?: (hiding: boolean) => void;
}
export interface WorkspaceArchiveController {
// Begins the archive flow. For a last-reference worktree this opens the inline
// keep/delete prompt; otherwise it archives the workspace record directly.
beginArchive: () => void;
// Inline prompt state for the last-reference worktree case.
deletePromptOpen: boolean;
confirmKeepOnDisk: () => void;
confirmDeleteFromDisk: () => void;
cancelDeletePrompt: () => void;
archive: () => void;
}
export function useWorkspaceArchive(input: {
workspace: SidebarWorkspaceEntry;
onArchiveStarted: () => void;
onSetHiding?: (hiding: boolean) => void;
}): WorkspaceArchiveController {
const { workspace, onArchiveStarted, onSetHiding } = input;
export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArchiveController {
const {
serverId,
workspaceId,
workspaceDirectory,
workspaceKind,
name,
isDirty,
aheadOfOrigin,
diffStat,
warningLabels = DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
onArchiveStarted,
onSetHiding,
} = input;
const { t } = useTranslation();
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const isLastWorktreeReference = useIsLastWorktreeReference(workspace);
const [deletePromptOpen, setDeletePromptOpen] = useState(false);
const archiveWorktreeRecord = useCallback(
(deleteWorktreeFromDisk: boolean) => {
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
onArchiveStarted();
void archiveWorktree({
serverId: workspace.serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
workspaceId: workspace.workspaceId,
deleteWorktreeFromDisk,
}).catch((error) => {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
);
const archiveWorktreeRecord = useCallback(() => {
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceDirectory({
workspaceId,
workspaceDirectory,
});
},
[archiveWorktree, onArchiveStarted, t, toast, workspace],
);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
onArchiveStarted();
void archiveWorktree({
serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
workspaceId,
}).catch((error) => {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
);
});
}, [archiveWorktree, onArchiveStarted, serverId, t, toast, workspaceDirectory, workspaceId]);
const archiveNonWorktreeRecord = useCallback(async () => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
return;
@@ -108,7 +86,10 @@ export function useWorkspaceArchive(input: {
try {
await archiveWorkspaceOptimistically({
client,
workspace,
workspace: {
serverId,
workspaceId,
},
afterHide: onArchiveStarted,
});
} catch (error) {
@@ -118,50 +99,40 @@ export function useWorkspaceArchive(input: {
} finally {
onSetHiding?.(false);
}
}, [onArchiveStarted, onSetHiding, t, toast, workspace]);
}, [onArchiveStarted, onSetHiding, serverId, t, toast, workspaceId]);
const beginArchive = useCallback(() => {
const archive = useCallback(() => {
void (async () => {
if (workspace.workspaceKind === "worktree") {
const confirmed = await confirmRiskyWorktreeArchive({
worktreeName: workspace.name,
isDirty: workspace.archiveHasUncommittedChanges,
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
diffStat: workspace.diffStat,
});
if (workspaceKind === "worktree") {
const confirmed = await confirmRiskyWorktreeArchive(
{
worktreeName: name,
isDirty,
aheadOfOrigin,
diffStat,
},
warningLabels,
);
if (!confirmed) {
return;
}
if (isLastWorktreeReference) {
setDeletePromptOpen(true);
return;
}
archiveWorktreeRecord(false);
archiveWorktreeRecord();
return;
}
await archiveNonWorktreeRecord();
})();
}, [archiveNonWorktreeRecord, archiveWorktreeRecord, isLastWorktreeReference, workspace]);
const confirmKeepOnDisk = useCallback(() => {
setDeletePromptOpen(false);
archiveWorktreeRecord(false);
}, [archiveWorktreeRecord]);
const confirmDeleteFromDisk = useCallback(() => {
setDeletePromptOpen(false);
archiveWorktreeRecord(true);
}, [archiveWorktreeRecord]);
const cancelDeletePrompt = useCallback(() => {
setDeletePromptOpen(false);
}, []);
}, [
aheadOfOrigin,
archiveNonWorktreeRecord,
archiveWorktreeRecord,
diffStat,
isDirty,
name,
warningLabels,
workspaceKind,
]);
return {
beginArchive,
deletePromptOpen,
confirmKeepOnDisk,
confirmDeleteFromDisk,
cancelDeletePrompt,
archive,
};
}
@@ -1,71 +0,0 @@
import { useMemo } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
// Inline keep/delete confirmation shown when archiving the last reference to a
// Paseo-owned worktree. "Keep on disk" is the default, non-destructive choice;
// "Delete" removes the worktree directory from disk.
export function WorktreeDeletePrompt({
visible,
workspaceName,
onKeep,
onDelete,
onCancel,
}: {
visible: boolean;
workspaceName: string;
onKeep: () => void;
onDelete: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const header = useMemo(
() => ({ title: t("sidebar.workspace.confirmations.deleteWorktreePrompt.title") }),
[t],
);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onCancel}
scrollable={false}
desktopMaxWidth={420}
testID="worktree-delete-confirm"
>
<View style={styles.body}>
<Text style={styles.message}>
{t("sidebar.workspace.confirmations.deleteWorktreePrompt.message", {
workspaceName,
})}
</Text>
<View style={styles.actions}>
<Button variant="default" onPress={onKeep} testID="worktree-delete-confirm-keep">
{t("sidebar.workspace.confirmations.deleteWorktreePrompt.keep")}
</Button>
<Button variant="destructive" onPress={onDelete} testID="worktree-delete-confirm-delete">
{t("sidebar.workspace.confirmations.deleteWorktreePrompt.delete")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
const styles = StyleSheet.create((theme) => ({
body: {
gap: theme.spacing[4],
},
message: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
actions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
}));