Skip to main content

routa_server/api/
codebases.rs

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    // Check for duplicate repo_path within the workspace
128    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    // Clean up worktrees on disk before deleting the codebase
218    if let Ok(Some(codebase)) = state.codebase_store.get(&id).await {
219        let repo_path = &codebase.repo_path;
220
221        // Acquire repo lock to prevent races with concurrent worktree operations
222        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}