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