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
@@ -81,6 +81,7 @@ import {
import { SyncedLoader } from "@/components/synced-loader";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -114,8 +115,8 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar
import { openExternalUrl } from "@/utils/open-external-url";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { archiveWorkspacesOptimistically } from "@/workspace/workspace-archive";
import { selectProjectWorkspacesToArchive } from "@/workspace/project-workspace-archive";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { WorktreeDeletePrompt } from "@/workspace/worktree-delete-prompt";
import {
isWeb as platformIsWeb,
isNative as platformIsNative,
@@ -1510,7 +1511,12 @@ function WorkspaceRowWithMenu({
}, [selected, workspace]);
const archiveController = useWorkspaceArchive({
workspace,
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
@@ -1519,7 +1525,7 @@ function WorkspaceRowWithMenu({
if (isArchiving) {
return;
}
archiveController.beginArchive();
archiveController.archive();
}, [archiveController, isArchiving]);
const handleCopyPath = useCallback(() => {
@@ -1624,13 +1630,6 @@ function WorkspaceRowWithMenu({
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<WorktreeDeletePrompt
visible={archiveController.deletePromptOpen}
workspaceName={workspace.name}
onKeep={archiveController.confirmKeepOnDisk}
onDelete={archiveController.confirmDeleteFromDisk}
onCancel={archiveController.cancelDeletePrompt}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title={t("sidebar.workspace.rename.title")}
@@ -1927,16 +1926,22 @@ function ProjectBlock({
}
setIsRemovingProject(true);
void archiveWorkspacesOptimistically({
client,
workspaces: project.workspaces,
}).then((failures) => {
if (failures.length > 0) {
toast.error(t("sidebar.project.toasts.removeFailed"));
}
setIsRemovingProject(false);
return;
});
void selectProjectWorkspacesToArchive(project.workspaces)
.then((workspaces) => archiveWorkspacesOptimistically({ client, workspaces }))
.then((failures) => {
if (failures.length > 0) {
toast.error(t("sidebar.project.toasts.removeFailed"));
}
setIsRemovingProject(false);
return;
})
.catch((error) => {
toast.error(
error instanceof Error ? error.message : t("sidebar.project.toasts.removeFailed"),
);
setIsRemovingProject(false);
return;
});
})();
}, [isRemovingProject, serverId, displayName, t, toast, project.workspaces]);
@@ -41,8 +41,8 @@ import { AdaptiveRenameModal } from "@/components/rename-modal";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { WorktreeDeletePrompt } from "@/workspace/worktree-delete-prompt";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import * as Clipboard from "expo-clipboard";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -376,14 +376,19 @@ function StatusWorkspaceRowWithMenu({
}, [selected, workspace]);
const archiveController = useWorkspaceArchive({
workspace,
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
...toWorktreeArchiveRisk(workspace),
onArchiveStarted: redirectAfterArchive,
onSetHiding: setIsHidingWorkspace,
});
const handleArchive = useCallback(() => {
if (isArchiving) return;
archiveController.beginArchive();
archiveController.archive();
}, [archiveController, isArchiving]);
const handleCopyPath = useCallback(() => {
@@ -472,13 +477,6 @@ function StatusWorkspaceRowWithMenu({
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<WorktreeDeletePrompt
visible={archiveController.deletePromptOpen}
workspaceName={workspace.name}
onKeep={archiveController.confirmKeepOnDisk}
onDelete={archiveController.confirmDeleteFromDisk}
onCancel={archiveController.cancelDeletePrompt}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
+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,
-15
View File
@@ -254,21 +254,6 @@ describe("translation resources", () => {
expect(en.openProject.tiles.addProject.title).toBe("Add a project");
});
it("resolves the worktree delete prompt keys used by the sidebar prompt", () => {
const strings = flattenStrings(en);
expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.title"]).toBe(
"Archive workspace",
);
expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.message"]).toBe(
"Also remove the worktree from disk?",
);
expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.keep"]).toBe(
"Keep on disk",
);
expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.delete"]).toBe("Delete");
});
it("includes provider selector and pairing keys for the Batch 4D migration", () => {
expect(en.modelSelector.title).toBe("Select provider");
expect(en.modelSelector.favorites).toBe("Favorites");
-6
View File
@@ -814,12 +814,6 @@ export const ar: TranslationResources = {
'إخفاء "{{workspaceName}}" من الشريط الجانبي؟\n\n لن يتم تغيير الملفات الموجودة على القرص.',
hideConfirm: "يخفي",
cancel: "يلغي",
deleteWorktreePrompt: {
title: "أرشفة مساحة العمل",
message: "هل تريد أيضًا إزالة شجرة العمل من القرص؟",
keep: "الاحتفاظ على القرص",
delete: "حذف",
},
},
rename: {
title: "إعادة تسمية مساحة العمل",
-6
View File
@@ -820,12 +820,6 @@ export const en = {
'Hide "{{workspaceName}}" from the sidebar?\n\nFiles on disk will not be changed.',
hideConfirm: "Hide",
cancel: "Cancel",
deleteWorktreePrompt: {
title: "Archive workspace",
message: "Also remove the worktree from disk?",
keep: "Keep on disk",
delete: "Delete",
},
},
rename: {
title: "Rename workspace",
-6
View File
@@ -840,12 +840,6 @@ export const es: TranslationResources = {
'¿Ocultar "{{workspaceName}}" de la barra lateral?\n\nLos archivos en el disco no se cambiarán.',
hideConfirm: "Esconder",
cancel: "Cancelar",
deleteWorktreePrompt: {
title: "Archivar espacio de trabajo",
message: "¿También eliminar el worktree del disco?",
keep: "Conservar en disco",
delete: "Eliminar",
},
},
rename: {
title: "Cambiar nombre del espacio de trabajo",
-6
View File
@@ -839,12 +839,6 @@ export const fr: TranslationResources = {
"Masquer «{{workspaceName}}» dans la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.",
hideConfirm: "Cacher",
cancel: "Annuler",
deleteWorktreePrompt: {
title: "Archiver l'espace de travail",
message: "Supprimer aussi le worktree du disque?",
keep: "Conserver sur le disque",
delete: "Supprimer",
},
},
rename: {
title: "Renommer l'espace de travail",
-6
View File
@@ -832,12 +832,6 @@ export const ru: TranslationResources = {
"Скрыть «{{workspaceName}}» на боковой панели?\n\n Файлы на диске не будут изменены.",
hideConfirm: "Скрывать",
cancel: "Отмена",
deleteWorktreePrompt: {
title: "Архивировать рабочее пространство",
message: "Также удалить рабочее дерево с диска?",
keep: "Оставить на диске",
delete: "Удалить",
},
},
rename: {
title: "Переименовать рабочую область",
-6
View File
@@ -805,12 +805,6 @@ export const zhCN: TranslationResources = {
hideMessage: "从侧边栏隐藏「{{workspaceName}}」?\n\n磁盘上的文件不会被更改。",
hideConfirm: "隐藏",
cancel: "取消",
deleteWorktreePrompt: {
title: "归档 workspace",
message: "同时从磁盘删除 worktree?",
keep: "保留在磁盘上",
delete: "删除",
},
},
rename: {
title: "重命名 workspace",
@@ -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],
},
}));