Edit branch name (vibe-kanban) (#1182)
* ## ✅ Implementation Complete! I've successfully implemented the branch name editing feature. Here's what was added: ### **Backend Changes** 1. **Database Model** ([task_attempt.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/db/src/models/task_attempt.rs#L407-L421)) - Added `update_branch_name()` method to update the branch field 2. **Git Service** ([git.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/services/src/services/git.rs#L1508-L1526)) - Added `rename_local_branch()` to handle actual Git branch renaming using libgit2 3. **API Endpoint** ([task_attempts.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/server/src/routes/task_attempts.rs#L1111-L1196)) - Added `POST /task-attempts/{id}/rename-branch` endpoint - **Validation:** empty names, Git name validity, duplicates, active rebase, open PRs - **Flow:** validate → ensure worktree → rename in git → update DB → track analytics ### **Frontend Changes** 4. **API Client** ([api.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/lib/api.ts#L533-L546)) - Added `renameBranch()` method 5. **Actions Menu** ([ActionsDropdown.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/components/ui/ActionsDropdown.tsx#L93-L102)) - Added "Edit Branch Name" menu item 6. **Dialog Component** ([EditBranchNameDialog.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/components/dialogs/tasks/EditBranchNameDialog.tsx)) - Text input with validation - Shows backend errors - Auto-refreshes data on success ### **Key Features** ✅ Renames actual Git branch (not just DB) ✅ Blocks when open PR exists ✅ Blocks during active rebase ✅ Validates branch name format ✅ Prevents duplicate branch names ✅ Analytics tracking ✅ All checks pass The feature is now ready to use from the task attempt dropdown menu! * Cleanup script changes for task attempt 98509988-cc54-4336-a94a-fb43c16a8235 * ## ✅ Cache Invalidation Fix Complete! I've updated [EditBranchNameDialog.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/components/dialogs/tasks/EditBranchNameDialog.tsx#L59-L71) to invalidate all relevant query keys after renaming a branch. ### What Changed The dialog now invalidates **5 query keys** instead of just 2: 1. ✅ `['taskAttempt', attemptId]` - **Main fix** for DiffsPanel 2. ✅ `['attempt', attemptId]` - Legacy compatibility 3. ✅ `['attemptBranch', attemptId]` - Branch-specific data 4. ✅ `['branchStatus', attemptId]` - Git operations panel 5. ✅ `['taskAttempts']` - Attempts list in TaskPanel ### Result Now when you rename a branch, **all components immediately show the new name**: - ✅ DiffsPanel (branch name in Git operations toolbar) - ✅ TaskPanel (attempts list) - ✅ GitOperations component - ✅ Any other component displaying the attempt data **No page reload needed!** All checks pass. * Cleanup script changes for task attempt 98509988-cc54-4336-a94a-fb43c16a8235 * ## ✅ Refactored to Use Hook Pattern! I've successfully extracted the mutation logic into a proper `useRenameBranch` hook following the codebase's established patterns. ### Changes Made **1. Created new hook:** [useRenameBranch.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/hooks/useRenameBranch.ts) - Uses `useMutation` from TanStack Query - Handles cache invalidation internally - Provides callbacks for success/error handling - Follows the same pattern as `useChangeTargetBranch`, `useMerge`, `usePush` **2. Simplified EditBranchNameDialog:** - Removed manual API call and cache invalidation - Removed local `isLoading` state (uses `mutation.isPending` instead) - Removed `queryClient` import - Much cleaner and more focused on UI **3. Updated hooks exports:** [index.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/hooks/index.ts#L7) ### Before vs After **Before (inline mutation):** - 40+ lines of try/catch, API calls, and invalidation logic - Manual loading state management - Hard to test, hard to reuse **After (hook-based):** - ~10 lines in dialog - Hook encapsulates all mutation logic - Reusable, testable, consistent with codebase ### Benefits ✅ **Consistency** - Matches existing patterns (useChangeTargetBranch, useMerge, etc.) ✅ **Reusability** - Can be used anywhere branch renaming is needed ✅ **Testability** - Hook can be unit tested separately ✅ **Maintainability** - Single source of truth for invalidation logic ✅ **Cleaner Dialog** - Dialog focuses purely on UI concerns All checks pass! * Cleanup script changes for task attempt 98509988-cc54-4336-a94a-fb43c16a8235 * ## ✅ i18n Check Complete! All internationalization issues have been resolved: ### Fixed Issues **1. Missing translation keys** - Added to all locales (en, es, ja, ko): - `actionsMenu.editBranchName` **2. Hardcoded strings in EditBranchNameDialog** - Now using translation keys: - Dialog title → `t('editBranchName.dialog.title')` - Dialog description → `t('editBranchName.dialog.description')` - Branch name label → `t('editBranchName.dialog.branchNameLabel')` - Placeholder text → `t('editBranchName.dialog.placeholder')` - "Renaming..." → `t('editBranchName.dialog.renaming')` - "Rename Branch" → `t('editBranchName.dialog.action')` ### Translations Added **English:** "Edit branch name" **Spanish (es):** "Editar nombre de rama" **Japanese (ja):** "ブランチ名を編集" **Korean (ko):** "브랜치 이름 편집" ### Results ✅ **No new literal strings introduced** ✅ **No duplicate keys found** ✅ **Translation keys consistent across all locales** ✅ **All type checks pass** The feature is now fully internationalized and ready for users in all supported languages! * Cleanup script changes for task attempt 98509988-cc54-4336-a94a-fb43c16a8235 * ## ✅ Child Task Updates Complete! I've successfully implemented the fix to update child task attempts when renaming a branch. ### What Was Added **1. Database Helper** ([task_attempt.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/db/src/models/task_attempt.rs#L422-L445)) - Added `update_target_branch_for_children_of_attempt()` method - Uses single scoped SQL UPDATE with subquery - Only affects attempts for direct child tasks - Returns count of updated attempts **2. Rename Branch Endpoint** ([task_attempts.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/server/src/routes/task_attempts.rs#L1219-L1239)) - Stores old branch name before update - After renaming, updates all child task attempts - Logs count of updated children - Tracks updated count in analytics ### How It Works **Scenario:** 1. Parent attempt has branch `feature/old` 2. User creates 2 subtasks from parent attempt 3. Subtask attempts inherit `target_branch = "feature/old"` 4. Parent branch renamed to `feature/new` 5. ✅ Both subtask attempts automatically get `target_branch = "feature/new"` **SQL Query:** ```sql UPDATE task_attempts SET target_branch = 'feature/new' WHERE target_branch = 'feature/old' AND task_id IN ( SELECT id FROM tasks WHERE parent_task_attempt = parent_attempt_id ) ``` ### What's Updated ✅ `target_branch` for all attempts of direct child tasks ✅ Only when it matches the old branch name ✅ Scoped by parent-child relationship (via `parent_task_attempt` FK) ### What's NOT Updated ❌ Unrelated attempts that happen to use same branch name ❌ Child attempts' own `branch` field (they have their own branches) ❌ GitHub PR bases (would need GitHub API call) ### Result ✅ Child task attempts continue to work correctly after parent branch rename ✅ Efficient single-query update ✅ Safe scoping prevents unintended changes ✅ All checks pass * Cleanup script changes for task attempt 98509988-cc54-4336-a94a-fb43c16a8235 * ## ✅ Analytics & Types Fixed! ### Changes Made **1. Analytics Anonymity** ([task_attempts.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/server/src/routes/task_attempts.rs#L1240-L1248)) - Removed `old_branch` and `new_branch` from analytics event - Now only tracks: `attempt_id` and `updated_children` (anonymous) **2. Type Export** ([generate_types.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/crates/server/src/bin/generate_types.rs#L51-L52)) - Added `RenameBranchRequest::decl()` and `RenameBranchResponse::decl()` - Types now exported to [shared/types.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/shared/types.ts) **3. Frontend Type Usage** ([api.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/9850-edit-branch-name/frontend/src/lib/api.ts#L49-L50)) - Imported `RenameBranchRequest` and `RenameBranchResponse` from shared types - Changed signature from `Promise<{ branch: string }>` to `Promise<RenameBranchResponse>` - Uses properly typed request payload ### Results ✅ **Anonymous analytics** - No branch names (PII) sent ✅ **Type-safe** - Frontend/backend use same shared types ✅ **Consistent** - Matches pattern of other API methods ✅ **All checks pass** - TypeScript, Rust, and i18n * ## ✅ Analytics Now Fully Anonymous! Removed `attempt_id` from the analytics event. Now the event only tracks: ```json { "updated_children": 0 } ``` Fully anonymous - no IDs, no branch names, no PII. Just aggregate counts for usage metrics. All checks pass!
This commit is contained in:
committed by
GitHub
parent
6ecf592c40
commit
947cb53f44
12
crates/db/.sqlx/query-6e553d3a71f3a3e33dd2418ac62babe4b75fc734a135cb45f5127a0411eeb89b.json
generated
Normal file
12
crates/db/.sqlx/query-6e553d3a71f3a3e33dd2418ac62babe4b75fc734a135cb45f5127a0411eeb89b.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE task_attempts SET branch = $1, updated_at = datetime('now') WHERE id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 2
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6e553d3a71f3a3e33dd2418ac62babe4b75fc734a135cb45f5127a0411eeb89b"
|
||||
}
|
||||
12
crates/db/.sqlx/query-a225b1f2538d56812ca241b30a2dbd7d836da8b58567ef6a7ba44e77920cfd37.json
generated
Normal file
12
crates/db/.sqlx/query-a225b1f2538d56812ca241b30a2dbd7d836da8b58567ef6a7ba44e77920cfd37.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "UPDATE task_attempts\n SET target_branch = $3, updated_at = datetime('now')\n WHERE target_branch = $2\n AND task_id IN (\n SELECT id FROM tasks \n WHERE parent_task_attempt = $1\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a225b1f2538d56812ca241b30a2dbd7d836da8b58567ef6a7ba44e77920cfd37"
|
||||
}
|
||||
@@ -404,6 +404,46 @@ impl TaskAttempt {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_branch_name(
|
||||
pool: &SqlitePool,
|
||||
attempt_id: Uuid,
|
||||
new_branch_name: &str,
|
||||
) -> Result<(), TaskAttemptError> {
|
||||
sqlx::query!(
|
||||
"UPDATE task_attempts SET branch = $1, updated_at = datetime('now') WHERE id = $2",
|
||||
new_branch_name,
|
||||
attempt_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_target_branch_for_children_of_attempt(
|
||||
pool: &SqlitePool,
|
||||
parent_attempt_id: Uuid,
|
||||
old_branch: &str,
|
||||
new_branch: &str,
|
||||
) -> Result<u64, TaskAttemptError> {
|
||||
let result = sqlx::query!(
|
||||
r#"UPDATE task_attempts
|
||||
SET target_branch = $3, updated_at = datetime('now')
|
||||
WHERE target_branch = $2
|
||||
AND task_id IN (
|
||||
SELECT id FROM tasks
|
||||
WHERE parent_task_attempt = $1
|
||||
)"#,
|
||||
parent_attempt_id,
|
||||
old_branch,
|
||||
new_branch
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn resolve_container_ref(
|
||||
pool: &SqlitePool,
|
||||
container_ref: &str,
|
||||
|
||||
@@ -49,6 +49,8 @@ fn generate_types_content() -> String {
|
||||
services::services::drafts::UpdateRetryFollowUpDraftRequest::decl(),
|
||||
server::routes::task_attempts::ChangeTargetBranchRequest::decl(),
|
||||
server::routes::task_attempts::ChangeTargetBranchResponse::decl(),
|
||||
server::routes::task_attempts::RenameBranchRequest::decl(),
|
||||
server::routes::task_attempts::RenameBranchResponse::decl(),
|
||||
server::routes::tasks::CreateAndStartTaskRequest::decl(),
|
||||
server::routes::task_attempts::CreateGitHubPrRequest::decl(),
|
||||
server::routes::images::ImageResponse::decl(),
|
||||
|
||||
@@ -1080,6 +1080,16 @@ pub struct ChangeTargetBranchResponse {
|
||||
pub status: (usize, usize),
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug, TS)]
|
||||
pub struct RenameBranchRequest {
|
||||
pub new_branch_name: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Debug, TS)]
|
||||
pub struct RenameBranchResponse {
|
||||
pub branch: String,
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn change_target_branch(
|
||||
Extension(task_attempt): Extension<TaskAttempt>,
|
||||
@@ -1140,6 +1150,107 @@ pub async fn change_target_branch(
|
||||
)))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn rename_branch(
|
||||
Extension(task_attempt): Extension<TaskAttempt>,
|
||||
State(deployment): State<DeploymentImpl>,
|
||||
Json(payload): Json<RenameBranchRequest>,
|
||||
) -> Result<ResponseJson<ApiResponse<RenameBranchResponse>>, ApiError> {
|
||||
let new_branch_name = payload.new_branch_name.trim();
|
||||
|
||||
if new_branch_name.is_empty() {
|
||||
return Ok(ResponseJson(ApiResponse::error(
|
||||
"Branch name cannot be empty",
|
||||
)));
|
||||
}
|
||||
|
||||
if new_branch_name == task_attempt.branch {
|
||||
return Ok(ResponseJson(ApiResponse::success(RenameBranchResponse {
|
||||
branch: task_attempt.branch.clone(),
|
||||
})));
|
||||
}
|
||||
|
||||
if !git2::Branch::name_is_valid(new_branch_name)? {
|
||||
return Ok(ResponseJson(ApiResponse::error(
|
||||
"Invalid branch name format",
|
||||
)));
|
||||
}
|
||||
|
||||
let pool = &deployment.db().pool;
|
||||
let task = task_attempt
|
||||
.parent_task(pool)
|
||||
.await?
|
||||
.ok_or(ApiError::TaskAttempt(TaskAttemptError::TaskNotFound))?;
|
||||
|
||||
let project = Project::find_by_id(pool, task.project_id)
|
||||
.await?
|
||||
.ok_or(ApiError::Project(ProjectError::ProjectNotFound))?;
|
||||
|
||||
if deployment
|
||||
.git()
|
||||
.check_branch_exists(&project.git_repo_path, new_branch_name)?
|
||||
{
|
||||
return Ok(ResponseJson(ApiResponse::error(
|
||||
"A branch with this name already exists",
|
||||
)));
|
||||
}
|
||||
|
||||
let worktree_path_buf = ensure_worktree_path(&deployment, &task_attempt).await?;
|
||||
let worktree_path = worktree_path_buf.as_path();
|
||||
|
||||
if deployment.git().is_rebase_in_progress(worktree_path)? {
|
||||
return Ok(ResponseJson(ApiResponse::error(
|
||||
"Cannot rename branch while rebase is in progress. Please complete or abort the rebase first.",
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(merge) = Merge::find_latest_by_task_attempt_id(pool, task_attempt.id).await?
|
||||
&& let Merge::Pr(pr_merge) = merge
|
||||
&& matches!(pr_merge.pr_info.status, MergeStatus::Open)
|
||||
{
|
||||
return Ok(ResponseJson(ApiResponse::error(
|
||||
"Cannot rename branch with an open pull request. Please close the PR first or create a new attempt.",
|
||||
)));
|
||||
}
|
||||
|
||||
deployment
|
||||
.git()
|
||||
.rename_local_branch(worktree_path, &task_attempt.branch, new_branch_name)?;
|
||||
|
||||
let old_branch = task_attempt.branch.clone();
|
||||
|
||||
TaskAttempt::update_branch_name(pool, task_attempt.id, new_branch_name).await?;
|
||||
|
||||
let updated_children_count = TaskAttempt::update_target_branch_for_children_of_attempt(
|
||||
pool,
|
||||
task_attempt.id,
|
||||
&old_branch,
|
||||
new_branch_name,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if updated_children_count > 0 {
|
||||
tracing::info!(
|
||||
"Updated {} child task attempts to target new branch '{}'",
|
||||
updated_children_count,
|
||||
new_branch_name
|
||||
);
|
||||
}
|
||||
|
||||
deployment
|
||||
.track_if_analytics_allowed(
|
||||
"task_attempt_branch_renamed",
|
||||
serde_json::json!({
|
||||
"updated_children": updated_children_count,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(ResponseJson(ApiResponse::success(RenameBranchResponse {
|
||||
branch: new_branch_name.to_string(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn rebase_task_attempt(
|
||||
Extension(task_attempt): Extension<TaskAttempt>,
|
||||
@@ -1540,6 +1651,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router<DeploymentImpl> {
|
||||
.route("/children", get(get_task_attempt_children))
|
||||
.route("/stop", post(stop_task_attempt_execution))
|
||||
.route("/change-target-branch", post(change_target_branch))
|
||||
.route("/rename-branch", post(rename_branch))
|
||||
.layer(from_fn_with_state(
|
||||
deployment.clone(),
|
||||
load_task_attempt_middleware,
|
||||
|
||||
@@ -1506,6 +1506,25 @@ impl GitService {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rename_local_branch(
|
||||
&self,
|
||||
worktree_path: &Path,
|
||||
old_branch_name: &str,
|
||||
new_branch_name: &str,
|
||||
) -> Result<(), GitServiceError> {
|
||||
let repo = self.open_repo(worktree_path)?;
|
||||
|
||||
let mut branch = repo
|
||||
.find_branch(old_branch_name, BranchType::Local)
|
||||
.map_err(|_| GitServiceError::BranchNotFound(old_branch_name.to_string()))?;
|
||||
|
||||
branch.rename(new_branch_name, false)?;
|
||||
|
||||
repo.set_head(&format!("refs/heads/{new_branch_name}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return true if a rebase is currently in progress in this worktree.
|
||||
pub fn is_rebase_in_progress(&self, worktree_path: &Path) -> Result<bool, GitServiceError> {
|
||||
let git = GitCli::new();
|
||||
|
||||
Reference in New Issue
Block a user