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
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 {} 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#[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 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 let lock = get_repo_lock(repo_path).await;
110 let _guard = lock.lock().await;
111
112 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 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 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 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 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 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 let _ = git::worktree_prune(repo_path);
167
168 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
196async 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#[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 let _ = git::worktree_remove(repo_path, &worktree.worktree_path, true);
240 let _ = git::worktree_prune(repo_path);
241
242 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
255async 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 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}