Skip to content
Merged
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
20 changes: 20 additions & 0 deletions apps/desktop/src/lib/trpc/routers/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,26 @@ export const createSettingsRouter = () => {
};
}),

getDeleteLocalBranch: publicProcedure.query(() => {
const row = getSettings();
return row.deleteLocalBranch ?? false;
}),

setDeleteLocalBranch: publicProcedure
.input(z.object({ enabled: z.boolean() }))
.mutation(({ input }) => {
localDb
.insert(settings)
.values({ id: 1, deleteLocalBranch: input.enabled })
.onConflictDoUpdate({
target: settings.id,
set: { deleteLocalBranch: input.enabled },
})
.run();

return { success: true };
}),

getNotificationSoundsMuted: publicProcedure.query(() => {
const row = getSettings();
return row.notificationSoundsMuted ?? false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
updateActiveWorkspaceIfRemoved,
} from "../utils/db-helpers";
import {
deleteLocalBranch,
hasUncommittedChanges,
hasUnpushedCommits,
worktreeExists,
Expand Down Expand Up @@ -148,7 +149,9 @@ export const createDeleteProcedures = () => {
}),

delete: publicProcedure
.input(z.object({ id: z.string() }))
.input(
z.object({ id: z.string(), deleteLocalBranch: z.boolean().optional() }),
)
.mutation(async ({ input }) => {
const workspace = getWorkspace(input.id);

Expand Down Expand Up @@ -247,6 +250,20 @@ export const createDeleteProcedures = () => {
} finally {
workspaceInitManager.releaseProjectLock(project.id);
}

if (input.deleteLocalBranch && workspace.branch) {
try {
await deleteLocalBranch({
mainRepoPath: project.mainRepoPath,
branch: workspace.branch,
});
} catch (error) {
console.error(
`[workspace/delete] Branch cleanup failed (non-blocking):`,
error instanceof Error ? error.message : String(error),
);
}
}
}

deleteWorkspace(input.id);
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,32 @@ export async function createWorktreeFromExistingBranch({
}
}

export async function deleteLocalBranch({
mainRepoPath,
branch,
}: {
mainRepoPath: string;
branch: string;
}): Promise<void> {
const env = await getGitEnv();

try {
await execFileAsync("git", ["-C", mainRepoPath, "branch", "-D", branch], {
env,
timeout: 10_000,
});
console.log(`[workspace/delete] Deleted local branch "${branch}"`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(
`[workspace/delete] Failed to delete local branch "${branch}": ${errorMessage}`,
);
throw new Error(
`Failed to delete local branch "${branch}": ${errorMessage}`,
);
}
}
Comment on lines +635 to +659
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Consider guarding against deletion of the default/current branch.

git branch -D will force-delete any branch, including main/master or the repo's default branch. If a user happens to create a worktree on the default branch and checks "Also delete local branch," this would delete it. While git would refuse to delete a currently checked-out branch, bare repos and edge cases could still be problematic.

Consider adding a safety check, either here or in the caller, to skip deletion when branch matches the project's default branch.

Proposed guard
 export async function deleteLocalBranch({
 	mainRepoPath,
 	branch,
+	defaultBranch,
 }: {
 	mainRepoPath: string;
 	branch: string;
+	defaultBranch?: string;
 }): Promise<void> {
+	const protectedBranches = ["main", "master", "develop"];
+	if (defaultBranch) protectedBranches.push(defaultBranch);
+	if (protectedBranches.includes(branch)) {
+		console.warn(
+			`[workspace/delete] Refusing to delete protected branch "${branch}"`,
+		);
+		return;
+	}
 	const env = await getGitEnv();
🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts` around lines 636 -
661, In deleteLocalBranch, add a safety check to avoid deleting the repository
default (or current) branch: before running execFileAsync in deleteLocalBranch({
mainRepoPath, branch }), call git to determine the repo default branch (e.g.,
run git -C mainRepoPath symbolic-ref --short refs/remotes/origin/HEAD or git -C
mainRepoPath rev-parse --abbrev-ref origin/HEAD) and also check the currently
checked-out branch (git -C mainRepoPath rev-parse --abbrev-ref HEAD); if branch
equals the default branch or the current branch, log and return early instead of
running git branch -D; keep using getGitEnv() and execFileAsync for these checks
and preserve existing error handling for other failures.


export async function removeWorktree(
mainRepoPath: string,
worktreePath: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export function BehaviorSettings({ visibleItems }: BehaviorSettingsProps) {
SETTING_ITEM_ID.BEHAVIOR_CONFIRM_QUIT,
visibleItems,
);
const showDeleteLocalBranch = isItemVisible(
SETTING_ITEM_ID.BEHAVIOR_DELETE_LOCAL_BRANCH,
visibleItems,
);
const showBranchPrefix = isItemVisible(
SETTING_ITEM_ID.BEHAVIOR_BRANCH_PREFIX,
visibleItems,
Expand Down Expand Up @@ -62,6 +66,33 @@ export function BehaviorSettings({ visibleItems }: BehaviorSettingsProps) {
setConfirmOnQuit.mutate({ enabled });
};

const { data: deleteLocalBranch, isLoading: isDeleteBranchLoading } =
electronTrpc.settings.getDeleteLocalBranch.useQuery();
const setDeleteLocalBranch =
electronTrpc.settings.setDeleteLocalBranch.useMutation({
onMutate: async ({ enabled }) => {
await utils.settings.getDeleteLocalBranch.cancel();
const previous = utils.settings.getDeleteLocalBranch.getData();
utils.settings.getDeleteLocalBranch.setData(undefined, enabled);
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous !== undefined) {
utils.settings.getDeleteLocalBranch.setData(
undefined,
context.previous,
);
}
},
onSettled: () => {
utils.settings.getDeleteLocalBranch.invalidate();
},
});

const handleDeleteBranchToggle = (enabled: boolean) => {
setDeleteLocalBranch.mutate({ enabled });
};

// TODO: remove telemetry query/mutation/handler once telemetry procedures are removed
const { data: telemetryEnabled, isLoading: isTelemetryLoading } =
electronTrpc.settings.getTelemetryEnabled.useQuery();
Comment on lines 96 to 98
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

getTelemetryEnabled query fires unconditionally despite the column being dropped in this PR's migration.

This useQuery executes on every render of BehaviorSettings regardless of the false && gate on line 281. After migration 0018 drops telemetry_enabled, this will likely produce runtime errors. Consider wrapping with enabled: false or removing the telemetry code entirely.

🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/routes/_authenticated/settings/behavior/components/BehaviorSettings/BehaviorSettings.tsx`
around lines 96 - 98, The getTelemetryEnabled query in the BehaviorSettings
component (electronTrpc.settings.getTelemetryEnabled.useQuery) runs
unconditionally and will break after migration drops telemetry_enabled; disable
it by adding query options { enabled: false } (or remove the telemetry-related
lines entirely). Locate the useQuery call and change it to
electronTrpc.settings.getTelemetryEnabled.useQuery(undefined, { enabled: false
}) so the hook does not execute, and remove or keep the TODO comment about
removing telemetry once migrations are complete; also remove any code that reads
telemetryEnabled/isTelemetryLoading if you choose to delete telemetry support.

Expand Down Expand Up @@ -171,6 +202,29 @@ export function BehaviorSettings({ visibleItems }: BehaviorSettingsProps) {
</div>
)}

{showDeleteLocalBranch && (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label
htmlFor="delete-local-branch"
className="text-sm font-medium"
>
Delete local branch on workspace removal
</Label>
<p className="text-xs text-muted-foreground">
Also delete the local git branch when deleting a worktree
workspace
</p>
</div>
<Switch
id="delete-local-branch"
checked={deleteLocalBranch ?? false}
onCheckedChange={handleDeleteBranchToggle}
disabled={isDeleteBranchLoading || setDeleteLocalBranch.isPending}
/>
</div>
)}

{showBranchPrefix && (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const SETTING_ITEM_ID = {
KEYBOARD_SHORTCUTS: "keyboard-shortcuts",

BEHAVIOR_CONFIRM_QUIT: "behavior-confirm-quit",
BEHAVIOR_DELETE_LOCAL_BRANCH: "behavior-delete-local-branch",
BEHAVIOR_BRANCH_PREFIX: "behavior-branch-prefix",
BEHAVIOR_TELEMETRY: "behavior-telemetry",

Expand Down Expand Up @@ -308,6 +309,24 @@ export const SETTINGS_ITEMS: SettingsItem[] = [
"unsaved",
],
},
{
id: SETTING_ITEM_ID.BEHAVIOR_DELETE_LOCAL_BRANCH,
section: "behavior",
title: "Delete local branch on workspace removal",
description:
"Also delete the local git branch when deleting a worktree workspace",
keywords: [
"features",
"delete",
"branch",
"local",
"worktree",
"workspace",
"remove",
"cleanup",
"git",
],
},
{
id: SETTING_ITEM_ID.BEHAVIOR_BRANCH_PREFIX,
section: "behavior",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { Button } from "@superset/ui/button";
import { toast } from "@superset/ui/sonner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
import { useState } from "react";
import { electronTrpc } from "renderer/lib/electron-trpc";
import {
useCloseWorkspace,
Expand All @@ -33,6 +34,18 @@ export function DeleteWorkspaceDialog({
const isBranch = workspaceType === "branch";
const deleteWorkspace = useDeleteWorkspace();
const closeWorkspace = useCloseWorkspace();
const setDeleteLocalBranchSetting =
electronTrpc.settings.setDeleteLocalBranch.useMutation();

const { data: deleteLocalBranchDefault } =
electronTrpc.settings.getDeleteLocalBranch.useQuery(undefined, {
enabled: open && !isBranch,
});
const [deleteLocalBranch, setDeleteLocalBranch] = useState<boolean | null>(
null,
);
const deleteLocalBranchChecked =
deleteLocalBranch ?? deleteLocalBranchDefault ?? false;
Comment on lines +44 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Local switch state is not reset when the dialog reopens.

deleteLocalBranch is initialized to null only on mount. Since DeleteWorkspaceDialog stays mounted in its parent WorkspaceListItem, re-opening the dialog (Cancel → reopen) keeps the previous local toggle value, which takes precedence over the server default via the ?? chain on line 48–49. This means a user who toggles the switch and cancels will see their unsaved toggle state next time, which is inconsistent with the "persisted preference" model.

Consider resetting local state when the dialog opens:

Proposed fix

Add a reset when the dialog opens, e.g. by using onOpenChange or an effect keyed on open:

// At top of component, after the useState:
const prevOpen = useRef(false);
if (open && !prevOpen.current) {
  setDeleteLocalBranch(null); // reset to server default on open
}
prevOpen.current = open;

Or, if acceptable, simply pass a key prop to force remount when open changes (though this re-fetches queries too).

🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx`
around lines 45 - 49, The local toggle state deleteLocalBranch in
DeleteWorkspaceDialog is only initialized once and isn't reset when the dialog
reopens, so it can incorrectly override deleteLocalBranchDefault; update the
component to reset deleteLocalBranch to null whenever the dialog is opened
(i.e., when the open prop transitions true) — implement this by adding an effect
keyed on open (or handling open changes via onOpenChange) that sets
setDeleteLocalBranch(null) on open so the deleteLocalBranchChecked expression
(deleteLocalBranch ?? deleteLocalBranchDefault ?? false) will honor the server
default on each open.


const { data: gitStatusData, isLoading: isLoadingGitStatus } =
electronTrpc.workspaces.canDelete.useQuery(
Expand Down Expand Up @@ -85,13 +98,22 @@ export function DeleteWorkspaceDialog({
const handleDelete = () => {
onOpenChange(false);

setDeleteLocalBranchSetting.mutate({
enabled: deleteLocalBranchChecked,
});

toast.promise(
deleteWorkspace.mutateAsync({ id: workspaceId }).then((result) => {
if (!result.success) {
throw new Error(result.error ?? "Failed to delete");
}
return result;
}),
deleteWorkspace
.mutateAsync({
id: workspaceId,
deleteLocalBranch: deleteLocalBranchChecked,
})
.then((result) => {
if (!result.success) {
throw new Error(result.error ?? "Failed to delete");
}
return result;
}),
{
loading: `Deleting "${workspaceName}"...`,
success: (result) => {
Expand Down Expand Up @@ -183,7 +205,7 @@ export function DeleteWorkspaceDialog({

{!isLoading && canDelete && hasWarnings && (
<div className="px-4 pb-2">
<div className="text-sm text-amber-700 dark:text-amber-300 bg-amber-100 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded px-2 py-1.5">
<div className="text-xs text-yellow-700 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/20 rounded-md px-2.5 py-1.5">
{hasChanges && hasUnpushedCommits
? "Has uncommitted changes and unpushed commits"
: hasChanges
Expand All @@ -193,6 +215,19 @@ export function DeleteWorkspaceDialog({
</div>
)}

{!isLoading && canDelete && (
<div className="px-4 pb-2">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={deleteLocalBranchChecked}
onChange={(e) => setDeleteLocalBranch(e.target.checked)}
/>
Also delete local branch
</label>
</div>
)}
Comment on lines +218 to +229
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing label association on the "Also delete local branch" switch.

The <span> text label isn't linked to the Switch (no id/htmlFor pairing), so clicking the text doesn't toggle the switch. Other settings toggles in this PR (e.g., BehaviorSettings.tsx line 208–220) use <Label htmlFor="..."> with a matching id on the Switch.

Proposed fix
 <div className="flex items-center justify-between px-4 pb-2">
-  <span className="text-xs text-muted-foreground">
+  <label htmlFor="delete-local-branch-switch" className="text-xs text-muted-foreground">
     Also delete local branch
-  </span>
+  </label>
   <Switch
+    id="delete-local-branch-switch"
     checked={deleteLocalBranchChecked}
     onCheckedChange={(checked) => setDeleteLocalBranch(checked)}
     className="scale-75"
   />
 </div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{!isLoading && canDelete && (
<div className="flex items-center justify-between px-4 pb-2">
<span className="text-xs text-muted-foreground">
Also delete local branch
</span>
<Switch
checked={deleteLocalBranchChecked}
onCheckedChange={(checked) => setDeleteLocalBranch(checked)}
className="scale-75"
/>
</div>
)}
{!isLoading && canDelete && (
<div className="flex items-center justify-between px-4 pb-2">
<label htmlFor="delete-local-branch-switch" className="text-xs text-muted-foreground">
Also delete local branch
</label>
<Switch
id="delete-local-branch-switch"
checked={deleteLocalBranchChecked}
onCheckedChange={(checked) => setDeleteLocalBranch(checked)}
className="scale-75"
/>
</div>
)}
🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx`
around lines 219 - 230, The "Also delete local branch" span in
DeleteWorkspaceDialog isn't associated with the Switch, so the label text
doesn't toggle the switch; update the JSX to replace or wrap the span with a
Label element that uses htmlFor matching an id prop on the Switch (e.g., give
the Switch an id like "delete-local-branch" and change the <span> to <Label
htmlFor="delete-local-branch">) and keep the same props wired to
deleteLocalBranchChecked and setDeleteLocalBranch so clicking the text toggles
the Switch.


<AlertDialogFooter className="px-4 pb-4 pt-2 flex-row justify-end gap-2">
<Button
variant="ghost"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ export function FilesView() {

const [searchTerm, setSearchTerm] = useState("");
const projectId = workspace?.project?.id;
const showHiddenFiles = useFileExplorerStore(
(s) => (projectId ? (s.showHiddenFiles[projectId] ?? false) : false),
const showHiddenFiles = useFileExplorerStore((s) =>
projectId ? (s.showHiddenFiles[projectId] ?? false) : false,
);
const toggleHiddenFiles = useFileExplorerStore((s) => s.toggleHiddenFiles);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE `settings` ADD `delete_local_branch` integer;--> statement-breakpoint
ALTER TABLE `settings` DROP COLUMN `telemetry_enabled`;
Loading
Loading