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}