blue/crates/blue-mcp/src/handlers/worktree.rs
Eric Garcia 489942cd35 feat: implement RFC 0006 (soft-delete) and RFC 0007 (branch naming)
RFC 0006 - Document Deletion Tools:
- Add soft-delete with 7-day retention before permanent deletion
- Add blue_delete, blue_restore, blue_deleted_list, blue_purge_deleted tools
- Add deleted_at column to documents table (schema v3)
- Block deletion of documents with ADR dependents
- Support dry_run, force, and permanent options

RFC 0007 - Consistent Branch Naming:
- Strip RFC number prefix from branch/worktree names
- Branch format: feature-description (not rfc/NNNN-feature-description)
- PR title format: RFC NNNN: Feature Description
- Add strip_rfc_number_prefix helper with tests

Also:
- Remove orphan .blue/repos/ and .blue/data/ directories
- Fix docs path resolution bug (spike documented)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 16:42:31 -05:00

379 lines
13 KiB
Rust

//! Worktree tool handlers
//!
//! Handles git worktree operations for isolated feature development.
//!
//! Branch naming convention (RFC 0007):
//! - RFC file: `NNNN-feature-description.md`
//! - Branch: `feature-description` (number prefix stripped)
//! - Worktree: `feature-description`
use blue_core::{DocType, ProjectState, Worktree as StoreWorktree};
use serde_json::{json, Value};
use crate::error::ServerError;
/// Strip RFC number prefix from title
///
/// Converts `0007-consistent-branch-naming` to `consistent-branch-naming`
/// Returns (stripped_name, rfc_number) if pattern matches, otherwise (original, None)
pub fn strip_rfc_number_prefix(title: &str) -> (String, Option<u32>) {
// Match pattern: NNNN-rest-of-title
if title.len() > 5 && title.chars().take(4).all(|c| c.is_ascii_digit()) && title.chars().nth(4) == Some('-') {
let number: Option<u32> = title[..4].parse().ok();
let stripped = title[5..].to_string();
(stripped, number)
} else {
(title.to_string(), None)
}
}
/// Handle blue_worktree_create
pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
// Find the RFC
let doc = state
.store
.find_document(DocType::Rfc, title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Check RFC is accepted or in-progress
if doc.status != "accepted" && doc.status != "in-progress" {
return Ok(json!({
"status": "error",
"message": blue_core::voice::error(
&format!("RFC '{}' is {} - can't create worktree", title, doc.status),
"Accept the RFC first with blue_rfc_update_status"
)
}));
}
// Check if worktree already exists
if let Some(id) = doc.id {
if let Ok(Some(_existing)) = state.store.get_worktree(id) {
return Ok(json!({
"status": "error",
"message": blue_core::voice::error(
&format!("Worktree for '{}' already exists", title),
"Use blue_worktree_list to see active worktrees"
)
}));
}
}
// Create branch name and worktree path (RFC 0007: strip number prefix)
let (stripped_name, _rfc_number) = strip_rfc_number_prefix(title);
let branch_name = stripped_name.clone();
let worktree_path = state.home.worktrees_path.join(&stripped_name);
// Try to create the git worktree
let repo_path = state.home.root.clone();
match git2::Repository::open(&repo_path) {
Ok(repo) => {
match blue_core::repo::create_worktree(&repo, &branch_name, &worktree_path) {
Ok(()) => {
// Record in store
if let Some(doc_id) = doc.id {
let wt = StoreWorktree {
id: None,
document_id: doc_id,
branch_name: branch_name.clone(),
worktree_path: worktree_path.display().to_string(),
created_at: None,
};
let _ = state.store.add_worktree(&wt);
}
// Update RFC status to in-progress if accepted
if doc.status == "accepted" {
let _ = state.store.update_document_status(DocType::Rfc, title, "in-progress");
}
Ok(json!({
"status": "success",
"title": title,
"branch": branch_name,
"path": worktree_path.display().to_string(),
"message": blue_core::voice::success(
&format!("Created worktree for '{}'", title),
Some(&format!("cd {} to start working", worktree_path.display()))
)
}))
}
Err(e) => Ok(json!({
"status": "error",
"message": blue_core::voice::error(
&format!("Couldn't create worktree: {}", e),
"Check git status and try again"
)
})),
}
}
Err(e) => Ok(json!({
"status": "error",
"message": blue_core::voice::error(
&format!("Couldn't open repository: {}", e),
"Make sure you're in a git repository"
)
})),
}
}
/// Handle blue_worktree_list
pub fn handle_list(state: &ProjectState) -> Result<Value, ServerError> {
// Get worktrees from store
let worktrees = state
.store
.list_worktrees()
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Enrich with document info
let enriched: Vec<Value> = worktrees
.iter()
.filter_map(|wt| {
state.store.get_document_by_id(wt.document_id).ok().map(|doc| {
json!({
"title": doc.title,
"status": doc.status,
"branch": wt.branch_name,
"path": wt.worktree_path,
"created_at": wt.created_at
})
})
})
.collect();
Ok(json!({
"count": enriched.len(),
"worktrees": enriched,
"message": if enriched.is_empty() {
"No active worktrees."
} else {
""
}
}))
}
/// Handle blue_worktree_cleanup
///
/// Full cleanup after PR merge:
/// 1. Verify PR is merged
/// 2. Remove worktree
/// 3. Delete local branch
/// 4. Return commands for switching to develop
pub fn handle_cleanup(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
// Support both old (rfc/title) and new (stripped) naming conventions
let (stripped_name, _) = strip_rfc_number_prefix(title);
let branch_name = stripped_name.clone();
// Find the RFC to get worktree info
let doc = state
.store
.find_document(DocType::Rfc, title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
let doc_id = doc.id.ok_or(ServerError::InvalidParams)?;
// Get worktree info
let worktree = state.store.get_worktree(doc_id).ok().flatten();
// Try to open the repository
let repo_path = state.home.root.clone();
let repo = match git2::Repository::open(&repo_path) {
Ok(r) => r,
Err(e) => {
return Ok(json!({
"status": "error",
"message": blue_core::voice::error(
&format!("Couldn't open repository: {}", e),
"Make sure you're in a git repository"
)
}));
}
};
// Check if branch is merged
let is_merged = blue_core::repo::is_branch_merged(&repo, &branch_name, "develop")
.or_else(|_| blue_core::repo::is_branch_merged(&repo, &branch_name, "main"))
.unwrap_or(false);
if !is_merged {
return Ok(json!({
"status": "error",
"message": blue_core::voice::error(
"PR doesn't appear to be merged yet",
"Complete the merge first with blue_pr_merge"
)
}));
}
// Remove worktree from git
let worktree_removed = if worktree.is_some() {
blue_core::repo::remove_worktree(&repo, &branch_name).is_ok()
} else {
false
};
// Delete local branch
let branch_deleted = if let Ok(mut branch) = repo.find_branch(&branch_name, git2::BranchType::Local) {
branch.delete().is_ok()
} else {
false
};
// Remove from store
if worktree.is_some() {
let _ = state.store.remove_worktree(doc_id);
}
let hint = format!(
"Worktree {}removed, branch {}deleted. Run the commands to complete cleanup.",
if worktree_removed { "" } else { "not " },
if branch_deleted { "" } else { "not " }
);
Ok(json!({
"status": "success",
"title": title,
"worktree_removed": worktree_removed,
"branch_deleted": branch_deleted,
"message": blue_core::voice::success(
&format!("Cleaned up after '{}'", title),
Some(&hint)
),
"commands": [
"git checkout develop",
"git pull"
],
"next_action": "Execute the commands to sync with develop"
}))
}
/// Handle blue_worktree_remove
pub fn handle_remove(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
// Find the RFC
let doc = state
.store
.find_document(DocType::Rfc, title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
let doc_id = doc.id.ok_or(ServerError::InvalidParams)?;
// Get worktree info
let worktree = state
.store
.get_worktree(doc_id)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?
.ok_or_else(|| ServerError::StateLoadFailed(format!("No worktree for '{}'", title)))?;
// Check if branch is merged (unless force)
if !force {
let repo_path = state.home.root.clone();
if let Ok(repo) = git2::Repository::open(&repo_path) {
match blue_core::repo::is_branch_merged(&repo, &worktree.branch_name, "main") {
Ok(false) => {
// Also check develop
match blue_core::repo::is_branch_merged(&repo, &worktree.branch_name, "develop") {
Ok(false) => {
return Ok(json!({
"status": "error",
"message": blue_core::voice::error(
&format!("Branch '{}' isn't merged yet", worktree.branch_name),
"Merge first, or use force=true to remove anyway"
)
}));
}
_ => {} // Merged into develop, ok
}
}
_ => {} // Merged into main, ok
}
}
}
// Remove from git
let repo_path = state.home.root.clone();
if let Ok(repo) = git2::Repository::open(&repo_path) {
if let Err(e) = blue_core::repo::remove_worktree(&repo, &worktree.branch_name) {
return Ok(json!({
"status": "error",
"message": blue_core::voice::error(
&format!("Couldn't remove worktree: {}", e),
"Try removing manually with 'git worktree remove'"
)
}));
}
}
// Remove from store
state
.store
.remove_worktree(doc_id)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
Ok(json!({
"status": "success",
"title": title,
"branch": worktree.branch_name,
"message": blue_core::voice::success(
&format!("Removed worktree for '{}'", title),
None
)
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_rfc_number_prefix() {
// Standard RFC title with number
let (stripped, number) = strip_rfc_number_prefix("0007-consistent-branch-naming");
assert_eq!(stripped, "consistent-branch-naming");
assert_eq!(number, Some(7));
// Another example
let (stripped, number) = strip_rfc_number_prefix("0001-some-feature");
assert_eq!(stripped, "some-feature");
assert_eq!(number, Some(1));
// High number
let (stripped, number) = strip_rfc_number_prefix("9999-last-rfc");
assert_eq!(stripped, "last-rfc");
assert_eq!(number, Some(9999));
}
#[test]
fn test_strip_rfc_number_prefix_no_number() {
// No number prefix
let (stripped, number) = strip_rfc_number_prefix("some-feature");
assert_eq!(stripped, "some-feature");
assert_eq!(number, None);
// Too few digits
let (stripped, number) = strip_rfc_number_prefix("007-james-bond");
assert_eq!(stripped, "007-james-bond");
assert_eq!(number, None);
// No hyphen after number
let (stripped, number) = strip_rfc_number_prefix("0007feature");
assert_eq!(stripped, "0007feature");
assert_eq!(number, None);
}
}