diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..4cff8ebbd69 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -15,6 +15,7 @@ const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + deleteRemoteBranchOnDelete: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 74064450fcb..bab34086861 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -9,6 +9,8 @@ import { type VcsSwitchRefResult, type VcsCreateRefInput, type VcsCreateRefResult, + type VcsDeleteBranchInput, + type VcsDeleteBranchResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, type VcsListRefsInput, @@ -67,6 +69,9 @@ export interface GitWorkflowServiceShape { readonly switchRef: ( input: VcsSwitchRefInput, ) => Effect.Effect; + readonly deleteBranch: ( + input: VcsDeleteBranchInput, + ) => Effect.Effect; readonly renameBranch: (input: { readonly cwd: string; readonly oldBranch: string; @@ -306,6 +311,10 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( Effect.andThen(Effect.scoped(git.switchRef(input))), ), + deleteBranch: (input) => + ensureGitCommand("GitWorkflowService.deleteBranch", input.cwd).pipe( + Effect.andThen(git.deleteBranch(input)), + ), renameBranch: (input) => ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( Effect.andThen(git.renameBranch(input)), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index c75570d4b1c..8a875fe5575 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -16,6 +16,8 @@ import { type VcsSwitchRefResult, type VcsCreateRefInput, type VcsCreateRefResult, + type VcsDeleteBranchInput, + type VcsDeleteBranchResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, type ReviewDiffPreviewInput, @@ -217,6 +219,9 @@ export interface GitVcsDriverShape { readonly switchRef: ( input: VcsSwitchRefInput, ) => Effect.Effect; + readonly deleteBranch: ( + input: VcsDeleteBranchInput, + ) => Effect.Effect; readonly initRepo: (input: VcsInitInput) => Effect.Effect; readonly listLocalBranchNames: (cwd: string) => Effect.Effect; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b6a48f5b18c..2e386573259 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -316,6 +316,15 @@ function commandLabel(args: readonly string[]): string { return `git ${args.join(" ")}`; } +function isMissingRemoteBranchError(stderr: string): boolean { + const normalized = stderr.toLowerCase(); + return ( + normalized.includes("remote ref does not exist") || + normalized.includes("does not exist") || + normalized.includes("unable to delete") + ); +} + function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); const prefix = `refs/remotes/${remoteName}/`; @@ -2280,6 +2289,59 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const deleteBranch: GitVcsDriver.GitVcsDriverShape["deleteBranch"] = Effect.fn("deleteBranch")( + function* (input) { + const isRemoteRef = input.isRemote === true; + + let deletedLocal = false; + if (!isRemoteRef) { + yield* executeGit( + "GitVcsDriver.deleteBranch.local", + input.cwd, + ["branch", input.force ? "-D" : "-d", "--", input.refName], + { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch delete failed", + }, + ); + deletedLocal = true; + } + + let deletedRemote = false; + if (isRemoteRef || input.deleteRemote) { + const remoteName = input.remoteName + ? input.remoteName + : yield* resolvePrimaryRemoteName(input.cwd).pipe( + Effect.catch((error) => (isRemoteRef ? Effect.fail(error) : Effect.succeed(null))), + ); + const remoteBranch = isRemoteRef + ? deriveLocalBranchNameFromRemoteRef(input.refName) + : input.refName; + if (remoteName && remoteBranch) { + const pushArgs = ["push", remoteName, "--delete", remoteBranch]; + const result = yield* executeGit( + "GitVcsDriver.deleteBranch.remote", + input.cwd, + pushArgs, + { timeoutMs: 30_000, allowNonZeroExit: true }, + ); + if (result.exitCode === 0) { + deletedRemote = true; + } else if (!isMissingRemoteBranchError(result.stderr)) { + return yield* createGitCommandError( + "GitVcsDriver.deleteBranch.remote", + input.cwd, + pushArgs, + result.stderr.trim() || "git push --delete failed", + ); + } + } + } + + return { refName: input.refName, deletedLocal, deletedRemote }; + }, + ); + const initRepo: GitVcsDriver.GitVcsDriverShape["initRepo"] = (input) => executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, @@ -2329,6 +2391,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* renameBranch, createRef, switchRef, + deleteBranch, initRepo, listLocalBranchNames, }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1d0af00e3a7..7a3697c0312 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1116,6 +1116,12 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), + [WS_METHODS.vcsDeleteBranch]: (input) => + observeRpcEffect( + WS_METHODS.vcsDeleteBranch, + gitWorkflow.deleteBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, + ), [WS_METHODS.vcsInit]: (input) => observeRpcEffect( WS_METHODS.vcsInit, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 152df1bf3e5..6d66f8b1fea 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,7 +1,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, Trash2 } from "lucide-react"; import { useCallback, useDeferredValue, @@ -31,6 +31,16 @@ import { resolveEffectiveEnvMode, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; +import { useSettings } from "../hooks/useSettings"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "./ui/alert-dialog"; import { Button } from "./ui/button"; import { Combobox, @@ -64,6 +74,15 @@ function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +function isGitCommandError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + (error as { _tag?: unknown })._tag === "GitCommandError" + ); +} + function getBranchTriggerLabel(input: { activeWorktreePath: string | null; effectiveEnvMode: "local" | "worktree"; @@ -200,6 +219,9 @@ export function BranchToolbarBranchSelector({ const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); + const deleteRemoteBranchOnDelete = useSettings((s) => s.deleteRemoteBranchOnDelete); + const [pendingDelete, setPendingDelete] = useState(null); + const [forceDeleteTarget, setForceDeleteTarget] = useState(null); const branchStatusQuery = useVcsStatus({ environmentId, cwd: branchCwd }); const trimmedBranchQuery = branchQuery.trim(); @@ -392,6 +414,48 @@ export function BranchToolbarBranchSelector({ }); }; + const deleteBranch = (ref: VcsRef, force: boolean) => { + const api = readEnvironmentApi(environmentId); + if (!api || !branchCwd) return; + + runBranchAction(async () => { + try { + const result = await api.vcs.deleteBranch({ + cwd: branchCwd, + refName: ref.name, + isRemote: ref.isRemote, + remoteName: ref.remoteName, + force, + deleteRemote: deleteRemoteBranchOnDelete, + }); + setPendingDelete(null); + setForceDeleteTarget(null); + toastManager.add( + stackedThreadToast({ + type: "success", + title: `Deleted ref "${ref.name}".`, + ...(result.deletedRemote ? { description: "Remote branch also deleted." } : {}), + }), + ); + } catch (error) { + if (!force && isGitCommandError(error)) { + setPendingDelete(null); + setForceDeleteTarget(ref); + return; + } + setPendingDelete(null); + setForceDeleteTarget(null); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete ref.", + description: toBranchActionErrorMessage(error), + }), + ); + } + }); + }; + useEffect(() => { if ( effectiveEnvMode !== "worktree" || @@ -557,86 +621,170 @@ export function BranchToolbarBranchSelector({ return ( selectBranch(refName)} >
- {itemValue} - {badge && {badge}} + + {itemValue} + {badge && ( + {badge} + )} + +
+ {refName.current ? ( + + ) : ( + + )} +
); } return ( - { - if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { - return; - } - branchListRef.current?.scrollIndexIntoView?.({ - index: eventDetails.index, - animated: false, - }); - }} - onOpenChange={handleOpenChange} - open={isBranchMenuOpen} - value={resolvedActiveBranch} - > - } - className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} - disabled={isInitialBranchesLoadPending || isBranchActionPending} + <> + { + if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { + return; + } + branchListRef.current?.scrollIndexIntoView?.({ + index: eventDetails.index, + animated: false, + }); + }} + onOpenChange={handleOpenChange} + open={isBranchMenuOpen} + value={resolvedActiveBranch} > - {triggerLabel} - - - -
- setBranchQuery(event.target.value)} - /> -
- No refs found. - - {shouldVirtualizeBranchList ? ( - - - ref={branchListRef} - data={filteredBranchPickerItems} - keyExtractor={(item) => item} - renderItem={({ item, index }) => renderPickerItem(item, index)} - estimatedItemSize={28} - drawDistance={336} - onEndReached={() => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextBranchPage(); - } - }} - style={{ maxHeight: "14rem" }} + } + className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} + disabled={isInitialBranchesLoadPending || isBranchActionPending} + > + {triggerLabel} + + + +
+ setBranchQuery(event.target.value)} /> - - ) : ( - - {filteredBranchPickerItems.map((itemValue, index) => - renderPickerItem(itemValue, index), - )} - - )} - {branchStatusText ? {branchStatusText} : null} - - +
+ No refs found. + + {shouldVirtualizeBranchList ? ( + + + ref={branchListRef} + data={filteredBranchPickerItems} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderPickerItem(item, index)} + estimatedItemSize={28} + drawDistance={336} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextBranchPage(); + } + }} + style={{ maxHeight: "14rem" }} + /> + + ) : ( + + {filteredBranchPickerItems.map((itemValue, index) => + renderPickerItem(itemValue, index), + )} + + )} + {branchStatusText ? {branchStatusText} : null} +
+
+ + { + if (!open) setPendingDelete(null); + }} + > + + + Delete branch "{pendingDelete?.name}"? + + {deleteRemoteBranchOnDelete + ? "This will delete the branch locally and its remote counterpart." + : "This will delete the branch locally."} + + + + }>Cancel + + + + + + { + if (!open) setForceDeleteTarget(null); + }} + > + + + Force delete "{forceDeleteTarget?.name}"? + + This branch may have unmerged commits that will be lost. + + + + }>Cancel + + + + + ); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 76d5d34c355..ebd11953276 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -426,6 +426,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.deleteRemoteBranchOnDelete !== + DEFAULT_UNIFIED_SETTINGS.deleteRemoteBranchOnDelete + ? ["Delete remote branch"] + : []), ...(isGitWritingModelDirty ? ["Git writing model"] : []), ], [ @@ -433,6 +437,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.autoOpenPlanSidebar, settings.confirmThreadArchive, settings.confirmThreadDelete, + settings.deleteRemoteBranchOnDelete, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.diffIgnoreWhitespace, @@ -468,6 +473,7 @@ export function useSettingsRestore(onRestored?: () => void) { addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + deleteRemoteBranchOnDelete: DEFAULT_UNIFIED_SETTINGS.deleteRemoteBranchOnDelete, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, }); onRestored?.(); @@ -818,6 +824,33 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + deleteRemoteBranchOnDelete: DEFAULT_UNIFIED_SETTINGS.deleteRemoteBranchOnDelete, + }) + } + /> + ) : null + } + control={ + + updateSettings({ deleteRemoteBranchOnDelete: Boolean(checked) }) + } + aria-label="Delete remote branch on delete" + /> + } + /> + { removeWorktree: vi.fn(), createRef: vi.fn(), switchRef: vi.fn(), + deleteBranch: vi.fn(), init: vi.fn(), }, git: { diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index bc912231d7f..9f65d570e8d 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -80,6 +80,7 @@ const rpcClientMock = { removeWorktree: vi.fn(), createRef: vi.fn(), switchRef: vi.fn(), + deleteBranch: vi.fn(), init: vi.fn(), }, git: { @@ -632,6 +633,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + deleteRemoteBranchOnDelete: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, @@ -695,6 +697,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + deleteRemoteBranchOnDelete: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index 407f840b46f..7e981bddf50 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -108,6 +108,7 @@ export interface WsRpcClient { readonly removeWorktree: RpcUnaryMethod; readonly createRef: RpcUnaryMethod; readonly switchRef: RpcUnaryMethod; + readonly deleteBranch: RpcUnaryMethod; readonly init: RpcUnaryMethod; }; readonly git: { @@ -247,6 +248,8 @@ export function createWsRpcClient( transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)), + deleteBranch: (input) => + transport.request((client) => client[WS_METHODS.vcsDeleteBranch](input)), init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), }, git: { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 0b155bf49b7..cf05db8922c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -178,6 +178,23 @@ export const VcsSwitchRefInput = Schema.Struct({ }); export type VcsSwitchRefInput = typeof VcsSwitchRefInput.Type; +export const VcsDeleteBranchInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + refName: TrimmedNonEmptyStringSchema, + isRemote: Schema.optional(Schema.Boolean), + remoteName: Schema.optional(TrimmedNonEmptyStringSchema), + force: Schema.optional(Schema.Boolean), + deleteRemote: Schema.optional(Schema.Boolean), +}); +export type VcsDeleteBranchInput = typeof VcsDeleteBranchInput.Type; + +export const VcsDeleteBranchResult = Schema.Struct({ + refName: TrimmedNonEmptyStringSchema, + deletedLocal: Schema.Boolean, + deletedRemote: Schema.Boolean, +}); +export type VcsDeleteBranchResult = typeof VcsDeleteBranchResult.Type; + export const VcsInitInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, kind: Schema.optional(VcsDriverKind), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index d643ccd2d9f..7f2c31be80f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,6 +1,8 @@ import type { VcsCreateRefInput, VcsCreateRefResult, + VcsDeleteBranchInput, + VcsDeleteBranchResult, VcsCreateWorktreeInput, VcsCreateWorktreeResult, VcsInitInput, @@ -542,6 +544,7 @@ export interface EnvironmentApi { removeWorktree: (input: VcsRemoveWorktreeInput) => Promise; createRef: (input: VcsCreateRefInput) => Promise; switchRef: (input: VcsSwitchRefInput) => Promise; + deleteBranch: (input: VcsDeleteBranchInput) => Promise; init: (input: VcsInitInput) => Promise; pull: (input: VcsPullInput) => Promise; refreshStatus: (input: VcsStatusInput) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 41f6a696d3a..aea177ecc54 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -16,6 +16,8 @@ import { GitCommandError, VcsCreateRefInput, VcsCreateRefResult, + VcsDeleteBranchInput, + VcsDeleteBranchResult, VcsCreateWorktreeInput, VcsCreateWorktreeResult, VcsInitInput, @@ -129,6 +131,7 @@ export const WS_METHODS = { vcsRemoveWorktree: "vcs.removeWorktree", vcsCreateRef: "vcs.createRef", vcsSwitchRef: "vcs.switchRef", + vcsDeleteBranch: "vcs.deleteBranch", vcsInit: "vcs.init", // Git workflow methods @@ -367,6 +370,12 @@ export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { error: GitCommandError, }); +export const WsVcsDeleteBranchRpc = Rpc.make(WS_METHODS.vcsDeleteBranch, { + payload: VcsDeleteBranchInput, + success: VcsDeleteBranchResult, + error: GitCommandError, +}); + export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { payload: VcsInitInput, error: VcsError, @@ -540,6 +549,7 @@ export const WsRpcGroup = RpcGroup.make( WsVcsRemoveWorktreeRpc, WsVcsCreateRefRpc, WsVcsSwitchRefRpc, + WsVcsDeleteBranchRpc, WsVcsInitRpc, WsReviewGetDiffPreviewRpc, WsTerminalOpenRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..90a9a1b8a99 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -43,6 +43,7 @@ export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + deleteRemoteBranchOnDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -478,6 +479,7 @@ export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), + deleteRemoteBranchOnDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffWordWrap: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey(