routa_server/api/
worktrees.rs1use 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
16type 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
23pub 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
46async 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 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#[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 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 let lock = get_repo_lock(repo_path).await;
117 let _guard = lock.lock().await;
118
119 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 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 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 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 let _ = git::worktree_prune(repo_path);
160
161 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
195async 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#[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 let _ = git::worktree_remove(repo_path, &worktree.worktree_path, true);
242 let _ = git::worktree_prune(repo_path);
243
244 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
257async 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 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}