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>
379 lines
13 KiB
Rust
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);
|
|
}
|
|
}
|