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 =
526        tokio::task::spawn_blocking(move || routa_core::git::discard_changes(&repo_path, &files))
527            .await
528            .map_err(|error| ServerError::Internal(error.to_string()))?;
529
530    match result {
531        Ok(()) => Ok((
532            StatusCode::OK,
533            Json(DiscardChangesResponse {
534                success: true,
535                discarded: Some(discarded_files),
536                error: None,
537            }),
538        )),
539        Err(error) => Ok((
540            StatusCode::INTERNAL_SERVER_ERROR,
541            Json(DiscardChangesResponse {
542                success: false,
543                discarded: None,
544                error: Some(error),
545            }),
546        )),
547    }
548}
549
550async fn get_file_diff(
551    State(state): State<AppState>,
552    Path((workspace_id, codebase_id)): Path<(String, String)>,
553    Query(query): Query<GetFileDiffQuery>,
554) -> Result<Json<GetFileDiffResponse>, ServerError> {
555    let path = query
556        .path
557        .as_deref()
558        .map(str::trim)
559        .filter(|value| !value.is_empty())
560        .ok_or_else(|| ServerError::BadRequest("Missing 'path' query parameter".to_string()))?
561        .to_string();
562    validate_git_file_path(&path).map_err(ServerError::BadRequest)?;
563    let staged = query.staged.unwrap_or(false);
564    let repo_path = resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await?;
565    let response_path = path.clone();
566
567    let diff = tokio::task::spawn_blocking(move || {
568        if staged {
569            git_command_output(&repo_path, &["diff", "--cached", "--", path.as_str()])
570        } else {
571            git_command_output(&repo_path, &["diff", "--", path.as_str()])
572        }
573    })
574    .await
575    .map_err(|error| ServerError::Internal(error.to_string()))?
576    .map_err(ServerError::Internal)?;
577
578    Ok(Json(GetFileDiffResponse {
579        diff,
580        path: response_path,
581        staged,
582    }))
583}
584
585async fn get_commit_diff(
586    State(state): State<AppState>,
587    Path((workspace_id, codebase_id, sha)): Path<(String, String, String)>,
588    Query(query): Query<GetCommitDiffQuery>,
589) -> Result<Json<GetCommitDiffResponse>, ServerError> {
590    let sha = resolve_commit_sha(Some(&sha))?;
591    let path = query
592        .path
593        .map(|value| value.trim().to_string())
594        .filter(|value| !value.is_empty());
595    if let Some(path_value) = path.as_deref() {
596        validate_git_file_path(path_value).map_err(ServerError::BadRequest)?;
597    }
598    let repo_path = resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await?;
599    let response_sha = sha.clone();
600    let response_path = path.clone();
601
602    let diff = tokio::task::spawn_blocking(move || {
603        if let Some(path_value) = path.as_deref() {
604            git_command_output(&repo_path, &["show", sha.as_str(), "--", path_value])
605        } else {
606            git_command_output(&repo_path, &["show", sha.as_str()])
607        }
608    })
609    .await
610    .map_err(|error| ServerError::Internal(error.to_string()))?
611    .map_err(ServerError::Internal)?;
612
613    Ok(Json(GetCommitDiffResponse {
614        diff,
615        sha: response_sha,
616        path: response_path,
617    }))
618}
619
620async fn pull_commits_handler(
621    State(state): State<AppState>,
622    Path((workspace_id, codebase_id)): Path<(String, String)>,
623    Json(req): Json<PullCommitsRequest>,
624) -> Result<(StatusCode, Json<GitOperationResponse>), ServerError> {
625    let repo_path = match resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await {
626        Ok(repo_path) => repo_path,
627        Err(error) => {
628            let status = match error {
629                ServerError::NotFound(_) => StatusCode::NOT_FOUND,
630                ServerError::BadRequest(_) => StatusCode::BAD_REQUEST,
631                _ => StatusCode::INTERNAL_SERVER_ERROR,
632            };
633            return Ok((
634                status,
635                Json(GitOperationResponse {
636                    success: false,
637                    error: Some(server_error_message(error)),
638                }),
639            ));
640        }
641    };
642    let remote = req.remote;
643    let branch = req.branch;
644
645    let result = tokio::task::spawn_blocking(move || {
646        routa_core::git::pull_commits(&repo_path, remote.as_deref(), branch.as_deref())
647    })
648    .await
649    .map_err(|error| ServerError::Internal(error.to_string()))?;
650
651    match result {
652        Ok(()) => Ok((
653            StatusCode::OK,
654            Json(GitOperationResponse {
655                success: true,
656                error: None,
657            }),
658        )),
659        Err(error) => Ok((
660            StatusCode::INTERNAL_SERVER_ERROR,
661            Json(GitOperationResponse {
662                success: false,
663                error: Some(error),
664            }),
665        )),
666    }
667}
668
669async fn rebase_branch_handler(
670    State(state): State<AppState>,
671    Path((workspace_id, codebase_id)): Path<(String, String)>,
672    Json(req): Json<RebaseBranchRequest>,
673) -> Result<(StatusCode, Json<GitOperationResponse>), ServerError> {
674    let onto = req
675        .onto
676        .as_deref()
677        .map(str::trim)
678        .filter(|value| !value.is_empty())
679        .ok_or_else(|| ServerError::BadRequest("Target branch 'onto' is required".to_string()))?
680        .to_string();
681    let repo_path = resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await?;
682
683    let result =
684        tokio::task::spawn_blocking(move || routa_core::git::rebase_branch(&repo_path, &onto))
685            .await
686            .map_err(|error| ServerError::Internal(error.to_string()))?;
687
688    match result {
689        Ok(()) => Ok((
690            StatusCode::OK,
691            Json(GitOperationResponse {
692                success: true,
693                error: None,
694            }),
695        )),
696        Err(error) => Ok((
697            StatusCode::INTERNAL_SERVER_ERROR,
698            Json(GitOperationResponse {
699                success: false,
700                error: Some(error),
701            }),
702        )),
703    }
704}
705
706async fn reset_branch_handler(
707    State(state): State<AppState>,
708    Path((workspace_id, codebase_id)): Path<(String, String)>,
709    Json(req): Json<ResetBranchRequest>,
710) -> Result<(StatusCode, Json<GitOperationResponse>), ServerError> {
711    let to = req
712        .to
713        .as_deref()
714        .map(str::trim)
715        .filter(|value| !value.is_empty())
716        .ok_or_else(|| {
717            ServerError::BadRequest("Target commit/branch 'to' is required".to_string())
718        })?
719        .to_string();
720    let mode = req
721        .mode
722        .as_deref()
723        .map(str::trim)
724        .filter(|value| !value.is_empty())
725        .ok_or_else(|| ServerError::BadRequest("Mode must be 'soft' or 'hard'".to_string()))?
726        .to_string();
727    if mode != "soft" && mode != "hard" {
728        return Ok((
729            StatusCode::BAD_REQUEST,
730            Json(GitOperationResponse {
731                success: false,
732                error: Some("Mode must be 'soft' or 'hard'".to_string()),
733            }),
734        ));
735    }
736    if mode == "hard" && req.confirm != Some(true) {
737        return Ok((
738            StatusCode::BAD_REQUEST,
739            Json(GitOperationResponse {
740                success: false,
741                error: Some("Hard reset requires explicit confirmation".to_string()),
742            }),
743        ));
744    }
745    let repo_path = resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await?;
746    let confirm = req.confirm.unwrap_or(false);
747
748    let result = tokio::task::spawn_blocking(move || {
749        routa_core::git::reset_branch(&repo_path, &to, &mode, confirm)
750    })
751    .await
752    .map_err(|error| ServerError::Internal(error.to_string()))?;
753
754    match result {
755        Ok(()) => Ok((
756            StatusCode::OK,
757            Json(GitOperationResponse {
758                success: true,
759                error: None,
760            }),
761        )),
762        Err(error) => Ok((
763            StatusCode::INTERNAL_SERVER_ERROR,
764            Json(GitOperationResponse {
765                success: false,
766                error: Some(error),
767            }),
768        )),
769    }
770}
771
772async fn export_changes_handler(
773    State(state): State<AppState>,
774    Path((workspace_id, codebase_id)): Path<(String, String)>,
775    Json(req): Json<ExportChangesRequest>,
776) -> Result<(StatusCode, Json<ExportChangesResponse>), ServerError> {
777    let repo_path = match resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await {
778        Ok(repo_path) => repo_path,
779        Err(error) => {
780            let status = match error {
781                ServerError::NotFound(_) => StatusCode::NOT_FOUND,
782                ServerError::BadRequest(_) => StatusCode::BAD_REQUEST,
783                _ => StatusCode::INTERNAL_SERVER_ERROR,
784            };
785            return Ok((
786                status,
787                Json(ExportChangesResponse {
788                    success: false,
789                    patch: None,
790                    filename: None,
791                    error: Some(server_error_message(error)),
792                }),
793            ));
794        }
795    };
796    let files = req.files.unwrap_or_default();
797    validate_git_file_paths(&files).map_err(ServerError::BadRequest)?;
798    let format = req.format.unwrap_or_else(|| "patch".to_string());
799    if format != "patch" && format != "diff" {
800        return Ok((
801            StatusCode::BAD_REQUEST,
802            Json(ExportChangesResponse {
803                success: false,
804                patch: None,
805                filename: None,
806                error: Some("format must be 'patch' or 'diff'".to_string()),
807            }),
808        ));
809    }
810
811    let result = tokio::task::spawn_blocking(move || {
812        if format == "patch" {
813            git_command_output(
814                &repo_path,
815                &["diff", "--cached", "--no-color", "--no-ext-diff"],
816            )
817        } else if files.is_empty() {
818            git_command_output(&repo_path, &["diff", "--no-color", "--no-ext-diff"])
819        } else {
820            let mut args = vec!["diff", "--no-color", "--no-ext-diff", "--"];
821            args.extend(files.iter().map(|value| value.as_str()));
822            git_command_output(&repo_path, &args)
823        }
824    })
825    .await
826    .map_err(|error| ServerError::Internal(error.to_string()))?;
827
828    match result {
829        Ok(patch) => {
830            if patch.trim().is_empty() {
831                Ok((
832                    StatusCode::BAD_REQUEST,
833                    Json(ExportChangesResponse {
834                        success: false,
835                        patch: None,
836                        filename: None,
837                        error: Some("No changes to export".to_string()),
838                    }),
839                ))
840            } else {
841                Ok((
842                    StatusCode::OK,
843                    Json(ExportChangesResponse {
844                        success: true,
845                        patch: Some(patch),
846                        filename: Some(build_export_filename()),
847                        error: None,
848                    }),
849                ))
850            }
851        }
852        Err(error) => Ok((
853            StatusCode::INTERNAL_SERVER_ERROR,
854            Json(ExportChangesResponse {
855                success: false,
856                patch: None,
857                filename: None,
858                error: Some(error),
859            }),
860        )),
861    }
862}
863
864async fn get_commits(
865    State(state): State<AppState>,
866    Path((workspace_id, codebase_id)): Path<(String, String)>,
867    Query(query): Query<GetCommitsQuery>,
868) -> Result<Json<GetCommitsResponse>, ServerError> {
869    let repo_path = resolve_codebase_repo_path(&state, &workspace_id, &codebase_id).await?;
870    let limit = query.limit;
871    let since = query.since;
872
873    let commits = tokio::task::spawn_blocking(move || {
874        routa_core::git::get_commit_list(&repo_path, limit, since.as_deref())
875    })
876    .await
877    .map_err(|error| ServerError::Internal(error.to_string()))?
878    .map_err(ServerError::Internal)?;
879
880    let count = commits.len();
881
882    Ok(Json(GetCommitsResponse { commits, count }))
883}