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