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
+1 -3
View File
@@ -252,7 +252,6 @@ interface CheckoutGitActionsStoreState {
cwd: string;
worktreePath: string;
workspaceId?: string;
deleteWorktreeFromDisk?: boolean;
}) => Promise<void>;
}
@@ -492,7 +491,7 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId, deleteWorktreeFromDisk }) => {
archiveWorktree: async ({ serverId, cwd, worktreePath, workspaceId }) => {
await runCheckoutAction({
serverId,
cwd,
@@ -517,7 +516,6 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
const payload = await client.archivePaseoWorktree({
worktreePath,
...(workspaceId !== undefined ? { workspaceId } : {}),
deleteWorktreeFromDisk,
});
if (payload.error) {
throw new Error(payload.error.message);
+1 -2
View File
@@ -2103,7 +2103,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
}),
[],
);
const { gitActions, branchLabel, worktreeDeletePrompt } = useGitActions({
const { gitActions, branchLabel } = useGitActions({
serverId,
cwd,
icons: gitActionsIcons,
@@ -2164,7 +2164,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
testID="changes-branch-switcher"
/>
{isMobile ? <GitActionsSplitButton gitActions={gitActions} /> : null}
{worktreeDeletePrompt}
</View>
) : null}
+69 -140
View File
@@ -13,14 +13,14 @@ import {
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useSessionStore } from "@/stores/session-store";
import {
confirmRiskyWorktreeArchive,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
import { WorktreeDeletePrompt } from "@/workspace/worktree-delete-prompt";
useActiveWorkspaceSelection,
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -53,65 +53,6 @@ function formatBaseRefLabel(baseRef: string | undefined, fallbackLabel: string):
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}
// The header archive only appears for Paseo-owned worktrees. When this is the
// last active workspace referencing the worktree directory, offer to remove it
// from disk; otherwise a sibling workspace still needs the directory.
function isLastWorktreeReference(workspaces: WorkspaceDescriptor[], worktreePath: string): boolean {
let references = 0;
for (const candidate of workspaces) {
// Git-fact: counting how many workspaces still reference the worktree
// directory on disk, not attributing ownership. Same-cwd siblings keep the
// directory alive, so the disk-deletion offer must compare directories.
if (candidate.workspaceDirectory === worktreePath) {
references += 1;
}
}
return references <= 1;
}
// Owns the inline keep/delete prompt for the last-reference worktree case so the
// archive flow stays a single decision point and `useGitActions` keeps a flat
// shape.
function useWorktreeDeletePrompt(
runArchive: (worktreePath: string, deleteWorktreeFromDisk: boolean) => void,
): {
open: (input: { worktreePath: string; workspaceName: string }) => void;
element: ReactElement;
} {
const [state, setState] = useState<{ worktreePath: string; workspaceName: string } | null>(null);
const resolve = useCallback(
(deleteWorktreeFromDisk: boolean) => {
const prompt = state;
setState(null);
if (prompt) {
runArchive(prompt.worktreePath, deleteWorktreeFromDisk);
}
},
[runArchive, state],
);
const onKeep = useCallback(() => resolve(false), [resolve]);
const onDelete = useCallback(() => resolve(true), [resolve]);
const onCancel = useCallback(() => setState(null), []);
const open = useCallback(
(input: { worktreePath: string; workspaceName: string }) => setState(input),
[],
);
const element = (
<WorktreeDeletePrompt
visible={state !== null}
workspaceName={state?.workspaceName ?? ""}
onKeep={onKeep}
onDelete={onDelete}
onCancel={onCancel}
/>
);
return { open, element };
}
type PrStatusValue = NonNullable<CheckoutPrStatusPayload["status"]> | null;
interface DeriveGitActionsStateArgs {
@@ -220,9 +161,59 @@ interface UseGitActionsResult {
gitActions: GitActions;
branchLabel: string;
isGit: boolean;
// Inline keep/delete confirmation for archiving the last reference to a
// Paseo-owned worktree. Consumers must render this so the prompt is visible.
worktreeDeletePrompt: ReactElement | null;
}
interface UseWorkspaceScreenArchiveControllerInput {
serverId: string;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
workspaceDirectory: string | null | undefined;
branchLabel: string;
gitStatus: CheckoutStatusPayload | null;
t: (key: string, options?: Record<string, unknown>) => string;
}
function useWorkspaceScreenArchiveController({
serverId,
activeWorkspaceSelection,
workspaceDirectory,
branchLabel,
gitStatus,
t,
}: UseWorkspaceScreenArchiveControllerInput) {
const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
const archiveWorkspaceRecord = useMemo(() => {
if (!workspaceDirectory) {
return null;
}
for (const candidate of sessionWorkspaces?.values() ?? []) {
if (candidate.workspaceDirectory === workspaceDirectory) {
return candidate;
}
}
return null;
}, [sessionWorkspaces, workspaceDirectory]);
return useWorkspaceArchive({
serverId,
workspaceId: activeWorkspaceSelection?.workspaceId ?? archiveWorkspaceRecord?.id ?? "",
workspaceDirectory,
workspaceKind: gitStatus?.isPaseoOwnedWorktree ? "worktree" : "local_checkout",
name: archiveWorkspaceRecord?.name ?? branchLabel,
isDirty: gitStatus?.isDirty,
aheadOfOrigin: gitStatus?.aheadOfOrigin,
diffStat: archiveWorkspaceRecord?.diffStat ?? null,
warningLabels: getWorktreeArchiveWarningLabels(t),
onArchiveStarted: () => {
if (!activeWorkspaceSelection) {
return;
}
redirectIfArchivingActiveWorkspace({
serverId,
workspaceId: activeWorkspaceSelection.workspaceId,
activeWorkspaceSelection,
});
},
});
}
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
@@ -364,7 +355,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const runDisablePrAutoMerge = useCheckoutGitActionsStore((s) => s.disablePrAutoMerge);
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree);
const githubAutoMergeActionsEnabled = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
);
@@ -533,79 +523,18 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
});
}, [baseRef, cwd, runMergeFromBase, serverId, t, toast, toastActionError, toastActionSuccess]);
const runArchiveWorktreeRecord = useCallback(
(worktreePath: string, deleteWorktreeFromDisk: boolean) => {
// These git actions only ever target the workspace the screen is showing,
// so the redirect is sourced from the active workspace selection (the
// screen's own route context), not from any cwd→workspace inference here.
if (activeWorkspaceSelection) {
redirectIfArchivingActiveWorkspace({
serverId,
workspaceId: activeWorkspaceSelection.workspaceId,
activeWorkspaceSelection,
});
}
// The server archive is keyed by worktreePath; the daemon owns the
// directory→workspace resolution for archive-by-path.
void runArchiveWorktree({ serverId, cwd, worktreePath, deleteWorktreeFromDisk }).catch(
(err) => {
toastActionError(err, t("workspace.git.actions.toasts.failedArchive"));
},
);
},
[activeWorkspaceSelection, cwd, runArchiveWorktree, serverId, t, toastActionError],
);
const worktreeDeletePrompt = useWorktreeDeletePrompt(runArchiveWorktreeRecord);
const archiveWorktreeAfterConfirmation = useCallback(async () => {
const worktreePath = status?.cwd;
if (!worktreePath) {
toast.error(t("workspace.git.actions.toasts.worktreePathUnavailable"));
return;
}
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceList = Array.from(workspaces?.values() ?? []);
// Git-fact lookup: find the workspace backing this worktree directory to show
// its name and diff stat in the confirmation. This reads display data by
// directory, the same as the disk-deletion reference counting, and never
// derives an id used to key the archive (the archive runs by path).
const workspace =
workspaceList.find((candidate) => candidate.workspaceDirectory === worktreePath) ?? null;
const confirmed = await confirmRiskyWorktreeArchive(
{
worktreeName: workspace?.name ?? branchLabel,
isDirty: gitStatus?.isDirty,
aheadOfOrigin: gitStatus?.aheadOfOrigin,
diffStat: workspace?.diffStat ?? null,
},
getWorktreeArchiveWarningLabels(t),
);
if (!confirmed) {
return;
}
if (isLastWorktreeReference(workspaceList, worktreePath)) {
worktreeDeletePrompt.open({ worktreePath, workspaceName: workspace?.name ?? branchLabel });
return;
}
runArchiveWorktreeRecord(worktreePath, false);
}, [
branchLabel,
gitStatus?.aheadOfOrigin,
gitStatus?.isDirty,
runArchiveWorktreeRecord,
const archiveController = useWorkspaceScreenArchiveController({
serverId,
status?.cwd,
activeWorkspaceSelection,
workspaceDirectory: status?.cwd,
branchLabel,
gitStatus,
t,
toast,
worktreeDeletePrompt,
]);
});
const handleArchiveWorktree = useCallback(() => {
void archiveWorktreeAfterConfirmation();
}, [archiveWorktreeAfterConfirmation]);
archiveController.archive();
}, [archiveController]);
const derived = deriveGitActionsState({
isGit,
@@ -811,7 +740,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
baseRef,
]);
return { gitActions, branchLabel, isGit, worktreeDeletePrompt: worktreeDeletePrompt.element };
return { gitActions, branchLabel, isGit };
}
function translateGitActions(
+2 -7
View File
@@ -48,7 +48,7 @@ const ICONS = {
};
export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitActionsProps) {
const { gitActions, isGit, worktreeDeletePrompt } = useGitActions({
const { gitActions, isGit } = useGitActions({
serverId,
cwd,
icons: ICONS,
@@ -58,10 +58,5 @@ export function WorkspaceGitActions({ serverId, cwd, hideLabels }: WorkspaceGitA
return null;
}
return (
<>
<GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />
{worktreeDeletePrompt}
</>
);
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
}
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
buildWorktreeArchiveConfirmationMessage,
buildWorktreeArchiveRiskReasons,
toWorktreeArchiveRisk,
} from "@/git/worktree-archive-warning";
describe("worktree archive warning", () => {
@@ -57,4 +58,18 @@ describe("worktree archive warning", () => {
}),
).toBe("Uncommitted changes (1 added line, 3 deleted lines)\n1 unpushed commit");
});
it("maps archive workspace fields into the shared worktree risk shape", () => {
expect(
toWorktreeArchiveRisk({
archiveHasUncommittedChanges: true,
archiveUnpushedCommitCount: 3,
diffStat: { additions: 2, deletions: 1 },
}),
).toEqual({
isDirty: true,
aheadOfOrigin: 3,
diffStat: { additions: 2, deletions: 1 },
});
});
});
@@ -7,6 +7,12 @@ export interface WorktreeArchiveRisk {
diffStat?: { additions: number; deletions: number } | null;
}
export interface WorktreeArchiveRiskInput {
archiveHasUncommittedChanges?: boolean | null;
archiveUnpushedCommitCount?: number | null;
diffStat?: WorktreeArchiveRisk["diffStat"];
}
export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
worktreeName: string;
}
@@ -43,6 +49,14 @@ export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabe
: i18n.t("workspace.git.actions.archiveWarning.unpushedCommits", { count }),
};
export function toWorktreeArchiveRisk(input: WorktreeArchiveRiskInput): WorktreeArchiveRisk {
return {
isDirty: input.archiveHasUncommittedChanges,
aheadOfOrigin: input.archiveUnpushedCommitCount,
diffStat: input.diffStat,
};
}
function formatDiffStat(
diffStat: WorktreeArchiveRisk["diffStat"],
labels: WorktreeArchiveWarningLabels,