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