Skip to main content

routa_server/api/
worktrees.rs

1use axum::{
2    extract::{Path, Query, State},
3    routing::{get, post},
4    Json, Router,
5};
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::sync::Arc;
9use tokio::sync::Mutex;
10
11use crate::error::ServerError;
12use crate::git;
13use crate::models::worktree::Worktree;
14use crate::state::AppState;
15
16/// Per-repository mutex for serializing git worktree operations.
17type RepoLocks = Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>;
18
19lazy_static::lazy_static! {
20    static ref REPO_LOCKS: RepoLocks = Arc::new(Mutex::new(HashMap::new()));
21}
22
23/// Get the global repo locks map (for reuse in codebase deletion).
24pub fn get_repo_locks() -> &'static RepoLocks {
25    &REPO_LOCKS
26}
27
28async fn get_repo_lock(repo_path: &str) -> Arc<Mutex<()>> {
29    let mut locks = REPO_LOCKS.lock().await;
30    locks
31        .entry(repo_path.to_string())
32        .or_insert_with(|| Arc::new(Mutex::new(())))
33        .clone()
34}
35
36pub fn router() -> Router<AppState> {
37    Router::new()
38        .route(
39            "/workspaces/{workspace_id}/codebases/{codebase_id}/worktrees",
40            get(list_worktrees).post(create_worktree),
41        )
42        .route("/worktrees/{id}", get(get_worktree).delete(delete_worktree))
43        .route("/worktrees/{id}/validate", post(validate_worktree))
44}
45
46// ─── List Worktrees ─────────────────────────────────────────────────────
47
48async fn list_worktrees(
49    State(state): State<AppState>,
50    Path((workspace_id, codebase_id)): Path<(String, String)>,
51) -> Result<Json<serde_json::Value>, ServerError> {
52    // Validate codebase belongs to the workspace
53    let codebase = state
54        .codebase_store
55        .get(&codebase_id)
56        .await?
57        .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", codebase_id)))?;
58    if codebase.workspace_id != workspace_id {
59        return Err(ServerError::NotFound(format!("Codebase {} not found", codebase_id)));
60    }
61
62    let worktrees = state.worktree_store.list_by_codebase(&codebase_id).await?;
63    Ok(Json(serde_json::json!({ "worktrees": worktrees })))
64}
65
66// ─── Create Worktree ────────────────────────────────────────────────────
67
68#[derive(Debug, Deserialize)]
69#[serde(rename_all = "camelCase")]
70struct CreateWorktreeRequest {
71    branch: Option<String>,
72    base_branch: Option<String>,
73    label: Option<String>,
74}
75
76async fn create_worktree(
77    State(state): State<AppState>,
78    Path((workspace_id, codebase_id)): Path<(String, String)>,
79    Json(body): Json<CreateWorktreeRequest>,
80) -> Result<Json<serde_json::Value>, ServerError> {
81    let codebase = state
82        .codebase_store
83        .get(&codebase_id)
84        .await?
85        .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", codebase_id)))?;
86
87    // Validate codebase belongs to the workspace
88    if codebase.workspace_id != workspace_id {
89        return Err(ServerError::NotFound(format!("Codebase {} not found", codebase_id)));
90    }
91
92    let repo_path = &codebase.repo_path;
93    let base_branch = body
94        .base_branch
95        .unwrap_or_else(|| codebase.branch.clone().unwrap_or_else(|| "main".to_string()));
96
97    let uuid_str = uuid::Uuid::new_v4().to_string();
98    let short_id = &uuid_str[..8];
99    let branch = body.branch.unwrap_or_else(|| {
100        let suffix = body
101            .label
102            .as_ref()
103            .map(|l| git::branch_to_safe_dir_name(l))
104            .unwrap_or_else(|| short_id.to_string());
105        format!("wt/{}", suffix)
106    });
107
108    // Acquire repo lock BEFORE branch check + DB insert to prevent races
109    let lock = get_repo_lock(repo_path).await;
110    let _guard = lock.lock().await;
111
112    // Check if branch already used by another worktree (inside lock)
113    if let Some(existing) = state.worktree_store.find_by_branch(&codebase_id, &branch).await? {
114        return Err(ServerError::Conflict(format!(
115            "Branch '{}' is already in use by worktree {}",
116            branch, existing.id
117        )));
118    }
119
120    // Get workspace to check for custom worktreeRoot in metadata
121    let workspace = state.workspace_store.get(&workspace_id).await?;
122    let worktree_root = workspace
123        .as_ref()
124        .and_then(|ws| ws.metadata.get("worktreeRoot"))
125        .filter(|s| !s.trim().is_empty())
126        .map(std::path::PathBuf::from)
127        .unwrap_or_else(|| git::get_default_workspace_worktree_root(&workspace_id));
128
129    // Use codebase label (or fallback to codebase_id) for the directory name
130    let codebase_label = codebase
131        .label
132        .as_ref()
133        .map(|l| git::branch_to_safe_dir_name(l))
134        .unwrap_or_else(|| git::branch_to_safe_dir_name(&codebase_id));
135
136    // Compute worktree path: {worktreeRoot}/{codebaseLabel}/{branchDir}
137    let worktree_dir = body
138        .label
139        .as_ref()
140        .map(|l| git::branch_to_safe_dir_name(l))
141        .unwrap_or_else(|| git::branch_to_safe_dir_name(&branch));
142    let worktree_path = worktree_root.join(&codebase_label).join(&worktree_dir);
143
144    // Ensure parent directory exists
145    if let Some(parent) = worktree_path.parent() {
146        std::fs::create_dir_all(parent).map_err(|e| {
147            ServerError::Internal(format!("Failed to create worktree parent dir: {}", e))
148        })?;
149    }
150
151    let worktree_path_str = worktree_path.to_string_lossy().to_string();
152
153    // Create DB record
154    let worktree = Worktree::new(
155        uuid::Uuid::new_v4().to_string(),
156        codebase_id.clone(),
157        codebase.workspace_id.clone(),
158        worktree_path_str.clone(),
159        branch.clone(),
160        base_branch.clone(),
161        body.label,
162    );
163    state.worktree_store.save(&worktree).await?;
164
165    // Prune stale references
166    let _ = git::worktree_prune(repo_path);
167
168    // Check if branch already exists
169    let branch_already_exists = git::branch_exists(repo_path, &branch);
170
171    let result = if branch_already_exists {
172        git::worktree_add(repo_path, &worktree_path_str, &branch, &base_branch, false)
173    } else {
174        git::worktree_add(repo_path, &worktree_path_str, &branch, &base_branch, true)
175    };
176
177    match result {
178        Ok(()) => {
179            state
180                .worktree_store
181                .update_status(&worktree.id, "active", None)
182                .await?;
183            let updated = state.worktree_store.get(&worktree.id).await?.unwrap_or(worktree);
184            Ok(Json(serde_json::json!({ "worktree": updated })))
185        }
186        Err(err) => {
187            state
188                .worktree_store
189                .update_status(&worktree.id, "error", Some(&err))
190                .await?;
191            Err(ServerError::Internal(format!("Failed to create worktree: {}", err)))
192        }
193    }
194}
195
196// ─── Get Worktree ───────────────────────────────────────────────────────
197
198async fn get_worktree(
199    State(state): State<AppState>,
200    Path(id): Path<String>,
201) -> Result<Json<serde_json::Value>, ServerError> {
202    let worktree = state
203        .worktree_store
204        .get(&id)
205        .await?
206        .ok_or_else(|| ServerError::NotFound(format!("Worktree {} not found", id)))?;
207    Ok(Json(serde_json::json!({ "worktree": worktree })))
208}
209
210// ─── Delete Worktree ────────────────────────────────────────────────────
211
212#[derive(Debug, Deserialize, Default)]
213#[serde(rename_all = "camelCase")]
214struct DeleteWorktreeQuery {
215    delete_branch: Option<bool>,
216}
217
218async fn delete_worktree(
219    State(state): State<AppState>,
220    Path(id): Path<String>,
221    Query(query): Query<DeleteWorktreeQuery>,
222) -> Result<Json<serde_json::Value>, ServerError> {
223    let worktree = state
224        .worktree_store
225        .get(&id)
226        .await?
227        .ok_or_else(|| ServerError::NotFound(format!("Worktree {} not found", id)))?;
228
229    let codebase = state.codebase_store.get(&worktree.codebase_id).await?;
230
231    if let Some(codebase) = codebase {
232        let repo_path = &codebase.repo_path;
233        let lock = get_repo_lock(repo_path).await;
234        let _guard = lock.lock().await;
235
236        state.worktree_store.update_status(&id, "removing", None).await?;
237
238        // Remove worktree from disk
239        let _ = git::worktree_remove(repo_path, &worktree.worktree_path, true);
240        let _ = git::worktree_prune(repo_path);
241
242        // Optionally delete the branch
243        if query.delete_branch.unwrap_or(false) {
244            let _ = std::process::Command::new("git")
245                .args(["branch", "-D", &worktree.branch])
246                .current_dir(repo_path)
247                .output();
248        }
249    }
250
251    state.worktree_store.delete(&id).await?;
252    Ok(Json(serde_json::json!({ "deleted": true })))
253}
254
255// ─── Validate Worktree ──────────────────────────────────────────────────
256
257async fn validate_worktree(
258    State(state): State<AppState>,
259    Path(id): Path<String>,
260) -> Result<Json<serde_json::Value>, ServerError> {
261    let worktree = state
262        .worktree_store
263        .get(&id)
264        .await?
265        .ok_or_else(|| ServerError::NotFound(format!("Worktree {} not found", id)))?;
266
267    let path = std::path::Path::new(&worktree.worktree_path);
268    if !path.exists() {
269        state
270            .worktree_store
271            .update_status(&id, "error", Some("Worktree directory missing"))
272            .await?;
273        return Ok(Json(
274            serde_json::json!({ "healthy": false, "error": "Worktree directory missing" }),
275        ));
276    }
277
278    let git_file = path.join(".git");
279    if !git_file.exists() {
280        state
281            .worktree_store
282            .update_status(&id, "error", Some("Not a valid worktree (.git file missing)"))
283            .await?;
284        return Ok(Json(
285            serde_json::json!({ "healthy": false, "error": "Not a valid worktree (.git file missing)" }),
286        ));
287    }
288
289    // Restore to active if was in error state
290    if worktree.status == "error" {
291        state.worktree_store.update_status(&id, "active", None).await?;
292    }
293
294    Ok(Json(serde_json::json!({ "healthy": true })))
295}