1use axum::{
2 extract::State,
3 routing::{get, patch, post},
4 Json, Router,
5};
6use serde::Deserialize;
7
8use crate::api::repo_context::{normalize_local_repo_path, validate_local_git_repo_path};
9use crate::error::ServerError;
10use crate::models::codebase::Codebase;
11use crate::state::AppState;
12
13fn repo_label_from_path(repo_path: &str) -> String {
14 std::path::Path::new(repo_path)
15 .file_name()
16 .and_then(|name| name.to_str())
17 .map(str::to_string)
18 .unwrap_or_else(|| repo_path.to_string())
19}
20
21pub fn router() -> Router<AppState> {
22 Router::new()
23 .route(
24 "/workspaces/{workspace_id}/codebases",
25 get(list_codebases).post(add_codebase),
26 )
27 .route(
28 "/workspaces/{workspace_id}/codebases/changes",
29 get(list_codebase_changes),
30 )
31 .route(
32 "/codebases/{id}",
33 patch(update_codebase).delete(delete_codebase),
34 )
35 .route("/codebases/{id}/default", post(set_default_codebase))
36}
37
38async fn list_codebases(
39 State(state): State<AppState>,
40 axum::extract::Path(workspace_id): axum::extract::Path<String>,
41) -> Result<Json<serde_json::Value>, ServerError> {
42 let codebases = state
43 .codebase_store
44 .list_by_workspace(&workspace_id)
45 .await?;
46 Ok(Json(serde_json::json!({ "codebases": codebases })))
47}
48
49async fn list_codebase_changes(
50 State(state): State<AppState>,
51 axum::extract::Path(workspace_id): axum::extract::Path<String>,
52) -> Result<Json<serde_json::Value>, ServerError> {
53 let codebases = state
54 .codebase_store
55 .list_by_workspace(&workspace_id)
56 .await?;
57
58 let repos = codebases
59 .into_iter()
60 .map(|codebase| {
61 let label = codebase
62 .label
63 .clone()
64 .unwrap_or_else(|| repo_label_from_path(&codebase.repo_path));
65
66 if codebase.repo_path.is_empty() {
67 return serde_json::json!({
68 "codebaseId": codebase.id,
69 "repoPath": codebase.repo_path,
70 "label": label,
71 "branch": codebase.branch.unwrap_or_else(|| "unknown".to_string()),
72 "status": { "clean": true, "ahead": 0, "behind": 0, "modified": 0, "untracked": 0 },
73 "files": [],
74 "error": "Missing repository path",
75 });
76 }
77
78 if !crate::git::is_git_repository(&codebase.repo_path) {
79 return serde_json::json!({
80 "codebaseId": codebase.id,
81 "repoPath": codebase.repo_path,
82 "label": label,
83 "branch": codebase.branch.unwrap_or_else(|| "unknown".to_string()),
84 "status": { "clean": true, "ahead": 0, "behind": 0, "modified": 0, "untracked": 0 },
85 "files": [],
86 "error": "Repository is missing or not a git repository",
87 });
88 }
89
90 let changes = crate::git::get_repo_changes(&codebase.repo_path);
91 serde_json::json!({
92 "codebaseId": codebase.id,
93 "repoPath": codebase.repo_path,
94 "label": label,
95 "branch": changes.branch,
96 "status": changes.status,
97 "files": changes.files,
98 })
99 })
100 .collect::<Vec<_>>();
101
102 Ok(Json(serde_json::json!({
103 "workspaceId": workspace_id,
104 "repos": repos,
105 })))
106}
107
108#[derive(Debug, Deserialize)]
109#[serde(rename_all = "camelCase")]
110struct AddCodebaseRequest {
111 repo_path: String,
112 branch: Option<String>,
113 label: Option<String>,
114 #[serde(default)]
115 is_default: bool,
116}
117
118async fn add_codebase(
119 State(state): State<AppState>,
120 axum::extract::Path(workspace_id): axum::extract::Path<String>,
121 Json(body): Json<AddCodebaseRequest>,
122) -> Result<Json<serde_json::Value>, ServerError> {
123 let repo_path = normalize_local_repo_path(&body.repo_path);
124 validate_local_git_repo_path(&repo_path)?;
125 let repo_path = repo_path.to_string_lossy().to_string();
126
127 if let Some(_existing) = state
129 .codebase_store
130 .find_by_repo_path(&workspace_id, &repo_path)
131 .await?
132 {
133 return Err(ServerError::Conflict(format!(
134 "Codebase with repo_path '{}' already exists in workspace {}",
135 repo_path, workspace_id
136 )));
137 }
138
139 let codebase = Codebase::new(
140 uuid::Uuid::new_v4().to_string(),
141 workspace_id,
142 repo_path,
143 body.branch,
144 body.label,
145 body.is_default,
146 );
147
148 state.codebase_store.save(&codebase).await?;
149 Ok(Json(serde_json::json!({ "codebase": codebase })))
150}
151
152#[derive(Debug, Deserialize)]
153#[serde(rename_all = "camelCase")]
154struct UpdateCodebaseRequest {
155 branch: Option<String>,
156 label: Option<String>,
157 repo_path: Option<String>,
158}
159
160async fn update_codebase(
161 State(state): State<AppState>,
162 axum::extract::Path(id): axum::extract::Path<String>,
163 Json(body): Json<UpdateCodebaseRequest>,
164) -> Result<Json<serde_json::Value>, ServerError> {
165 let existing = state
166 .codebase_store
167 .get(&id)
168 .await?
169 .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", id)))?;
170
171 let repo_path = if let Some(repo_path) = body.repo_path.as_deref() {
172 let normalized = normalize_local_repo_path(repo_path);
173 validate_local_git_repo_path(&normalized)?;
174 let normalized = normalized.to_string_lossy().to_string();
175
176 if let Some(duplicate) = state
177 .codebase_store
178 .find_by_repo_path(&existing.workspace_id, &normalized)
179 .await?
180 {
181 if duplicate.id != id {
182 return Err(ServerError::Conflict(format!(
183 "Codebase with repo_path '{}' already exists in workspace {}",
184 normalized, existing.workspace_id
185 )));
186 }
187 }
188
189 Some(normalized)
190 } else {
191 None
192 };
193
194 state
195 .codebase_store
196 .update(
197 &id,
198 body.branch.as_deref(),
199 body.label.as_deref(),
200 repo_path.as_deref(),
201 )
202 .await?;
203
204 let codebase = state
205 .codebase_store
206 .get(&id)
207 .await?
208 .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", id)))?;
209
210 Ok(Json(serde_json::json!({ "codebase": codebase })))
211}
212
213async fn delete_codebase(
214 State(state): State<AppState>,
215 axum::extract::Path(id): axum::extract::Path<String>,
216) -> Result<Json<serde_json::Value>, ServerError> {
217 if let Ok(Some(codebase)) = state.codebase_store.get(&id).await {
219 let repo_path = &codebase.repo_path;
220
221 let lock = {
223 let mut locks = crate::api::worktrees::get_repo_locks().lock().await;
224 locks
225 .entry(repo_path.to_string())
226 .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(())))
227 .clone()
228 };
229 let _guard = lock.lock().await;
230
231 let worktrees = state
232 .worktree_store
233 .list_by_codebase(&id)
234 .await
235 .map_err(|e| ServerError::Internal(format!("Failed to list worktrees: {}", e)))?;
236 for wt in &worktrees {
237 if let Err(e) = crate::git::worktree_remove(repo_path, &wt.worktree_path, true) {
238 tracing::warn!(
239 "[Codebase DELETE] Failed to remove worktree {}: {}",
240 wt.id,
241 e
242 );
243 }
244 }
245 if !worktrees.is_empty() {
246 let _ = crate::git::worktree_prune(repo_path);
247 }
248 }
249
250 state.codebase_store.delete(&id).await?;
251 Ok(Json(serde_json::json!({ "deleted": true })))
252}
253
254async fn set_default_codebase(
255 State(state): State<AppState>,
256 axum::extract::Path(id): axum::extract::Path<String>,
257) -> Result<Json<serde_json::Value>, ServerError> {
258 let codebase = state
259 .codebase_store
260 .get(&id)
261 .await?
262 .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", id)))?;
263
264 state
265 .codebase_store
266 .set_default(&codebase.workspace_id, &id)
267 .await?;
268
269 let updated = state
270 .codebase_store
271 .get(&id)
272 .await?
273 .ok_or_else(|| ServerError::NotFound(format!("Codebase {} not found", id)))?;
274
275 Ok(Json(serde_json::json!({ "codebase": updated })))
276}