Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions mobile/src/screens/ProjectsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export function ProjectsScreen(): JSX.Element {
// Show confirmation dialog
Alert.alert(
"Delete Workspace?",
`This will permanently remove "${metadata.name}" from ${metadata.projectName}.\n\nThis action cannot be undone.`,
`This will permanently remove "${metadata.name}" from ${metadata.projectName} and delete the local branch "${metadata.name}".\n\nThis action cannot be undone.`,
[
{ text: "Cancel", style: "cancel" },
{
Expand All @@ -280,7 +280,7 @@ export function ProjectsScreen(): JSX.Element {
// Show force delete option
Alert.alert(
"Workspace Has Changes",
`${errorMsg}\n\nForce delete will discard these changes permanently.`,
`${errorMsg}\n\nForce delete will discard these changes permanently and delete the local branch "${metadata.name}".`,
[
{ text: "Cancel", style: "cancel" },
{
Expand Down
10 changes: 7 additions & 3 deletions src/browser/components/ArchivedWorkspaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,9 @@ export const ArchivedWorkspaces: React.FC<ArchivedWorkspacesProps> = ({
<span className="text-muted text-xs">{selectedIds.size} selected</span>
{bulkDeleteConfirm ? (
<>
<span className="text-muted text-xs">Delete permanently?</span>
<span className="text-muted text-xs">
Delete permanently (also deletes local branches)?
</span>
<button
onClick={() => void handleBulkDelete()}
className="rounded bg-red-600 px-2 py-0.5 text-xs text-white hover:bg-red-700"
Expand Down Expand Up @@ -556,7 +558,9 @@ export const ArchivedWorkspaces: React.FC<ArchivedWorkspacesProps> = ({
<Trash2 className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Delete selected permanently</TooltipContent>
<TooltipContent>
Delete selected permanently (local branches too)
</TooltipContent>
</Tooltip>
<button
onClick={() => setSelectedIds(new Set())}
Expand Down Expand Up @@ -680,7 +684,7 @@ export const ArchivedWorkspaces: React.FC<ArchivedWorkspacesProps> = ({
<Trash2 className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>Delete permanently</TooltipContent>
<TooltipContent>Delete permanently (local branch too)</TooltipContent>
</Tooltip>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/browser/components/ForceDeleteModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const ForceDeleteModal: React.FC<ForceDeleteModalProps> = ({
<WarningBox>
<WarningTitle>This action cannot be undone</WarningTitle>
<WarningText>
Force deleting will permanently remove the workspace and{" "}
Force deleting will permanently remove the workspace and its local branch, and{" "}
{error.includes("unpushed commits:")
? "discard the unpushed commits shown above"
: "may discard uncommitted work or lose data"}
Expand Down
13 changes: 11 additions & 2 deletions src/browser/utils/commands/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,13 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi
subtitle: workspaceDisplayName,
section: section.workspaces,
run: async () => {
const ok = confirm("Remove current workspace? This cannot be undone.");
const branchName =
selectedMeta?.name ??
selected.namedWorkspacePath.split("/").pop() ??
selected.namedWorkspacePath;
const ok = confirm(
`Remove current workspace? This will delete the worktree and local branch "${branchName}". This cannot be undone.`
);
if (ok) await p.onRemoveWorkspace(selected.workspaceId);
},
});
Expand Down Expand Up @@ -305,7 +311,10 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi
(m) => m.id === vals.workspaceId
);
const workspaceName = meta ? `${meta.projectName}/${meta.name}` : vals.workspaceId;
const ok = confirm(`Remove workspace ${workspaceName}? This cannot be undone.`);
const branchName = meta?.name ?? workspaceName.split("/").pop() ?? workspaceName;
const ok = confirm(
`Remove workspace ${workspaceName}? This will delete the worktree and local branch "${branchName}". This cannot be undone.`
);
if (ok) {
await p.onRemoveWorkspace(vals.workspaceId);
}
Expand Down
133 changes: 128 additions & 5 deletions src/node/runtime/WorktreeRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe("WorktreeRuntime.resolvePath", () => {
});

describe("WorktreeRuntime.deleteWorkspace", () => {
it("deletes agent branches when removing worktrees", async () => {
it("deletes non-agent branches when removing worktrees (force)", async () => {
const rootDir = await fsPromises.realpath(
await fsPromises.mkdtemp(path.join(os.tmpdir(), "worktree-runtime-delete-"))
);
Expand All @@ -106,7 +106,7 @@ describe("WorktreeRuntime.deleteWorkspace", () => {
const runtime = new WorktreeRuntime(srcBaseDir);
const initLogger = createNullInitLogger();

const branchName = "agent_explore_aaaaaaaaaa";
const branchName = "feature_aaaaaaaaaa";
const createResult = await runtime.createWorkspace({
projectPath,
branchName,
Expand All @@ -116,16 +116,80 @@ describe("WorktreeRuntime.deleteWorkspace", () => {
});
expect(createResult.success).toBe(true);
if (!createResult.success) return;
if (!createResult.workspacePath) {
throw new Error("Expected workspacePath from createWorkspace");
}
const workspacePath = createResult.workspacePath;

const before = execSync(`git branch --list "${branchName}"`, {
// Make the branch unmerged (so -d would fail); force delete should still delete it.
execSync("bash -lc 'echo \"change\" >> README.md'", {
cwd: workspacePath,
stdio: "ignore",
});
execSync("git add README.md", { cwd: workspacePath, stdio: "ignore" });
execSync('git commit -m "change"', { cwd: workspacePath, stdio: "ignore" });

const deleteResult = await runtime.deleteWorkspace(projectPath, branchName, true);
expect(deleteResult.success).toBe(true);

const after = execSync(`git branch --list "${branchName}"`, {
cwd: projectPath,
stdio: ["ignore", "pipe", "ignore"],
})
.toString()
.trim();
expect(before).toContain(branchName);
expect(after).toBe("");
} finally {
await fsPromises.rm(rootDir, { recursive: true, force: true });
}
}, 20_000);

const deleteResult = await runtime.deleteWorkspace(projectPath, branchName, true);
it("deletes merged branches when removing worktrees (safe delete)", async () => {
const rootDir = await fsPromises.realpath(
await fsPromises.mkdtemp(path.join(os.tmpdir(), "worktree-runtime-delete-"))
);

try {
const projectPath = path.join(rootDir, "repo");
await fsPromises.mkdir(projectPath, { recursive: true });
initGitRepo(projectPath);

const srcBaseDir = path.join(rootDir, "src");
await fsPromises.mkdir(srcBaseDir, { recursive: true });

const runtime = new WorktreeRuntime(srcBaseDir);
const initLogger = createNullInitLogger();

const branchName = "feature_merge_aaaaaaaaaa";
const createResult = await runtime.createWorkspace({
projectPath,
branchName,
trunkBranch: "main",
directoryName: branchName,
initLogger,
});
expect(createResult.success).toBe(true);
if (!createResult.success) return;
if (!createResult.workspacePath) {
throw new Error("Expected workspacePath from createWorkspace");
}
const workspacePath = createResult.workspacePath;

// Commit on the workspace branch.
execSync("bash -lc 'echo \"merged-change\" >> README.md'", {
cwd: workspacePath,
stdio: "ignore",
});
execSync("git add README.md", { cwd: workspacePath, stdio: "ignore" });
execSync('git commit -m "merged-change"', {
cwd: workspacePath,
stdio: "ignore",
});

// Merge into main so `git branch -d` succeeds.
execSync(`git merge "${branchName}"`, { cwd: projectPath, stdio: "ignore" });

const deleteResult = await runtime.deleteWorkspace(projectPath, branchName, false);
expect(deleteResult.success).toBe(true);

const after = execSync(`git branch --list "${branchName}"`, {
Expand All @@ -139,4 +203,63 @@ describe("WorktreeRuntime.deleteWorkspace", () => {
await fsPromises.rm(rootDir, { recursive: true, force: true });
}
}, 20_000);

it("does not delete protected branches", async () => {
const rootDir = await fsPromises.realpath(
await fsPromises.mkdtemp(path.join(os.tmpdir(), "worktree-runtime-delete-"))
);

try {
const projectPath = path.join(rootDir, "repo");
await fsPromises.mkdir(projectPath, { recursive: true });
initGitRepo(projectPath);

// Move the main worktree off main so we can add a separate worktree on main.
execSync("git checkout -b other", { cwd: projectPath, stdio: "ignore" });

const srcBaseDir = path.join(rootDir, "src");
await fsPromises.mkdir(srcBaseDir, { recursive: true });

const runtime = new WorktreeRuntime(srcBaseDir);
const initLogger = createNullInitLogger();

const branchName = "main";
const createResult = await runtime.createWorkspace({
projectPath,
branchName,
trunkBranch: "main",
directoryName: branchName,
initLogger,
});
expect(createResult.success).toBe(true);
if (!createResult.success) return;
if (!createResult.workspacePath) {
throw new Error("Expected workspacePath from createWorkspace");
}
const workspacePath = createResult.workspacePath;

const deleteResult = await runtime.deleteWorkspace(projectPath, branchName, true);
expect(deleteResult.success).toBe(true);

// The worktree directory should be removed.
let worktreeExists = true;
try {
await fsPromises.access(workspacePath);
} catch {
worktreeExists = false;
}
expect(worktreeExists).toBe(false);

// But protected branches (like main) should never be deleted.
const after = execSync(`git branch --list "${branchName}"`, {
cwd: projectPath,
stdio: ["ignore", "pipe", "ignore"],
})
.toString()
.trim();
expect(after).toBe("main");
} finally {
await fsPromises.rm(rootDir, { recursive: true, force: true });
}
}, 20_000);
});
Loading