Confirm risky worktree archive (#994)

This commit is contained in:
Mohamed Boudra
2026-05-13 23:27:49 +08:00
committed by GitHub
parent 512702fe46
commit f1e5640a26
8 changed files with 261 additions and 81 deletions

View File

@@ -216,7 +216,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
- Raw DOM APIs without an `isWeb` guard.
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
- Color changes for disabled state. Opacity only.
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.

View File

@@ -106,6 +106,7 @@ import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
@@ -1474,92 +1475,93 @@ function WorkspaceRowWithMenu({
});
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorktree = useCallback(() => {
const archiveWorktreeAfterConfirmation = useCallback(async () => {
if (isArchiving) {
return;
}
void (async () => {
const confirmed = await confirmDialog({
title: "Archive worktree?",
message: `Archive "${workspace.name}"?\n\nThe worktree will be removed from disk, terminals will be stopped, and agents inside will be archived.\n\nYour branch is still accessible if you committed.`,
confirmLabel: "Archive",
cancelLabel: "Cancel",
destructive: true,
const confirmed = await confirmRiskyWorktreeArchive({
worktreeName: workspace.name,
isDirty: workspace.archiveHasUncommittedChanges,
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
diffStat: workspace.diffStat,
});
if (!confirmed) {
return;
}
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
if (!confirmed) {
return;
}
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
if (!archiveDirectory) {
toast.error("Workspace path not available");
return;
}
if (!archiveDirectory) {
toast.error("Workspace path not available");
return;
}
redirectAfterArchive();
redirectAfterArchive();
void archiveWorktree({
serverId: workspace.serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to archive worktree";
toast.error(message);
});
})();
void archiveWorktree({
serverId: workspace.serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to archive worktree";
toast.error(message);
});
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
const handleArchiveWorkspace = useCallback(() => {
const handleArchiveWorktree = useCallback(() => {
void archiveWorktreeAfterConfirmation();
}, [archiveWorktreeAfterConfirmation]);
const hideWorkspaceAfterConfirmation = useCallback(async () => {
if (isArchivingWorkspace) {
return;
}
void (async () => {
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
destructive: true,
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
return;
}
setIsArchivingWorkspace(true);
try {
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
});
if (!confirmed) {
return;
}
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
return;
}
setIsArchivingWorkspace(true);
void (async () => {
try {
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
const handleArchiveWorkspace = useCallback(() => {
void hideWorkspaceAfterConfirmation();
}, [hideWorkspaceAfterConfirmation]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
@@ -1589,7 +1591,7 @@ function WorkspaceRowWithMenu({
priority: 0,
handle: () => {
if (isWorktree) {
handleArchiveWorktree();
void archiveWorktreeAfterConfirmation();
} else {
handleArchiveWorkspace();
}

View File

@@ -11,6 +11,7 @@ import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -169,6 +170,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
cwd,
enabled: isGit,
});
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
const branchLabel = resolveBranchLabel({
currentBranch: gitStatus?.currentBranch,
notGit,
});
// Ship default persistence
const shipDefaultStorageKey = useMemo(() => {
@@ -389,31 +395,59 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
});
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
const handleArchiveWorktree = useCallback(() => {
const archiveWorktreeAfterConfirmation = useCallback(async () => {
const worktreePath = status?.cwd;
if (!worktreePath) {
toast.error("Worktree path unavailable");
return;
}
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
const workspaceList = Array.from(workspaces?.values() ?? []);
const workspace = workspaceList.find(
(candidate) => candidate.workspaceDirectory === worktreePath,
);
const confirmed = await confirmRiskyWorktreeArchive({
worktreeName: workspace?.name ?? branchLabel,
isDirty: gitStatus?.isDirty,
aheadOfOrigin: gitStatus?.aheadOfOrigin,
diffStat: workspace?.diffStat ?? null,
});
if (!confirmed) {
return;
}
const archivedWorkspaceId =
resolveWorkspaceIdByExecutionDirectory({
workspaces: workspaces?.values(),
workspaces: workspaceList,
workspaceDirectory: worktreePath,
}) ?? worktreePath;
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId,
archivedWorkspaceId,
workspaces: workspaces?.values() ?? [],
workspaces: workspaceList,
}) as Href,
);
void runArchiveWorktree({ serverId, cwd, worktreePath }).catch((err) => {
toastActionError(err, "Failed to archive worktree");
});
}, [cwd, runArchiveWorktree, serverId, status, toast, toastActionError]);
}, [
branchLabel,
cwd,
gitStatus?.aheadOfOrigin,
gitStatus?.isDirty,
runArchiveWorktree,
serverId,
status?.cwd,
toast,
toastActionError,
]);
const handleArchiveWorktree = useCallback(() => {
void archiveWorktreeAfterConfirmation();
}, [archiveWorktreeAfterConfirmation]);
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
const derived = deriveGitActionsState({
isGit,
status,
@@ -437,11 +471,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
shouldPromoteArchive,
} = derived;
const branchLabel = resolveBranchLabel({
currentBranch: gitStatus?.currentBranch,
notGit,
});
const handlePrAction = useCallback(() => {
if (prStatus?.url) {
openURLInNewTab(prStatus.url);

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
buildWorktreeArchiveConfirmationMessage,
buildWorktreeArchiveRiskReasons,
} from "@/git/worktree-archive-warning";
describe("worktree archive warning", () => {
it("does not require a confirmation for clean and pushed worktrees", () => {
expect(
buildWorktreeArchiveConfirmationMessage({
worktreeName: "feature",
isDirty: false,
aheadOfOrigin: 0,
diffStat: null,
}),
).toBeNull();
});
it("explains uncommitted line changes", () => {
expect(
buildWorktreeArchiveRiskReasons({
isDirty: true,
aheadOfOrigin: 0,
diffStat: { additions: 12, deletions: 1 },
}),
).toEqual(["Uncommitted changes (12 added lines, 1 deleted line)"]);
});
it("treats nonzero diff stats as dirty when dirty state is missing", () => {
expect(
buildWorktreeArchiveRiskReasons({
isDirty: undefined,
aheadOfOrigin: 0,
diffStat: { additions: 4, deletions: 0 },
}),
).toEqual(["Uncommitted changes (4 added lines)"]);
});
it("explains unpushed commits", () => {
expect(
buildWorktreeArchiveRiskReasons({
isDirty: false,
aheadOfOrigin: 2,
diffStat: null,
}),
).toEqual(["2 unpushed commits"]);
});
it("includes every archive risk in the confirmation copy", () => {
expect(
buildWorktreeArchiveConfirmationMessage({
worktreeName: "risky-feature",
isDirty: true,
aheadOfOrigin: 1,
diffStat: { additions: 1, deletions: 3 },
}),
).toBe("Uncommitted changes (1 added line, 3 deleted lines)\n1 unpushed commit");
});
});

View File

@@ -0,0 +1,79 @@
import { confirmDialog } from "@/utils/confirm-dialog";
export interface WorktreeArchiveRisk {
isDirty?: boolean | null;
aheadOfOrigin?: number | null;
diffStat?: { additions: number; deletions: number } | null;
}
export interface WorktreeArchiveConfirmationInput extends WorktreeArchiveRisk {
worktreeName: string;
}
function pluralize(count: number, singular: string, plural = `${singular}s`): string {
return count === 1 ? singular : plural;
}
function formatDiffStat(diffStat: WorktreeArchiveRisk["diffStat"]): string | null {
if (!diffStat) {
return null;
}
const parts: string[] = [];
if (diffStat.additions > 0) {
parts.push(`${diffStat.additions} added ${pluralize(diffStat.additions, "line")}`);
}
if (diffStat.deletions > 0) {
parts.push(`${diffStat.deletions} deleted ${pluralize(diffStat.deletions, "line")}`);
}
return parts.length > 0 ? parts.join(", ") : null;
}
export function buildWorktreeArchiveRiskReasons(input: WorktreeArchiveRisk): string[] {
const reasons: string[] = [];
const diffStat = input.diffStat;
const hasDiffStatChanges = diffStat ? diffStat.additions > 0 || diffStat.deletions > 0 : false;
const hasUncommittedChanges =
input.isDirty === true || (input.isDirty == null && hasDiffStatChanges);
if (hasUncommittedChanges) {
const diffStatLabel = formatDiffStat(diffStat);
reasons.push(diffStatLabel ? `Uncommitted changes (${diffStatLabel})` : "Uncommitted changes");
}
if ((input.aheadOfOrigin ?? 0) > 0) {
const aheadOfOrigin = input.aheadOfOrigin ?? 0;
reasons.push(`${aheadOfOrigin} unpushed ${pluralize(aheadOfOrigin, "commit")}`);
}
return reasons;
}
export function buildWorktreeArchiveConfirmationMessage(
input: WorktreeArchiveConfirmationInput,
): string | null {
const reasons = buildWorktreeArchiveRiskReasons(input);
if (reasons.length === 0) {
return null;
}
return reasons.join("\n");
}
export async function confirmRiskyWorktreeArchive(
input: WorktreeArchiveConfirmationInput,
): Promise<boolean> {
const message = buildWorktreeArchiveConfirmationMessage(input);
if (!message) {
return true;
}
return await confirmDialog({
title: `Archive "${input.worktreeName}"?`,
message,
confirmLabel: "Archive",
cancelLabel: "Cancel",
destructive: true,
});
}

View File

@@ -31,6 +31,8 @@ export interface SidebarWorkspaceEntry {
statusBucket: SidebarStateBucket;
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
archiveHasUncommittedChanges: boolean | null;
archiveUnpushedCommitCount: number | null;
scripts: WorkspaceDescriptor["scripts"];
hasRunningScripts: boolean;
}
@@ -69,6 +71,8 @@ function createStructuralWorkspaceEntry(input: {
statusBucket: "done",
archivingAt: null,
diffStat: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
};
@@ -91,6 +95,8 @@ export function createSidebarWorkspaceEntry(input: {
statusBucket: input.workspace.status,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
scripts: input.workspace.scripts,
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
};

View File

@@ -20,6 +20,8 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
name: "paseo",
statusBucket: "done",
diffStat: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
...overrides,

View File

@@ -25,6 +25,8 @@ function workspace(input: {
statusBucket: "done",
archivingAt: null,
diffStat: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
};