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!(
60            "Codebase {} not found",
61            codebase_id
62        )));
63    }
64
65    let worktrees = state.worktree_store.list_by_codebase(&codebase_id).await?;
66    Ok(Json(serde_json::json!({ "worktrees": worktrees })))
67}
68
69// ─── Create Worktree ────────────────────────────────────────────────────
70
71#[derive(Debug, Deserialize)]
72#[serde(rename_all = "camelCase")]
73struct CreateWorktreeRequest {
74    branch: Option<String>,
75    base_branch: Option<String>,
76    label: Option<String>,
77}
78
79async fn create_worktree(
80    State(state): State<AppState>,
81    Path((workspace_id, codebase_id)): Path<(String, String)>,
82    Json(body): Json<CreateWorktreeRequest>,
83) -> Result<Json<serde_json::Value>, ServerError> {
84    let codebase = state
85        .codebase_store
86        .get(&codebase_id)
87        .await?
88        .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", codebase_id)))?;
89
90    // Validate codebase belongs to the workspace
91    if codebase.workspace_id != workspace_id {
92        return Err(ServerError::NotFound(format!(
93            "Codebase {} not found",
94            codebase_id
95        )));
96    }
97
98    let repo_path = &codebase.repo_path;
99    let base_branch = body.base_branch.unwrap_or_else(|| {
100        codebase
101            .branch
102            .clone()
103            .unwrap_or_else(|| "main".to_string())
104    });
105
106    let uuid_str = uuid::Uuid::new_v4().to_string();
107    let short_id = &uuid_str[..8];
108    let branch = body.branch.unwrap_or_else(|| {
109        let suffix = body
110            .label
111            .as_ref()
112            .map(|l| git::branch_to_safe_dir_name(l))
113            .unwrap_or_else(|| short_id.to_string());
114        format!("wt/{}", suffix)
115    });
116
117    // Acquire repo lock BEFORE branch check + DB insert to prevent races
118    let lock = get_repo_lock(repo_path).await;
119    let _guard = lock.lock().await;
120
121    // Check if branch already used by another worktree (inside lock)
122    if let Some(existing) = state
123        .worktree_store
124        .find_by_branch(&codebase_id, &branch)
125        .await?
126    {
127        return Err(ServerError::Conflict(format!(
128            "Branch '{}' is already in use by worktree {}",
129            branch, existing.id
130        )));
131    }
132
133    // Get workspace to check for custom worktreeRoot in metadata
134    let workspace = state.workspace_store.get(&workspace_id).await?;
135    let worktree_root = workspace
136        .as_ref()
137        .and_then(|ws| ws.metadata.get("worktreeRoot"))
138        .filter(|s| !s.trim().is_empty())
139        .map(std::path::PathBuf::from)
140        .unwrap_or_else(|| git::get_default_workspace_worktree_root(&workspace_id));
141
142    // Use codebase label (or fallback to codebase_id) for the directory name
143    let codebase_label = codebase
144        .label
145        .as_ref()
146        .map(|l| git::branch_to_safe_dir_name(l))
147        .unwrap_or_else(|| git::branch_to_safe_dir_name(&codebase_id));
148
149    // Compute worktree path: {worktreeRoot}/{codebaseLabel}/{branchDir}
150    let worktree_dir = body
151        .label
152        .as_ref()
153        .map(|l| git::branch_to_safe_dir_name(l))
154        .unwrap_or_else(|| git::branch_to_safe_dir_name(&branch));
155    let worktree_path = worktree_root.join(&codebase_label).join(&worktree_dir);
156
157    // Ensure parent directory exists
158    if let Some(parent) = worktree_path.parent() {
159        std::fs::create_dir_all(parent).map_err(|e| {
160            ServerError::Internal(format!("Failed to create worktree parent dir: {}", e))
161        })?;
162    }
163
164    let worktree_path_str = worktree_path.to_string_lossy().to_string();
165
166    // Create DB record
167    let worktree = Worktree::new(
168        uuid::Uuid::new_v4().to_string(),
169        codebase_id.clone(),
170        codebase.workspace_id.clone(),
171        worktree_path_str.clone(),
172        branch.clone(),
173        base_branch.clone(),
174        body.label,
175    );
176    state.worktree_store.save(&worktree).await?;
177
178    // Prune stale references
179    let _ = git::worktree_prune(repo_path);
180
181    // Check if branch already exists
182    let branch_already_exists = git::branch_exists(repo_path, &branch);
183
184    let result = if branch_already_exists {
185        git::worktree_add(repo_path, &worktree_path_str, &branch, &base_branch, false)
186    } else {
187        git::worktree_add(repo_path, &worktree_path_str, &branch, &base_branch, true)
188    };
189
190    match result {
191        Ok(()) => {
192            state
193                .worktree_store
194                .update_status(&worktree.id, "active", None)
195                .await?;
196            let updated = state
197                .worktree_store
198                .get(&worktree.id)
199                .await?
200                .unwrap_or(worktree);
201            Ok(Json(serde_json::json!({ "worktree": updated })))
202        }
203        Err(err) => {
204            state
205                .worktree_store
206                .update_status(&worktree.id, "error", Some(&err))
207                .await?;
208            Err(ServerError::Internal(format!(
209                "Failed to create worktree: {}",
210                err
211            )))
212        }
213    }
214}
215
216// ─── Get Worktree ───────────────────────────────────────────────────────
217
218async fn get_worktree(
219    State(state): State<AppState>,
220    Path(id): Path<String>,
221) -> Result<Json<serde_json::Value>, ServerError> {
222    let worktree = state
223        .worktree_store
224        .get(&id)
225        .await?
226        .ok_or_else(|| ServerError::NotFound(format!("Worktree {} not found", id)))?;
227    Ok(Json(serde_json::json!({ "worktree": worktree })))
228}
229
230// ─── Delete Worktree ────────────────────────────────────────────────────
231
232#[derive(Debug, Deserialize, Default)]
233#[serde(rename_all = "camelCase")]
234struct DeleteWorktreeQuery {
235    delete_branch: Option<bool>,
236}
237
238async fn delete_worktree(
239    State(state): State<AppState>,
240    Path(id): Path<String>,
241    Query(query): Query<DeleteWorktreeQuery>,
242) -> Result<Json<serde_json::Value>, ServerError> {
243    let worktree = state
244        .worktree_store
245        .get(&id)
246        .await?
247        .ok_or_else(|| ServerError::NotFound(format!("Worktree {} not found", id)))?;
248
249    let codebase = state.codebase_store.get(&worktree.codebase_id).await?;
250
251    if let Some(codebase) = codebase {
252        let repo_path = &codebase.repo_path;
253        let lock = get_repo_lock(repo_path).await;
254        let _guard = lock.lock().await;
255
256        state
257            .worktree_store
258            .update_status(&id, "removing", None)
259            .await?;
260
261        // Remove worktree from disk
262        let _ = git::worktree_remove(repo_path, &worktree.worktree_path, true);
263        let _ = git::worktree_prune(repo_path);
264
265        // Optionally delete the branch
266        if query.delete_branch.unwrap_or(false) {
267            let _ = std::process::Command::new("git")
268                .args(["branch", "-D", &worktree.branch])
269                .current_dir(repo_path)
270                .output();
271        }
272    }
273
274    state.worktree_store.delete(&id).await?;
275    Ok(Json(serde_json::json!({ "deleted": true })))
276}
277
278// ─── Validate Worktree ──────────────────────────────────────────────────
279
280async fn validate_worktree(
281    State(state): State<AppState>,
282    Path(id): Path<String>,
283) -> Result<Json<serde_json::Value>, ServerError> {
284    let worktree = state
285        .worktree_store
286        .get(&id)
287        .await?
288        .ok_or_else(|| ServerError::NotFound(format!("Worktree {} not found", id)))?;
289
290    let path = std::path::Path::new(&worktree.worktree_path);
291    if !path.exists() {
292        state
293            .worktree_store
294            .update_status(&id, "error", Some("Worktree directory missing"))
295            .await?;
296        return Ok(Json(
297            serde_json::json!({ "healthy": false, "error": "Worktree directory missing" }),
298        ));
299    }
300
301    let git_file = path.join(".git");
302    if !git_file.exists() {
303        state
304            .worktree_store
305            .update_status(
306                &id,
307                "error",
308                Some("Not a valid worktree (.git file missing)"),
309            )
310            .await?;
311        return Ok(Json(
312            serde_json::json!({ "healthy": false, "error": "Not a valid worktree (.git file missing)" }),
313        ));
314    }
315
316    // Restore to active if was in error state
317    if worktree.status == "error" {
318        state
319            .worktree_store
320            .update_status(&id, "active", None)
321            .await?;
322    }
323
324    Ok(Json(serde_json::json!({ "healthy": true })))
325}