feat(device): add rename & delete actions to branch switcher (#15774)

Hover a branch row in the branch switcher to rename or delete it. Wires
new renameGitBranch / deleteGitBranch operations through both transports
(Electron IPC for the local machine, device.* TRPC RPCs for remote/web),
mirroring the existing checkoutGitBranch / revertGitFile stack.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-13 20:07:45 +08:00
committed by GitHub
parent 09fd6f3411
commit 381e87474c
13 changed files with 512 additions and 3 deletions
@@ -390,6 +390,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
);
}
case 'renameGitBranch': {
return this.gitCtr.renameGitBranch(params as { from: string; path: string; to: string });
}
case 'deleteGitBranch': {
return this.gitCtr.deleteGitBranch(params as { branch: string; path: string });
}
case 'pullGitBranch': {
return this.gitCtr.pullGitBranch(params as { path: string });
}
@@ -10,12 +10,14 @@ import type {
GitBranchInfo,
GitBranchListItem,
GitCheckoutResult,
GitDeleteBranchResult,
GitFileDiffStatus,
GitFileRevertResult,
GitLinkedPullRequestResult,
GitPullResult,
GitPushResult,
GitRemoteBranchListItem,
GitRenameBranchResult,
GitWorkingTreeFiles,
GitWorkingTreePatch,
GitWorkingTreePatches,
@@ -1084,6 +1086,67 @@ export default class GitController extends ControllerModule {
}
}
/**
* Rename a local branch (`git branch -m <from> <to>`). Works on the current
* branch too. Uses the non-force `-m`, so git rejects (and we surface) a
* rename onto an existing branch name. Mirrors `checkoutGitBranch`'s early
* ref validation on the new name.
*/
@IpcMethod()
async renameGitBranch(payload: {
from: string;
path: string;
to: string;
}): Promise<GitRenameBranchResult> {
const { path: dirPath, from, to } = payload;
if (!from?.trim() || !to?.trim()) {
return { error: 'Branch name is required', success: false };
}
// Reject obviously invalid refs early to avoid a confusing git error
if (/[\s~^:?*[\\]/.test(to) || to.startsWith('-') || to.includes('..')) {
return { error: `Invalid branch name: ${to}`, success: false };
}
const execFileAsync = promisify(execFile);
try {
await execFileAsync('git', ['branch', '-m', from, to], { cwd: dirPath, timeout: 10_000 });
return { success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
logger.debug('[renameGitBranch] failed', { from, stderr, to });
return { error: stderr || 'git branch rename failed', success: false };
}
}
/**
* Delete a local branch (`git branch -D <branch>`). Force delete (`-D`) is
* intentional: the UI gates this behind an explicit confirm, so we don't want
* git's "not fully merged" guard to block a deliberate cleanup. git still
* refuses to delete the currently checked-out branch, and that error is
* surfaced to the renderer.
*/
@IpcMethod()
async deleteGitBranch(payload: { branch: string; path: string }): Promise<GitDeleteBranchResult> {
const { path: dirPath, branch } = payload;
if (!branch?.trim()) {
return { error: 'Branch name is required', success: false };
}
// Reject obviously invalid refs early to avoid a confusing git error
if (/[\s~^:?*[\\]/.test(branch) || branch.startsWith('-') || branch.includes('..')) {
return { error: `Invalid branch name: ${branch}`, success: false };
}
const execFileAsync = promisify(execFile);
try {
await execFileAsync('git', ['branch', '-D', branch], { cwd: dirPath, timeout: 10_000 });
return { success: true };
} catch (error: any) {
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
logger.debug('[deleteGitBranch] failed', { branch, stderr });
return { error: stderr || 'git branch delete failed', success: false };
}
}
/**
* Pull the current branch's upstream via fast-forward only.
*
+44
View File
@@ -163,6 +163,50 @@ export const deviceRouter = router({
}),
),
/**
* Rename a branch in a directory on a remote device, via the device's
* `renameGitBranch` RPC.
*/
renameGitBranch: deviceProcedure
.input(
z.object({
deviceId: z.string(),
from: z.string(),
path: z.string(),
to: z.string(),
}),
)
.mutation(async ({ ctx, input }) =>
deviceGateway.renameGitBranch({
deviceId: input.deviceId,
from: input.from,
path: input.path,
to: input.to,
userId: ctx.userId,
}),
),
/**
* Delete a branch in a directory on a remote device, via the device's
* `deleteGitBranch` RPC.
*/
deleteGitBranch: deviceProcedure
.input(
z.object({
branch: z.string(),
deviceId: z.string(),
path: z.string(),
}),
)
.mutation(async ({ ctx, input }) =>
deviceGateway.deleteGitBranch({
branch: input.branch,
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
}),
),
/**
* Pull (`--ff-only`) the current branch of a directory on a remote device, via
* the device's `pullGitBranch` RPC.
@@ -14,9 +14,11 @@ import type {
DeviceGitBranchInfo,
DeviceGitBranchListItem,
DeviceGitCheckoutResult,
DeviceGitDeleteBranchResult,
DeviceGitFileRevertResult,
DeviceGitLinkedPullRequestResult,
DeviceGitRemoteBranchListItem,
DeviceGitRenameBranchResult,
DeviceGitSyncResult,
DeviceGitWorkingTreeFiles,
DeviceGitWorkingTreePatches,
@@ -272,6 +274,73 @@ export class DeviceGateway {
}
}
/**
* Rename a branch in a directory on a remote device via the `renameGitBranch`
* device RPC.
*/
async renameGitBranch(params: {
deviceId: string;
from: string;
path: string;
timeout?: number;
to: string;
userId: string;
}): Promise<DeviceGitRenameBranchResult> {
const { userId, deviceId, from, to, path, timeout = 30_000 } = params;
const client = this.getClient();
if (!client) return { error: 'Device gateway not configured', success: false };
try {
const result = await client.invokeRpc<DeviceGitRenameBranchResult>(
{ deviceId, timeout, userId },
{ method: 'renameGitBranch', params: { from, path, to } },
);
if (!result.success || !result.data) {
log('renameGitBranch: failed for deviceId=%s — %s', deviceId, result.error);
return { error: result.error || 'Rename failed', success: false };
}
return result.data;
} catch (error) {
log('renameGitBranch: error for deviceId=%s — %O', deviceId, error);
return { error: (error as Error)?.message || 'Rename failed', success: false };
}
}
/**
* Delete a branch in a directory on a remote device via the `deleteGitBranch`
* device RPC.
*/
async deleteGitBranch(params: {
branch: string;
deviceId: string;
path: string;
timeout?: number;
userId: string;
}): Promise<DeviceGitDeleteBranchResult> {
const { userId, deviceId, branch, path, timeout = 30_000 } = params;
const client = this.getClient();
if (!client) return { error: 'Device gateway not configured', success: false };
try {
const result = await client.invokeRpc<DeviceGitDeleteBranchResult>(
{ deviceId, timeout, userId },
{ method: 'deleteGitBranch', params: { branch, path } },
);
if (!result.success || !result.data) {
log('deleteGitBranch: failed for deviceId=%s — %s', deviceId, result.error);
return { error: result.error || 'Delete failed', success: false };
}
return result.data;
} catch (error) {
log('deleteGitBranch: error for deviceId=%s — %O', deviceId, error);
return { error: (error as Error)?.message || 'Delete failed', success: false };
}
}
/**
* Pull (`--ff-only`) the current branch of a directory on a remote device via
* the `pullGitBranch` device RPC.