use axum::{
extract::{Path, Query, State},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::error::ServerError;
use crate::git;
use crate::models::worktree::Worktree;
use crate::state::AppState;
type RepoLocks = Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>;
lazy_static::lazy_static! {
static ref REPO_LOCKS: RepoLocks = Arc::new(Mutex::new(HashMap::new()));
}
pub fn get_repo_locks() -> &'static RepoLocks {
&REPO_LOCKS
}
async fn get_repo_lock(repo_path: &str) -> Arc<Mutex<()>> {
let mut locks = REPO_LOCKS.lock().await;
locks
.entry(repo_path.to_string())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
}
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/workspaces/{workspace_id}/codebases/{codebase_id}/worktrees",
get(list_worktrees).post(create_worktree),
)
.route("/worktrees/{id}", get(get_worktree).delete(delete_worktree))
.route("/worktrees/{id}/validate", post(validate_worktree))
}
async fn list_worktrees(
State(state): State<AppState>,
Path((workspace_id, codebase_id)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, ServerError> {
let codebase = state
.codebase_store
.get(&codebase_id)
.await?
.ok_or_else(|| ServerError::NotFound(format!("Codebase {codebase_id} not found")))?;
if codebase.workspace_id != workspace_id {
return Err(ServerError::NotFound(format!(
"Codebase {codebase_id} not found"
)));
}
let worktrees = state.worktree_store.list_by_codebase(&codebase_id).await?;
Ok(Json(serde_json::json!({ "worktrees": worktrees })))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateWorktreeRequest {
branch: Option<String>,
base_branch: Option<String>,
label: Option<String>,
}
async fn create_worktree(
State(state): State<AppState>,
Path((workspace_id, codebase_id)): Path<(String, String)>,
Json(body): Json<CreateWorktreeRequest>,
) -> Result<Json<serde_json::Value>, ServerError> {
let codebase = state
.codebase_store
.get(&codebase_id)
.await?
.ok_or_else(|| ServerError::NotFound(format!("Codebase {codebase_id} not found")))?;
if codebase.workspace_id != workspace_id {
return Err(ServerError::NotFound(format!(
"Codebase {codebase_id} not found"
)));
}
let repo_path = &codebase.repo_path;
let base_branch = body.base_branch.unwrap_or_else(|| {
codebase
.branch
.clone()
.unwrap_or_else(|| "main".to_string())
});
let uuid_str = uuid::Uuid::new_v4().to_string();
let short_id = &uuid_str[..8];
let branch = body.branch.unwrap_or_else(|| {
let suffix = body
.label
.as_ref()
.map(|l| git::branch_to_safe_dir_name(l))
.unwrap_or_else(|| short_id.to_string());
format!("wt/{suffix}")
});
let lock = get_repo_lock(repo_path).await;
let _guard = lock.lock().await;
if let Some(existing) = state
.worktree_store
.find_by_branch(&codebase_id, &branch)
.await?
{
return Err(ServerError::Conflict(format!(
"Branch '{}' is already in use by worktree {}",
branch, existing.id
)));
}
let worktree_path = git::get_worktree_base_dir()
.join(&codebase.workspace_id)
.join(&codebase_id)
.join(git::branch_to_safe_dir_name(&branch));
if let Some(parent) = worktree_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
ServerError::Internal(format!("Failed to create worktree parent dir: {e}"))
})?;
}
let worktree_path_str = worktree_path.to_string_lossy().to_string();
let worktree = Worktree::new(
uuid::Uuid::new_v4().to_string(),
codebase_id.clone(),
codebase.workspace_id.clone(),
worktree_path_str.clone(),
branch.clone(),
base_branch.clone(),
body.label,
);
state.worktree_store.save(&worktree).await?;
let _ = git::worktree_prune(repo_path);
let branch_already_exists = git::branch_exists(repo_path, &branch);
let result = if branch_already_exists {
git::worktree_add(repo_path, &worktree_path_str, &branch, &base_branch, false)
} else {
git::worktree_add(repo_path, &worktree_path_str, &branch, &base_branch, true)
};
match result {
Ok(()) => {
state
.worktree_store
.update_status(&worktree.id, "active", None)
.await?;
let updated = state
.worktree_store
.get(&worktree.id)
.await?
.unwrap_or(worktree);
Ok(Json(serde_json::json!({ "worktree": updated })))
}
Err(err) => {
state
.worktree_store
.update_status(&worktree.id, "error", Some(&err))
.await?;
Err(ServerError::Internal(format!(
"Failed to create worktree: {err}"
)))
}
}
}
async fn get_worktree(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ServerError> {
let worktree = state
.worktree_store
.get(&id)
.await?
.ok_or_else(|| ServerError::NotFound(format!("Worktree {id} not found")))?;
Ok(Json(serde_json::json!({ "worktree": worktree })))
}
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct DeleteWorktreeQuery {
delete_branch: Option<bool>,
}
async fn delete_worktree(
State(state): State<AppState>,
Path(id): Path<String>,
Query(query): Query<DeleteWorktreeQuery>,
) -> Result<Json<serde_json::Value>, ServerError> {
let worktree = state
.worktree_store
.get(&id)
.await?
.ok_or_else(|| ServerError::NotFound(format!("Worktree {id} not found")))?;
let codebase = state.codebase_store.get(&worktree.codebase_id).await?;
if let Some(codebase) = codebase {
let repo_path = &codebase.repo_path;
let lock = get_repo_lock(repo_path).await;
let _guard = lock.lock().await;
state
.worktree_store
.update_status(&id, "removing", None)
.await?;
let _ = git::worktree_remove(repo_path, &worktree.worktree_path, true);
let _ = git::worktree_prune(repo_path);
if query.delete_branch.unwrap_or(false) {
let _ = crate::git::git_command()
.args(["branch", "-D", &worktree.branch])
.current_dir(repo_path)
.output();
}
}
state.worktree_store.delete(&id).await?;
Ok(Json(serde_json::json!({ "deleted": true })))
}
async fn validate_worktree(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ServerError> {
let worktree = state
.worktree_store
.get(&id)
.await?
.ok_or_else(|| ServerError::NotFound(format!("Worktree {id} not found")))?;
let path = std::path::Path::new(&worktree.worktree_path);
if !path.exists() {
state
.worktree_store
.update_status(&id, "error", Some("Worktree directory missing"))
.await?;
return Ok(Json(
serde_json::json!({ "healthy": false, "error": "Worktree directory missing" }),
));
}
let git_file = path.join(".git");
if !git_file.exists() {
state
.worktree_store
.update_status(
&id,
"error",
Some("Not a valid worktree (.git file missing)"),
)
.await?;
return Ok(Json(
serde_json::json!({ "healthy": false, "error": "Not a valid worktree (.git file missing)" }),
));
}
if worktree.status == "error" {
state
.worktree_store
.update_status(&id, "active", None)
.await?;
}
Ok(Json(serde_json::json!({ "healthy": true })))
}