Files
paseo/packages/cli/src/commands/worktree/archive.ts
T
Mohamed BoudraandGitHub e202ca5036 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.
2026-06-17 00:38:56 +08:00

147 lines
4.1 KiB
TypeScript

import path from "path";
import type { Command } from "commander";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
/** Result type for worktree archive command */
export interface WorktreeArchiveResult {
name: string;
status: "archived";
removedAgents: string[];
}
/** Schema for archive command output */
export const archiveSchema: OutputSchema<WorktreeArchiveResult> = {
idField: "name",
columns: [
{ header: "NAME", field: "name" },
{ header: "STATUS", field: "status" },
{
header: "REMOVED AGENTS",
field: (item) => (item.removedAgents.length > 0 ? item.removedAgents.join(", ") : "-"),
},
],
};
export interface WorktreeArchiveOptions extends CommandOptions {
host?: string;
}
export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>;
export async function runArchiveCommand(
nameArg: string,
options: WorktreeArchiveOptions,
_command: Command,
): Promise<WorktreeArchiveCommandResult> {
return runArchiveCommandWithDeps(nameArg, options, { connectToDaemon });
}
export async function runArchiveCommandWithDeps(
nameArg: string,
options: WorktreeArchiveOptions,
deps: { connectToDaemon: typeof connectToDaemon },
): Promise<WorktreeArchiveCommandResult> {
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!nameArg || nameArg.trim().length === 0) {
const error: CommandError = {
code: "MISSING_WORKTREE_NAME",
message: "Worktree name is required",
details: "Usage: paseo worktree archive <name>",
};
throw error;
}
let client: DaemonClient;
try {
client = await deps.connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
// Get the list of worktrees first to resolve the name
const listResponse = await client.getPaseoWorktreeList({});
if (listResponse.error) {
const error: CommandError = {
code: "WORKTREE_LIST_FAILED",
message: `Failed to list worktrees: ${listResponse.error.message}`,
};
throw error;
}
// Find the worktree by name or branch
const worktree = listResponse.worktrees.find((wt) => {
const name = path.basename(wt.worktreePath);
return name === nameArg || wt.branchName === nameArg;
});
if (!worktree) {
const error: CommandError = {
code: "WORKTREE_NOT_FOUND",
message: `Worktree not found: ${nameArg}`,
details: 'Use "paseo worktree ls" to list available worktrees',
};
throw error;
}
// Archive the worktree. scope:"worktree" archives every active workspace on
// the directory and then removes the directory (Paseo-owned gated).
const response = await client.archivePaseoWorktree({
worktreePath: worktree.worktreePath,
scope: "worktree",
});
await client.close();
if (response.error) {
const error: CommandError = {
code: "WORKTREE_ARCHIVE_FAILED",
message: `Failed to archive worktree: ${response.error.message}`,
};
throw error;
}
const worktreeName = path.basename(worktree.worktreePath) || nameArg;
return {
type: "single",
data: {
name: worktreeName,
status: "archived",
removedAgents: response.removedAgents ?? [],
},
schema: archiveSchema,
};
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "WORKTREE_ARCHIVE_FAILED",
message: `Failed to archive worktree: ${message}`,
};
throw error;
}
}