Confirm risky worktree archive (#994)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user