diff --git a/packages/desktop/src-tauri/src/commands/git.rs b/packages/desktop/src-tauri/src/commands/git.rs index 8f9da93d..c3b94cc2 100644 --- a/packages/desktop/src-tauri/src/commands/git.rs +++ b/packages/desktop/src-tauri/src/commands/git.rs @@ -1696,6 +1696,22 @@ pub async fn create_branch( Ok(()) } +#[tauri::command] +pub async fn rename_branch( + directory: String, + old_name: String, + new_name: String, + state: State<'_, DesktopRuntime>, +) -> Result<(), String> { + let root = validate_git_path(&directory, state.settings()) + .await + .map_err(|e| e.to_string())?; + run_git(&["branch", "-m", &old_name, &new_name], &root) + .await + .map_err(|e| e.to_string())?; + Ok(()) +} + #[tauri::command] pub async fn get_git_log( directory: String, diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 697e24b2..8ad6a03d 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -30,7 +30,7 @@ use axum::{ }; use commands::files::{create_directory, exec_commands, list_directory, read_file, search_files, write_file}; use commands::git::{ - add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, + add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch, create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch, ensure_openchamber_ignored, generate_commit_message, get_commit_files, get_current_git_identity, get_git_branches, get_git_diff, get_git_file_diff, @@ -863,6 +863,7 @@ fn main() { git_fetch, checkout_branch, create_branch, + rename_branch, get_git_log, get_commit_files, get_git_identities, diff --git a/packages/desktop/src/api/git.ts b/packages/desktop/src/api/git.ts index faf4753f..424c2c94 100644 --- a/packages/desktop/src/api/git.ts +++ b/packages/desktop/src/api/git.ts @@ -183,6 +183,15 @@ export const createDesktopGitAPI = (): GitAPI => ({ return { success: true, branch: name }; }, + async renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }> { + await safeGitInvoke('rename_branch', { + directory, + oldName, + newName + }); + return { success: true, branch: newName }; + }, + async getGitLog(directory: string, options?: GitLogOptions): Promise { return safeGitInvoke('get_git_log', { directory, diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 99f81219..8a70b2b0 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -424,6 +424,21 @@ export const GitView: React.FC = () => { } }; + const handleRenameBranch = async (oldName: string, newName: string) => { + if (!currentDirectory) return; + + try { + await git.renameBranch(currentDirectory, oldName, newName); + toast.success(`Renamed branch ${oldName} to ${newName}`); + await refreshStatusAndBranches(); + await refreshLog(); + } catch (err) { + const message = + err instanceof Error ? err.message : `Failed to rename branch ${oldName} to ${newName}`; + toast.error(message); + } + }; + const handleCheckoutBranch = async (branch: string) => { if (!currentDirectory) return; const normalized = branch.replace(/^remotes\//, ''); @@ -652,6 +667,7 @@ export const GitView: React.FC = () => { onPush={() => handleSyncAction('push')} onCheckoutBranch={handleCheckoutBranch} onCreateBranch={handleCreateBranch} + onRenameBranch={handleRenameBranch} activeIdentityProfile={activeIdentityProfile} availableIdentities={availableIdentities} onSelectIdentity={handleApplyIdentity} diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index c8360d8b..582bac5f 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -21,6 +21,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { BranchSelector } from './BranchSelector'; +import { WorktreeBranchDisplay } from './WorktreeBranchDisplay'; import { SyncActions } from './SyncActions'; import type { GitStatus, GitIdentityProfile } from '@/lib/api/types'; @@ -37,6 +38,7 @@ interface GitHeaderProps { onPush: () => void; onCheckoutBranch: (branch: string) => void; onCreateBranch: (name: string) => Promise; + onRenameBranch?: (oldName: string, newName: string) => Promise; activeIdentityProfile: GitIdentityProfile | null; availableIdentities: GitIdentityProfile[]; onSelectIdentity: (profile: GitIdentityProfile) => void; @@ -187,6 +189,7 @@ export const GitHeader: React.FC = ({ onPush, onCheckoutBranch, onCreateBranch, + onRenameBranch, activeIdentityProfile, availableIdentities, onSelectIdentity, @@ -199,7 +202,12 @@ export const GitHeader: React.FC = ({ return (
- {!isWorktreeMode && ( + {isWorktreeMode ? ( + + ) : ( Promise; +} + +const sanitizeBranchNameInput = (value: string): string => { + return value + .trim() + .replace(/\s+/g, '-') + .replace(/[^A-Za-z0-9._/-]/g, '-') + .replace(/-+/g, '-') + .replace(/\/{2,}/g, '/') + .replace(/\/-+/g, '/') + .replace(/-+\//g, '/') + .replace(/^[-/]+/, '') + .replace(/[-/]+$/, ''); +}; + +export const WorktreeBranchDisplay: React.FC = ({ + currentBranch, + onRename, +}) => { + const [isEditing, setIsEditing] = React.useState(false); + const [editBranchName, setEditBranchName] = React.useState(currentBranch || ''); + const [isRenaming, setIsRenaming] = React.useState(false); + const inputRef = React.useRef(null); + + const handleStartEdit = () => { + if (!currentBranch || !onRename) return; + setEditBranchName(currentBranch); + setIsEditing(true); + // Focus input after state update + setTimeout(() => inputRef.current?.focus(), 0); + }; + + const handleSaveEdit = async () => { + if (!currentBranch || !onRename || !editBranchName.trim()) return; + + const sanitizedName = sanitizeBranchNameInput(editBranchName); + if (sanitizedName === currentBranch) { + setIsEditing(false); + return; + } + + setIsRenaming(true); + try { + await onRename(currentBranch, sanitizedName); + setIsEditing(false); + setEditBranchName(''); + } finally { + setIsRenaming(false); + } + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setEditBranchName(''); + }; + + // Handle Enter key to save, Escape to cancel + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveEdit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancelEdit(); + } + }; + + if (isEditing) { + return ( +
+
{ + e.preventDefault(); + handleSaveEdit(); + }} + > + + setEditBranchName(e.target.value)} + className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground" + placeholder="Branch name" + onKeyDown={handleKeyDown} + autoFocus + /> + + + +
+ ); + } + + return ( +
+
+ + + {currentBranch || 'Detached HEAD'} + +
+ {onRename && currentBranch && ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index f9339c03..b4e6de03 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -281,6 +281,7 @@ export interface GitAPI { gitFetch(directory: string, options?: { remote?: string; branch?: string }): Promise<{ success: boolean }>; checkoutBranch(directory: string, branch: string): Promise<{ success: boolean; branch: string }>; createBranch(directory: string, name: string, startPoint?: string): Promise<{ success: boolean; branch: string }>; + renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }>; getGitLog(directory: string, options?: GitLogOptions): Promise; getCommitFiles(directory: string, hash: string): Promise; getCurrentGitIdentity(directory: string): Promise; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index cf9d2ef9..3196924a 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -179,6 +179,16 @@ export async function createBranch( return gitHttp.createBranch(directory, name, startPoint); } +export async function renameBranch( + directory: string, + oldName: string, + newName: string +): Promise<{ success: boolean; branch: string }> { + const runtime = getRuntimeGit(); + if (runtime) return runtime.renameBranch(directory, oldName, newName); + return gitHttp.renameBranch(directory, oldName, newName); +} + export async function getGitLog( directory: string, options: import('./api/types').GitLogOptions = {} diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 863e8154..cfcf0678 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -404,6 +404,23 @@ export async function createBranch( return response.json(); } +export async function renameBranch( + directory: string, + oldName: string, + newName: string +): Promise<{ success: boolean; branch: string }> { + const response = await fetch(buildUrl(`${API_BASE}/branches/rename`, directory), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oldName, newName }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to rename branch'); + } + return response.json(); +} + export async function getGitLog( directory: string, options: GitLogOptions = {} diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index f111a613..9f163e2c 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -174,6 +174,15 @@ export const createVSCodeGitAPI = (): GitAPI => ({ }); }, + renameBranch: async (directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }> => { + return sendBridgeMessage<{ success: boolean; branch: string }>('api:git/branches/rename', { + directory, + method: 'PUT', + oldName, + newName, + }); + }, + getGitLog: async (directory: string, options?: GitLogOptions): Promise => { return sendBridgeMessage('api:git/log', { directory, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index fe22f131..fc5079b5 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -3818,6 +3818,30 @@ async function main(options = {}) { } }); + + app.put('/api/git/branches/rename', async (req, res) => { + const { renameBranch } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { oldName, newName } = req.body; + if (!oldName) { + return res.status(400).json({ error: 'oldName is required' }); + } + if (!newName) { + return res.status(400).json({ error: 'newName is required' }); + } + + const result = await renameBranch(directory, oldName, newName); + res.json(result); + } catch (error) { + console.error('Failed to rename branch:', error); + res.status(500).json({ error: error.message || 'Failed to rename branch' }); + } + }); app.delete('/api/git/remote-branches', async (req, res) => { const { deleteRemoteBranch } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git-service.js b/packages/web/server/lib/git-service.js index 9c0f6a25..9877b7cf 100644 --- a/packages/web/server/lib/git-service.js +++ b/packages/web/server/lib/git-service.js @@ -29,6 +29,22 @@ const normalizeDirectoryPath = (value) => { return trimmed; }; +const cleanBranchName = (branch) => { + if (!branch) { + return branch; + } + if (branch.startsWith('refs/heads/')) { + return branch.substring('refs/heads/'.length); + } + if (branch.startsWith('heads/')) { + return branch.substring('heads/'.length); + } + if (branch.startsWith('refs/')) { + return branch.substring('refs/'.length); + } + return branch; +}; + export async function isGitRepository(directory) { const directoryPath = normalizeDirectoryPath(directory); if (!directoryPath || !fs.existsSync(directoryPath)) { @@ -805,7 +821,7 @@ export async function getWorktrees(directory) { } else if (line.startsWith('HEAD ')) { current.head = line.substring(5); } else if (line.startsWith('branch ')) { - current.branch = line.substring(7); + current.branch = cleanBranchName(line.substring(7)); } else if (line === '') { if (current.worktree) { worktrees.push(current); @@ -1086,3 +1102,16 @@ export async function getCommitFiles(directory, commitHash) { throw error; } } + +export async function renameBranch(directory, oldName, newName) { + const git = simpleGit(normalizeDirectoryPath(directory)); + + try { + // Use git branch -m command to rename the branch + await git.raw(['branch', '-m', oldName, newName]); + return { success: true, branch: newName }; + } catch (error) { + console.error('Failed to rename branch:', error); + throw error; + } +} diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index fc37e94a..ec576324 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -28,6 +28,7 @@ export const createWebGitAPI = (): GitAPI => ({ gitFetch: gitApiHttp.gitFetch, checkoutBranch: gitApiHttp.checkoutBranch, createBranch: gitApiHttp.createBranch, + renameBranch: gitApiHttp.renameBranch, getGitLog(directory: string, options?: GitLogOptions) { return gitApiHttp.getGitLog(directory, options); },