Skip to main content

routa_server/api/
git.rs

1use axum::{
2    extract::{Path, Query, State},
3    http::StatusCode,
4    routing::{get, post},
5    Json, Router,
6};
7use chrono::Utc;
8use serde::{Deserialize, Serialize};
9use std::path::{Component, Path as FilePath};
10use std::process::Command;
11
12use crate::api::repo_context::{normalize_local_repo_path, validate_local_git_repo_path};
13use crate::error::ServerError;
14use crate::state::AppState;
15
16pub fn router() -> Router<AppState> {
17    Router::new()
18        .route("/stage", post(stage_files))
19        .route("/unstage", post(unstage_files))
20    .route("/discard", post(discard_changes))
21        .route("/commit", post(create_commit))
22        .route("/commits", axum::routing::get(get_commits))
23    .route("/commits/{sha}/diff", get(get_commit_diff))
24    .route("/diff", get(get_file_diff))
25    .route("/pull", post(pull_commits_handler))
26    .route("/rebase", post(rebase_branch_handler))
27    .route("/reset", post(reset_branch_handler))
28    .route("/export", post(export_changes_handler))
29}
30
31pub fn read_router() -> Router<AppState> {
32    Router::new()
33        .route("/refs", get(get_refs))
34        .route("/log", get(get_log_page))
35        .route("/commit", get(get_commit_detail))
36}
37
38fn resolve_repo_path(repo_path: Option<&str>) -> Result<String, ServerError> {
39    let repo_path = repo_path
40        .map(str::trim)
41        .filter(|value| !value.is_empty())
42        .ok_or_else(|| ServerError::BadRequest("repoPath is required".to_string()))?;
43
44    let normalized = normalize_local_repo_path(repo_path);
45    validate_local_git_repo_path(&normalized)?;
46
47    Ok(normalized.to_string_lossy().to_string())
48}
49
50fn resolve_commit_sha(sha: Option<&str>) -> Result<String, ServerError> {
51    let sha = sha
52        .map(str::trim)
53        .filter(|value| !value.is_empty())
54        .ok_or_else(|| ServerError::BadRequest("sha is required".to_string()))?;
55
56    if sha.len() < 4 || !sha.chars().all(|character| character.is_ascii_hexdigit()) {
57        return Err(ServerError::BadRequest("sha is invalid".to_string()));
58    }
59
60    Ok(sha.to_string())
61}
62
63async fn resolve_codebase_repo_path(
64    state: &AppState,
65    workspace_id: &str,
66    codebase_id: &str,
67) -> Result<String, ServerError> {
68    let _workspace = state
69        .workspace_store
70        .get(workspace_id)
71        .await
72        .map_err(|error| ServerError::Internal(error.to_string()))?
73        .ok_or_else(|| ServerError::NotFound("Workspace not found".to_string()))?;
74
75    let codebase = state
76        .codebase_store
77        .get(codebase_id)
78        .await
79        .map_err(|error| ServerError::Internal(error.to_string()))?
80        .ok_or_else(|| ServerError::NotFound("Codebase not found".to_string()))?;
81
82    if !routa_core::git::is_git_repository(&codebase.repo_path) {
83        return Err(ServerError::BadRequest(
84            "Not a valid git repository".to_string(),
85        ));
86    }
87
88    Ok(codebase.repo_path)
89}
90
91fn validate_git_file_path(path: &str) -> Result<(), String> {
92    let trimmed = path.trim();
93    if trimmed.is_empty() {
94        return Err("File path cannot be empty".to_string());
95    }
96
97    let candidate = FilePath::new(trimmed);
98    if candidate.is_absolute() {
99        return Err(format!("Absolute file paths are not allowed: {}", trimmed));
100    }
101
102    if candidate.components().any(|component| {
103        matches!(
104            component,
105            Component::ParentDir | Component::RootDir | Component::Prefix(_)
106        )
107    }) {
108        return Err(format!(
109            "File paths must stay within the repository root: {}",
110            trimmed
111        ));
112    }
113
114    Ok(())
115}
116
117fn validate_git_file_paths(files: &[String]) -> Result<(), String> {
118    for file in files {
119        validate_git_file_path(file)?;
120    }
121
122    Ok(())
123}
124
125fn git_command_output(repo_path: &str, args: &[&str]) -> Result<String, String> {
126    let output = Command::new("git")
127        .args(args)
128        .current_dir(repo_path)
129        .output()
130        .map_err(|error| error.to_string())?;
131
132    if output.status.success() {
133        Ok(String::from_utf8_lossy(&output.stdout).to_string())
134    } else {
135        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
136    }
137}
138
139fn build_export_filename() -> String {
140    format!("changes-{}.patch", Utc::now().format("%Y-%m-%dT%H-%M-%S"))
141}
142
143fn server_error_message(error: ServerError) -> String {
144    match error {
145        ServerError::Database(message)
146        | ServerError::NotFound(message)
147        | ServerError::BadRequest(message)
148        | ServerError::Conflict(message)
149        | ServerError::Internal(message)
150        | ServerError::NotImplemented(message) => message,
151    }
152}
153
154#[derive(Debug, Deserialize)]
155#[serde(rename_all = "camelCase")]
156struct GitRefsQuery {
157    repo_path: Option<String>,
158}
159
160#[derive(Debug, Deserialize)]
161#[serde(rename_all = "camelCase")]
162struct GitLogPageQuery {
163    repo_path: Option<String>,
164    branches: Option<String>,
165    search: Option<String>,
166    limit: Option<usize>,
167    skip: Option<usize>,
168}
169
170#[derive(Debug, Deserialize)]
171#[serde(rename_all = "camelCase")]
172struct GitCommitDetailQuery {
173    repo_path: Option<String>,
174    sha: Option<String>,
175}
176
177async fn get_refs(
178    Query(query): Query<GitRefsQuery>,
179) -> Result<Json<routa_core::git::GitRefsResult>, ServerError> {
180    let repo_path = resolve_repo_path(query.repo_path.as_deref())?;
181    let refs = tokio::task::spawn_blocking(move || routa_core::git::list_git_refs(&repo_path))
182        .await
183        .map_err(|error| ServerError::Internal(error.to_string()))?
184        .map_err(ServerError::Internal)?;
185
186    Ok(Json(refs))
187}
188
189async fn get_log_page(
190    Query(query): Query<GitLogPageQuery>,
191) -> Result<Json<routa_core::git::GitLogPage>, ServerError> {
192    let repo_path = resolve_repo_path(query.repo_path.as_deref())?;
193    let branches = query
194        .branches
195        .as_deref()
196        .map(|value| {
197            value
198                .split(',')
199                .map(str::trim)
200                .filter(|value| !value.is_empty())
201                .map(str::to_string)
202                .collect::<Vec<_>>()
203        })
204        .filter(|value| !value.is_empty());
205    let search = query
206        .search
207        .map(|value| value.trim().to_string())
208        .filter(|value| !value.is_empty());
209    let limit = query.limit;
210    let skip = query.skip;
211
212    let page = tokio::task::spawn_blocking(move || {
213        routa_core::git::get_git_log_page(
214            &repo_path,
215            branches.as_deref(),
216            search.as_deref(),
217            limit,
218            skip,
219        )
220    })
221    .await
222    .map_err(|error| ServerError::Internal(error.to_string()))?
223    .map_err(ServerError::Internal)?;
224
225    Ok(Json(page))
226}
227
228async fn get_commit_detail(
229    Query(query): Query<GitCommitDetailQuery>,
230) -> Result<Json<routa_core::git::GitCommitDetail>, ServerError> {
231    let repo_path = resolve_repo_path(query.repo_path.as_deref())?;
232    let sha = resolve_commit_sha(query.sha.as_deref())?;
233    let detail = tokio::task::spawn_blocking(move || {
234        routa_core::git::get_git_commit_detail(&repo_path, &sha)
235    })
236    .await
237    .map_err(|error| ServerError::Internal(error.to_string()))?
238    .map_err(ServerError::Internal)?;
239
240    Ok(Json(detail))
241}
242
243#[derive(Debug, Deserialize)]
244struct StageFilesRequest {
245    files: Vec<String>,
246}
247
248#[derive(Debug, Serialize)]
249struct StageFilesResponse {
250    success: bool,
251    staged: Option<Vec<String>>,
252    error: Option<String>,
253}
254
255#[derive(Debug, Deserialize)]
256#[serde(rename_all = "camelCase")]
257struct DiscardChangesRequest {
258    files: Vec<String>,
259    confirm: Option<bool>,
260}
261
262#[derive(Debug, Serialize)]
263struct DiscardChangesResponse {
264    success: bool,
265    discarded: Option<Vec<String>>,
266    error: Option<String>,
267}
268
269#[derive(Debug, Deserialize)]
270#[serde(rename_all = "camelCase")]
271struct GetFileDiffQuery {
272    path: Option<String>,
273    staged: Option<bool>,
274}
275
276#[derive(Debug, Serialize)]
277#[serde(rename_all = "camelCase")]
278struct GetFileDiffResponse {
279    diff: String,
280    path: String,
281    staged: bool,
282}
283
284#[derive(Debug, Deserialize)]
285#[serde(rename_all = "camelCase")]
286struct GetCommitDiffQuery {
287    path: Option<String>,
288}
289
290#[derive(Debug, Serialize)]
291#[serde(rename_all = "camelCase")]
292struct GetCommitDiffResponse {
293    diff: String,
294    sha: String,
295    path: Option<String>,
296}
297
298#[derive(Debug, Deserialize)]
299#[serde(rename_all = "camelCase")]
300struct PullCommitsRequest {
301    remote: Option<String>,
302    branch: Option<String>,
303}
304
305#[derive(Debug, Deserialize)]
306#[serde(rename_all = "camelCase")]
307struct RebaseBranchRequest {
308    onto: Option<String>,
309}
310
311#[derive(Debug, Deserialize)]
312#[serde(rename_all = "camelCase")]
313struct ResetBranchRequest {
314    to: Option<String>,
315    mode: Option<String>,
316    confirm: Option<bool>,
317}
318
319#[derive(Debug, Serialize)]
320struct GitOperationResponse {
321    success: bool,
322    error: Option<String>,
323}
324
325#[derive(Debug, Deserialize)]
326#[serde(rename_all = "camelCase")]
327struct ExportChangesRequest {
328    files: Option<Vec<String>>,
329    format: Option<String>,
330}
331
332#[derive(Debug, Serialize)]
333struct ExportChangesResponse {
334    success: bool,
335    patch: Option<String>,
336    filename: Option<String>,
337    error: Option<String>,
338}
339
340async fn stage_files(
341    State(state): State<AppState>,
342    Path((workspace_id, codebase_id)): Path<(String, String)>,
343    Json(req): Json<StageFilesRequest>,
344) -> Result<Json<StageFilesResponse>, ServerError> {
345    let repo_path = match resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await {
346        Ok(repo_path) => repo_path,
347        Err(error) => {
348            return Ok(Json(StageFilesResponse {
349                success: false,
350                staged: None,
351                error: Some(server_error_message(error)),
352            }))
353        }
354    };
355    let files = req.files;
356    let staged_files = files.clone();
357
358    match tokio::task::spawn_blocking(move || routa_core::git::stage_files(&repo_path, &files))
359        .await
360        .map_err(|error| ServerError::Internal(error.to_string()))?
361    {
362        Ok(()) => Ok(Json(StageFilesResponse {
363            success: true,
364            staged: Some(staged_files),
365            error: None,
366        })),
367        Err(e) => Ok(Json(StageFilesResponse {
368            success: false,
369            staged: None,
370            error: Some(e),
371        })),
372    }
373}
374
375async fn unstage_files(
376    State(state): State<AppState>,
377    Path((workspace_id, codebase_id)): Path<(String, String)>,
378    Json(req): Json<StageFilesRequest>,
379) -> Result<Json<StageFilesResponse>, ServerError> {
380    let repo_path = match resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await {
381        Ok(repo_path) => repo_path,
382        Err(error) => {
383            return Ok(Json(StageFilesResponse {
384                success: false,
385                staged: None,
386                error: Some(server_error_message(error)),
387            }))
388        }
389    };
390    let files = req.files;
391    let staged_files = files.clone();
392
393    match tokio::task::spawn_blocking(move || routa_core::git::unstage_files(&repo_path, &files))
394        .await
395        .map_err(|error| ServerError::Internal(error.to_string()))?
396    {
397        Ok(()) => Ok(Json(StageFilesResponse {
398            success: true,
399            staged: Some(staged_files),
400            error: None,
401        })),
402        Err(e) => Ok(Json(StageFilesResponse {
403            success: false,
404            staged: None,
405            error: Some(e),
406        })),
407    }
408}
409
410#[derive(Debug, Deserialize)]
411struct CreateCommitRequest {
412    message: String,
413    files: Option<Vec<String>>,
414}
415
416#[derive(Debug, Serialize)]
417struct CreateCommitResponse {
418    success: bool,
419    sha: Option<String>,
420    message: Option<String>,
421    error: Option<String>,
422}
423
424async fn create_commit(
425    State(state): State<AppState>,
426    Path((workspace_id, codebase_id)): Path<(String, String)>,
427    Json(req): Json<CreateCommitRequest>,
428) -> Result<Json<CreateCommitResponse>, ServerError> {
429    let repo_path = match resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await {
430        Ok(repo_path) => repo_path,
431        Err(error) => {
432            return Ok(Json(CreateCommitResponse {
433                success: false,
434                sha: None,
435                message: None,
436                error: Some(server_error_message(error)),
437            }))
438        }
439    };
440    let message = req.message;
441    let files = req.files;
442    let response_message = message.clone();
443
444    match tokio::task::spawn_blocking(move || {
445        routa_core::git::create_commit(&repo_path, &message, files.as_deref())
446    })
447    .await
448    .map_err(|error| ServerError::Internal(error.to_string()))?
449    {
450        Ok(sha) => Ok(Json(CreateCommitResponse {
451            success: true,
452            sha: Some(sha),
453            message: Some(response_message),
454            error: None,
455        })),
456        Err(e) => Ok(Json(CreateCommitResponse {
457            success: false,
458            sha: None,
459            message: None,
460            error: Some(e),
461        })),
462    }
463}
464
465#[derive(Debug, Deserialize)]
466struct GetCommitsQuery {
467    limit: Option<usize>,
468    since: Option<String>,
469}
470
471#[derive(Debug, Serialize)]
472struct GetCommitsResponse {
473    commits: Vec<routa_core::git::CommitInfo>,
474    count: usize,
475}
476
477async fn discard_changes(
478    State(state): State<AppState>,
479    Path((workspace_id, codebase_id)): Path<(String, String)>,
480    Json(req): Json<DiscardChangesRequest>,
481) -> Result<(StatusCode, Json<DiscardChangesResponse>), ServerError> {
482    if req.files.is_empty() {
483        return Ok((
484            StatusCode::BAD_REQUEST,
485            Json(DiscardChangesResponse {
486                success: false,
487                discarded: None,
488                error: Some("Missing or invalid 'files' array in request body".to_string()),
489            }),
490        ));
491    }
492
493    if req.confirm != Some(true) {
494        return Ok((
495            StatusCode::BAD_REQUEST,
496            Json(DiscardChangesResponse {
497                success: false,
498                discarded: None,
499                error: Some("Discard changes requires explicit confirmation".to_string()),
500            }),
501        ));
502    }
503
504    let repo_path = match resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await {
505        Ok(repo_path) => repo_path,
506        Err(error) => {
507            let status = match error {
508                ServerError::NotFound(_) => StatusCode::NOT_FOUND,
509                ServerError::BadRequest(_) => StatusCode::BAD_REQUEST,
510                _ => StatusCode::INTERNAL_SERVER_ERROR,
511            };
512            return Ok((
513                status,
514                Json(DiscardChangesResponse {
515                    success: false,
516                    discarded: None,
517                    error: Some(server_error_message(error)),
518                }),
519            ));
520        }
521    };
522    let files = req.files;
523    let discarded_files = files.clone();
524
525    let result = tokio::task::spawn_blocking(move || {
526        routa_core::git::discard_changes(&repo_path, &files)
527    })
528    .await
529    .map_err(|error| ServerError::Internal(error.to_string()))?;
530
531    match result {
532        Ok(()) => Ok((
533            StatusCode::OK,
534            Json(DiscardChangesResponse {
535                success: true,
536                discarded: Some(discarded_files),
537                error: None,
538            }),
539        )),
540        Err(error) => Ok((
541            StatusCode::INTERNAL_SERVER_ERROR,
542            Json(DiscardChangesResponse {
543                success: false,
544                discarded: None,
545                error: Some(error),
546            }),
547        )),
548    }
549}
550
551async fn get_file_diff(
552    State(state): State<AppState>,
553    Path((workspace_id, codebase_id)): Path<(String, String)>,
554    Query(query): Query<GetFileDiffQuery>,
555) -> Result<Json<GetFileDiffResponse>, ServerError> {
556    let path = query
557        .path
558        .as_deref()
559        .map(str::trim)
560        .filter(|value| !value.is_empty())
561        .ok_or_else(|| ServerError::BadRequest("Missing 'path' query parameter".to_string()))?
562        .to_string();
563    validate_git_file_path(&path).map_err(ServerError::BadRequest)?;
564    let staged = query.staged.unwrap_or(false);
565    let repo_path = resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await?;
566    let response_path = path.clone();
567
568    let diff = tokio::task::spawn_blocking(move || {
569        if staged {
570            git_command_output(&repo_path, &["diff", "--cached", "--", path.as_str()])
571        } else {
572            git_command_output(&repo_path, &["diff", "--", path.as_str()])
573        }
574    })
575    .await
576    .map_err(|error| ServerError::Internal(error.to_string()))?
577    .map_err(ServerError::Internal)?;
578
579    Ok(Json(GetFileDiffResponse {
580        diff,
581        path: response_path,
582        staged,
583    }))
584}
585
586async fn get_commit_diff(
587    State(state): State<AppState>,
588    Path((workspace_id, codebase_id, sha)): Path<(String, String, String)>,
589    Query(query): Query<GetCommitDiffQuery>,
590) -> Result<Json<GetCommitDiffResponse>, ServerError> {
591    let sha = resolve_commit_sha(Some(&sha))?;
592    let path = query.path.map(|value| value.trim().to_string()).filter(|value| !value.is_empty());
593    if let Some(path_value) = path.as_deref() {
594        validate_git_file_path(path_value).map_err(ServerError::BadRequest)?;
595    }
596    let repo_path = resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await?;
597    let response_sha = sha.clone();
598    let response_path = path.clone();
599
600    let diff = tokio::task::spawn_blocking(move || {
601        if let Some(path_value) = path.as_deref() {
602            git_command_output(&repo_path, &["show", sha.as_str(), "--", path_value])
603        } else {
604            git_command_output(&repo_path, &["show", sha.as_str()])
605        }
606    })
607    .await
608    .map_err(|error| ServerError::Internal(error.to_string()))?
609    .map_err(ServerError::Internal)?;
610
611    Ok(Json(GetCommitDiffResponse {
612        diff,
613        sha: response_sha,
614        path: response_path,
615    }))
616}
617
618async fn pull_commits_handler(
619    State(state): State<AppState>,
620    Path((workspace_id, codebase_id)): Path<(String, String)>,
621    Json(req): Json<PullCommitsRequest>,
622) -> Result<(StatusCode, Json<GitOperationResponse>), ServerError> {
623    let repo_path = match resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await {
624        Ok(repo_path) => repo_path,
625        Err(error) => {
626            let status = match error {
627                ServerError::NotFound(_) => StatusCode::NOT_FOUND,
628                ServerError::BadRequest(_) => StatusCode::BAD_REQUEST,
629                _ => StatusCode::INTERNAL_SERVER_ERROR,
630            };
631            return Ok((
632                status,
633                Json(GitOperationResponse {
634                    success: false,
635                    error: Some(server_error_message(error)),
636                }),
637            ));
638        }
639    };
640    let remote = req.remote;
641    let branch = req.branch;
642
643    let result = tokio::task::spawn_blocking(move || {
644        routa_core::git::pull_commits(&repo_path, remote.as_deref(), branch.as_deref())
645    })
646    .await
647    .map_err(|error| ServerError::Internal(error.to_string()))?;
648
649    match result {
650        Ok(()) => Ok((
651            StatusCode::OK,
652            Json(GitOperationResponse {
653                success: true,
654                error: None,
655            }),
656        )),
657        Err(error) => Ok((
658            StatusCode::INTERNAL_SERVER_ERROR,
659            Json(GitOperationResponse {
660                success: false,
661                error: Some(error),
662            }),
663        )),
664    }
665}
666
667async fn rebase_branch_handler(
668    State(state): State<AppState>,
669    Path((workspace_id, codebase_id)): Path<(String, String)>,
670    Json(req): Json<RebaseBranchRequest>,
671) -> Result<(StatusCode, Json<GitOperationResponse>), ServerError> {
672    let onto = req
673        .onto
674        .as_deref()
675        .map(str::trim)
676        .filter(|value| !value.is_empty())
677        .ok_or_else(|| ServerError::BadRequest("Target branch 'onto' is required".to_string()))?
678        .to_string();
679    let repo_path = resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await?;
680
681    let result = tokio::task::spawn_blocking(move || routa_core::git::rebase_branch(&repo_path, &onto))
682        .await
683        .map_err(|error| ServerError::Internal(error.to_string()))?;
684
685    match result {
686        Ok(()) => Ok((
687            StatusCode::OK,
688            Json(GitOperationResponse {
689                success: true,
690                error: None,
691            }),
692        )),
693        Err(error) => Ok((
694            StatusCode::INTERNAL_SERVER_ERROR,
695            Json(GitOperationResponse {
696                success: false,
697                error: Some(error),
698            }),
699        )),
700    }
701}
702
703async fn reset_branch_handler(
704    State(state): State<AppState>,
705    Path((workspace_id, codebase_id)): Path<(String, String)>,
706    Json(req): Json<ResetBranchRequest>,
707) -> Result<(StatusCode, Json<GitOperationResponse>), ServerError> {
708    let to = req
709        .to
710        .as_deref()
711        .map(str::trim)
712        .filter(|value| !value.is_empty())
713        .ok_or_else(|| ServerError::BadRequest("Target commit/branch 'to' is required".to_string()))?
714        .to_string();
715    let mode = req
716        .mode
717        .as_deref()
718        .map(str::trim)
719        .filter(|value| !value.is_empty())
720        .ok_or_else(|| ServerError::BadRequest("Mode must be 'soft' or 'hard'".to_string()))?
721        .to_string();
722    if mode != "soft" && mode != "hard" {
723        return Ok((
724            StatusCode::BAD_REQUEST,
725            Json(GitOperationResponse {
726                success: false,
727                error: Some("Mode must be 'soft' or 'hard'".to_string()),
728            }),
729        ));
730    }
731    if mode == "hard" && req.confirm != Some(true) {
732        return Ok((
733            StatusCode::BAD_REQUEST,
734            Json(GitOperationResponse {
735                success: false,
736                error: Some("Hard reset requires explicit confirmation".to_string()),
737            }),
738        ));
739    }
740    let repo_path = resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await?;
741    let confirm = req.confirm.unwrap_or(false);
742
743    let result = tokio::task::spawn_blocking(move || {
744        routa_core::git::reset_branch(&repo_path, &to, &mode, confirm)
745    })
746    .await
747    .map_err(|error| ServerError::Internal(error.to_string()))?;
748
749    match result {
750        Ok(()) => Ok((
751            StatusCode::OK,
752            Json(GitOperationResponse {
753                success: true,
754                error: None,
755            }),
756        )),
757        Err(error) => Ok((
758            StatusCode::INTERNAL_SERVER_ERROR,
759            Json(GitOperationResponse {
760                success: false,
761                error: Some(error),
762            }),
763        )),
764    }
765}
766
767async fn export_changes_handler(
768    State(state): State<AppState>,
769    Path((workspace_id, codebase_id)): Path<(String, String)>,
770    Json(req): Json<ExportChangesRequest>,
771) -> Result<(StatusCode, Json<ExportChangesResponse>), ServerError> {
772    let repo_path = match resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await {
773        Ok(repo_path) => repo_path,
774        Err(error) => {
775            let status = match error {
776                ServerError::NotFound(_) => StatusCode::NOT_FOUND,
777                ServerError::BadRequest(_) => StatusCode::BAD_REQUEST,
778                _ => StatusCode::INTERNAL_SERVER_ERROR,
779            };
780            return Ok((
781                status,
782                Json(ExportChangesResponse {
783                    success: false,
784                    patch: None,
785                    filename: None,
786                    error: Some(server_error_message(error)),
787                }),
788            ));
789        }
790    };
791    let files = req.files.unwrap_or_default();
792    validate_git_file_paths(&files).map_err(ServerError::BadRequest)?;
793    let format = req.format.unwrap_or_else(|| "patch".to_string());
794    if format != "patch" && format != "diff" {
795        return Ok((
796            StatusCode::BAD_REQUEST,
797            Json(ExportChangesResponse {
798                success: false,
799                patch: None,
800                filename: None,
801                error: Some("format must be 'patch' or 'diff'".to_string()),
802            }),
803        ));
804    }
805
806    let result = tokio::task::spawn_blocking(move || {
807        if format == "patch" {
808            git_command_output(&repo_path, &["diff", "--cached", "--no-color", "--no-ext-diff"])
809        } else if files.is_empty() {
810            git_command_output(&repo_path, &["diff", "--no-color", "--no-ext-diff"])
811        } else {
812            let mut args = vec!["diff", "--no-color", "--no-ext-diff", "--"];
813            args.extend(files.iter().map(|value| value.as_str()));
814            git_command_output(&repo_path, &args)
815        }
816    })
817    .await
818    .map_err(|error| ServerError::Internal(error.to_string()))?;
819
820    match result {
821        Ok(patch) => {
822            if patch.trim().is_empty() {
823                Ok((
824                    StatusCode::BAD_REQUEST,
825                    Json(ExportChangesResponse {
826                        success: false,
827                        patch: None,
828                        filename: None,
829                        error: Some("No changes to export".to_string()),
830                    }),
831                ))
832            } else {
833                Ok((
834                    StatusCode::OK,
835                    Json(ExportChangesResponse {
836                        success: true,
837                        patch: Some(patch),
838                        filename: Some(build_export_filename()),
839                        error: None,
840                    }),
841                ))
842            }
843        }
844        Err(error) => Ok((
845            StatusCode::INTERNAL_SERVER_ERROR,
846            Json(ExportChangesResponse {
847                success: false,
848                patch: None,
849                filename: None,
850                error: Some(error),
851            }),
852        )),
853    }
854}
855
856async fn get_commits(
857    State(state): State<AppState>,
858    Path((workspace_id, codebase_id)): Path<(String, String)>,
859    Query(query): Query<GetCommitsQuery>,
860) -> Result<Json<GetCommitsResponse>, ServerError> {
861    let repo_path = resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await?;
862    let limit = query.limit;
863    let since = query.since;
864
865    let commits = tokio::task::spawn_blocking(move || {
866        routa_core::git::get_commit_list(&repo_path, limit, since.as_deref())
867    })
868    .await
869    .map_err(|error| ServerError::Internal(error.to_string()))?
870    .map_err(ServerError::Internal)?;
871
872    let count = commits.len();
873
874    Ok(Json(GetCommitsResponse { commits, count }))
875}