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}